├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── bindings ├── README.md ├── _tools │ └── generate │ │ ├── addJSDoc.ts │ │ ├── getProtocol.ts │ │ └── mod.ts └── celestial.ts ├── deno.jsonc ├── docs ├── pages │ ├── advanced │ │ ├── binary.md │ │ ├── bindings.md │ │ ├── connect.md │ │ ├── index.md │ │ ├── polish.md │ │ └── sandbox.md │ ├── guides │ │ ├── attributes.md │ │ ├── evaluate.md │ │ ├── index.md │ │ ├── navigation.md │ │ └── screenshot.md │ ├── index.md │ └── showcase.tsx ├── pyro.yml └── static │ ├── astral.png │ ├── examples │ └── screenshot.png │ ├── icon.png │ ├── icon.psd │ └── showcase │ ├── blog │ └── scraping.png │ └── rnbguy │ └── manuscript-marauder.png ├── examples ├── authenticate.ts ├── element_evaluate.ts ├── evaluate.ts ├── navigation.ts ├── screenshot.ts └── user_agent.ts ├── mod.ts ├── src ├── browser.ts ├── cache.ts ├── debug.ts ├── dialog.ts ├── element_handle.ts ├── file_chooser.ts ├── keyboard │ ├── layout.ts │ └── mod.ts ├── locator.ts ├── mouse.ts ├── page.ts ├── touchscreen.ts └── util.ts └── tests ├── __snapshots__ ├── evaluate_test.ts.snap ├── extract_test.ts.snap ├── navigate_test.ts.snap └── wait_test.ts.snap ├── authenticate_test.ts ├── benchs └── _get_binary_bench.ts ├── dialog_test.ts ├── element_evaluate_test.ts ├── evaluate_test.ts ├── event_test.ts ├── existing_ws_endpoint_test.ts ├── extract_test.ts ├── fixtures ├── counter.html ├── evaluate.html ├── fill.html └── wait_for_element.html ├── get_attributes_test.ts ├── get_binary_test.ts ├── install_lock_file_test.ts ├── keyboard_test.ts ├── leak_test.ts ├── locator_test.ts ├── mouse_test.ts ├── navigation_test.ts ├── query_test.ts ├── reliability_test.ts ├── resource_test.ts ├── sandbox_test.ts ├── set_content_test.ts ├── stealth_test.ts └── wait_test.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: tests (${{ matrix.os }}) 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macOS-latest] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: download deno 19 | uses: denoland/setup-deno@v2 20 | with: 21 | deno-version: v2.x 22 | 23 | - name: Disable AppArmor 24 | if: ${{ matrix.os == 'ubuntu-latest' }} 25 | run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns 26 | 27 | - name: check format 28 | if: matrix.os == 'ubuntu-latest' 29 | run: deno fmt --check 30 | 31 | - name: check linting 32 | if: matrix.os == 'ubuntu-latest' 33 | run: deno lint 34 | 35 | - name: run tests 36 | run: deno task test 37 | 38 | - name: pretend to publish package 39 | run: deno publish --dry-run 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install Deno 19 | uses: denoland/setup-deno@v2 20 | 21 | - name: Publish package 22 | run: deno publish 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | types.json 2 | .vscode 3 | ./docs/build 4 | .DS_Store 5 | vendor 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lino Le Van 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | Astral is a high-level puppeteer/playwright-like library that allows for control 6 | over a web browser (primarily for automation and testing). It is written from 7 | scratch with Deno in mind. 8 | 9 | ## Usage 10 | 11 | Take a screenshot of a website. 12 | 13 | ```ts 14 | // Import Astral 15 | import { launch } from "jsr:@astral/astral"; 16 | 17 | // Launch the browser 18 | await using browser = await launch(); 19 | 20 | // Open a new page 21 | await using page = await browser.newPage("https://deno.land"); 22 | 23 | // Take a screenshot of the page and save that to disk 24 | const screenshot = await page.screenshot(); 25 | Deno.writeFileSync("screenshot.png", screenshot); 26 | 27 | // Clean up is performed automatically 28 | ``` 29 | 30 | You can use the evaluate function to run code in the context of the browser. 31 | 32 | ```ts 33 | // Import Astral 34 | import { launch } from "jsr:@astral/astral"; 35 | 36 | // Launch the browser 37 | await using browser = await launch(); 38 | 39 | // Open a new page 40 | await using page = await browser.newPage("https://deno.land"); 41 | 42 | // Run code in the context of the browser 43 | const value = await page.evaluate(() => { 44 | return document.body.innerHTML; 45 | }); 46 | console.log(value); 47 | 48 | // Run code with args 49 | const result = await page.evaluate((x, y) => { 50 | return `The result of adding ${x}+${y} = ${x + y}`; 51 | }, { 52 | args: [10, 15], 53 | }); 54 | console.log(result); 55 | ``` 56 | 57 | You can navigate to a page and interact with it. 58 | 59 | ```ts 60 | // Import Astral 61 | import { launch } from "jsr:@astral/astral"; 62 | 63 | // Launch browser in headfull mode 64 | await using browser = await launch({ headless: false }); 65 | 66 | // Open the webpage 67 | await using page = await browser.newPage("https://deno.land"); 68 | 69 | // Click the search button 70 | const button = await page.$("button"); 71 | await button!.click(); 72 | 73 | // Type in the search input 74 | const input = await page.$("#search-input"); 75 | await input!.type("pyro", { delay: 1000 }); 76 | 77 | // Wait for the search results to come back 78 | await page.waitForNetworkIdle({ idleConnections: 0, idleTime: 1000 }); 79 | 80 | // Click the 'pyro' link 81 | const xLink = await page.$("a.justify-between:nth-child(1)"); 82 | await Promise.all([ 83 | page.waitForNavigation(), 84 | xLink!.click(), 85 | ]); 86 | 87 | // Click the link to 'pyro.deno.dev' 88 | const dLink = await page.$( 89 | ".markdown-body > p:nth-child(8) > a:nth-child(1)", 90 | ); 91 | await Promise.all([ 92 | page.waitForNavigation(), 93 | dLink!.click(), 94 | ]); 95 | ``` 96 | 97 | TODO: Document the locator API. 98 | 99 | ## Advanced Usage 100 | 101 | If you already have a browser process running somewhere else or you're using a 102 | service that provides remote browsers for automation (such as 103 | [browserless.io](https://www.browserless.io/)), it is possible to directly 104 | connect to its endpoint rather than spawning a new process. 105 | 106 | ```ts 107 | // Import Astral 108 | import { connect } from "jsr:@astral/astral"; 109 | 110 | // Connect to remote endpoint 111 | const browser = await connect({ 112 | endpoint: "wss://remote-browser-endpoint.example.com", 113 | }); 114 | 115 | // Do stuff 116 | const page = await browser.newPage("http://example.com"); 117 | console.log(await page.evaluate(() => document.title)); 118 | 119 | // Close connection 120 | await browser.close(); 121 | ``` 122 | 123 | If you'd like to instead re-use a browser that you already launched, astral 124 | exposes the WebSocket endpoint through `browser.wsEndpoint()`. 125 | 126 | ```ts 127 | // Spawn a browser process 128 | const browser = await launch(); 129 | 130 | // Connect to first browser instead 131 | const anotherBrowser = await connect({ endpoint: browser.wsEndpoint() }); 132 | ``` 133 | 134 | ### Page authenticate 135 | 136 | [authenticate example code](https://github.com/lino-levan/astral/blob/main/examples/authenticate.ts): 137 | 138 | ```ts 139 | // Open a new page 140 | const page = await browser.newPage(); 141 | 142 | // Provide credentials for HTTP authentication. 143 | const url = "https://postman-echo.com/basic-auth"; 144 | await page.authenticate({ username: "postman", password: "password" }); 145 | await page.goto(url, { waitUntil: "networkidle2" }); 146 | ``` 147 | 148 | ## BYOB - Bring Your Own Browser 149 | 150 | Essentially the process is as simple as running a chromium-like binary with the 151 | following flags: 152 | 153 | ``` 154 | chromium --remote-debugging-port=1337 \ 155 | --headless=new \ 156 | --no-first-run \ 157 | --password-store=basic \ 158 | --use-mock-keychain \ 159 | --hide-scrollbars 160 | ``` 161 | 162 | Technically, only the first flag is necessary, though I've found that these 163 | flags generally get the best result. Once your browser process is running, 164 | connecting to it is as simple as 165 | 166 | ```typescript 167 | // Import Astral 168 | import { connect } from "jsr:@astral/astral"; 169 | 170 | // Connect to remote endpoint 171 | const browser = await connect({ 172 | endpoint: "localhost:1337", 173 | headless: false, 174 | }); 175 | 176 | console.log(browser.wsEndpoint()); 177 | 178 | // Do stuff 179 | const page = await browser.newPage("http://example.com"); 180 | console.log(await page.evaluate(() => document.title)); 181 | 182 | // Close connection 183 | await browser.close(); 184 | ``` 185 | 186 | ## FAQ 187 | 188 | ### Launch FAQ 189 | 190 | #### "No usable sandbox!" with user namespace cloning enabled 191 | 192 | > Ubuntu 23.10+ (or possibly other Linux distros in the future) ship an AppArmor 193 | > profile that applies to Chrome stable binaries installed at 194 | > /opt/google/chrome/chrome (the default installation path). This policy is 195 | > stored at /etc/apparmor.d/chrome. This AppArmor policy prevents Chrome for 196 | > Testing binaries downloaded by Puppeteer from using user namespaces resulting 197 | > in the No usable sandbox! error when trying to launch the browser. For 198 | > workarounds, see 199 | > https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md 200 | 201 | The following command removes AppArmor restrictions on user namespaces, allowing 202 | Puppeteer to launch Chrome without the "No usable sandbox!" error (see 203 | [puppeteer#13196](https://github.com/puppeteer/puppeteer/pull/13196)): 204 | 205 | echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns 206 | -------------------------------------------------------------------------------- /bindings/README.md: -------------------------------------------------------------------------------- 1 | # Astral Bindings 2 | 3 | Astral binds to the Chrome DevTools Protocol (and maybe more in the future!), 4 | and those need to be written somehow. The CDP is HUGE, and writing bindings by 5 | hand would take so long that they would be out of date by the time that they 6 | were finished. 7 | 8 | This folder contains these protocol bindings. Celestial is the name of our 9 | home-grown CDP bindings. We hope to have bindings to the safari equivalent of 10 | this protocol soon (and that project will probably be under the name 11 | "Etherial"). 12 | -------------------------------------------------------------------------------- /bindings/_tools/generate/addJSDoc.ts: -------------------------------------------------------------------------------- 1 | import type { JSDocable } from "./getProtocol.ts"; 2 | 3 | export function addJSDoc(obj: JSDocable) { 4 | let result = ""; 5 | if (obj.description || obj.deprecated || obj.experimental) { 6 | result += "/**\n"; 7 | if (obj.experimental) { 8 | result += ` * @experimental\n`; 9 | } 10 | if (obj.deprecated) { 11 | result += ` * @deprecated\n`; 12 | } 13 | if (obj.description) { 14 | result += ` * ${obj.description.split("\n").join("\n * ")}\n`; 15 | } 16 | result += " */\n"; 17 | } 18 | 19 | return result; 20 | } 21 | -------------------------------------------------------------------------------- /bindings/_tools/generate/getProtocol.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "@std/fs/exists"; 2 | import { getBinary } from "../../../src/cache.ts"; 3 | 4 | export interface JSDocable { 5 | description?: string; 6 | experimental?: boolean; 7 | deprecated?: boolean; 8 | } 9 | 10 | export type ObjectProperty = 11 | & JSDocable 12 | & { 13 | name: string; 14 | optional?: boolean; 15 | } 16 | & ( 17 | { 18 | type: "number" | "integer" | "boolean" | "any" | "object" | "binary"; 19 | } | { 20 | type: "string"; 21 | enum?: string[]; 22 | } | { 23 | type: "array"; 24 | items: { 25 | $ref: string; 26 | } | { 27 | type: "number" | "string" | "integer" | "any"; 28 | }; 29 | } | { 30 | $ref: string; 31 | } 32 | ); 33 | 34 | export type Type = 35 | & JSDocable 36 | & { 37 | id: string; 38 | } 39 | & ( 40 | { 41 | type: "string"; 42 | enum?: string[]; 43 | } | { 44 | type: "number" | "integer"; 45 | } | { 46 | type: "object"; 47 | properties?: ObjectProperty[]; 48 | } | { 49 | type: "array"; 50 | items: { 51 | $ref: string; 52 | } | { 53 | type: "number" | "string" | "integer" | "any"; 54 | }; 55 | } 56 | ); 57 | 58 | export type CommandParameter = 59 | & JSDocable 60 | & { 61 | name: string; 62 | optional?: boolean; 63 | } 64 | & ( 65 | { 66 | type: "boolean" | "integer" | "number" | "binary"; 67 | } | { 68 | type: "string"; 69 | enum?: string[]; 70 | } | { 71 | type: "array"; 72 | items: { 73 | $ref: string; 74 | } | { 75 | type: "string"; 76 | }; 77 | } | { 78 | $ref: string; 79 | } 80 | ); 81 | 82 | export type Command = JSDocable & { 83 | name: string; 84 | parameters?: CommandParameter[]; 85 | // TODO: verify typing here 86 | returns?: CommandParameter[]; 87 | }; 88 | 89 | export type Event = JSDocable & { 90 | name: string; 91 | // TODO: verify typing here 92 | parameters?: CommandParameter[]; 93 | }; 94 | 95 | export type Domain = JSDocable & { 96 | domain: string; 97 | dependencies?: string[]; 98 | types?: Type[]; 99 | events?: Event[]; 100 | commands?: Command[]; 101 | }; 102 | 103 | export interface Protocol { 104 | version: { 105 | major: number; 106 | minor: number; 107 | }; 108 | domains: Domain[]; 109 | } 110 | 111 | export async function getProtocol(): Promise { 112 | if (existsSync("types.json")) { 113 | return JSON.parse(Deno.readTextFileSync("types.json")); 114 | } else { 115 | // Configuration 116 | console.log("1. Getting binary path"); 117 | const path = await getBinary("chrome"); 118 | 119 | // Launch child process 120 | console.log("2. Launching process"); 121 | const launch = new Deno.Command(path, { 122 | args: [ 123 | "-remote-debugging-port=9222", 124 | "--headless=new", 125 | "--password-store=basic", 126 | "--use-mock-keychain", 127 | ], 128 | stderr: "piped", 129 | }); 130 | const process = launch.spawn(); 131 | 132 | const reader = process.stderr 133 | .pipeThrough(new TextDecoderStream()) 134 | .getReader(); 135 | 136 | console.log("3. Waiting until binary is ready"); 137 | let message: string | undefined; 138 | do { 139 | message = (await reader.read()).value; 140 | } while (message && !message.includes("127.0.0.1:9222")); 141 | 142 | console.log("4. Requesting the protocol spec"); 143 | // Get protocol information and close process 144 | const protocolReq = await fetch("http://localhost:9222/json/protocol"); 145 | const res = await protocolReq.json(); 146 | process.kill("SIGKILL"); 147 | return res; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /bindings/_tools/generate/mod.ts: -------------------------------------------------------------------------------- 1 | import { addJSDoc } from "./addJSDoc.ts"; 2 | import { 3 | type CommandParameter, 4 | type Domain, 5 | getProtocol, 6 | } from "./getProtocol.ts"; 7 | 8 | // 1. Get current protocol version 9 | console.log("Getting the protocol spec..."); 10 | const protocol = await getProtocol(); 11 | 12 | // 2. Generate boilerplate at the top 13 | console.log("Generating header..."); 14 | let types = 15 | "// These bindings are auto-generated by ./_tools/generate/mod.ts\n"; 16 | types += `// Last generated at ${new Date().toISOString()}\n`; 17 | types += "// deno-lint-ignore-file no-explicit-any\n\n"; 18 | types += `import { attachWs, DEBUG } from "../src/debug.ts";\n`; 19 | types += 20 | `export const PROTOCOL_VERSION = "${protocol.version.major}.${protocol.version.minor}";\n\n`; 21 | 22 | // 3. Generate actual bindings 23 | 24 | function nameToType( 25 | name: 26 | | "string" 27 | | "integer" 28 | | "number" 29 | | "boolean" 30 | | "binary" 31 | | "any" 32 | | "object", 33 | ) { 34 | if (name === "binary") { 35 | return "string"; 36 | } else if (name === "integer") { 37 | return "number"; 38 | } else { 39 | return name; 40 | } 41 | } 42 | 43 | function generateTypes(domain: Domain) { 44 | for (const type of domain.types ?? []) { 45 | types += addJSDoc(type); 46 | types += `export type ${domain.domain}_${type.id} = `; 47 | 48 | if (type.type === "integer" || type.type === "number") { 49 | types += "number"; 50 | } else if (type.type === "string") { 51 | if (type.enum) { 52 | types += (type.enum as string[]).map((str) => `"${str}"`).join(" | "); 53 | } else { 54 | types += "string"; 55 | } 56 | } else if (type.type === "object") { 57 | if (type.properties) { 58 | types += "{\n"; 59 | for (const property of type.properties) { 60 | types += addJSDoc(property); 61 | types += `\t${property.name}`; 62 | types += property.optional ? "?: " : ": "; 63 | if ("$ref" in property) { 64 | if (property.$ref.includes(".")) { 65 | types += property.$ref.replaceAll(".", "_"); 66 | } else { 67 | types += `${domain.domain}_${property.$ref}`; 68 | } 69 | } else { 70 | if (property.type === "string") { 71 | if (property.enum) { 72 | types += (property.enum as string[]).map((str) => `"${str}"`) 73 | .join(" | "); 74 | } else { 75 | types += "string"; 76 | } 77 | } else if (property.type === "array") { 78 | if ("type" in property.items) { 79 | types += `${nameToType(property.items.type)}[]`; 80 | } else { 81 | if (property.items.$ref.includes(".")) { 82 | types += property.items.$ref.replaceAll(".", "_") + "[]"; 83 | } else { 84 | types += `${domain.domain}_${property.items.$ref}[]`; 85 | } 86 | } 87 | } else { 88 | types += nameToType(property.type); 89 | } 90 | } 91 | types += ";\n"; 92 | } 93 | types += "}"; 94 | } else { 95 | types += "object"; 96 | } 97 | } else if (type.type === "array") { 98 | if ("type" in type.items) { 99 | types += `${nameToType(type.items.type)}[]`; 100 | } else { 101 | if (type.items.$ref.includes(".")) { 102 | types += type.items.$ref.replaceAll(".", "_") + "[]"; 103 | } else { 104 | types += `${domain.domain}_${type.items.$ref}[]`; 105 | } 106 | } 107 | } 108 | 109 | types += ";\n\n"; 110 | } 111 | } 112 | 113 | function generateParameters(commandParams: CommandParameter[], domain: string) { 114 | return commandParams.map((param) => { 115 | let p = addJSDoc(param); 116 | p += param.name; 117 | p += param.optional ? "?: " : ":"; 118 | if ("$ref" in param) { 119 | if (param.$ref.includes(".")) { 120 | p += param.$ref.replaceAll(".", "_"); 121 | } else { 122 | p += `${domain}_${param.$ref}`; 123 | } 124 | } else { 125 | if (param.type === "array") { 126 | if ("type" in param.items) { 127 | p += `${nameToType(param.items.type)}[]`; 128 | } else { 129 | if (param.items.$ref.includes(".")) { 130 | p += param.items.$ref.replaceAll(".", "_") + "[]"; 131 | } else { 132 | p += `${domain}_${param.items.$ref}[]`; 133 | } 134 | } 135 | } else if (param.type === "string") { 136 | if (param.enum) { 137 | p += (param.enum as string[]).map((str) => `"${str}"`) 138 | .join(" | "); 139 | } else { 140 | p += "string"; 141 | } 142 | } else { 143 | p += nameToType(param.type); 144 | } 145 | } 146 | return p; 147 | }).join(", \n"); 148 | } 149 | 150 | let events = ""; 151 | let eventMap = "const CelestialEvents = {\n"; 152 | let eventMapType = "\nexport interface CelestialEventMap {"; 153 | 154 | let celestial = ` 155 | export class Celestial extends EventTarget { 156 | ws: WebSocket; 157 | #wsClosed: Promise; 158 | #id = 0; 159 | #handlers: Map void> = new Map(); 160 | 161 | /** 162 | * Celestial expects a open websocket to communicate over 163 | */ 164 | constructor(ws: WebSocket) { 165 | super(); 166 | 167 | this.ws = ws; 168 | 169 | if (DEBUG) { 170 | attachWs(ws) 171 | } 172 | 173 | this.ws.onmessage = (e) => { 174 | const data = JSON.parse(e.data); 175 | 176 | const handler = this.#handlers.get(data.id); 177 | 178 | if(handler) { 179 | handler(data.result); 180 | this.#handlers.delete(data.id); 181 | } else { 182 | const className = CelestialEvents[data.method as keyof CelestialEventMap]; 183 | if (className === undefined) { 184 | if (DEBUG) { 185 | console.error("[CELESTIAL] Unknown event", data); 186 | } 187 | return; 188 | } 189 | if(data.params) { 190 | this.dispatchEvent(new className(data.params)) 191 | } else { 192 | // @ts-ignore trust me 193 | this.dispatchEvent(new className()) 194 | } 195 | } 196 | }; 197 | 198 | const { promise, resolve } = Promise.withResolvers(); 199 | this.#wsClosed = promise; 200 | const closed = () => { 201 | if (this.ws.readyState === WebSocket.CLOSED) { 202 | resolve(true); 203 | return; 204 | } 205 | setTimeout(closed, 100); 206 | }; 207 | this.ws.onclose = closed; 208 | } 209 | 210 | /** 211 | * Close the websocket connection, does nothing if already closed. 212 | */ 213 | async close() { 214 | this.ws.close(); 215 | await this.#wsClosed; 216 | } 217 | 218 | // @ts-ignore everything is fine 219 | addEventListener(type: K, listener: (this: Celestial, ev: CelestialEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void { 220 | // @ts-ignore and I am calm. 221 | super.addEventListener(type, listener, options); 222 | } 223 | 224 | #sendReq(method: string, params?: unknown): Promise { 225 | this.ws.send(JSON.stringify({ 226 | id: ++this.#id, 227 | method, 228 | params 229 | })) 230 | 231 | return new Promise((res)=>{ 232 | this.#handlers.set(this.#id, res) 233 | }) 234 | } 235 | `; 236 | 237 | console.log("Generating domains..."); 238 | for (const domain of protocol.domains) { 239 | types += `// ----------------- ${domain.domain} Types -----------------\n\n`; 240 | generateTypes(domain); 241 | 242 | celestial += addJSDoc(domain); 243 | celestial += `${domain.domain} = {\n`; 244 | 245 | for (const command of domain.commands || []) { 246 | celestial += addJSDoc(command); 247 | celestial += `\n${command.name} = async (`; 248 | if (command.parameters) { 249 | celestial += `opts: {${ 250 | generateParameters(command.parameters, domain.domain) 251 | }}`; 252 | } 253 | celestial += "): Promise<"; 254 | if (command.returns) { 255 | celestial += `{${generateParameters(command.returns, domain.domain)}}`; 256 | } else { 257 | celestial += "void"; 258 | } 259 | celestial += "> => {\n"; 260 | if (command.parameters) { 261 | celestial += 262 | `return await this.#sendReq("${domain.domain}.${command.name}", opts)`; 263 | } else { 264 | celestial += 265 | `return await this.#sendReq("${domain.domain}.${command.name}")`; 266 | } 267 | celestial += `},\n\n`; 268 | } 269 | celestial += "}\n\n"; 270 | 271 | for (const event of domain.events || []) { 272 | if (event.parameters) { 273 | events += ` 274 | export interface ${domain.domain}_${event.name} { 275 | ${generateParameters(event.parameters, domain.domain)} 276 | } 277 | 278 | export class ${domain.domain}_${event.name}Event extends CustomEvent<${domain.domain}_${event.name}> { 279 | constructor(detail: ${domain.domain}_${event.name}) { 280 | super("${domain.domain}.${event.name}", { detail }) 281 | } 282 | }\n\n`; 283 | eventMap += 284 | `\t"${domain.domain}.${event.name}": ${domain.domain}_${event.name}Event,\n`; 285 | eventMapType += 286 | `\t"${domain.domain}.${event.name}": ${domain.domain}_${event.name}Event;\n`; 287 | } else { 288 | eventMap += `\t"${domain.domain}.${event.name}": Event,\n`; 289 | eventMapType += `\t"${domain.domain}.${event.name}": Event;\n`; 290 | } 291 | } 292 | } 293 | 294 | celestial += "}\n"; 295 | eventMap += "}\n"; 296 | eventMapType += "}\n"; 297 | 298 | // 4. Write data to ./bindings/celestial.ts 299 | console.log("Writing to file..."); 300 | Deno.writeTextFileSync( 301 | "./bindings/celestial.ts", 302 | types + events + eventMap + eventMapType + celestial, 303 | ); 304 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astral/astral", 3 | "version": "0.5.3", 4 | "exports": "./mod.ts", 5 | "tasks": { 6 | // The task to automatically generate `./src/celestial.ts` 7 | "bind": "deno run -A ./bindings/_tools/generate/mod.ts && deno fmt", 8 | "test": "deno test -A --trace-leaks", 9 | "bench": "deno bench -A", 10 | "www": "cd docs && pyro dev" 11 | }, 12 | "compilerOptions": { 13 | "jsx": "react-jsx", 14 | "jsxImportSource": "https://esm.sh/preact@10.16.0" 15 | }, 16 | "lock": false, 17 | "imports": { 18 | "@deno-library/progress": "jsr:@deno-library/progress@^1.5.1", 19 | "@std/assert": "jsr:@std/assert@^1", 20 | "@std/async": "jsr:@std/async@^1", 21 | "@std/fs": "jsr:@std/fs@^1", 22 | "@std/path": "jsr:@std/path@^1", 23 | "@std/testing": "jsr:@std/testing@^1", 24 | "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.7.52" 25 | }, 26 | "publish": { 27 | "include": [ 28 | "LICENSE", 29 | "README.md", 30 | "deno.jsonc", 31 | "mod.ts", 32 | "src/**/*.ts", 33 | "docs/static/icon.png", 34 | "bindings/celestial.ts" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/pages/advanced/binary.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Binaries 3 | description: How Astral deals with binaries 4 | index: 0 5 | --- 6 | 7 | The simplest part of Astral is the actual **CDP-compatible** binary that you 8 | use. What does that actually mean? 9 | 10 | ## CDP (Chrome DevTools Protocol) 11 | 12 | Ignoring all of the complexities of implementation, all Astral does is interface 13 | with a well-defined protocol which is the Chrome DevTools Protocol. 14 | 15 | The best documentation that exists on this protocol can be found 16 | [here](https://chromedevtools.github.io/devtools-protocol/), from the official 17 | github repo. I should note that while this documentation is extensive (it's 18 | auto-generated), there are no good examples on how to interact with the protocol 19 | itself. Most of the learnings I've had on that side were by reading the code on 20 | how others interface with the protocol. 21 | 22 | Ideally we'd interact with the protocol through a unix pipe, but we currently do 23 | it through an exposed port and a websocket. 24 | 25 | All of this is to say that all browsers that implement the CDP protocol _should_ 26 | just work out of the box with Astral. This includes Firefox, though I haven't 27 | tested it. I believe Webkit-backed browsers do not work yet, though we'll see 28 | about that in the future. 29 | -------------------------------------------------------------------------------- /docs/pages/advanced/bindings.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Bindings 3 | description: How Astral generates bindings 4 | index: 1 5 | --- 6 | 7 | One of the two big components of what Astral actually is, is the bindings we 8 | generate for the [CDP protocol](/advanced/binary). These bindings are generated 9 | from a hand-rolled script. They should be fully typed and JSDoc'd. 10 | 11 | They can be re-generated for a binary by using `deno task bind`. 12 | 13 | ## How do work??? 14 | 15 | All of this work actually happens in `bindings/_tools`, with `mod.ts` being the 16 | file to run to generate the bindings. This script can be broken down into three 17 | parts: 18 | 19 | 1. Extract the protocol JSON from the browser 20 | 21 | - Essentially, just make a request to `http://localhost:9222/json/protocol` 22 | 23 | 2. Extract the types from the JSON 24 | 3. Generate bindings for the methods and the events 25 | 26 | When generating bindings, we essentially use some clever format strings. There's 27 | nothing too crazy on that front. To avoid making the code look awful, we run 28 | `deno fmt` right after the bindings are generated. 29 | -------------------------------------------------------------------------------- /docs/pages/advanced/connect.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Connect to existing browser 3 | description: How to connect an existing browser process with astral 4 | index: 3 5 | --- 6 | 7 | If you already have a browser process running somewhere else or you're using a 8 | service that provides remote browsers for automation (such as 9 | [browserless.io](https://www.browserless.io/)), it is possible to directly 10 | connect to its endpoint rather than spawning a new process. 11 | 12 | ## Code 13 | 14 | ```ts 15 | // Import Astral 16 | import { launch } from "jsr:@astral/astral"; 17 | 18 | // Connect to remote endpoint 19 | const browser = await launch({ 20 | endpoint: "wss://remote-browser-endpoint.example.com", 21 | }); 22 | 23 | // Do stuff 24 | const page = await browser.newPage("http://example.com"); 25 | console.log(await page.evaluate(() => document.title)); 26 | 27 | // Close connection 28 | await browser.close(); 29 | ``` 30 | 31 | ## Reusing a browser spawned by astral 32 | 33 | A browser instance expose its WebSocket endpoint through `browser.wsEndpoint()`. 34 | 35 | ```ts 36 | // Spawn a browser process 37 | const browser = await launch(); 38 | 39 | // Connect to first browser instead 40 | const anotherBrowser = await launch({ endpoint: browser.wsEndpoint() }); 41 | ``` 42 | 43 | This is especially useful in unit testing as you can setup a shared browser 44 | instance before all your tests, while also properly closing resources to avoid 45 | operations leaks. 46 | -------------------------------------------------------------------------------- /docs/pages/advanced/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Advanced 3 | description: Advanced topics for Astral 4 | index: 2 5 | --- 6 | 7 | Astral is a relatively complicated library with a few moving parts. Getting to 8 | know about them is critical if you want to contribute (or you're just curious!) 9 | 10 | The project is mainly composed of three components: 11 | 12 | - A Chrome / Firefox binary to run 13 | - Bindings generated for those binaries 14 | - A layer on top of that for a nicer API 15 | -------------------------------------------------------------------------------- /docs/pages/advanced/polish.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Polish 3 | description: How Astral finalizes the polish to make a usable API. 4 | index: 2 5 | --- 6 | 7 | ## Philosophy 8 | 9 | When you can't write, read. Browser automation has existed for years, and better 10 | APIs trend towards popularity. People clearly are fans of the 11 | `puppeteer`/`playwright`-style APIs and thus we choose to loosely follow those. 12 | 13 | Astral is not trying to be a drop-in replacement for Puppeteer. I personally 14 | believe they made some big mistakes in API design that we have the possibility 15 | of fixing. One instance of this is putting top-level `type` and `click` which 16 | both accept selectors. Another is probably the complexity of the selectors API. 17 | We can and should do better on those regards. 18 | 19 | ## Actual Implementation 20 | 21 | In terms of actual implementation, we essentially do a nice wrapping of our 22 | celestial bindings. A lot of the Puppeteer APIs bind incredibly nicely to the 23 | CDP API, which makes sense given they were built together over at Google. 24 | 25 | Sometimes, they don't follow each other as nicely, in which case the 26 | implementation is a lot harder. This can be seen in the `waitFor*` family of 27 | APIs. 28 | -------------------------------------------------------------------------------- /docs/pages/advanced/sandbox.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sandbox permissions 3 | description: How to use Deno permissions to sandbox pages 4 | index: 4 5 | --- 6 | 7 | `Browser.newPage()` supports a `sandbox` mode, which use 8 | [Deno permissions](https://docs.deno.com/runtime/manual/basics/permissions) to 9 | validate network requests (using `--allow-net` permissions) and file requests 10 | (using `--allow-read` permissions) on the opened page. 11 | 12 | ## Code 13 | 14 | ```ts 15 | // Import Astral 16 | import { launch } from "jsr:@astral/astral"; 17 | import { fromFileUrl } from "@std/path/from-file-url"; 18 | 19 | // Launch browser 20 | const browser = await launch(); 21 | 22 | // Open the page if permission granted, or throws Deno.errors.PermissionDenied 23 | { 24 | const { state } = await Deno.permissions.query({ 25 | name: "net", 26 | path: "example.com", 27 | }); 28 | await browser.newPage("https://example.com", { sandbox: true }); 29 | } 30 | 31 | // Open the page if permission granted, or throws Deno.errors.PermissionDenied 32 | { 33 | const { state } = await Deno.permissions.query({ 34 | name: "read", 35 | path: fromFileUrl(import.meta.url), 36 | }); 37 | await browser.newPage(fromFileUrl(import.meta.url), { sandbox: true }); 38 | } 39 | 40 | // Close browser 41 | await browser.close(); 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/pages/guides/attributes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Attributes 3 | description: A small example on how to do get attributes in Astral 4 | index: 3 5 | --- 6 | 7 | ## Code 8 | 9 | ```ts 10 | // Import Astral 11 | import { launch } from "jsr:@astral/astral"; 12 | 13 | // Launch the browser 14 | const browser = await launch(); 15 | 16 | // Open a new page 17 | const page = await browser.newPage("https://deno.land"); 18 | 19 | // Take an element 20 | const element = await page.$("img"); 21 | 22 | // Take attributes from an element 23 | const attributes = await element.getAttributes(); 24 | 25 | console.log(attributes); 26 | /* 27 | { 28 | class: "max-w-[28rem] hidden lg:block", 29 | src: "/runtime/deno-looking-up.svg?__frsh_c=6f92b045bc7486e03053e1977adceb7e4aa071f4", 30 | alt: "", 31 | width: "670", 32 | height: "503" 33 | } 34 | */ 35 | 36 | // Take only one attribute from an element 37 | const src = await element.getAttribute("src"); 38 | 39 | console.log(src); 40 | /* 41 | "/runtime/deno-looking-up.svg?__frsh_c=6f92b045bc7486e03053e1977adceb7e4aa071f4" 42 | */ 43 | 44 | // Close the browser 45 | await browser.close(); 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/pages/guides/evaluate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Evaluate 3 | description: A small example on how to do complex evaluation in Astral 4 | index: 2 5 | --- 6 | 7 | ## Running 8 | 9 | ```bash 10 | deno run -A https://deno.land/x/astral/examples/evaluate.ts 11 | ``` 12 | 13 | ## Code 14 | 15 | ```ts 16 | // Import Astral 17 | import { launch } from "jsr:@astral/astral"; 18 | 19 | // Launch the browser 20 | const browser = await launch(); 21 | 22 | // Open a new page 23 | const page = await browser.newPage("https://deno.land"); 24 | 25 | // Run code in the context of the browser 26 | const value = await page.evaluate(() => { 27 | return document.body.innerHTML; 28 | }); 29 | console.log(value); 30 | 31 | // Run code with args 32 | const result = await page.evaluate((x, y) => { 33 | return `The result of adding ${x}+${y} = ${x + y}`; 34 | }, { 35 | args: [10, 15], 36 | }); 37 | console.log(result); 38 | 39 | // Close the browser 40 | await browser.close(); 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/pages/guides/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Guides 3 | description: Small guides for Astral 4 | index: 1 5 | --- 6 | 7 | Astral can be overwheming for beginners. This chapter is a collection of demos 8 | on how to do basic things using Astral-isms. 9 | -------------------------------------------------------------------------------- /docs/pages/guides/navigation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Navigation 3 | description: A small example on how to do complex navigation in Astral 4 | index: 1 5 | --- 6 | 7 | ## Running 8 | 9 | ```bash 10 | deno run -A https://deno.land/x/astral/examples/navigation.ts 11 | ``` 12 | 13 | ## Code 14 | 15 | ```ts 16 | // Import Astral 17 | import { launch } from "jsr:@astral/astral"; 18 | 19 | // Launch browser in headfull mode 20 | const browser = await launch({ headless: false }); 21 | 22 | // Open the webpage 23 | const page = await browser.newPage("https://deno.land"); 24 | 25 | // Click the search button 26 | const button = await page.$("button"); 27 | await button!.click(); 28 | 29 | // Type in the search input 30 | const input = await page.$("#search-input"); 31 | await input!.type("pyro", { delay: 1000 }); 32 | 33 | // Wait for the search results to come back 34 | await page.waitForNetworkIdle({ idleConnections: 0, idleTime: 1000 }); 35 | 36 | // Click the 'pyro' link 37 | const xLink = await page.$("a.justify-between:nth-child(1)"); 38 | await Promise.all([ 39 | page.waitForNavigation(), 40 | xLink!.click(), 41 | ]); 42 | 43 | // Click the link to 'pyro.deno.dev' 44 | const dLink = await page.$( 45 | ".markdown-body > p:nth-child(8) > a:nth-child(1)", 46 | ); 47 | await Promise.all([ 48 | page.waitForNavigation(), 49 | dLink!.click(), 50 | ]); 51 | 52 | // Close browser 53 | await browser.close(); 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/pages/guides/screenshot.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Screenshots 3 | description: A small example on how to do screenshots in Astral 4 | index: 0 5 | --- 6 | 7 | ## Running 8 | 9 | ```bash 10 | deno run -A https://deno.land/x/astral/examples/screenshot.ts 11 | ``` 12 | 13 | ## Code 14 | 15 | ```ts 16 | // Import Astral 17 | import { launch } from "jsr:@astral/astral"; 18 | 19 | // Launch the browser 20 | const browser = await launch(); 21 | 22 | // Open a new page 23 | const page = await browser.newPage("https://deno.land"); 24 | 25 | // Take a screenshot of the page and save that to disk 26 | const screenshot = await page.screenshot(); 27 | Deno.writeFileSync("screenshot.png", screenshot); 28 | 29 | // Close the browser 30 | await browser.close(); 31 | ``` 32 | 33 | ## Result 34 | 35 | 36 | -------------------------------------------------------------------------------- /docs/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: A simple introduction to Astral 4 | index: 0 5 | --- 6 | 7 | 8 | 9 | > Astral is a Puppeteer/Playwright-like library designed with Deno in mind. 10 | 11 | ## What can I do? 12 | 13 | Most things that you can do manually in the browser can be done using Astral! 14 | Here are a few examples to get you started: 15 | 16 | - Generate screenshots and PDFs of pages. 17 | - Crawl a SPA (Single-Page Application) and generate pre-rendered content (i.e. 18 | "SSR" (Server-Side Rendering)). 19 | - Automate form submission, UI testing, keyboard input, etc. 20 | - Create a reproducible, automated testing environment using the latest 21 | JavaScript and browser features. 22 | - Stealthy by default. No need to install or setup extra libraries to get 23 | features that should come by default. 24 | 25 | ## Usage 26 | 27 | Before we go into the depths of the API, let's see a quick demo first: 28 | 29 | ```ts 30 | // Import Astral 31 | import { launch } from "jsr:@astral/astral"; 32 | 33 | // Launch the browser 34 | const browser = await launch(); 35 | 36 | // Open a new page 37 | const page = await browser.newPage("https://deno.land"); 38 | 39 | // Take a screenshot of the page and save that to disk 40 | const screenshot = await page.screenshot(); 41 | Deno.writeFileSync("screenshot.png", screenshot); 42 | 43 | // Close the browser 44 | await browser.close(); 45 | ``` 46 | 47 | You can run this from the command line using: 48 | 49 | ```bash 50 | deno run -A https://deno.land/x/astral/examples/screenshot.ts 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/pages/showcase.tsx: -------------------------------------------------------------------------------- 1 | import { launch } from "../../mod.ts"; 2 | import type { PageProps } from "https://deno.land/x/pyro@0.6.1/page.ts"; 3 | import { ensureFileSync } from "https://deno.land/std@0.215.0/fs/ensure_file.ts"; 4 | 5 | export const config = { 6 | title: "Showcase", 7 | description: "A small showcase for projects that use Astral!", 8 | }; 9 | 10 | interface Project { 11 | title: string; 12 | description: string; 13 | source: string; 14 | } 15 | 16 | const projects: Project[] = [ 17 | { 18 | title: "Manuscript Marauder", 19 | description: "Download manuscripts using a proxy", 20 | source: "https://github.com/rnbguy/manuscript-marauder", 21 | }, 22 | { 23 | title: "Personal-scale Web scraping for fun and profit", 24 | description: "A great blog post which details web scraping techniques.", 25 | source: "https://bhmt.dev/blog/scraping", 26 | }, 27 | ]; 28 | 29 | export default function Page(props: PageProps) { 30 | return ( 31 |
32 | {props.header} 33 |
34 |
35 |

Astral Project Showcase

36 |

37 | List of projects people are building with Astral 38 |

39 | 43 | 🙏 Please add your project 44 | 45 |
46 |
47 | {projects.map((project) => ( 48 |
49 | 50 | 54 | 55 |
56 | 60 | {project.title} 61 | 62 |

63 | {project.description} 64 |

65 |
66 |
67 | ))} 68 |
69 |
70 | {props.footer} 71 |
72 | ); 73 | } 74 | 75 | // let's boot astral and download some stuff :) 76 | if (import.meta.main) { 77 | const browser = await launch(); 78 | const page = await browser.newPage(); 79 | 80 | for (const project of projects) { 81 | await page.goto(project.source); 82 | const screenshot = await page.screenshot(); 83 | const path = `./docs/static/showcase${ 84 | new URL(project.source).pathname 85 | }.png`; 86 | ensureFileSync(path); 87 | 88 | Deno.writeFileSync(path, screenshot); 89 | } 90 | 91 | await browser.close(); 92 | } 93 | -------------------------------------------------------------------------------- /docs/pyro.yml: -------------------------------------------------------------------------------- 1 | title: Astral 2 | github: https://github.com/lino-levan/astral 3 | copyright: |- 4 | Copyright © 2023 Lino Le Van 5 | MIT Licensed 6 | header: 7 | left: 8 | - Docs / 9 | - Showcase /showcase 10 | footer: 11 | Learn: 12 | - Introduction / 13 | Community: 14 | - Discord https://discord.gg/XJMMSSC4Fj 15 | - Support https://github.com/lino-levan/astral/issues/new 16 | -------------------------------------------------------------------------------- /docs/static/astral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lino-levan/astral/79e45704a67e14ccf3fe64404e772bb65fcdf6b7/docs/static/astral.png -------------------------------------------------------------------------------- /docs/static/examples/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lino-levan/astral/79e45704a67e14ccf3fe64404e772bb65fcdf6b7/docs/static/examples/screenshot.png -------------------------------------------------------------------------------- /docs/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lino-levan/astral/79e45704a67e14ccf3fe64404e772bb65fcdf6b7/docs/static/icon.png -------------------------------------------------------------------------------- /docs/static/icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lino-levan/astral/79e45704a67e14ccf3fe64404e772bb65fcdf6b7/docs/static/icon.psd -------------------------------------------------------------------------------- /docs/static/showcase/blog/scraping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lino-levan/astral/79e45704a67e14ccf3fe64404e772bb65fcdf6b7/docs/static/showcase/blog/scraping.png -------------------------------------------------------------------------------- /docs/static/showcase/rnbguy/manuscript-marauder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lino-levan/astral/79e45704a67e14ccf3fe64404e772bb65fcdf6b7/docs/static/showcase/rnbguy/manuscript-marauder.png -------------------------------------------------------------------------------- /examples/authenticate.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Import Astral 4 | import { launch } from "../mod.ts"; 5 | 6 | // Launch the browser 7 | const browser = await launch(); 8 | 9 | // Open a new page 10 | const page = await browser.newPage(); 11 | 12 | // Provide credentials for HTTP authentication. 13 | await page.authenticate({ username: "postman", password: "password" }); 14 | const url = "https://postman-echo.com/basic-auth"; 15 | await page.goto(url, { waitUntil: "networkidle2" }); 16 | 17 | // Get response 18 | const content = await page.evaluate(() => { 19 | return document.body.innerText; 20 | }); 21 | console.log(content); 22 | 23 | // Close the browser 24 | await browser.close(); 25 | -------------------------------------------------------------------------------- /examples/element_evaluate.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Import Astral 4 | import { launch } from "../mod.ts"; 5 | 6 | // Launch the browser 7 | const browser = await launch(); 8 | 9 | // Open the webpage 10 | const page = await browser.newPage("https://deno.land"); 11 | 12 | // Click the search button 13 | const button = await page.$("button"); 14 | await button!.click(); 15 | 16 | // Type in the search input 17 | const input = await page.$("#search-input"); 18 | await input!.type("astral", { delay: 50 }); 19 | 20 | // Get the entered value 21 | const inputValue: string = await input!.evaluate((el: HTMLInputElement) => { 22 | return el.value; 23 | }); 24 | 25 | console.log(inputValue); 26 | 27 | // Close the browser 28 | await browser.close(); 29 | -------------------------------------------------------------------------------- /examples/evaluate.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Import Astral 4 | import { launch } from "../mod.ts"; 5 | 6 | // Launch the browser 7 | const browser = await launch(); 8 | 9 | // Open a new page 10 | const page = await browser.newPage("https://deno.land"); 11 | 12 | // Run code in the context of the browser 13 | const value = await page.evaluate(() => { 14 | return document.body.innerHTML; 15 | }); 16 | console.log(value); 17 | 18 | // Run code with args 19 | const result = await page.evaluate((x, y) => { 20 | return `The result of adding ${x}+${y} = ${x + y}`; 21 | }, { 22 | args: [10, 15], 23 | }); 24 | console.log(result); 25 | 26 | // Close the browser 27 | await browser.close(); 28 | -------------------------------------------------------------------------------- /examples/navigation.ts: -------------------------------------------------------------------------------- 1 | import { launch } from "../mod.ts"; 2 | 3 | // Launch browser 4 | const browser = await launch({ headless: false }); 5 | 6 | // Open the webpage 7 | const page = await browser.newPage("https://deno.land"); 8 | 9 | // Click the search button 10 | const button = await page.$("button"); 11 | await button!.click(); 12 | 13 | // Type in the search input 14 | const input = await page.$("#search-input"); 15 | await input!.type("pyro", { delay: 1000 }); 16 | 17 | // Wait for the search results to come back 18 | await page.waitForNetworkIdle({ idleConnections: 0, idleTime: 1000 }); 19 | 20 | // Click the 'pyro' link 21 | const xLink = await page.$("a.justify-between:nth-child(1)"); 22 | await Promise.all([ 23 | page.waitForNavigation(), 24 | xLink!.click(), 25 | ]); 26 | 27 | // Click the link to 'pyro.deno.dev' 28 | const dLink = await page.$( 29 | ".markdown-body > p:nth-child(8) > a:nth-child(1)", 30 | ); 31 | await Promise.all([ 32 | page.waitForNavigation(), 33 | dLink!.click(), 34 | ]); 35 | 36 | // Close browser 37 | await browser.close(); 38 | -------------------------------------------------------------------------------- /examples/screenshot.ts: -------------------------------------------------------------------------------- 1 | // Import Astral 2 | import { launch } from "../mod.ts"; 3 | 4 | // Launch the browser 5 | const browser = await launch(); 6 | 7 | // Open a new page 8 | const page = await browser.newPage("https://deno.land"); 9 | 10 | // Take a screenshot of the page and save that to disk 11 | const screenshot = await page.screenshot(); 12 | Deno.writeFileSync("screenshot.png", screenshot); 13 | 14 | // Close the browser 15 | await browser.close(); 16 | -------------------------------------------------------------------------------- /examples/user_agent.ts: -------------------------------------------------------------------------------- 1 | import { launch } from "../mod.ts"; 2 | 3 | const browser = await launch(); 4 | 5 | const page = await browser.newPage("https://whatsmybrowser.org"); 6 | 7 | const getUserAgent = async () => { 8 | const uaElement = await page.$(".user-agent"); 9 | 10 | const ua = await uaElement?.innerText() || ""; 11 | 12 | console.log(ua); 13 | 14 | return ua; 15 | }; 16 | 17 | const userAgent = await getUserAgent(); 18 | 19 | // get current version of Chrome 20 | const version = userAgent?.split("Chrome/")?.at(1)?.split(".").at(0) || ""; 21 | 22 | // NOTE: this is *just* the major version, and may produce version 23 | // numbers that don't actually exist... but this is just for 24 | // demonstration, so that's fine 25 | const nextVersion = String(Number(version) - 1); 26 | 27 | await page.setUserAgent(userAgent?.replace(version, nextVersion)); 28 | 29 | await page.reload(); 30 | 31 | await getUserAgent(); 32 | 33 | await browser.close(); 34 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/browser.ts"; 2 | export * from "./src/cache.ts"; 3 | export * from "./src/dialog.ts"; 4 | export * from "./src/element_handle.ts"; 5 | export * from "./src/file_chooser.ts"; 6 | export * from "./src/locator.ts"; 7 | export * from "./src/keyboard/mod.ts"; 8 | export * from "./src/mouse.ts"; 9 | export * from "./src/page.ts"; 10 | export * from "./src/touchscreen.ts"; 11 | export * from "./src/util.ts"; 12 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "@std/async/retry"; 2 | import { deadline } from "@std/async/deadline"; 3 | 4 | import { Celestial, PROTOCOL_VERSION } from "../bindings/celestial.ts"; 5 | import { getBinary } from "./cache.ts"; 6 | import { 7 | Page, 8 | type SandboxOptions, 9 | type UserAgentOptions, 10 | type WaitForOptions, 11 | } from "./page.ts"; 12 | import { WEBSOCKET_ENDPOINT_REGEX, websocketReady } from "./util.ts"; 13 | import { DEBUG } from "./debug.ts"; 14 | 15 | async function runCommand( 16 | command: Deno.Command, 17 | { retries = 60 } = {}, 18 | ): Promise<{ process: Deno.ChildProcess; endpoint: string }> { 19 | const process = command.spawn(); 20 | let endpoint = null; 21 | 22 | // Wait until write to stdout containing the localhost address 23 | // This probably means that the process is read to accept communication 24 | const textDecoder = new TextDecoder(); 25 | const stack: string[] = []; 26 | let error = true; 27 | for await (const chunk of process.stderr) { 28 | const message = textDecoder.decode(chunk); 29 | stack.push(message); 30 | 31 | endpoint = message.trim().match(WEBSOCKET_ENDPOINT_REGEX)?.[1]; 32 | if (endpoint) { 33 | error = false; 34 | break; 35 | } 36 | 37 | // Recover from garbage "SingletonLock" nonsense 38 | if (message.includes("SingletonLock")) { 39 | const path = message.split("Failed to create ")[1].split(":")[0]; 40 | 41 | process.kill(); 42 | await process.status; 43 | 44 | try { 45 | Deno.removeSync(path); 46 | } catch (error) { 47 | if (!(error instanceof Deno.errors.NotFound)) { 48 | throw error; 49 | } 50 | } 51 | return runCommand(command); 52 | } 53 | } 54 | 55 | if (error) { 56 | const { code } = await process.status; 57 | stack.push(`Process exited with code ${code}`); 58 | // Handle recoverable error code 21 on Windows 59 | // https://source.chromium.org/chromium/chromium/src/+/main:net/base/net_error_list.h;l=90-91 60 | if (Deno.build.os === "windows" && code === 21 && retries > 0) { 61 | return runCommand(command, { retries: retries - 1 }); 62 | } 63 | console.error(stack.join("\n")); 64 | // https://github.com/lino-levan/astral/issues/82 65 | if (stack.join("").includes("error while loading shared libraries")) { 66 | throw new Error( 67 | "Your binary refused to boot due to missing system dependencies. This can happen if you are using a minimal Docker image. If you're running in a Debian-based container, the following code could work:\n\nRUN apt-get update && apt-get install -y wget gnupg && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list' && apt-get update && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends && rm -rf /var/lib/apt/lists/*\n\nLook at puppeteer docs for more information: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-puppeteer-in-docker", 68 | ); 69 | } 70 | throw new Error("Your binary refused to boot"); 71 | } 72 | 73 | if (!endpoint) throw new Error("Somehow did not get a websocket endpoint"); 74 | 75 | return { process, endpoint }; 76 | } 77 | 78 | /** Options for launching a browser */ 79 | export interface BrowserOptions { 80 | headless?: boolean; 81 | product?: "chrome" | "firefox"; 82 | userAgent?: string; 83 | } 84 | 85 | /** 86 | * The browser class is instantiated when you run the `launch` method. 87 | * 88 | * @example 89 | * ```ts 90 | * const browser = await launch(); 91 | * ``` 92 | */ 93 | export class Browser implements AsyncDisposable { 94 | #options: BrowserOptions; 95 | #celestial: Celestial; 96 | #process: Deno.ChildProcess | null; 97 | readonly pages: Page[] = []; 98 | 99 | constructor( 100 | ws: WebSocket, 101 | process: Deno.ChildProcess | null, 102 | opts: BrowserOptions, 103 | ) { 104 | this.#celestial = new Celestial(ws); 105 | this.#process = process; 106 | this.#options = opts; 107 | } 108 | 109 | [Symbol.asyncDispose](): Promise { 110 | if (this.isRemoteConnection) return this.disconnect(); 111 | 112 | return this.close(); 113 | } 114 | 115 | /** Returns true if browser is connected remotely instead of using a subprocess */ 116 | get isRemoteConnection(): boolean { 117 | return !this.#process; 118 | } 119 | 120 | /** 121 | * Returns raw celestial bindings for the browser. Super unsafe unless you know what you're doing. 122 | */ 123 | unsafelyGetCelestialBindings(): Celestial { 124 | return this.#celestial; 125 | } 126 | 127 | /** 128 | * Disconnects the browser from the websocket connection. This is useful if you want to keep the browser running but don't want to use it anymore. 129 | */ 130 | async disconnect() { 131 | await this.#celestial.close(); 132 | } 133 | 134 | /** 135 | * Closes the browser and all of its pages (if any were opened). The Browser object itself is considered to be disposed and cannot be used anymore. 136 | */ 137 | async close() { 138 | await this.#celestial.Browser.close(); 139 | await this.#celestial.close(); 140 | 141 | // First we get the process, if this is null then this is a remote connection 142 | const process = this.#process; 143 | 144 | // If we use a remote connection, then close all pages websockets 145 | if (!process) { 146 | await Promise.allSettled(this.pages.map((page) => page.close())); 147 | } else { 148 | try { 149 | // ask nicely first 150 | process.kill(); 151 | await deadline(process.status, 10 * 1000); 152 | } catch { 153 | // then force 154 | process.kill("SIGKILL"); 155 | await process.status; 156 | } 157 | } 158 | } 159 | 160 | /** 161 | * Promise which resolves to a new `Page` object. 162 | */ 163 | async newPage( 164 | url?: string, 165 | options?: WaitForOptions & SandboxOptions & UserAgentOptions, 166 | ): Promise { 167 | const { targetId } = await this.#celestial.Target.createTarget({ 168 | url: "", 169 | }); 170 | const browserWsUrl = new URL(this.#celestial.ws.url); 171 | const wsUrl = 172 | `${browserWsUrl.origin}/devtools/page/${targetId}${browserWsUrl.search}`; 173 | const websocket = new WebSocket(wsUrl); 174 | await websocketReady(websocket); 175 | 176 | const { waitUntil, sandbox } = options ?? {}; 177 | const page = new Page(targetId, url, websocket, this, { sandbox }); 178 | this.pages.push(page); 179 | 180 | const celestial = page.unsafelyGetCelestialBindings(); 181 | const { userAgent: defaultUserAgent } = await celestial.Browser 182 | .getVersion(); 183 | 184 | const userAgent = options?.userAgent || 185 | this.#options.userAgent || 186 | defaultUserAgent.replaceAll("Headless", ""); 187 | 188 | await Promise.all([ 189 | celestial.Emulation.setUserAgentOverride({ userAgent }), 190 | celestial.Page.enable(), 191 | celestial.Runtime.enable(), 192 | celestial.Network.enable({}), 193 | celestial.Page.setInterceptFileChooserDialog({ enabled: true }), 194 | sandbox ? celestial.Fetch.enable({}) : null, 195 | ]); 196 | 197 | if (url) { 198 | await page.goto(url, { waitUntil }); 199 | } 200 | 201 | return page; 202 | } 203 | 204 | /** 205 | * The browser's original user agent. 206 | */ 207 | async userAgent(): Promise { 208 | const { userAgent } = await this.#celestial.Browser.getVersion(); 209 | return userAgent; 210 | } 211 | 212 | /** 213 | * A string representing the browser name and version. 214 | */ 215 | async version(): Promise { 216 | const { product, revision } = await this.#celestial.Browser.getVersion(); 217 | return `${product}/${revision}`; 218 | } 219 | 220 | /** 221 | * The browser's websocket endpoint 222 | */ 223 | wsEndpoint(): string { 224 | return this.#celestial.ws.url; 225 | } 226 | 227 | /** 228 | * Returns true if the browser and its websocket have been closed 229 | */ 230 | get closed(): boolean { 231 | return this.#celestial.ws.readyState === WebSocket.CLOSED; 232 | } 233 | } 234 | 235 | /** 236 | * Get the websocket endpoint for the browser. 237 | */ 238 | async function getWebSocketEndpoint(endpoint: string): Promise { 239 | if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) { 240 | return endpoint; 241 | } 242 | 243 | const httpEndpoint = endpoint.startsWith("http") 244 | ? endpoint 245 | : `http://${endpoint}`; 246 | 247 | const browserRes = await retry(async () => { 248 | const browserReq = await fetch(`${httpEndpoint}/json/version`); 249 | return await browserReq.json(); 250 | }); 251 | 252 | if (browserRes["Protocol-Version"] !== PROTOCOL_VERSION) { 253 | throw new Error("Differing protocol versions between binary and bindings."); 254 | } 255 | 256 | return browserRes.webSocketDebuggerUrl; 257 | } 258 | 259 | export type LaunchOptions = BrowserOptions & { 260 | path?: string; 261 | args?: string[]; 262 | cache?: string; 263 | }; 264 | 265 | export type ConnectOptions = BrowserOptions & { 266 | endpoint: string; 267 | }; 268 | 269 | /** 270 | * Connects to a given browser over an HTTP/WebSocket endpoint. 271 | */ 272 | export async function connect(opts: ConnectOptions): Promise { 273 | const { endpoint, product = "chrome" } = opts; 274 | 275 | const options: BrowserOptions = { 276 | product, 277 | }; 278 | 279 | const wsEndpoint = await getWebSocketEndpoint(endpoint); 280 | const ws = new WebSocket(wsEndpoint); 281 | await websocketReady(ws); 282 | return new Browser(ws, null, options); 283 | } 284 | 285 | /** 286 | * Launches a browser instance with given arguments and options when specified. 287 | */ 288 | export async function launch(opts?: LaunchOptions): Promise { 289 | const headless = opts?.headless ?? true; 290 | const product = opts?.product ?? "chrome"; 291 | const args = opts?.args ?? []; 292 | const cache = opts?.cache; 293 | let path = opts?.path; 294 | 295 | const options: BrowserOptions = { 296 | headless, 297 | product, 298 | }; 299 | 300 | if (!path) { 301 | path = await getBinary(product, { cache }); 302 | } 303 | 304 | if (!args.find((arg) => arg.startsWith("--user-data-dir="))) { 305 | const tempDir = Deno.makeTempDirSync(); 306 | args.push(`--user-data-dir=${tempDir}`); 307 | } 308 | 309 | // Launch child process 310 | const binArgs = [ 311 | "--remote-debugging-port=0", 312 | "--no-first-run", 313 | "--password-store=basic", 314 | "--use-mock-keychain", 315 | ...(product === "chrome" 316 | ? ["--disable-blink-features=AutomationControlled"] 317 | : []), 318 | // "--no-startup-window", 319 | ...(headless 320 | ? [ 321 | product === "chrome" ? "--headless=new" : "--headless", 322 | "--hide-scrollbars", 323 | ] 324 | : []), 325 | ...args, 326 | ]; 327 | 328 | if (DEBUG) { 329 | console.log(`Launching: ${path} ${binArgs.join(" ")}`); 330 | } 331 | 332 | const launch = new Deno.Command(path, { 333 | args: binArgs, 334 | stderr: "piped", 335 | }); 336 | const { process, endpoint } = await runCommand(launch); 337 | 338 | // Fetch browser websocket 339 | const browserRes = await retry(async () => { 340 | const browserReq = await fetch(`http://${endpoint}/json/version`); 341 | return await browserReq.json(); 342 | }); 343 | 344 | if (browserRes["Protocol-Version"] !== PROTOCOL_VERSION) { 345 | throw new Error("Differing protocol versions between binary and bindings."); 346 | } 347 | 348 | // Set up browser websocket 349 | const ws = new WebSocket(browserRes.webSocketDebuggerUrl); 350 | 351 | // Make sure that websocket is open before continuing 352 | await websocketReady(ws); 353 | 354 | // Construct browser and return 355 | return new Browser(ws, process, options); 356 | } 357 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { ensureDir, ensureDirSync } from "@std/fs/ensure-dir"; 2 | import { exists, existsSync } from "@std/fs/exists"; 3 | import { resolve } from "@std/path/resolve"; 4 | import { dirname } from "@std/path/dirname"; 5 | import { retry } from "@std/async/retry"; 6 | import { join } from "@std/path/join"; 7 | import { ZipReader } from "@zip-js/zip-js"; 8 | import ProgressBar from "@deno-library/progress"; 9 | 10 | /** The automatically downloaded browser versions that are known to work. */ 11 | export const SUPPORTED_VERSIONS = { 12 | chrome: "125.0.6400.0", 13 | firefox: "116.0", 14 | } as const; 15 | 16 | const CONFIG_FILE = "cache.json"; 17 | 18 | const LOCK_FILES = {} as { [cache: string]: { [product: string]: Lock } }; 19 | 20 | interface KnownGoodVersions { 21 | timestamps: string; 22 | versions: { 23 | version: string; 24 | revision: string; 25 | downloads: { 26 | chrome: { 27 | platform: "linux64" | "mac-arm64" | "mac-x64" | "win32" | "win64"; 28 | url: string; 29 | }[]; 30 | }; 31 | }[]; 32 | } 33 | 34 | async function knownGoodVersions(): Promise { 35 | const req = await fetch( 36 | "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json", 37 | ); 38 | return await req.json(); 39 | } 40 | 41 | /** Stolen from https://github.com/justjavac/deno_dirs/blob/main/cache_dir/mod.ts 42 | * 43 | * Returns the path to the user's cache directory. 44 | * 45 | * The returned value depends on the operating system and is either a string, 46 | * containing a value from the following table, or `null`. 47 | * 48 | * |Platform | Value | Example | 49 | * | ------- | ----------------------------------- | -------------------------------- | 50 | * | Linux | `$XDG_CACHE_HOME` or `$HOME`/.cache | /home/justjavac/.cache | 51 | * | macOS | `$HOME`/Library/Caches | /Users/justjavac/Library/Caches | 52 | * | Windows | `$LOCALAPPDATA` | C:\Users\justjavac\AppData\Local | 53 | */ 54 | function cacheDir(): string | null { 55 | switch (Deno.build.os) { 56 | case "linux": { 57 | const xdg = Deno.env.get("XDG_CACHE_HOME"); 58 | if (xdg) return xdg; 59 | 60 | const home = Deno.env.get("HOME"); 61 | if (home) return `${home}/.cache`; 62 | break; 63 | } 64 | 65 | case "darwin": { 66 | const home = Deno.env.get("HOME"); 67 | if (home) return `${home}/Library/Caches`; 68 | break; 69 | } 70 | 71 | case "windows": 72 | return Deno.env.get("LOCALAPPDATA") ?? null; 73 | } 74 | 75 | return null; 76 | } 77 | export function getDefaultCachePath(): string { 78 | const path = cacheDir(); 79 | if (!path) throw new Error("couldn't determine default cache directory"); 80 | return join(path, "astral"); 81 | } 82 | 83 | function getCachedConfig( 84 | { cache = getDefaultCachePath() } = {}, 85 | ): Record { 86 | try { 87 | return JSON.parse(Deno.readTextFileSync(resolve(cache, CONFIG_FILE))); 88 | } catch { 89 | return {}; 90 | } 91 | } 92 | 93 | /** 94 | * Clean cache 95 | */ 96 | export async function cleanCache({ cache = getDefaultCachePath() } = {}) { 97 | try { 98 | if (await exists(cache)) { 99 | delete LOCK_FILES[cache]; 100 | await Deno.remove(cache, { recursive: true }); 101 | } 102 | } catch (error) { 103 | console.warn(`Failed to clean cache: ${error}`); 104 | } 105 | } 106 | 107 | async function isQuietInstall() { 108 | // Hide-progress in CI environment 109 | const ci = await Deno.permissions.query({ 110 | name: "env", 111 | variable: "CI", 112 | }); 113 | if ((ci.state === "granted") && (`${Deno.env.get("CI") ?? ""}`.length)) { 114 | return true; 115 | } 116 | // Hide-progress if asked by user 117 | const quiet = await Deno.permissions.query({ 118 | name: "env", 119 | variable: "ASTRAL_QUIET_INSTALL", 120 | }); 121 | if (quiet.state === "granted") { 122 | const value = `${Deno.env.get("ASTRAL_QUIET_INSTALL") ?? ""}`; 123 | return value.length && 124 | !/^(0|[Nn]o?|NO|[Oo]ff|OFF|[Ff]alse|FALSE)$/.test(value); 125 | } 126 | return false; 127 | } 128 | 129 | async function decompressArchive(source: string, destination: string) { 130 | const quiet = await isQuietInstall(); 131 | const archive = await Deno.open(source); 132 | const zip = new ZipReader(archive); 133 | const entries = await zip.getEntries(); 134 | const bar = !quiet 135 | ? new ProgressBar({ 136 | title: `Inflating ${destination}`, 137 | total: entries.length, 138 | clear: true, 139 | display: ":title :bar :percent", 140 | output: Deno.stderr, 141 | }) 142 | : null; 143 | let progress = 0; 144 | for (const entry of entries) { 145 | if ((!entry.directory) && (entry.getData)) { 146 | const path = join(destination, entry.filename); 147 | await ensureDir(dirname(path)); 148 | const file = await Deno.open(path, { 149 | create: true, 150 | truncate: true, 151 | write: true, 152 | mode: 0o755, 153 | }); 154 | await entry.getData(file, { checkSignature: true, useWebWorkers: false }); 155 | } 156 | progress++; 157 | bar?.render(progress); 158 | } 159 | await zip.close(); 160 | if (!quiet) { 161 | console.error(`\nBrowser saved to ${destination}`); 162 | } 163 | } 164 | 165 | /** 166 | * Get path for the binary for this OS. Downloads a browser if none is cached. 167 | */ 168 | export async function getBinary( 169 | browser: "chrome" | "firefox", 170 | { cache = getDefaultCachePath(), timeout = 60000 } = {}, 171 | ): Promise { 172 | // TODO(lino-levan): fix firefox downloading 173 | const VERSION = SUPPORTED_VERSIONS[browser]; 174 | const product = `${browser}-${SUPPORTED_VERSIONS[browser]}`; 175 | const config = getCachedConfig({ cache }); 176 | 177 | // If the config doesn't have the revision and there is a lock file, reload config after release 178 | if (!config[VERSION] && LOCK_FILES[cache]?.[product]?.exists()) { 179 | await LOCK_FILES[cache]?.[product]?.waitRelease({ timeout }); 180 | Object.assign(config, getCachedConfig({ cache })); 181 | } 182 | 183 | // If the config doesn't have the revision, download it and return that 184 | if (!config[VERSION]) { 185 | const quiet = await isQuietInstall(); 186 | const versions = await knownGoodVersions(); 187 | const version = versions.versions.filter((val) => 188 | val.version === VERSION 189 | )[0]; 190 | const download = version.downloads.chrome.filter((val) => { 191 | if (Deno.build.os === "darwin" && Deno.build.arch === "aarch64") { 192 | return val.platform === "mac-arm64"; 193 | } else if (Deno.build.os === "darwin" && Deno.build.arch === "x86_64") { 194 | return val.platform === "mac-x64"; 195 | } else if (Deno.build.os === "windows") { 196 | return val.platform === "win64"; 197 | } else if (Deno.build.os === "linux") { 198 | return val.platform === "linux64"; 199 | } 200 | throw new Error( 201 | "Unsupported platform, provide a path to a chromium or firefox binary instead", 202 | ); 203 | })[0]; 204 | 205 | ensureDirSync(cache); 206 | const lock = new Lock({ cache }); 207 | LOCK_FILES[cache] ??= {}; 208 | LOCK_FILES[cache][product] = lock; 209 | if (!lock.create()) { 210 | return getBinary(browser, { cache, timeout }); 211 | } 212 | try { 213 | const req = await fetch(download.url); 214 | if (!req.body) { 215 | throw new Error( 216 | "Download failed, please check your internet connection and try again", 217 | ); 218 | } 219 | if (quiet) { 220 | await Deno.writeFile(resolve(cache, `raw_${VERSION}.zip`), req.body); 221 | } else { 222 | const reader = req.body.getReader(); 223 | const archive = await Deno.open(resolve(cache, `raw_${VERSION}.zip`), { 224 | write: true, 225 | truncate: true, 226 | create: true, 227 | }); 228 | const bar = new ProgressBar({ 229 | title: `Downloading ${browser} ${VERSION}`, 230 | total: Number(req.headers.get("Content-Length") ?? 0), 231 | clear: true, 232 | display: ":title :bar :percent", 233 | output: Deno.stderr, 234 | }); 235 | let downloaded = 0; 236 | while (true) { 237 | const { done, value } = await reader.read(); 238 | if (done) { 239 | break; 240 | } 241 | await archive.write(value); 242 | downloaded += value.length; 243 | bar.render(downloaded); 244 | } 245 | archive.close(); 246 | console.error(`\nDownload complete (${browser} version ${VERSION})`); 247 | } 248 | await decompressArchive( 249 | resolve(cache, `raw_${VERSION}.zip`), 250 | resolve(cache, VERSION), 251 | ); 252 | 253 | config[VERSION] = resolve(cache, VERSION); 254 | Deno.writeTextFileSync( 255 | resolve(cache, CONFIG_FILE), 256 | JSON.stringify(config), 257 | ); 258 | } finally { 259 | LOCK_FILES[cache]?.[product]?.release(); 260 | } 261 | } 262 | 263 | // It now exists, return the path to the known good binary 264 | const folder = config[VERSION]; 265 | 266 | if (Deno.build.os === "darwin" && Deno.build.arch === "aarch64") { 267 | return resolve( 268 | folder, 269 | "chrome-mac-arm64", 270 | "Google Chrome for Testing.app", 271 | "Contents", 272 | "MacOS", 273 | "Google Chrome for Testing", 274 | ); 275 | } else if (Deno.build.os === "darwin" && Deno.build.arch === "x86_64") { 276 | return resolve( 277 | folder, 278 | "chrome-mac-x64", 279 | "Google Chrome for Testing.app", 280 | "Contents", 281 | "MacOS", 282 | "Google Chrome for Testing", 283 | ); 284 | } else if (Deno.build.os === "windows") { 285 | return resolve(folder, "chrome-win64", "chrome.exe"); 286 | } else if (Deno.build.os === "linux") { 287 | return resolve(folder, "chrome-linux64", "chrome"); 288 | } 289 | throw new Error( 290 | "Unsupported platform, provide a path to a chromium or firefox binary instead", 291 | ); 292 | } 293 | 294 | /** 295 | * Create a lock file in cache 296 | * Only the process with the same PID can release created lock file through this API 297 | * TODO: Use Deno.flock/Deno.funlock when stabilized (https://deno.land/api@v1.37.1?s=Deno.flock&unstable) 298 | */ 299 | class Lock { 300 | readonly path; 301 | 302 | constructor({ cache = getDefaultCachePath() } = {}) { 303 | this.path = resolve(cache, ".lock"); 304 | this.removeExpiredLockPath(); 305 | } 306 | 307 | /** Clean expired lock path */ 308 | removeExpiredLockPath() { 309 | // if this.path's create time is older than cacheTTL, remove it 310 | try { 311 | const fileInfo = Deno.statSync(this.path); 312 | const lockTTL = 1800 * 1000; 313 | if ( 314 | fileInfo.birthtime && 315 | Date.now() - fileInfo.birthtime.getTime() > lockTTL 316 | ) { 317 | Deno.removeSync(this.path); 318 | console.log( 319 | `%c There is an old lock file (${this.path}), this is probably due to a failed download. It has been removed automatically.`, 320 | "color: #ff0000", 321 | ); 322 | } 323 | } catch (error) { 324 | if (!(error instanceof Deno.errors.NotFound)) { 325 | throw error; 326 | } 327 | } 328 | } 329 | 330 | /** Returns true if lock file exists */ 331 | exists() { 332 | return existsSync(this.path); 333 | } 334 | 335 | /** Create a lock file and returns true if it succeeds, false if it was already existing */ 336 | create() { 337 | try { 338 | Deno.writeTextFileSync(this.path, `${Deno.pid}`, { createNew: true }); 339 | return true; 340 | } catch (error) { 341 | if (!(error instanceof Deno.errors.AlreadyExists)) { 342 | throw error; 343 | } 344 | return false; 345 | } 346 | } 347 | 348 | /** Release lock file */ 349 | release() { 350 | try { 351 | if (Deno.readTextFileSync(this.path) === `${Deno.pid}`) { 352 | Deno.removeSync(this.path); 353 | } 354 | } catch (error) { 355 | if (!(error instanceof Deno.errors.NotFound)) { 356 | throw error; 357 | } 358 | } 359 | } 360 | 361 | /** Wait for lock release */ 362 | async waitRelease({ timeout = 60000 } = {}) { 363 | await retry(() => { 364 | if (this.exists()) { 365 | throw new Error( 366 | `Timeout while waiting for lockfile release at: ${this.path}`, 367 | ); 368 | } 369 | }, { 370 | maxTimeout: timeout, 371 | maxAttempts: Infinity, 372 | multiplier: 1, 373 | minTimeout: 100, 374 | }); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | // Needed to fix `Deno.permissions.querySync` not being defined in Deno Deploy 2 | // See: https://github.com/denoland/deploy_feedback/issues/527 3 | let querySync = Deno.permissions.querySync; 4 | if (!querySync) { 5 | const permissions = { 6 | run: "denied", 7 | read: "granted", 8 | write: "denied", 9 | net: "granted", 10 | env: "granted", 11 | sys: "denied", 12 | ffi: "denied", 13 | } as const; 14 | 15 | querySync = ({ name }) => { 16 | return { 17 | state: permissions[name], 18 | onchange: null, 19 | partial: false, 20 | addEventListener() {}, 21 | removeEventListener() {}, 22 | dispatchEvent() { 23 | return false; 24 | }, 25 | }; 26 | }; 27 | } 28 | 29 | /** Whether to enable debug logging. */ 30 | export const DEBUG = 31 | (querySync({ name: "env", variable: "DEBUG" }).state === "granted") && 32 | (!!Deno.env.get("DEBUG")); 33 | 34 | /** Attach a websocket to the console for debugging. */ 35 | export function attachWs(ws: WebSocket) { 36 | ws.addEventListener("message", (ev) => { 37 | console.log(`<--`, ev.data); 38 | }); 39 | 40 | const send = ws.send.bind(ws); 41 | ws.send = (data) => { 42 | console.log(`-->`, data); 43 | return send(data); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/dialog.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Celestial, 3 | Page_DialogType, 4 | Page_javascriptDialogOpening, 5 | } from "../bindings/celestial.ts"; 6 | 7 | /** The type of the dialog. */ 8 | export type DialogType = Page_DialogType; 9 | 10 | /** 11 | * Dialog provides an api for managing a page's dialog events. 12 | */ 13 | export class Dialog { 14 | #celestial: Celestial; 15 | 16 | /** 17 | * The default value of the prompt, or an empty string if the dialog is not a prompt. 18 | */ 19 | readonly defaultValue: string; 20 | 21 | /** 22 | * The message displayed in the dialog. 23 | */ 24 | readonly message: string; 25 | 26 | /** 27 | * The type of the dialog. 28 | */ 29 | readonly type: DialogType; 30 | 31 | constructor(celestial: Celestial, config: Page_javascriptDialogOpening) { 32 | this.#celestial = celestial; 33 | this.defaultValue = config.defaultPrompt ?? ""; 34 | this.message = config.message; 35 | this.type = config.type; 36 | } 37 | 38 | /** 39 | * Returns when the dialog has been accepted. 40 | * 41 | * @param promptText A text to enter in prompt. Does not cause any effects if the dialog's type is not prompt. Optional. 42 | */ 43 | async accept(promptText?: string) { 44 | await this.#celestial.Page.handleJavaScriptDialog({ 45 | accept: true, 46 | promptText, 47 | }); 48 | } 49 | 50 | /** 51 | * Returns when the dialog has been dismissed. 52 | */ 53 | async dismiss() { 54 | await this.#celestial.Page.handleJavaScriptDialog({ 55 | accept: false, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/element_handle.ts: -------------------------------------------------------------------------------- 1 | import { deadline } from "@std/async/deadline"; 2 | 3 | import type { Celestial, Runtime_CallArgument } from "../bindings/celestial.ts"; 4 | import type { KeyboardTypeOptions } from "./keyboard/mod.ts"; 5 | import type { MouseClickOptions } from "./mouse.ts"; 6 | import type { 7 | Page, 8 | ScreenshotOptions, 9 | WaitForSelectorOptions, 10 | } from "./page.ts"; 11 | import { retryDeadline } from "./util.ts"; 12 | 13 | /** The x and y coordinates of a point. */ 14 | export interface Offset { 15 | x: number; 16 | y: number; 17 | } 18 | 19 | /** The x and y coordinates of a point. */ 20 | export type Point = Offset; 21 | 22 | /** The xywh model of an element. */ 23 | export interface BoundingBox extends Point { 24 | height: number; 25 | width: number; 26 | } 27 | 28 | /** The box model of an element. */ 29 | export interface BoxModel { 30 | border: Point[]; 31 | content: Point[]; 32 | height: number; 33 | margin: Point[]; 34 | padding: Point[]; 35 | width: number; 36 | } 37 | 38 | /** Click options on an element */ 39 | export type ElementClickOptions = { offset?: Offset } & MouseClickOptions; 40 | 41 | function intoPoints(pointsRaw: number[]) { 42 | const points: Point[] = []; 43 | 44 | for (let pair = 0; pair < pointsRaw.length; pair += 2) { 45 | points.push({ 46 | x: pointsRaw[pair], 47 | y: pointsRaw[pair + 1], 48 | }); 49 | } 50 | 51 | return points; 52 | } 53 | 54 | function getTopLeft(points: Point[]) { 55 | let result = points[0]; 56 | 57 | for (const point of points) { 58 | if (point.x < result.x && point.y < result.y) { 59 | result = point; 60 | } 61 | } 62 | 63 | return result; 64 | } 65 | 66 | type AnyArray = readonly unknown[]; 67 | 68 | /** The evaluate function for `ElementHandle.evaluate` method. */ 69 | export type ElementEvaluateFunction< 70 | E extends unknown, 71 | R extends AnyArray, 72 | T, 73 | > = (element: E, ...args: R) => T; 74 | 75 | /** The options for `ElementHandle.evaluate` method. */ 76 | export interface ElementEvaluateOptions { 77 | args: Readonly; 78 | } 79 | 80 | /** 81 | * ElementHandle represents an in-page DOM element. 82 | */ 83 | export class ElementHandle { 84 | #id: number; 85 | #celestial: Celestial; 86 | #page: Page; 87 | 88 | constructor(id: number, celestial: Celestial, page: Page) { 89 | this.#id = id; 90 | this.#celestial = celestial; 91 | this.#page = page; 92 | } 93 | 94 | /** 95 | * Queries the current element for an element matching the given selector. 96 | * 97 | * @example 98 | * ```ts 99 | * const elementWithClass = await element.$(".class"); 100 | * ``` 101 | */ 102 | async $(selector: string): Promise { 103 | const result = await retryDeadline( 104 | this.#celestial.DOM.querySelector({ 105 | nodeId: this.#id, 106 | selector, 107 | }), 108 | this.#page.timeout, 109 | ); 110 | 111 | if (!result?.nodeId) { 112 | return null; 113 | } 114 | 115 | return new ElementHandle(result.nodeId, this.#celestial, this.#page); 116 | } 117 | 118 | /** 119 | * Queries the current element for all elements matching the given selector. 120 | * 121 | * @example 122 | * ```ts 123 | * const elementsWithClass = await element.$$(".class"); 124 | * ``` 125 | */ 126 | async $$(selector: string): Promise { 127 | const result = await retryDeadline( 128 | this.#celestial.DOM.querySelectorAll({ 129 | nodeId: this.#id, 130 | selector, 131 | }), 132 | this.#page.timeout, 133 | ); 134 | 135 | if (!result) { 136 | return []; 137 | } 138 | 139 | return result.nodeIds.map((nodeId) => 140 | new ElementHandle(nodeId, this.#celestial, this.#page) 141 | ); 142 | } 143 | 144 | /** 145 | * This method returns boxes of the element, or `null` if the element is not visible. 146 | */ 147 | async boundingBox(): Promise { 148 | const result = await this.boxModel(); 149 | 150 | if (!result) { 151 | return null; 152 | } 153 | 154 | const { x, y } = getTopLeft(result.content); 155 | 156 | return { 157 | x, 158 | y, 159 | width: result.width, 160 | height: result.height, 161 | }; 162 | } 163 | 164 | /** 165 | * This method returns boxes of the element, or `null` if the element is not visible. 166 | */ 167 | async boxModel(): Promise { 168 | const result = await retryDeadline( 169 | this.#celestial.DOM.getBoxModel({ nodeId: this.#id }), 170 | this.#page.timeout, 171 | ); 172 | 173 | if (!result) { 174 | return null; 175 | } 176 | 177 | const { model } = result; 178 | 179 | return { 180 | border: intoPoints(model.border), 181 | content: intoPoints(model.content), 182 | height: model.height, 183 | margin: intoPoints(model.margin), 184 | padding: intoPoints(model.padding), 185 | width: model.width, 186 | }; 187 | } 188 | 189 | /** 190 | * This method scrolls element into view if needed, and then uses `Page.mouse` to click in the center of the element. 191 | */ 192 | async click(opts?: ElementClickOptions) { 193 | await this.scrollIntoView(); 194 | 195 | const model: BoxModel | null = await this.boxModel(); 196 | if (!model) throw new Error("Unable to get stable box model to click on"); 197 | 198 | const { x, y } = getTopLeft(model.content); 199 | 200 | if (opts?.offset) { 201 | await this.#page.mouse.click( 202 | x + opts.offset.x, 203 | y + opts.offset.y, 204 | opts, 205 | ); 206 | } else { 207 | await this.#page.mouse.click( 208 | x + (model.width / 2), 209 | y + (model.height / 2), 210 | opts, 211 | ); 212 | } 213 | } 214 | 215 | /** 216 | * Calls `focus` on the element. 217 | */ 218 | async focus() { 219 | await retryDeadline( 220 | this.#celestial.DOM.focus({ nodeId: this.#id }), 221 | this.#page.timeout, 222 | ); 223 | } 224 | 225 | /** 226 | * Returns the `element.innerHTML` 227 | */ 228 | async innerHTML(): Promise { 229 | return await retryDeadline( 230 | (async () => { 231 | const { object } = await this.#celestial.DOM.resolveNode({ 232 | nodeId: this.#id, 233 | }); 234 | 235 | const result = await this.#celestial.Runtime.callFunctionOn({ 236 | functionDeclaration: "(element)=>element.innerHTML", 237 | objectId: object.objectId, 238 | arguments: [ 239 | { 240 | objectId: object.objectId, 241 | }, 242 | ], 243 | awaitPromise: true, 244 | returnByValue: true, 245 | }); 246 | 247 | return result.result.value; 248 | })(), 249 | this.#page.timeout, 250 | ); 251 | } 252 | 253 | /** 254 | * Returns the `element.innerText` 255 | */ 256 | async innerText(): Promise { 257 | return await retryDeadline( 258 | (async () => { 259 | const { object } = await this.#celestial.DOM.resolveNode({ 260 | nodeId: this.#id, 261 | }); 262 | 263 | const result = await this.#celestial.Runtime.callFunctionOn({ 264 | functionDeclaration: "(element)=>element.innerText", 265 | objectId: object.objectId, 266 | arguments: [ 267 | { 268 | objectId: object.objectId, 269 | }, 270 | ], 271 | awaitPromise: true, 272 | returnByValue: true, 273 | }); 274 | 275 | return result.result.value; 276 | })(), 277 | this.#page.timeout, 278 | ); 279 | } 280 | 281 | /** 282 | * This method scrolls element into view if needed, and then uses `Page.screenshot()` to take a screenshot of the element. 283 | */ 284 | async screenshot( 285 | opts?: Omit & { scale?: number }, 286 | ): Promise { 287 | await this.scrollIntoView(); 288 | 289 | const boxModel = await this.boxModel(); 290 | if (!boxModel) { 291 | throw new Error( 292 | "No bounding box found when trying to screenshot element", 293 | ); 294 | } 295 | 296 | return await this.#page.screenshot({ 297 | ...opts, 298 | clip: { 299 | x: boxModel.border[0].x, 300 | y: boxModel.border[0].y, 301 | width: boxModel.border[2].x - boxModel.border[0].x, 302 | height: boxModel.border[2].y - boxModel.border[0].y, 303 | scale: opts?.scale ?? 1, 304 | }, 305 | }); 306 | } 307 | 308 | /** 309 | * Scrolls the element into view using the automation protocol client. 310 | */ 311 | async scrollIntoView() { 312 | await retryDeadline( 313 | this.#celestial.DOM.scrollIntoViewIfNeeded({ nodeId: this.#id }), 314 | this.#page.timeout, 315 | ); 316 | } 317 | 318 | /** 319 | * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and `keyup` event for each character in the text. 320 | */ 321 | async type(text: string, opts?: KeyboardTypeOptions) { 322 | await this.focus(); 323 | await this.#page.keyboard.type(text, opts); 324 | } 325 | 326 | /** 327 | * Wait for an element matching the given selector to appear in the current element. 328 | */ 329 | async waitForSelector( 330 | selector: string, 331 | options?: WaitForSelectorOptions, 332 | ): Promise { 333 | // TODO(lino-levan): Make this easier to read, it's a little scuffed 334 | try { 335 | return await deadline( 336 | (async () => { 337 | while (true) { 338 | const result = await this.#celestial.DOM.querySelector({ 339 | nodeId: this.#id, 340 | selector, 341 | }); 342 | 343 | if (!result?.nodeId) { 344 | continue; 345 | } 346 | 347 | return new ElementHandle( 348 | result.nodeId, 349 | this.#celestial, 350 | this.#page, 351 | ); 352 | } 353 | })(), 354 | options?.timeout || this.#page.timeout, 355 | ); 356 | } catch { 357 | throw new Error(`Unable to get element from selector: ${selector}`); 358 | } 359 | } 360 | 361 | /** 362 | * Retrieve the attributes of an element. 363 | * Returns the key-value object 364 | */ 365 | async getAttributes(): Promise> { 366 | return await retryDeadline( 367 | (async () => { 368 | const { attributes } = await this.#celestial.DOM.getAttributes({ 369 | nodeId: this.#id, 370 | }); 371 | 372 | const map: Record = {}; 373 | 374 | for (let i = 0; i < attributes.length; i += 2) { 375 | const key = attributes[i]; 376 | const value = attributes[i + 1]; 377 | map[key] = value; 378 | } 379 | 380 | return map; 381 | })(), 382 | this.#page.timeout, 383 | ); 384 | } 385 | 386 | /** 387 | * Returns the `element.getAttribute` 388 | */ 389 | async getAttribute(name: string): Promise { 390 | return await retryDeadline( 391 | (async () => { 392 | const { object } = await this.#celestial.DOM.resolveNode({ 393 | nodeId: this.#id, 394 | }); 395 | 396 | const result = await this.#celestial.Runtime.callFunctionOn({ 397 | functionDeclaration: "(element,name)=>element.getAttribute(name)", 398 | objectId: object.objectId, 399 | arguments: [ 400 | { 401 | objectId: object.objectId, 402 | }, 403 | { 404 | value: name, 405 | }, 406 | ], 407 | awaitPromise: true, 408 | returnByValue: true, 409 | }); 410 | 411 | return result.result.value; 412 | })(), 413 | this.#page.timeout, 414 | ); 415 | } 416 | 417 | /** 418 | * Executes the given function or string whose first argument is a DOM element and returns the result of the execution. 419 | * 420 | * @example 421 | * ```ts 422 | * /// 423 | * const value: string = await element.evaluate((element: HTMLInputElement) => element.value) 424 | * ``` 425 | * 426 | * @example 427 | * ``` 428 | * /// 429 | * await element.evaluate( 430 | * (el: HTMLInputElement, key: string, value: string) => el.setAttribute(key, value), 431 | * { args: ["href", "astral"] } 432 | * ) 433 | * ``` 434 | */ 435 | async evaluate( 436 | func: ElementEvaluateFunction | string, 437 | evaluateOptions?: ElementEvaluateOptions, 438 | ): Promise { 439 | const { object } = await retryDeadline( 440 | this.#celestial.DOM.resolveNode({ 441 | nodeId: this.#id, 442 | }), 443 | this.#page.timeout, 444 | ); 445 | 446 | const args: Runtime_CallArgument[] = [{ 447 | objectId: object.objectId, 448 | }]; 449 | 450 | if (evaluateOptions?.args) { 451 | for (const argument of evaluateOptions.args) { 452 | if (Number.isNaN(argument)) { 453 | args.push({ 454 | unserializableValue: "NaN", 455 | }); 456 | } else { 457 | args.push({ 458 | value: argument, 459 | }); 460 | } 461 | } 462 | } 463 | 464 | const { result, exceptionDetails } = await retryDeadline( 465 | this.#celestial.Runtime 466 | .callFunctionOn({ 467 | functionDeclaration: func.toString(), 468 | objectId: object.objectId, 469 | arguments: args, 470 | awaitPromise: true, 471 | returnByValue: true, 472 | }), 473 | this.#page.timeout, 474 | ); 475 | 476 | if (exceptionDetails) { 477 | throw exceptionDetails; 478 | } 479 | 480 | if (result.type === "bigint") { 481 | return BigInt(result.unserializableValue!.slice(0, -1)) as T; 482 | } else if (result.type === "undefined") { 483 | return undefined as T; 484 | } else if (result.type === "object") { 485 | if (result.subtype === "null") { 486 | return null as T; 487 | } 488 | } else if (result.type === "number") { 489 | if (result.unserializableValue === "NaN") { 490 | return NaN as T; 491 | } 492 | } 493 | 494 | return result.value; 495 | } 496 | } 497 | -------------------------------------------------------------------------------- /src/file_chooser.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "@std/path/resolve"; 2 | 3 | import type { 4 | Celestial, 5 | Page_fileChooserOpened, 6 | } from "../bindings/celestial.ts"; 7 | 8 | /** 9 | * Dialog provides an api for managing a page's dialog events. 10 | */ 11 | export class FileChooser { 12 | #celestial: Celestial; 13 | #backendNodeId: number; 14 | 15 | /** 16 | * Whether this file chooser accepts multiple files. 17 | */ 18 | readonly multiple: boolean; 19 | 20 | constructor(celestial: Celestial, config: Required) { 21 | this.multiple = config.mode === "selectMultiple"; 22 | this.#celestial = celestial; 23 | this.#backendNodeId = config.backendNodeId; 24 | } 25 | 26 | /** 27 | * Sets the value of the file input this chooser is associated with. If some of the filePaths are relative paths, then they are resolved relative to the current working directory. For empty array, clears the selected files. 28 | */ 29 | async setFiles(files: string[]) { 30 | await this.#celestial.DOM.setFileInputFiles({ 31 | files: files.map((file) => resolve(file)), 32 | backendNodeId: this.#backendNodeId, 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/keyboard/layout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2017 Google Inc. 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * @internal 9 | */ 10 | export interface KeyDefinition { 11 | keyCode?: number; 12 | shiftKeyCode?: number; 13 | key?: string; 14 | shiftKey?: string; 15 | code?: string; 16 | text?: string; 17 | shiftText?: string; 18 | location?: number; 19 | } 20 | 21 | /** 22 | * All the valid keys that can be passed to functions that take user input, such 23 | * as {@link Keyboard.press | keyboard.press } 24 | * 25 | * @public 26 | */ 27 | export type KeyInput = 28 | | "0" 29 | | "1" 30 | | "2" 31 | | "3" 32 | | "4" 33 | | "5" 34 | | "6" 35 | | "7" 36 | | "8" 37 | | "9" 38 | | "Power" 39 | | "Eject" 40 | | "Abort" 41 | | "Help" 42 | | "Backspace" 43 | | "Tab" 44 | | "Numpad5" 45 | | "NumpadEnter" 46 | | "Enter" 47 | | "\r" 48 | | "\n" 49 | | "ShiftLeft" 50 | | "ShiftRight" 51 | | "ControlLeft" 52 | | "ControlRight" 53 | | "AltLeft" 54 | | "AltRight" 55 | | "Pause" 56 | | "CapsLock" 57 | | "Escape" 58 | | "Convert" 59 | | "NonConvert" 60 | | "Space" 61 | | "Numpad9" 62 | | "PageUp" 63 | | "Numpad3" 64 | | "PageDown" 65 | | "End" 66 | | "Numpad1" 67 | | "Home" 68 | | "Numpad7" 69 | | "ArrowLeft" 70 | | "Numpad4" 71 | | "Numpad8" 72 | | "ArrowUp" 73 | | "ArrowRight" 74 | | "Numpad6" 75 | | "Numpad2" 76 | | "ArrowDown" 77 | | "Select" 78 | | "Open" 79 | | "PrintScreen" 80 | | "Insert" 81 | | "Numpad0" 82 | | "Delete" 83 | | "NumpadDecimal" 84 | | "Digit0" 85 | | "Digit1" 86 | | "Digit2" 87 | | "Digit3" 88 | | "Digit4" 89 | | "Digit5" 90 | | "Digit6" 91 | | "Digit7" 92 | | "Digit8" 93 | | "Digit9" 94 | | "KeyA" 95 | | "KeyB" 96 | | "KeyC" 97 | | "KeyD" 98 | | "KeyE" 99 | | "KeyF" 100 | | "KeyG" 101 | | "KeyH" 102 | | "KeyI" 103 | | "KeyJ" 104 | | "KeyK" 105 | | "KeyL" 106 | | "KeyM" 107 | | "KeyN" 108 | | "KeyO" 109 | | "KeyP" 110 | | "KeyQ" 111 | | "KeyR" 112 | | "KeyS" 113 | | "KeyT" 114 | | "KeyU" 115 | | "KeyV" 116 | | "KeyW" 117 | | "KeyX" 118 | | "KeyY" 119 | | "KeyZ" 120 | | "MetaLeft" 121 | | "MetaRight" 122 | | "ContextMenu" 123 | | "NumpadMultiply" 124 | | "NumpadAdd" 125 | | "NumpadSubtract" 126 | | "NumpadDivide" 127 | | "F1" 128 | | "F2" 129 | | "F3" 130 | | "F4" 131 | | "F5" 132 | | "F6" 133 | | "F7" 134 | | "F8" 135 | | "F9" 136 | | "F10" 137 | | "F11" 138 | | "F12" 139 | | "F13" 140 | | "F14" 141 | | "F15" 142 | | "F16" 143 | | "F17" 144 | | "F18" 145 | | "F19" 146 | | "F20" 147 | | "F21" 148 | | "F22" 149 | | "F23" 150 | | "F24" 151 | | "NumLock" 152 | | "ScrollLock" 153 | | "AudioVolumeMute" 154 | | "AudioVolumeDown" 155 | | "AudioVolumeUp" 156 | | "MediaTrackNext" 157 | | "MediaTrackPrevious" 158 | | "MediaStop" 159 | | "MediaPlayPause" 160 | | "Semicolon" 161 | | "Equal" 162 | | "NumpadEqual" 163 | | "Comma" 164 | | "Minus" 165 | | "Period" 166 | | "Slash" 167 | | "Backquote" 168 | | "BracketLeft" 169 | | "Backslash" 170 | | "BracketRight" 171 | | "Quote" 172 | | "AltGraph" 173 | | "Props" 174 | | "Cancel" 175 | | "Clear" 176 | | "Shift" 177 | | "Control" 178 | | "Alt" 179 | | "Accept" 180 | | "ModeChange" 181 | | " " 182 | | "Print" 183 | | "Execute" 184 | | "\u0000" 185 | | "a" 186 | | "b" 187 | | "c" 188 | | "d" 189 | | "e" 190 | | "f" 191 | | "g" 192 | | "h" 193 | | "i" 194 | | "j" 195 | | "k" 196 | | "l" 197 | | "m" 198 | | "n" 199 | | "o" 200 | | "p" 201 | | "q" 202 | | "r" 203 | | "s" 204 | | "t" 205 | | "u" 206 | | "v" 207 | | "w" 208 | | "x" 209 | | "y" 210 | | "z" 211 | | "Meta" 212 | | "*" 213 | | "+" 214 | | "-" 215 | | "/" 216 | | ";" 217 | | "=" 218 | | "," 219 | | "." 220 | | "`" 221 | | "[" 222 | | "\\" 223 | | "]" 224 | | "'" 225 | | "Attn" 226 | | "CrSel" 227 | | "ExSel" 228 | | "EraseEof" 229 | | "Play" 230 | | "ZoomOut" 231 | | ")" 232 | | "!" 233 | | "@" 234 | | "#" 235 | | "$" 236 | | "%" 237 | | "^" 238 | | "&" 239 | | "(" 240 | | "A" 241 | | "B" 242 | | "C" 243 | | "D" 244 | | "E" 245 | | "F" 246 | | "G" 247 | | "H" 248 | | "I" 249 | | "J" 250 | | "K" 251 | | "L" 252 | | "M" 253 | | "N" 254 | | "O" 255 | | "P" 256 | | "Q" 257 | | "R" 258 | | "S" 259 | | "T" 260 | | "U" 261 | | "V" 262 | | "W" 263 | | "X" 264 | | "Y" 265 | | "Z" 266 | | ":" 267 | | "<" 268 | | "_" 269 | | ">" 270 | | "?" 271 | | "~" 272 | | "{" 273 | | "|" 274 | | "}" 275 | | '"' 276 | | "SoftLeft" 277 | | "SoftRight" 278 | | "Camera" 279 | | "Call" 280 | | "EndCall" 281 | | "VolumeDown" 282 | | "VolumeUp"; 283 | 284 | /** 285 | * @internal 286 | */ 287 | export const KEY_DEFINITIONS: Readonly> = { 288 | "0": { keyCode: 48, key: "0", code: "Digit0" }, 289 | "1": { keyCode: 49, key: "1", code: "Digit1" }, 290 | "2": { keyCode: 50, key: "2", code: "Digit2" }, 291 | "3": { keyCode: 51, key: "3", code: "Digit3" }, 292 | "4": { keyCode: 52, key: "4", code: "Digit4" }, 293 | "5": { keyCode: 53, key: "5", code: "Digit5" }, 294 | "6": { keyCode: 54, key: "6", code: "Digit6" }, 295 | "7": { keyCode: 55, key: "7", code: "Digit7" }, 296 | "8": { keyCode: 56, key: "8", code: "Digit8" }, 297 | "9": { keyCode: 57, key: "9", code: "Digit9" }, 298 | Power: { key: "Power", code: "Power" }, 299 | Eject: { key: "Eject", code: "Eject" }, 300 | Abort: { keyCode: 3, code: "Abort", key: "Cancel" }, 301 | Help: { keyCode: 6, code: "Help", key: "Help" }, 302 | Backspace: { keyCode: 8, code: "Backspace", key: "Backspace" }, 303 | Tab: { keyCode: 9, code: "Tab", key: "Tab" }, 304 | Numpad5: { 305 | keyCode: 12, 306 | shiftKeyCode: 101, 307 | key: "Clear", 308 | code: "Numpad5", 309 | shiftKey: "5", 310 | location: 3, 311 | }, 312 | NumpadEnter: { 313 | keyCode: 13, 314 | code: "NumpadEnter", 315 | key: "Enter", 316 | text: "\r", 317 | location: 3, 318 | }, 319 | Enter: { keyCode: 13, code: "Enter", key: "Enter", text: "\r" }, 320 | "\r": { keyCode: 13, code: "Enter", key: "Enter", text: "\r" }, 321 | "\n": { keyCode: 13, code: "Enter", key: "Enter", text: "\r" }, 322 | ShiftLeft: { keyCode: 16, code: "ShiftLeft", key: "Shift", location: 1 }, 323 | ShiftRight: { keyCode: 16, code: "ShiftRight", key: "Shift", location: 2 }, 324 | ControlLeft: { 325 | keyCode: 17, 326 | code: "ControlLeft", 327 | key: "Control", 328 | location: 1, 329 | }, 330 | ControlRight: { 331 | keyCode: 17, 332 | code: "ControlRight", 333 | key: "Control", 334 | location: 2, 335 | }, 336 | AltLeft: { keyCode: 18, code: "AltLeft", key: "Alt", location: 1 }, 337 | AltRight: { keyCode: 18, code: "AltRight", key: "Alt", location: 2 }, 338 | Pause: { keyCode: 19, code: "Pause", key: "Pause" }, 339 | CapsLock: { keyCode: 20, code: "CapsLock", key: "CapsLock" }, 340 | Escape: { keyCode: 27, code: "Escape", key: "Escape" }, 341 | Convert: { keyCode: 28, code: "Convert", key: "Convert" }, 342 | NonConvert: { keyCode: 29, code: "NonConvert", key: "NonConvert" }, 343 | Space: { keyCode: 32, code: "Space", key: " " }, 344 | Numpad9: { 345 | keyCode: 33, 346 | shiftKeyCode: 105, 347 | key: "PageUp", 348 | code: "Numpad9", 349 | shiftKey: "9", 350 | location: 3, 351 | }, 352 | PageUp: { keyCode: 33, code: "PageUp", key: "PageUp" }, 353 | Numpad3: { 354 | keyCode: 34, 355 | shiftKeyCode: 99, 356 | key: "PageDown", 357 | code: "Numpad3", 358 | shiftKey: "3", 359 | location: 3, 360 | }, 361 | PageDown: { keyCode: 34, code: "PageDown", key: "PageDown" }, 362 | End: { keyCode: 35, code: "End", key: "End" }, 363 | Numpad1: { 364 | keyCode: 35, 365 | shiftKeyCode: 97, 366 | key: "End", 367 | code: "Numpad1", 368 | shiftKey: "1", 369 | location: 3, 370 | }, 371 | Home: { keyCode: 36, code: "Home", key: "Home" }, 372 | Numpad7: { 373 | keyCode: 36, 374 | shiftKeyCode: 103, 375 | key: "Home", 376 | code: "Numpad7", 377 | shiftKey: "7", 378 | location: 3, 379 | }, 380 | ArrowLeft: { keyCode: 37, code: "ArrowLeft", key: "ArrowLeft" }, 381 | Numpad4: { 382 | keyCode: 37, 383 | shiftKeyCode: 100, 384 | key: "ArrowLeft", 385 | code: "Numpad4", 386 | shiftKey: "4", 387 | location: 3, 388 | }, 389 | Numpad8: { 390 | keyCode: 38, 391 | shiftKeyCode: 104, 392 | key: "ArrowUp", 393 | code: "Numpad8", 394 | shiftKey: "8", 395 | location: 3, 396 | }, 397 | ArrowUp: { keyCode: 38, code: "ArrowUp", key: "ArrowUp" }, 398 | ArrowRight: { keyCode: 39, code: "ArrowRight", key: "ArrowRight" }, 399 | Numpad6: { 400 | keyCode: 39, 401 | shiftKeyCode: 102, 402 | key: "ArrowRight", 403 | code: "Numpad6", 404 | shiftKey: "6", 405 | location: 3, 406 | }, 407 | Numpad2: { 408 | keyCode: 40, 409 | shiftKeyCode: 98, 410 | key: "ArrowDown", 411 | code: "Numpad2", 412 | shiftKey: "2", 413 | location: 3, 414 | }, 415 | ArrowDown: { keyCode: 40, code: "ArrowDown", key: "ArrowDown" }, 416 | Select: { keyCode: 41, code: "Select", key: "Select" }, 417 | Open: { keyCode: 43, code: "Open", key: "Execute" }, 418 | PrintScreen: { keyCode: 44, code: "PrintScreen", key: "PrintScreen" }, 419 | Insert: { keyCode: 45, code: "Insert", key: "Insert" }, 420 | Numpad0: { 421 | keyCode: 45, 422 | shiftKeyCode: 96, 423 | key: "Insert", 424 | code: "Numpad0", 425 | shiftKey: "0", 426 | location: 3, 427 | }, 428 | Delete: { keyCode: 46, code: "Delete", key: "Delete" }, 429 | NumpadDecimal: { 430 | keyCode: 46, 431 | shiftKeyCode: 110, 432 | code: "NumpadDecimal", 433 | key: "\u0000", 434 | shiftKey: ".", 435 | location: 3, 436 | }, 437 | Digit0: { keyCode: 48, code: "Digit0", shiftKey: ")", key: "0" }, 438 | Digit1: { keyCode: 49, code: "Digit1", shiftKey: "!", key: "1" }, 439 | Digit2: { keyCode: 50, code: "Digit2", shiftKey: "@", key: "2" }, 440 | Digit3: { keyCode: 51, code: "Digit3", shiftKey: "#", key: "3" }, 441 | Digit4: { keyCode: 52, code: "Digit4", shiftKey: "$", key: "4" }, 442 | Digit5: { keyCode: 53, code: "Digit5", shiftKey: "%", key: "5" }, 443 | Digit6: { keyCode: 54, code: "Digit6", shiftKey: "^", key: "6" }, 444 | Digit7: { keyCode: 55, code: "Digit7", shiftKey: "&", key: "7" }, 445 | Digit8: { keyCode: 56, code: "Digit8", shiftKey: "*", key: "8" }, 446 | Digit9: { keyCode: 57, code: "Digit9", shiftKey: "(", key: "9" }, 447 | KeyA: { keyCode: 65, code: "KeyA", shiftKey: "A", key: "a" }, 448 | KeyB: { keyCode: 66, code: "KeyB", shiftKey: "B", key: "b" }, 449 | KeyC: { keyCode: 67, code: "KeyC", shiftKey: "C", key: "c" }, 450 | KeyD: { keyCode: 68, code: "KeyD", shiftKey: "D", key: "d" }, 451 | KeyE: { keyCode: 69, code: "KeyE", shiftKey: "E", key: "e" }, 452 | KeyF: { keyCode: 70, code: "KeyF", shiftKey: "F", key: "f" }, 453 | KeyG: { keyCode: 71, code: "KeyG", shiftKey: "G", key: "g" }, 454 | KeyH: { keyCode: 72, code: "KeyH", shiftKey: "H", key: "h" }, 455 | KeyI: { keyCode: 73, code: "KeyI", shiftKey: "I", key: "i" }, 456 | KeyJ: { keyCode: 74, code: "KeyJ", shiftKey: "J", key: "j" }, 457 | KeyK: { keyCode: 75, code: "KeyK", shiftKey: "K", key: "k" }, 458 | KeyL: { keyCode: 76, code: "KeyL", shiftKey: "L", key: "l" }, 459 | KeyM: { keyCode: 77, code: "KeyM", shiftKey: "M", key: "m" }, 460 | KeyN: { keyCode: 78, code: "KeyN", shiftKey: "N", key: "n" }, 461 | KeyO: { keyCode: 79, code: "KeyO", shiftKey: "O", key: "o" }, 462 | KeyP: { keyCode: 80, code: "KeyP", shiftKey: "P", key: "p" }, 463 | KeyQ: { keyCode: 81, code: "KeyQ", shiftKey: "Q", key: "q" }, 464 | KeyR: { keyCode: 82, code: "KeyR", shiftKey: "R", key: "r" }, 465 | KeyS: { keyCode: 83, code: "KeyS", shiftKey: "S", key: "s" }, 466 | KeyT: { keyCode: 84, code: "KeyT", shiftKey: "T", key: "t" }, 467 | KeyU: { keyCode: 85, code: "KeyU", shiftKey: "U", key: "u" }, 468 | KeyV: { keyCode: 86, code: "KeyV", shiftKey: "V", key: "v" }, 469 | KeyW: { keyCode: 87, code: "KeyW", shiftKey: "W", key: "w" }, 470 | KeyX: { keyCode: 88, code: "KeyX", shiftKey: "X", key: "x" }, 471 | KeyY: { keyCode: 89, code: "KeyY", shiftKey: "Y", key: "y" }, 472 | KeyZ: { keyCode: 90, code: "KeyZ", shiftKey: "Z", key: "z" }, 473 | MetaLeft: { keyCode: 91, code: "MetaLeft", key: "Meta", location: 1 }, 474 | MetaRight: { keyCode: 92, code: "MetaRight", key: "Meta", location: 2 }, 475 | ContextMenu: { keyCode: 93, code: "ContextMenu", key: "ContextMenu" }, 476 | NumpadMultiply: { 477 | keyCode: 106, 478 | code: "NumpadMultiply", 479 | key: "*", 480 | location: 3, 481 | }, 482 | NumpadAdd: { keyCode: 107, code: "NumpadAdd", key: "+", location: 3 }, 483 | NumpadSubtract: { 484 | keyCode: 109, 485 | code: "NumpadSubtract", 486 | key: "-", 487 | location: 3, 488 | }, 489 | NumpadDivide: { keyCode: 111, code: "NumpadDivide", key: "/", location: 3 }, 490 | F1: { keyCode: 112, code: "F1", key: "F1" }, 491 | F2: { keyCode: 113, code: "F2", key: "F2" }, 492 | F3: { keyCode: 114, code: "F3", key: "F3" }, 493 | F4: { keyCode: 115, code: "F4", key: "F4" }, 494 | F5: { keyCode: 116, code: "F5", key: "F5" }, 495 | F6: { keyCode: 117, code: "F6", key: "F6" }, 496 | F7: { keyCode: 118, code: "F7", key: "F7" }, 497 | F8: { keyCode: 119, code: "F8", key: "F8" }, 498 | F9: { keyCode: 120, code: "F9", key: "F9" }, 499 | F10: { keyCode: 121, code: "F10", key: "F10" }, 500 | F11: { keyCode: 122, code: "F11", key: "F11" }, 501 | F12: { keyCode: 123, code: "F12", key: "F12" }, 502 | F13: { keyCode: 124, code: "F13", key: "F13" }, 503 | F14: { keyCode: 125, code: "F14", key: "F14" }, 504 | F15: { keyCode: 126, code: "F15", key: "F15" }, 505 | F16: { keyCode: 127, code: "F16", key: "F16" }, 506 | F17: { keyCode: 128, code: "F17", key: "F17" }, 507 | F18: { keyCode: 129, code: "F18", key: "F18" }, 508 | F19: { keyCode: 130, code: "F19", key: "F19" }, 509 | F20: { keyCode: 131, code: "F20", key: "F20" }, 510 | F21: { keyCode: 132, code: "F21", key: "F21" }, 511 | F22: { keyCode: 133, code: "F22", key: "F22" }, 512 | F23: { keyCode: 134, code: "F23", key: "F23" }, 513 | F24: { keyCode: 135, code: "F24", key: "F24" }, 514 | NumLock: { keyCode: 144, code: "NumLock", key: "NumLock" }, 515 | ScrollLock: { keyCode: 145, code: "ScrollLock", key: "ScrollLock" }, 516 | AudioVolumeMute: { 517 | keyCode: 173, 518 | code: "AudioVolumeMute", 519 | key: "AudioVolumeMute", 520 | }, 521 | AudioVolumeDown: { 522 | keyCode: 174, 523 | code: "AudioVolumeDown", 524 | key: "AudioVolumeDown", 525 | }, 526 | AudioVolumeUp: { keyCode: 175, code: "AudioVolumeUp", key: "AudioVolumeUp" }, 527 | MediaTrackNext: { 528 | keyCode: 176, 529 | code: "MediaTrackNext", 530 | key: "MediaTrackNext", 531 | }, 532 | MediaTrackPrevious: { 533 | keyCode: 177, 534 | code: "MediaTrackPrevious", 535 | key: "MediaTrackPrevious", 536 | }, 537 | MediaStop: { keyCode: 178, code: "MediaStop", key: "MediaStop" }, 538 | MediaPlayPause: { 539 | keyCode: 179, 540 | code: "MediaPlayPause", 541 | key: "MediaPlayPause", 542 | }, 543 | Semicolon: { keyCode: 186, code: "Semicolon", shiftKey: ":", key: ";" }, 544 | Equal: { keyCode: 187, code: "Equal", shiftKey: "+", key: "=" }, 545 | NumpadEqual: { keyCode: 187, code: "NumpadEqual", key: "=", location: 3 }, 546 | Comma: { keyCode: 188, code: "Comma", shiftKey: "<", key: "," }, 547 | Minus: { keyCode: 189, code: "Minus", shiftKey: "_", key: "-" }, 548 | Period: { keyCode: 190, code: "Period", shiftKey: ">", key: "." }, 549 | Slash: { keyCode: 191, code: "Slash", shiftKey: "?", key: "/" }, 550 | Backquote: { keyCode: 192, code: "Backquote", shiftKey: "~", key: "`" }, 551 | BracketLeft: { keyCode: 219, code: "BracketLeft", shiftKey: "{", key: "[" }, 552 | Backslash: { keyCode: 220, code: "Backslash", shiftKey: "|", key: "\\" }, 553 | BracketRight: { keyCode: 221, code: "BracketRight", shiftKey: "}", key: "]" }, 554 | Quote: { keyCode: 222, code: "Quote", shiftKey: '"', key: "'" }, 555 | AltGraph: { keyCode: 225, code: "AltGraph", key: "AltGraph" }, 556 | Props: { keyCode: 247, code: "Props", key: "CrSel" }, 557 | Cancel: { keyCode: 3, key: "Cancel", code: "Abort" }, 558 | Clear: { keyCode: 12, key: "Clear", code: "Numpad5", location: 3 }, 559 | Shift: { keyCode: 16, key: "Shift", code: "ShiftLeft", location: 1 }, 560 | Control: { keyCode: 17, key: "Control", code: "ControlLeft", location: 1 }, 561 | Alt: { keyCode: 18, key: "Alt", code: "AltLeft", location: 1 }, 562 | Accept: { keyCode: 30, key: "Accept" }, 563 | ModeChange: { keyCode: 31, key: "ModeChange" }, 564 | " ": { keyCode: 32, key: " ", code: "Space" }, 565 | Print: { keyCode: 42, key: "Print" }, 566 | Execute: { keyCode: 43, key: "Execute", code: "Open" }, 567 | "\u0000": { keyCode: 46, key: "\u0000", code: "NumpadDecimal", location: 3 }, 568 | a: { keyCode: 65, key: "a", code: "KeyA" }, 569 | b: { keyCode: 66, key: "b", code: "KeyB" }, 570 | c: { keyCode: 67, key: "c", code: "KeyC" }, 571 | d: { keyCode: 68, key: "d", code: "KeyD" }, 572 | e: { keyCode: 69, key: "e", code: "KeyE" }, 573 | f: { keyCode: 70, key: "f", code: "KeyF" }, 574 | g: { keyCode: 71, key: "g", code: "KeyG" }, 575 | h: { keyCode: 72, key: "h", code: "KeyH" }, 576 | i: { keyCode: 73, key: "i", code: "KeyI" }, 577 | j: { keyCode: 74, key: "j", code: "KeyJ" }, 578 | k: { keyCode: 75, key: "k", code: "KeyK" }, 579 | l: { keyCode: 76, key: "l", code: "KeyL" }, 580 | m: { keyCode: 77, key: "m", code: "KeyM" }, 581 | n: { keyCode: 78, key: "n", code: "KeyN" }, 582 | o: { keyCode: 79, key: "o", code: "KeyO" }, 583 | p: { keyCode: 80, key: "p", code: "KeyP" }, 584 | q: { keyCode: 81, key: "q", code: "KeyQ" }, 585 | r: { keyCode: 82, key: "r", code: "KeyR" }, 586 | s: { keyCode: 83, key: "s", code: "KeyS" }, 587 | t: { keyCode: 84, key: "t", code: "KeyT" }, 588 | u: { keyCode: 85, key: "u", code: "KeyU" }, 589 | v: { keyCode: 86, key: "v", code: "KeyV" }, 590 | w: { keyCode: 87, key: "w", code: "KeyW" }, 591 | x: { keyCode: 88, key: "x", code: "KeyX" }, 592 | y: { keyCode: 89, key: "y", code: "KeyY" }, 593 | z: { keyCode: 90, key: "z", code: "KeyZ" }, 594 | Meta: { keyCode: 91, key: "Meta", code: "MetaLeft", location: 1 }, 595 | "*": { keyCode: 106, key: "*", code: "NumpadMultiply", location: 3 }, 596 | "+": { keyCode: 107, key: "+", code: "NumpadAdd", location: 3 }, 597 | "-": { keyCode: 109, key: "-", code: "NumpadSubtract", location: 3 }, 598 | "/": { keyCode: 111, key: "/", code: "NumpadDivide", location: 3 }, 599 | ";": { keyCode: 186, key: ";", code: "Semicolon" }, 600 | "=": { keyCode: 187, key: "=", code: "Equal" }, 601 | ",": { keyCode: 188, key: ",", code: "Comma" }, 602 | ".": { keyCode: 190, key: ".", code: "Period" }, 603 | "`": { keyCode: 192, key: "`", code: "Backquote" }, 604 | "[": { keyCode: 219, key: "[", code: "BracketLeft" }, 605 | "\\": { keyCode: 220, key: "\\", code: "Backslash" }, 606 | "]": { keyCode: 221, key: "]", code: "BracketRight" }, 607 | "'": { keyCode: 222, key: "'", code: "Quote" }, 608 | Attn: { keyCode: 246, key: "Attn" }, 609 | CrSel: { keyCode: 247, key: "CrSel", code: "Props" }, 610 | ExSel: { keyCode: 248, key: "ExSel" }, 611 | EraseEof: { keyCode: 249, key: "EraseEof" }, 612 | Play: { keyCode: 250, key: "Play" }, 613 | ZoomOut: { keyCode: 251, key: "ZoomOut" }, 614 | ")": { keyCode: 48, key: ")", code: "Digit0" }, 615 | "!": { keyCode: 49, key: "!", code: "Digit1" }, 616 | "@": { keyCode: 50, key: "@", code: "Digit2" }, 617 | "#": { keyCode: 51, key: "#", code: "Digit3" }, 618 | $: { keyCode: 52, key: "$", code: "Digit4" }, 619 | "%": { keyCode: 53, key: "%", code: "Digit5" }, 620 | "^": { keyCode: 54, key: "^", code: "Digit6" }, 621 | "&": { keyCode: 55, key: "&", code: "Digit7" }, 622 | "(": { keyCode: 57, key: "(", code: "Digit9" }, 623 | A: { keyCode: 65, key: "A", code: "KeyA" }, 624 | B: { keyCode: 66, key: "B", code: "KeyB" }, 625 | C: { keyCode: 67, key: "C", code: "KeyC" }, 626 | D: { keyCode: 68, key: "D", code: "KeyD" }, 627 | E: { keyCode: 69, key: "E", code: "KeyE" }, 628 | F: { keyCode: 70, key: "F", code: "KeyF" }, 629 | G: { keyCode: 71, key: "G", code: "KeyG" }, 630 | H: { keyCode: 72, key: "H", code: "KeyH" }, 631 | I: { keyCode: 73, key: "I", code: "KeyI" }, 632 | J: { keyCode: 74, key: "J", code: "KeyJ" }, 633 | K: { keyCode: 75, key: "K", code: "KeyK" }, 634 | L: { keyCode: 76, key: "L", code: "KeyL" }, 635 | M: { keyCode: 77, key: "M", code: "KeyM" }, 636 | N: { keyCode: 78, key: "N", code: "KeyN" }, 637 | O: { keyCode: 79, key: "O", code: "KeyO" }, 638 | P: { keyCode: 80, key: "P", code: "KeyP" }, 639 | Q: { keyCode: 81, key: "Q", code: "KeyQ" }, 640 | R: { keyCode: 82, key: "R", code: "KeyR" }, 641 | S: { keyCode: 83, key: "S", code: "KeyS" }, 642 | T: { keyCode: 84, key: "T", code: "KeyT" }, 643 | U: { keyCode: 85, key: "U", code: "KeyU" }, 644 | V: { keyCode: 86, key: "V", code: "KeyV" }, 645 | W: { keyCode: 87, key: "W", code: "KeyW" }, 646 | X: { keyCode: 88, key: "X", code: "KeyX" }, 647 | Y: { keyCode: 89, key: "Y", code: "KeyY" }, 648 | Z: { keyCode: 90, key: "Z", code: "KeyZ" }, 649 | ":": { keyCode: 186, key: ":", code: "Semicolon" }, 650 | "<": { keyCode: 188, key: "<", code: "Comma" }, 651 | _: { keyCode: 189, key: "_", code: "Minus" }, 652 | ">": { keyCode: 190, key: ">", code: "Period" }, 653 | "?": { keyCode: 191, key: "?", code: "Slash" }, 654 | "~": { keyCode: 192, key: "~", code: "Backquote" }, 655 | "{": { keyCode: 219, key: "{", code: "BracketLeft" }, 656 | "|": { keyCode: 220, key: "|", code: "Backslash" }, 657 | "}": { keyCode: 221, key: "}", code: "BracketRight" }, 658 | '"': { keyCode: 222, key: '"', code: "Quote" }, 659 | SoftLeft: { key: "SoftLeft", code: "SoftLeft", location: 4 }, 660 | SoftRight: { key: "SoftRight", code: "SoftRight", location: 4 }, 661 | Camera: { keyCode: 44, key: "Camera", code: "Camera", location: 4 }, 662 | Call: { key: "Call", code: "Call", location: 4 }, 663 | EndCall: { keyCode: 95, key: "EndCall", code: "EndCall", location: 4 }, 664 | VolumeDown: { 665 | keyCode: 182, 666 | key: "VolumeDown", 667 | code: "VolumeDown", 668 | location: 4, 669 | }, 670 | VolumeUp: { keyCode: 183, key: "VolumeUp", code: "VolumeUp", location: 4 }, 671 | }; 672 | -------------------------------------------------------------------------------- /src/keyboard/mod.ts: -------------------------------------------------------------------------------- 1 | import type { Celestial } from "../../bindings/celestial.ts"; 2 | import { 3 | KEY_DEFINITIONS, 4 | type KeyDefinition, 5 | type KeyInput, 6 | } from "./layout.ts"; 7 | 8 | /** Options for typing on the keyboard */ 9 | export interface KeyboardTypeOptions { 10 | delay?: number; 11 | } 12 | 13 | /** Options for pressing a key down */ 14 | export interface KeyDownOptions { 15 | text?: string; 16 | } 17 | 18 | /** Options for pressing a key */ 19 | export interface KeyPressOptions extends KeyDownOptions { 20 | delay?: number; 21 | } 22 | 23 | export interface KeyboardPageData { 24 | modifiers: number; 25 | } 26 | 27 | /** 28 | * Keyboard provides an api for managing a virtual keyboard. The high level api is `Keyboard.type()`, which takes raw characters and generates proper `keydown`, `keypress`/`input`, and `keyup` events on your page. 29 | */ 30 | export class Keyboard { 31 | #celestial: Celestial; 32 | #pageData: KeyboardPageData; 33 | #pressedKeys = new Set(); 34 | 35 | constructor(celestial: Celestial, pageData?: KeyboardPageData) { 36 | this.#celestial = celestial; 37 | this.#pageData = pageData || { modifiers: 0 }; 38 | } 39 | 40 | /** 41 | * Returns the modifier bit for a given key 42 | */ 43 | #modifierBit(key: string): number { 44 | if (key === "Alt") return 1; 45 | if (key === "Control") return 2; 46 | if (key === "Meta") return 4; 47 | if (key === "Shift") return 8; 48 | return 0; 49 | } 50 | 51 | /** 52 | * Gets key description including code, key, text, and keyCode 53 | */ 54 | #getKeyDescription(key: KeyInput): KeyDefinition { 55 | const shift = this.#pageData.modifiers & 8; 56 | const description: KeyDefinition = { 57 | key: "", 58 | keyCode: 0, 59 | code: "", 60 | text: "", 61 | location: 0, 62 | }; 63 | 64 | // Get definition from your keyboard layout 65 | const definition = KEY_DEFINITIONS[key]; 66 | 67 | if (!definition) { 68 | throw new Error(`Unknown key: "${key}"`); 69 | } 70 | 71 | if (definition.key) { 72 | description.key = definition.key; 73 | } 74 | if (shift && definition.shiftKey) { 75 | description.key = definition.shiftKey; 76 | } 77 | 78 | if (definition.keyCode) { 79 | description.keyCode = definition.keyCode; 80 | } 81 | if (shift && definition.shiftKeyCode) { 82 | description.keyCode = definition.shiftKeyCode; 83 | } 84 | 85 | if (definition.code) { 86 | description.code = definition.code; 87 | } 88 | 89 | if (definition.location) { 90 | description.location = definition.location; 91 | } 92 | 93 | if (description.key && description.key.length === 1) { 94 | description.text = description.key; 95 | } 96 | 97 | if (definition.text) { 98 | description.text = definition.text; 99 | } 100 | if (shift && definition.shiftText) { 101 | description.text = definition.shiftText; 102 | } 103 | 104 | // If any modifiers besides shift are pressed, no text should be sent 105 | if (this.#pageData.modifiers & ~8) { 106 | description.text = ""; 107 | } 108 | 109 | return description; 110 | } 111 | 112 | /** 113 | * Dispatches a `keydown` event and updates modifier state. 114 | */ 115 | async down(key: KeyInput, options: KeyDownOptions = {}) { 116 | const description = this.#getKeyDescription(key); 117 | 118 | const autoRepeat = this.#pressedKeys.has(description.code || ""); 119 | if (description.code) this.#pressedKeys.add(description.code); 120 | if (description.key) { 121 | this.#pageData.modifiers |= this.#modifierBit(description.key); 122 | } 123 | 124 | const text = options.text === undefined ? description.text : options.text; 125 | 126 | await this.#celestial.Input.dispatchKeyEvent({ 127 | type: text ? "keyDown" : "rawKeyDown", 128 | modifiers: this.#pageData.modifiers, 129 | windowsVirtualKeyCode: description.keyCode, 130 | code: description.code, 131 | key: description.key, 132 | text: text, 133 | unmodifiedText: text, 134 | autoRepeat, 135 | location: description.location, 136 | isKeypad: description.location === 3, 137 | }); 138 | } 139 | 140 | /** 141 | * Dispatches a `keyup` event and updates modifier state. 142 | */ 143 | async up(key: KeyInput) { 144 | const description = this.#getKeyDescription(key); 145 | 146 | if (description.key) { 147 | this.#pageData.modifiers &= ~this.#modifierBit(description.key); 148 | } 149 | if (description.code) this.#pressedKeys.delete(description.code); 150 | 151 | await this.#celestial.Input.dispatchKeyEvent({ 152 | type: "keyUp", 153 | modifiers: this.#pageData.modifiers, 154 | windowsVirtualKeyCode: description.keyCode, 155 | key: description.key, 156 | code: description.code, 157 | location: description.location, 158 | }); 159 | } 160 | 161 | /** 162 | * Dispatches a `keypress` and `input` event. This does not send a `keydown` or `keyup` event. 163 | */ 164 | async sendCharacter(char: string) { 165 | await this.#celestial.Input.insertText({ text: char }); 166 | } 167 | 168 | /** 169 | * Shortcut for `Keyboard.down()` and `Keyboard.up()`. 170 | */ 171 | async press(key: KeyInput, options: KeyPressOptions = {}) { 172 | const delay = options.delay; 173 | await this.down(key, options); 174 | if (delay) { 175 | await new Promise((f) => setTimeout(f, delay)); 176 | } 177 | await this.up(key); 178 | } 179 | 180 | /** 181 | * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character in the text. 182 | */ 183 | async type(text: string | KeyInput[], options: KeyboardTypeOptions = {}) { 184 | const delay = options.delay; 185 | for (const char of text) { 186 | const key = char as KeyInput; 187 | if (key in KEY_DEFINITIONS) { 188 | await this.press(key, { delay }); 189 | } else { 190 | if (delay) { 191 | await new Promise((f) => setTimeout(f, delay)); 192 | } 193 | await this.sendCharacter(char as string); 194 | } 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/locator.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "@std/async/retry"; 2 | import { deadline } from "@std/async/deadline"; 3 | import type { Page } from "./page.ts"; 4 | import type { ElementHandle } from "./element_handle.ts"; 5 | import type { ElementClickOptions } from "./element_handle.ts"; 6 | 7 | /** Locator provides an api for interacting with elements on a page in a way that avoids race conditions. */ 8 | export class Locator { 9 | #page: Page; 10 | #selector: string; 11 | #timeout: number; 12 | 13 | constructor( 14 | page: Page, 15 | selector: string, 16 | timeout: number, 17 | ) { 18 | this.#page = page; 19 | this.#selector = selector; 20 | this.#timeout = timeout; 21 | } 22 | 23 | async #runLocator(func: (handle: ElementHandle) => Promise) { 24 | return await retry(async () => { 25 | const p = (async () => { 26 | await this.#page.waitForSelector(this.#selector); 27 | const handle = await this.#page.$(this.#selector); 28 | if (handle === null) { 29 | throw new Error(`Selector "${this.#selector}" not found.`); 30 | } 31 | 32 | return await func(handle); 33 | })(); 34 | await deadline(p, this.#timeout); 35 | return await p; 36 | }); 37 | } 38 | 39 | /** Clicks the element. */ 40 | async click(opts?: ElementClickOptions) { 41 | await this.#runLocator(async (handle) => { 42 | await handle.click(opts); 43 | }); 44 | } 45 | 46 | /** Evaluates the given function in the context of the element. */ 47 | async evaluate(fn: (el: T) => R): Promise { 48 | return await this.#runLocator(async (handle) => { 49 | // deno-lint-ignore no-explicit-any 50 | return await handle.evaluate(fn, { args: [handle] as any }) as R; 51 | }); 52 | } 53 | 54 | /** Fills the element with the given text. */ 55 | async fill(text: string) { 56 | await this.#runLocator(async (handle) => { 57 | await handle.type(text); 58 | }); 59 | } 60 | 61 | /** Focuses the element. */ 62 | async focus() { 63 | await this.#runLocator(async (handle) => { 64 | await handle.focus(); 65 | }); 66 | } 67 | 68 | /** Waits for the element to appear in the page. */ 69 | async wait(): Promise { 70 | return await this.#page.waitForSelector(this.#selector); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/mouse.ts: -------------------------------------------------------------------------------- 1 | import type { Celestial, Input_MouseButton } from "../bindings/celestial.ts"; 2 | import type { KeyboardPageData } from "./keyboard/mod.ts"; 3 | 4 | /** Options for mouse clicking. */ 5 | export interface MouseClickOptions { 6 | count?: number; 7 | delay?: number; 8 | } 9 | 10 | /** Options for mouse events. */ 11 | export interface MouseOptions { 12 | button?: Input_MouseButton; 13 | clickCount?: number; 14 | } 15 | 16 | /** 17 | * The Mouse class operates in main-frame CSS pixels relative to the top-left corner of the viewport. 18 | */ 19 | export class Mouse { 20 | #celestial: Celestial; 21 | #x = 0; 22 | #y = 0; 23 | #keyboardPageData: KeyboardPageData; 24 | 25 | constructor(celestial: Celestial, keyboardPageData?: KeyboardPageData) { 26 | this.#celestial = celestial; 27 | this.#keyboardPageData = keyboardPageData || { modifiers: 0 }; 28 | } 29 | 30 | /** 31 | * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`. 32 | */ 33 | async click(x: number, y: number, opts?: MouseClickOptions) { 34 | await this.move(x, y); 35 | 36 | const totalCount = opts?.count ?? 1; 37 | let clickCount = 0; 38 | 39 | while (clickCount < totalCount) { 40 | clickCount++; 41 | await this.down({ clickCount }); 42 | await new Promise((r) => setTimeout(r, opts?.delay ?? 0)); 43 | await this.up({ clickCount }); 44 | } 45 | } 46 | 47 | /** 48 | * Presses the mouse. 49 | */ 50 | async down(opts?: MouseOptions) { 51 | await this.#celestial.Input.dispatchMouseEvent({ 52 | type: "mousePressed", 53 | x: this.#x, 54 | y: this.#y, 55 | button: opts?.button ?? "left", 56 | clickCount: opts?.clickCount ?? 1, 57 | modifiers: this.#keyboardPageData.modifiers, 58 | }); 59 | } 60 | 61 | /** 62 | * Moves the mouse to the given coordinate. 63 | */ 64 | async move(x: number, y: number, options?: { steps?: number }) { 65 | const startX = this.#x; 66 | const startY = this.#y; 67 | const steps = options?.steps ?? 1; 68 | let stepsLeft = steps; 69 | 70 | while (stepsLeft > 0) { 71 | stepsLeft--; 72 | this.#x += (x - startX) / steps; 73 | this.#y += (y - startY) / steps; 74 | await this.#celestial.Input.dispatchMouseEvent({ 75 | type: "mouseMoved", 76 | x: this.#x, 77 | y: this.#y, 78 | modifiers: this.#keyboardPageData.modifiers, 79 | }); 80 | } 81 | } 82 | 83 | /** 84 | * Resets the mouse to the default state: No buttons pressed; position at (0,0). 85 | */ 86 | async reset() { 87 | this.#x = 0; 88 | this.#y = 0; 89 | 90 | await this.#celestial.Input.dispatchMouseEvent({ 91 | type: "mouseMoved", 92 | x: 0, 93 | y: 0, 94 | modifiers: this.#keyboardPageData.modifiers, 95 | }); 96 | } 97 | 98 | /** 99 | * Releases the mouse. 100 | */ 101 | async up(opts?: MouseOptions) { 102 | await this.#celestial.Input.dispatchMouseEvent({ 103 | type: "mouseReleased", 104 | x: this.#x, 105 | y: this.#y, 106 | button: opts?.button ?? "left", 107 | clickCount: opts?.clickCount ?? 1, 108 | modifiers: this.#keyboardPageData.modifiers, 109 | }); 110 | } 111 | 112 | /** 113 | * Dispatches a `mousewheel` event. 114 | */ 115 | async wheel(options?: { deltaX?: number; deltaY?: number }) { 116 | await this.#celestial.Input.dispatchMouseEvent({ 117 | type: "mouseWheel", 118 | x: this.#x, 119 | y: this.#y, 120 | deltaX: options?.deltaX ?? 0, 121 | deltaY: options?.deltaY ?? 0, 122 | modifiers: this.#keyboardPageData.modifiers, 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/touchscreen.ts: -------------------------------------------------------------------------------- 1 | import type { Celestial } from "../bindings/celestial.ts"; 2 | import type { KeyboardPageData } from "./keyboard/mod.ts"; 3 | 4 | /** 5 | * The Touchscreen class exposes touchscreen events. 6 | */ 7 | export class Touchscreen { 8 | #celestial: Celestial; 9 | #keyboardPageData: KeyboardPageData; 10 | 11 | constructor(celestial: Celestial, keyboardPageData?: KeyboardPageData) { 12 | this.#celestial = celestial; 13 | this.#keyboardPageData = keyboardPageData || { modifiers: 0 }; 14 | } 15 | 16 | /** 17 | * Dispatches a `touchstart` and `touchend` event. 18 | */ 19 | async tap(x: number, y: number) { 20 | await this.touchStart(x, y); 21 | await this.touchEnd(); 22 | } 23 | 24 | /** 25 | * Dispatches a `touchend` event. 26 | */ 27 | async touchEnd() { 28 | await this.#celestial.Input.dispatchTouchEvent({ 29 | type: "touchEnd", 30 | touchPoints: [], 31 | modifiers: this.#keyboardPageData.modifiers, 32 | }); 33 | } 34 | 35 | /** 36 | * Dispatches a `touchMove` event. 37 | */ 38 | async touchMove(x: number, y: number) { 39 | await this.#celestial.Input.dispatchTouchEvent({ 40 | type: "touchMove", 41 | touchPoints: [{ x, y }], 42 | modifiers: this.#keyboardPageData.modifiers, 43 | }); 44 | } 45 | 46 | /** 47 | * Dispatches a `touchstart` event. 48 | */ 49 | async touchStart(x: number, y: number) { 50 | await this.#celestial.Input.dispatchTouchEvent({ 51 | type: "touchStart", 52 | touchPoints: [{ x, y }], 53 | modifiers: this.#keyboardPageData.modifiers, 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { deadline } from "@std/async/deadline"; 2 | import { retry } from "@std/async/retry"; 3 | 4 | /** Regular expression to extract the endpoint from a websocket url */ 5 | export const WEBSOCKET_ENDPOINT_REGEX = /ws:\/\/(.*:.*?)\//; 6 | 7 | /** 8 | * Utility method to wait until a websocket is ready 9 | */ 10 | export async function websocketReady(ws: WebSocket) { 11 | await new Promise((res) => { 12 | ws.onopen = () => { 13 | res(); 14 | }; 15 | }); 16 | } 17 | 18 | /** 19 | * Utility method to convert a base64 encoded string into a byte array 20 | */ 21 | export function convertToUint8Array(data: string): Uint8Array { 22 | const byteString = atob(data); 23 | return new Uint8Array([...byteString].map((ch) => ch.charCodeAt(0))); 24 | } 25 | 26 | /** 27 | * Utility method to retry an operation a number of times with a deadline 28 | */ 29 | export function retryDeadline(t: Promise, timeout: number): Promise { 30 | return retry(() => deadline(t, timeout)); 31 | } 32 | -------------------------------------------------------------------------------- /tests/__snapshots__/evaluate_test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`Testing evaluate 1`] = ` 4 | ' 5 |

Example Domain

6 |

This domain is for use in illustrative examples in documents. You may use this 7 | domain in literature without prior coordination or asking for permission.

8 |

More information...

9 | ' 10 | `; 11 | 12 | snapshot[`Testing evaluate 2`] = ` 13 | "Example Domain 14 | 15 | This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission. 16 | 17 | More information..." 18 | `; 19 | -------------------------------------------------------------------------------- /tests/__snapshots__/navigate_test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`General navigation 1`] = ` 4 | \` 5 | Pyro | Pyro

Pyro

Docs

The SSG documentation site framework you've been waiting for.

Rundown

  • ⚡️ Pyro will help you ship a beautiful documentation site in no time.
  • 💸 Building a custom tech stack is expensive. Instead, focus on your content and just write Markdown files.
  • 💥 Ready for more? Advanced features like versioning, i18n, search and theme customizations are built-in with zero config required.
  • 🧐 Pyro at its core is a static-site generator. That means it can be deployed anywhere.

Getting started

Understand Pyro in 5 minutes by trying it out!

First install Pyro

deno run -Ar https://deno.land/x/pyro/install.ts

Create the site

pyro gen my-site

Start the dev server

cd my-site && pyro dev

Open http://localhost:8000 and create your first site!

Features

Pyro was built from the ground up to maximize DX.

  • Built with Deno, Preact, and Typescript
    Stop using antiquated technology in your stack. Embrace the new!
  • Created for developer experience first
    Stop using tools that make you miserable. Life is too short.
  • No configuration required
    Pyro has fantastic defaults, and uses a lot of magic to make sure that your experience is as smooth as possible. No more hunting down plugins!
\` 93 | `; 94 | -------------------------------------------------------------------------------- /tests/__snapshots__/wait_test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`Wait for function 1`] = `undefined`; 4 | -------------------------------------------------------------------------------- /tests/authenticate_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assertEquals, assertNotEquals } from "@std/assert"; 4 | 5 | import { launch } from "../mod.ts"; 6 | 7 | Deno.test("Testing authenticate", async (_t) => { 8 | // Open the webpage 9 | const browser = await launch({ headless: true }); 10 | const page = await browser.newPage(); 11 | 12 | // Provide credentials for HTTP authentication. 13 | await page.authenticate({ username: "postman", password: "password" }); 14 | const url = "https://postman-echo.com/basic-auth"; 15 | await page.goto(url, { waitUntil: "networkidle2" }); 16 | 17 | // Get JSON response 18 | const content = await page.evaluate(() => { 19 | return document.body.innerText; 20 | }); 21 | 22 | // Assert JSON response 23 | assertNotEquals(content, ""); 24 | const response = JSON.parse(content); 25 | assertEquals(response.authenticated, true); 26 | 27 | // Close browser 28 | await browser.close(); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/benchs/_get_binary_bench.ts: -------------------------------------------------------------------------------- 1 | import { cleanCache, getBinary } from "../../mod.ts"; 2 | import { assert } from "@std/assert/assert"; 3 | 4 | Deno.bench({ 5 | name: "Download progress", 6 | group: "Download browser", 7 | async fn(t) { 8 | // Download browser 9 | await cleanCache(); 10 | t.start(); 11 | assert(await getBinary("chrome")); 12 | t.end(); 13 | }, 14 | }); 15 | 16 | Deno.bench({ 17 | name: "Download quiet", 18 | group: "Download browser", 19 | async fn(t) { 20 | // Download browser 21 | await cleanCache(); 22 | t.start(); 23 | Deno.env.set("ASTRAL_QUIET_INSTALL", "true"); 24 | assert(await getBinary("chrome")); 25 | t.end(); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /tests/dialog_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | 3 | import { launch } from "../mod.ts"; 4 | 5 | Deno.test("Accepting basic alert", async () => { 6 | // Launch browser 7 | const browser = await launch(); 8 | 9 | // Open the webpage 10 | const page = await browser.newPage(); 11 | 12 | // listen for dialog events 13 | page.addEventListener("dialog", async (e) => { 14 | const dialog = e.detail; 15 | 16 | assertEquals(dialog.message, "hi"); 17 | assertEquals(dialog.type, "alert"); 18 | 19 | await dialog.accept(); 20 | }); 21 | 22 | // navigate to a page with an alert 23 | await page.setContent(""); 24 | 25 | // Close browser 26 | await browser.close(); 27 | }); 28 | 29 | Deno.test({ 30 | name: "Accepting basic alert with playwright-like syntax", 31 | async fn() { 32 | // Launch browser 33 | const browser = await launch(); 34 | 35 | // Open the webpage 36 | const page = await browser.newPage(); 37 | 38 | // listen for dialog events 39 | const dialogPromise = page.waitForEvent("dialog"); 40 | 41 | // navigate to a page with an alert 42 | page.setContent(""); 43 | 44 | // handle dialog 45 | const dialog = await dialogPromise; 46 | assertEquals(dialog.message, "hi"); 47 | assertEquals(dialog.type, "alert"); 48 | await dialog.accept(); 49 | 50 | // Close browser 51 | await browser.close(); 52 | }, 53 | // TODO(lino-levan): Remove this once this Deno bug is fixed 54 | sanitizeResources: false, 55 | sanitizeOps: false, 56 | }); 57 | 58 | Deno.test("Accepting prompt", async () => { 59 | // Launch browser 60 | const browser = await launch(); 61 | 62 | // Open the webpage 63 | const page = await browser.newPage(); 64 | 65 | // listen for dialog events 66 | page.addEventListener("dialog", async (e) => { 67 | const dialog = e.detail; 68 | 69 | assertEquals(dialog.message, "Please type your username"); 70 | assertEquals(dialog.type, "prompt"); 71 | 72 | await dialog.accept("John Doe"); 73 | }); 74 | 75 | // navigate to a page with an alert 76 | await page.setContent( 77 | "", 78 | ); 79 | 80 | // Close browser 81 | await browser.close(); 82 | }); 83 | 84 | Deno.test("Declining confirm", async () => { 85 | // Launch browser 86 | const browser = await launch(); 87 | 88 | // Open the webpage 89 | const page = await browser.newPage(); 90 | 91 | // listen for dialog events 92 | page.addEventListener("dialog", async (e) => { 93 | const dialog = e.detail; 94 | 95 | assertEquals(dialog.message, "Is this good?"); 96 | assertEquals(dialog.type, "confirm"); 97 | 98 | await dialog.dismiss(); 99 | }); 100 | 101 | // navigate to a page with an alert 102 | await page.setContent(""); 103 | 104 | // Close browser 105 | await browser.close(); 106 | }); 107 | 108 | Deno.test("Input choose file", async () => { 109 | // Launch browser 110 | const browser = await launch(); 111 | 112 | // Open the webpage 113 | const page = await browser.newPage(); 114 | 115 | // navigate to a page with an alert 116 | await page.setContent(''); 117 | 118 | // click input and handle file chooser 119 | const element = await page.$("input"); 120 | 121 | const [fileChooser] = await Promise.all([ 122 | page.waitForEvent("filechooser"), 123 | element?.click(), 124 | ]); 125 | 126 | assertEquals(fileChooser.multiple, false); 127 | 128 | await fileChooser.setFiles(["./tests/assets/file.txt"]); 129 | 130 | // Close browser 131 | await browser.close(); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/element_evaluate_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assertEquals, assertRejects } from "@std/assert"; 4 | 5 | import { launch } from "../mod.ts"; 6 | 7 | Deno.test("Testing element evaluate", async () => { 8 | // Launch browser 9 | const browser = await launch(); 10 | 11 | // Open the webpage 12 | const page = await browser.newPage("http://example.com"); 13 | 14 | const element = (await page.$("div"))!; 15 | 16 | // passing arguments to evaluate 17 | const result = await element.evaluate( 18 | (element: HTMLDivElement, key, value) => { 19 | element.setAttribute(key, value); 20 | return element.getAttribute(key); 21 | }, 22 | { 23 | args: ["astral", "test"], 24 | }, 25 | ); 26 | 27 | assertEquals(result, "test"); 28 | 29 | const string_function = await element.evaluate('()=>{return"test"}'); 30 | 31 | assertEquals(string_function, "test"); 32 | 33 | const result_undefined = await element.evaluate(() => { 34 | return undefined; 35 | }); 36 | 37 | assertEquals(result_undefined, undefined); 38 | 39 | const result_null = await element.evaluate(() => { 40 | return null; 41 | }); 42 | 43 | assertEquals(result_null, null); 44 | 45 | const result_number = await element.evaluate(() => { 46 | return 10.5123; 47 | }); 48 | 49 | assertEquals(result_number, 10.5123); 50 | 51 | const result_nan = await element.evaluate(() => { 52 | return NaN; 53 | }); 54 | 55 | assertEquals(result_nan, NaN); 56 | 57 | const result_boolean = await element.evaluate(() => { 58 | return true; 59 | }); 60 | 61 | assertEquals(result_boolean, true); 62 | 63 | const result_void = await element.evaluate(() => { 64 | return; 65 | }); 66 | 67 | assertEquals(result_void, undefined); 68 | 69 | const input_arguments = await element.evaluate( 70 | ( 71 | _, 72 | _null, 73 | _undefined, 74 | _number, 75 | _string, 76 | _nan, 77 | _object: { test: boolean }, 78 | _array: [number, null], 79 | ) => { 80 | return [ 81 | _null === null, 82 | _undefined === undefined, 83 | typeof _number == "number", 84 | typeof _string == "string", 85 | Number.isNaN(_nan), 86 | _object.test === true, 87 | _array[0] === 1 && _array[1] === null, 88 | ]; 89 | }, 90 | { 91 | args: [ 92 | null, 93 | undefined, 94 | 1, 95 | "", 96 | NaN, 97 | { test: true }, 98 | [1, null], 99 | ], 100 | }, 101 | ); 102 | 103 | assertEquals(input_arguments, [true, true, true, true, true, true, true]); 104 | 105 | await assertRejects( 106 | async () => { 107 | await element.evaluate( 108 | () => { 109 | throw new Error("test"); 110 | }, 111 | ); 112 | }, 113 | ); 114 | 115 | // Close browser 116 | await browser.close(); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/evaluate_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assertEquals } from "@std/assert"; 4 | import { assertSnapshot } from "@std/testing/snapshot"; 5 | 6 | import { launch } from "../mod.ts"; 7 | 8 | Deno.test("Testing evaluate", async (t) => { 9 | // Launch browser 10 | const browser = await launch(); 11 | 12 | // Open the webpage 13 | const page = await browser.newPage("http://example.com"); 14 | 15 | // passing arguments to evaluate 16 | const result = await page.evaluate((x, y) => { 17 | return x + y; 18 | }, { 19 | args: ["string", "concat"] as const, 20 | }); 21 | assertEquals(result, "stringconcat"); 22 | 23 | // innerHTML / innerText 24 | const element = (await page.$("div"))!; 25 | assertSnapshot(t, await element.innerHTML()); 26 | assertSnapshot(t, await element.innerText()); 27 | 28 | // Resize the page 29 | const viewportSize = { width: 1000, height: 1000 }; 30 | await page.setViewportSize(viewportSize); 31 | const pageSize = await page.evaluate(() => { 32 | return { 33 | width: document.documentElement.clientWidth, 34 | height: document.documentElement.clientHeight, 35 | }; 36 | }); 37 | assertEquals(pageSize, viewportSize); 38 | 39 | // Close browser 40 | await browser.close(); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/event_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | 3 | import { launch } from "../mod.ts"; 4 | 5 | Deno.test("Testing events", async () => { 6 | // Launch browser 7 | const browser = await launch(); 8 | 9 | // Open the webpage 10 | const page = await browser.newPage("http://example.com"); 11 | 12 | // page.addEventListener() 13 | 14 | // log something to console 15 | const [consoleEvent] = await Promise.all([ 16 | page.waitForEvent("console"), 17 | page.evaluate(() => { 18 | console.log("hey"); 19 | }), 20 | ]); 21 | assertEquals(consoleEvent.type, "log"); 22 | assertEquals(consoleEvent.text, "hey"); 23 | 24 | const [pageErrorEvent] = await Promise.all([ 25 | page.waitForEvent("pageerror"), 26 | page.goto('data:text/html,'), 27 | ]); 28 | assertEquals( 29 | pageErrorEvent.message, 30 | 'Error: Test\n at data:text/html,:1:15', 31 | ); 32 | 33 | // Close browser 34 | await browser.close(); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/existing_ws_endpoint_test.ts: -------------------------------------------------------------------------------- 1 | import { connect, launch } from "../mod.ts"; 2 | import { assertThrows } from "@std/assert"; 3 | import { assert } from "@std/assert/assert"; 4 | 5 | Deno.test("Test existing ws endpoint", async () => { 6 | // Spawn one browser instance and spawn another one connecting to the first one 7 | const a = await launch(); 8 | const b = await connect({ endpoint: a.wsEndpoint() }); 9 | 10 | // Test that second instance works without any process attached 11 | const page = await b.newPage("http://example.com"); 12 | await page.waitForSelector("h1"); 13 | await page.close(); 14 | assert(!b.pages.includes(page)); 15 | 16 | // Close first instance and ensure that b instance is inactive too 17 | await a.close(); 18 | assert(a.closed); 19 | assert(b.closed); 20 | }); 21 | 22 | Deno.test("Test existing http endpoint", async () => { 23 | // Spawn one browser instance and spawn another one connecting to the first one 24 | const a = await launch(); 25 | const b = await connect({ endpoint: new URL(a.wsEndpoint()).host }); 26 | 27 | // Test that second instance works without any process attached 28 | const page = await b.newPage("http://example.com"); 29 | await page.waitForSelector("h1"); 30 | await page.close(); 31 | assert(!b.pages.includes(page)); 32 | 33 | // Close first instance and ensure that b instance is inactive too 34 | await a.close(); 35 | assert(a.closed); 36 | assert(b.closed); 37 | }); 38 | 39 | Deno.test("Ensure pages are properly closed when closing existing endpoint", async () => { 40 | // Spawn one browser instance and spawn another one connecting to the first one 41 | const a = await launch(); 42 | const b = await connect({ endpoint: a.wsEndpoint() }); 43 | 44 | // Ensure closing existing endpoint properly clean resources 45 | await b.newPage("http://example.com"); 46 | await b.newPage("http://example.com"); 47 | await b.close(); 48 | assertThrows(() => b.pages[0].close(), "Page has already been closed"); 49 | assertThrows(() => b.pages[1].close(), "Page has already been closed"); 50 | assert(b.closed); 51 | await a.close(); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/extract_test.ts: -------------------------------------------------------------------------------- 1 | import { assertSnapshot } from "@std/testing/snapshot"; 2 | 3 | import { launch } from "../mod.ts"; 4 | 5 | Deno.test("Extracting page content", async (t) => { 6 | // Launch browser 7 | const browser = await launch(); 8 | 9 | // Open the webpage 10 | const page = await browser.newPage("http://example.com"); 11 | 12 | // Content of page 13 | assertSnapshot(t, await page.content()); 14 | 15 | // Close browser 16 | await browser.close(); 17 | }); 18 | 19 | Deno.test("Screenshot page content", async () => { 20 | // Launch browser 21 | const browser = await launch(); 22 | 23 | // Open the webpage 24 | const page = await browser.newPage("http://example.com"); 25 | 26 | // TODO(lino-levan): figure out why screenshot is causing issues for snapshots 27 | // Screenshot page 28 | // assertSnapshot(t, await page.screenshot()); 29 | await page.screenshot(); 30 | 31 | // Close browser 32 | await browser.close(); 33 | }); 34 | 35 | Deno.test("Make a PDF of page content", async () => { 36 | // Launch browser 37 | const browser = await launch(); 38 | 39 | // Open the webpage 40 | const page = await browser.newPage("http://example.com"); 41 | 42 | // TODO(lino-levan): figure out why pdf is causing issues for snapshots 43 | // PDF of page 44 | // assertSnapshot(t, await page.pdf()) 45 | await page.pdf(); 46 | 47 | // Close browser 48 | await browser.close(); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/fixtures/counter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter 7 | 8 | 9 |
10 | 0 11 | 12 |
13 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tests/fixtures/evaluate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Evaluate 7 | 8 | 9 |
hello
10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/fixtures/fill.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Fill 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/fixtures/wait_for_element.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Wait for Element 7 | 8 | 9 |
10 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/get_attributes_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assertEquals, assertExists } from "@std/assert"; 4 | 5 | import { launch } from "../mod.ts"; 6 | 7 | Deno.test("Testing attributes", async () => { 8 | const browser = await launch(); 9 | const content = ` 10 | 11 | 12 | 13 | Astral 14 | 15 | 16 | Hello world 17 | 18 | `; 19 | 20 | // Open the webpage and set content 21 | const page = await browser.newPage(); 22 | await page.setContent(content); 23 | 24 | const element = await page.$("a"); 25 | 26 | assertExists(element); 27 | 28 | const attributes = await element.getAttributes(); 29 | 30 | assertEquals(attributes, { 31 | href: "https://example.com", 32 | target: "_blank", 33 | disabled: "", 34 | }); 35 | 36 | const target_attribute = await element.getAttribute("target"); 37 | 38 | assertEquals(target_attribute, "_blank"); 39 | 40 | const disabled_attribute = await element.getAttribute("disabled"); 41 | 42 | assertEquals(disabled_attribute, ""); 43 | 44 | const nonexistent_attribute = await element.getAttribute("nonexistent"); 45 | 46 | assertEquals(nonexistent_attribute, null); 47 | 48 | // Close browser 49 | await browser.close(); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/get_binary_test.ts: -------------------------------------------------------------------------------- 1 | import { assertMatch, assertRejects, assertStringIncludes } from "@std/assert"; 2 | import { assert } from "@std/assert/assert"; 3 | import { resolve } from "@std/path/resolve"; 4 | import { cleanCache, getBinary, launch } from "../mod.ts"; 5 | 6 | const tempDir = Deno.env.get("TMPDIR") || Deno.env.get("TMP") || 7 | Deno.env.get("TEMP") || "/tmp"; 8 | 9 | // Tests should be performed in directory different from others tests as cache is cleaned during this one 10 | Deno.env.set("ASTRAL_QUIET_INSTALL", "true"); 11 | const cache = await Deno.makeTempDir({ prefix: "astral_test_get_binary" }); 12 | const permissions = { 13 | write: [ 14 | cache, 15 | // Chromium lock on Linux 16 | `${Deno.env.get("HOME")}/.config/chromium/SingletonLock`, 17 | // Chromium lock on MacOS 18 | `${ 19 | Deno.env.get("HOME") 20 | }/Library/Application Support/Chromium/SingletonLock`, 21 | // OS temporary directory, used by chromium profile 22 | tempDir, 23 | ], 24 | env: ["CI", "ASTRAL_QUIET_INSTALL"], 25 | read: [cache], 26 | net: true, 27 | run: true, 28 | }; 29 | 30 | Deno.test("Test download", { permissions }, async () => { 31 | // Download browser 32 | await cleanCache({ cache }); 33 | const path = await getBinary("chrome", { cache }); 34 | assertStringIncludes(path, cache); 35 | 36 | // Ensure browser is executable 37 | // Note: it seems that on Windows the --version flag does not exists and spawn a 38 | // browser instance instead. The next test ensure that everything is working 39 | // properly anyways 40 | if (Deno.build.os !== "windows") { 41 | const command = new Deno.Command(path, { 42 | args: [ 43 | "--version", 44 | ], 45 | }); 46 | const { success, stdout } = await command.output(); 47 | assert(success); 48 | assertMatch(new TextDecoder().decode(stdout), /Google Chrome/i); 49 | } 50 | 51 | // Ensure browser is capable of loading pages 52 | const browser = await launch({ path }); 53 | const page = await browser.newPage("http://example.com"); 54 | await page.waitForSelector("h1"); 55 | await browser.close(); 56 | }); 57 | 58 | Deno.test("Test download after failure", { permissions }, async () => { 59 | await cleanCache({ cache }); 60 | const testCache = resolve(cache, "test_failure"); 61 | 62 | // Test download failure (create a file instead of directory as the cache to force a write error) 63 | await Deno.mkdir(cache, { recursive: true }); 64 | await Deno.writeTextFile(testCache, ""); 65 | await assertRejects( 66 | () => getBinary("chrome", { cache: testCache }), 67 | "Not a directory", 68 | ); 69 | 70 | // Retry download 71 | await Deno.remove(testCache, { recursive: true }); 72 | assert(await getBinary("chrome", { cache: testCache })); 73 | }); 74 | 75 | Deno.test("Clean cache after tests", async () => { 76 | await cleanCache({ cache }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/install_lock_file_test.ts: -------------------------------------------------------------------------------- 1 | import { deadline } from "@std/async/deadline"; 2 | import { cleanCache, getBinary, launch } from "../mod.ts"; 3 | 4 | // Tests should be performed in directory different from others tests as cache is cleaned during this one 5 | //Deno.env.set("ASTRAL_QUIET_INSTALL", "true"); 6 | const cache = await Deno.makeTempDir({ 7 | prefix: "astral_test_install_lock_file", 8 | }); 9 | 10 | Deno.test("Test concurrent getBinary calls", async () => { 11 | // Spawn concurrent getBinary calls 12 | await cleanCache({ cache }); 13 | const promises = []; 14 | for (let i = 0; i < 20; i++) { 15 | promises.push(getBinary("chrome", { cache })); 16 | } 17 | const path = await Promise.race(promises); 18 | 19 | // Ensure binary sent by first promise is executable 20 | const browser = await launch({ path }); 21 | 22 | // Other promises should resolve at around the same time as they wait for lock file 23 | await deadline(Promise.all(promises), 250); 24 | 25 | // Ensure binary is still working (no files overwritten) 26 | await browser.newPage("https://example.com"); 27 | await browser.close(); 28 | }); 29 | 30 | Deno.test("Clean cache after tests", async () => { 31 | await cleanCache({ cache }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/keyboard_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assertEquals, assertExists } from "@std/assert"; 4 | import { launch } from "../mod.ts"; 5 | 6 | Deno.test("Keyboard - basic input", async () => { 7 | const browser = await launch(); 8 | const page = await browser.newPage(); 9 | 10 | await page.setContent(` 11 | 12 | 13 | 14 | 15 |
16 |
17 | 27 | 28 | 29 | `); 30 | 31 | const input = await page.$("input"); 32 | assertExists(input); 33 | await input.click(); 34 | 35 | // Test individual key presses 36 | await page.keyboard.type("Hello"); 37 | 38 | const inputValue = await input.evaluate((el) => 39 | (el as HTMLInputElement).value 40 | ); 41 | assertEquals(inputValue, "Hello"); 42 | 43 | const keydowns = await page.evaluate(() => 44 | document.getElementById("keydowns")?.textContent || "" 45 | ); 46 | assertEquals(keydowns, "Hello"); 47 | 48 | const keyups = await page.evaluate(() => 49 | document.getElementById("keyups")?.textContent || "" 50 | ); 51 | assertEquals(keyups, "Hello"); 52 | 53 | await browser.close(); 54 | }); 55 | 56 | Deno.test("Keyboard - modifier keys", async () => { 57 | const browser = await launch(); 58 | const page = await browser.newPage(); 59 | 60 | await page.setContent(` 61 | 62 | 63 | 64 | 65 |
66 | 83 | 84 | 85 | `); 86 | 87 | const input = await page.$("input"); 88 | assertExists(input); 89 | await input.click(); 90 | 91 | // Test Shift + key 92 | await page.keyboard.down("ShiftLeft"); 93 | await page.keyboard.press("a"); 94 | await page.keyboard.up("ShiftLeft"); 95 | 96 | // Test Ctrl + key 97 | await page.keyboard.down("ControlLeft"); 98 | await page.keyboard.press("c"); 99 | await page.keyboard.up("ControlLeft"); 100 | 101 | // Test Alt + key 102 | await page.keyboard.down("AltLeft"); 103 | await page.keyboard.press("x"); 104 | await page.keyboard.up("AltLeft"); 105 | 106 | // Test multiple modifiers 107 | await page.keyboard.down("ShiftLeft"); 108 | await page.keyboard.down("ControlLeft"); 109 | await page.keyboard.press("z"); 110 | await page.keyboard.up("ControlLeft"); 111 | await page.keyboard.up("ShiftLeft"); 112 | 113 | // Verify the events were recorded correctly 114 | const events = await page.evaluate(() => 115 | document.getElementById("events")?.textContent || "" 116 | ); 117 | 118 | assertEquals( 119 | events, 120 | "Shift+Shift,Shift+a,Ctrl+Control,Ctrl+c,Alt+Alt,Alt+x,Shift+Shift,Shift+Ctrl+Control,Shift+Ctrl+z,", 121 | ); 122 | await browser.close(); 123 | }); 124 | 125 | Deno.test("Keyboard - shift doesn't affect capitalization", async () => { 126 | const browser = await launch(); 127 | const page = await browser.newPage(); 128 | 129 | await page.setContent(` 130 | 131 | 132 | 133 | 134 | 135 | 136 | `); 137 | 138 | const input = await page.$("input"); 139 | assertExists(input); 140 | await input.click(); 141 | 142 | // Test typing with Shift held down 143 | await page.keyboard.down("ShiftLeft"); 144 | await page.keyboard.type("test"); 145 | await page.keyboard.up("ShiftLeft"); 146 | 147 | const upperValue = await input.evaluate((el) => 148 | (el as HTMLInputElement).value 149 | ); 150 | assertEquals(upperValue, "test"); 151 | 152 | // Clear input 153 | await input.evaluate((el) => (el as HTMLInputElement).value = ""); 154 | 155 | // Test mixed case with Shift 156 | await page.keyboard.type("t"); 157 | await page.keyboard.down("ShiftLeft"); 158 | await page.keyboard.type("EST"); 159 | await page.keyboard.up("ShiftLeft"); 160 | 161 | const mixedValue = await input.evaluate((el) => 162 | (el as HTMLInputElement).value 163 | ); 164 | assertEquals(mixedValue, "tEST"); 165 | 166 | await browser.close(); 167 | }); 168 | 169 | Deno.test("Keyboard - special keys", async () => { 170 | const browser = await launch(); 171 | const page = await browser.newPage(); 172 | 173 | await page.setContent(` 174 | 175 | 176 | 177 | 178 | 181 | 182 | 183 | `); 184 | 185 | const textarea = await page.$("textarea"); 186 | assertExists(textarea); 187 | await textarea.click(); 188 | 189 | // Test Enter key 190 | await page.keyboard.type("First line"); 191 | await page.keyboard.press("Enter"); 192 | await page.keyboard.type("Second line"); 193 | 194 | const value = await textarea.evaluate((el) => 195 | (el as HTMLTextAreaElement).value 196 | ); 197 | assertEquals(value, "First line\nSecond line"); 198 | 199 | // Test Backspace 200 | for (let i = 0; i < 5; i++) { 201 | await page.keyboard.press("Backspace"); 202 | } 203 | 204 | const valueAfterBackspace = await textarea.evaluate((el) => 205 | (el as HTMLTextAreaElement).value 206 | ); 207 | assertEquals(valueAfterBackspace, "First line\nSecond"); 208 | 209 | await browser.close(); 210 | }); 211 | 212 | Deno.test("Keyboard - typing with delay", async () => { 213 | const browser = await launch(); 214 | const page = await browser.newPage(); 215 | 216 | await page.setContent(` 217 | 218 | 219 | 220 | 221 |
222 | 236 | 237 | 238 | `); 239 | 240 | const input = await page.$("input"); 241 | assertExists(input); 242 | await input.click(); 243 | 244 | // Type with 100ms delay between each key 245 | await page.keyboard.type("test", { delay: 100 }); 246 | 247 | const inputValue = await input.evaluate((el) => 248 | (el as HTMLInputElement).value 249 | ); 250 | assertEquals(inputValue, "test"); 251 | 252 | // Verify delays between keystrokes are approximately correct 253 | const timings = await page.evaluate(() => { 254 | const el = document.getElementById("timings"); 255 | const delays = (el?.textContent || "").split(",").filter(Boolean).map( 256 | Number, 257 | ); 258 | return delays.every((delay) => delay >= 90); // Allow for small timing variations 259 | }); 260 | assertEquals(timings, true); 261 | 262 | await browser.close(); 263 | }); 264 | 265 | Deno.test("Keyboard - tab navigation", async () => { 266 | const browser = await launch(); 267 | const page = await browser.newPage(); 268 | 269 | await page.setContent(` 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 |
278 | 286 | 287 | 288 | `); 289 | 290 | // Start with first input focused 291 | const firstInput = await page.$("input#first"); 292 | assertExists(firstInput); 293 | await firstInput.click(); 294 | 295 | // Press tab multiple times to move through elements 296 | await page.keyboard.press("Tab"); 297 | await page.keyboard.press("Tab"); 298 | await page.keyboard.press("Tab"); 299 | 300 | // Check the focus order 301 | const focusOrder = await page.evaluate(() => 302 | document.getElementById("focused")?.textContent || "" 303 | ); 304 | assertEquals(focusOrder, "first,second,button,textarea,"); 305 | 306 | // Test shift+tab to go backwards 307 | await page.keyboard.down("ShiftLeft"); 308 | await page.keyboard.press("Tab"); 309 | await page.keyboard.press("Tab"); 310 | await page.keyboard.up("ShiftLeft"); 311 | 312 | // Verify we can type in the input we tabbed to 313 | await page.keyboard.type("Hello"); 314 | const secondInputValue = await page.evaluate(() => 315 | (document.getElementById("second") as HTMLInputElement).value 316 | ); 317 | assertEquals(secondInputValue, "Hello"); 318 | 319 | await browser.close(); 320 | }); 321 | 322 | Deno.test("Keyboard - modifier keys with click", async () => { 323 | const browser = await launch(); 324 | const page = await browser.newPage(); 325 | 326 | await page.setContent(` 327 | 328 | 329 | 330 |
Click here
331 |
332 | 350 | 351 | 352 | `); 353 | 354 | const clickTarget = await page.$("#clickTarget"); 355 | assertExists(clickTarget); 356 | 357 | // Basic click without modifiers 358 | await clickTarget.click(); 359 | 360 | // Shift + Click 361 | await page.keyboard.down("ShiftLeft"); 362 | await clickTarget.click(); 363 | await page.keyboard.up("ShiftLeft"); 364 | 365 | // Alt + Click 366 | await page.keyboard.down("AltLeft"); 367 | await clickTarget.click(); 368 | await page.keyboard.up("AltLeft"); 369 | 370 | // Multiple modifiers: Shift + Alt + Click 371 | await page.keyboard.down("ShiftLeft"); 372 | await page.keyboard.down("AltLeft"); 373 | await clickTarget.click(); 374 | await page.keyboard.up("AltLeft"); 375 | await page.keyboard.up("ShiftLeft"); 376 | 377 | // Verify the events were recorded correctly 378 | const events = await page.evaluate(() => 379 | document.getElementById("clickEvents")?.textContent || "" 380 | ); 381 | 382 | assertEquals( 383 | events, 384 | "Click,Shift+Click,Alt+Click,Shift+Alt+Click,", 385 | ); 386 | 387 | await browser.close(); 388 | }); 389 | -------------------------------------------------------------------------------- /tests/leak_test.ts: -------------------------------------------------------------------------------- 1 | import { launch } from "../mod.ts"; 2 | 3 | const browser = await launch(); 4 | 5 | addEventListener("unload", async () => { 6 | await browser.close(); 7 | }); 8 | 9 | Deno.test("Opening and closing a page without closing the browser isn't leak", async () => { 10 | await using _ = await browser.newPage(); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/locator_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { assertEquals } from "@std/assert"; 3 | import { launch } from "../mod.ts"; 4 | import * as path from "@std/path"; 5 | 6 | const dirname = import.meta.dirname!; 7 | 8 | async function serveFixture(name: string) { 9 | const file = path.join(dirname, name); 10 | 11 | // Ensure we don't traverse outside of intended dir 12 | if (path.relative(path.join(dirname, "fixtures"), file).startsWith(".")) { 13 | throw new Error(`fixture: "${name}" resolved outside fixture directory`); 14 | } 15 | 16 | const html = await Deno.readFile(file); 17 | 18 | const server = Deno.serve({ port: 0 }, () => { 19 | return new Response(html, { 20 | headers: { "Content-Type": "text/html; charset=utf-8" }, 21 | }); 22 | }); 23 | 24 | return { 25 | address: `http://localhost:${server.addr.port}`, 26 | async [Symbol.asyncDispose]() { 27 | await server.shutdown(); 28 | await server.finished; 29 | }, 30 | }; 31 | } 32 | 33 | Deno.test("Locator - click()", async () => { 34 | await using server = await serveFixture("fixtures/counter.html"); 35 | 36 | const browser = await launch(); 37 | const page = await browser.newPage(server.address); 38 | 39 | await page.waitForNetworkIdle(); 40 | await page.locator("button").click(); 41 | 42 | const res = await page.evaluate(() => { 43 | return document.querySelector("output")!.textContent; 44 | }); 45 | 46 | assertEquals(res, "1"); 47 | 48 | await page.close(); 49 | await browser.close(); 50 | }); 51 | 52 | Deno.test("Locator - wait()", async () => { 53 | await using server = await serveFixture("fixtures/wait_for_element.html"); 54 | 55 | const browser = await launch(); 56 | const page = await browser.newPage(server.address); 57 | 58 | await page.waitForNetworkIdle(); 59 | await page.locator("h1").wait(); 60 | 61 | const res = await page.evaluate(() => { 62 | return document.querySelector("h1") !== null; 63 | }); 64 | 65 | assertEquals(res, true); 66 | 67 | await page.close(); 68 | await browser.close(); 69 | }); 70 | 71 | Deno.test("Locator - evaluate()", async () => { 72 | await using server = await serveFixture("fixtures/evaluate.html"); 73 | 74 | const browser = await launch(); 75 | const page = await browser.newPage(server.address); 76 | await page.waitForNetworkIdle(); 77 | 78 | const text = await page.locator("#target").evaluate((el) => 79 | el.textContent 80 | ); 81 | assertEquals(text, "hello"); 82 | 83 | await page.close(); 84 | await browser.close(); 85 | }); 86 | 87 | Deno.test("Locator - fill()", async () => { 88 | await using server = await serveFixture("fixtures/fill.html"); 89 | 90 | const browser = await launch(); 91 | const page = await browser.newPage(server.address); 92 | await page.waitForNetworkIdle(); 93 | 94 | await page.locator("#target").fill("hello"); 95 | 96 | const text = await page.locator("#target").evaluate((el) => 97 | el.value 98 | ); 99 | assertEquals(text, "hello"); 100 | 101 | await page.close(); 102 | await browser.close(); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/mouse_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assertEquals, assertExists } from "@std/assert"; 4 | import { launch } from "../mod.ts"; 5 | 6 | Deno.test("Mouse - basic click", async () => { 7 | const browser = await launch(); 8 | const page = await browser.newPage(); 9 | 10 | await page.setContent(` 11 | 12 | 13 | 14 |
Click here
15 |
0
16 | 24 | 25 | 26 | `); 27 | 28 | const clickTarget = await page.$("#clickTarget"); 29 | assertExists(clickTarget); 30 | 31 | // Get element position 32 | const boundingBox = await clickTarget.boundingBox(); 33 | assertExists(boundingBox); 34 | 35 | // Click in the middle of the element 36 | const x = boundingBox.x + boundingBox.width / 2; 37 | const y = boundingBox.y + boundingBox.height / 2; 38 | 39 | await page.mouse.click(x, y); 40 | 41 | // Verify click was registered 42 | const count = await page.evaluate(() => 43 | document.getElementById("clickCount")?.textContent 44 | ); 45 | assertEquals(count, "1"); 46 | 47 | await browser.close(); 48 | }); 49 | 50 | Deno.test("Mouse - multiple clicks", async () => { 51 | const browser = await launch(); 52 | const page = await browser.newPage(); 53 | 54 | await page.setContent(` 55 | 56 | 57 | 58 |
Click here
59 |
0
60 |
0
61 | 74 | 75 | 76 | `); 77 | 78 | const clickTarget = await page.$("#clickTarget"); 79 | assertExists(clickTarget); 80 | 81 | // Get element position 82 | const boundingBox = await clickTarget.boundingBox(); 83 | assertExists(boundingBox); 84 | 85 | // Click in the middle of the element with count=2 (double-click) 86 | const x = boundingBox.x + boundingBox.width / 2; 87 | const y = boundingBox.y + boundingBox.height / 2; 88 | 89 | await page.mouse.click(x, y, { count: 2 }); 90 | 91 | // Verify clicks were registered 92 | const clickCount = await page.evaluate(() => 93 | document.getElementById("clickCount")?.textContent 94 | ); 95 | assertEquals(clickCount, "2"); // Two individual clicks 96 | 97 | const dblClickCount = await page.evaluate(() => 98 | document.getElementById("dblClickCount")?.textContent 99 | ); 100 | assertEquals(dblClickCount, "1"); // One double-click 101 | 102 | await browser.close(); 103 | }); 104 | 105 | Deno.test("Mouse - click with delay", async () => { 106 | const browser = await launch(); 107 | const page = await browser.newPage(); 108 | 109 | await page.setContent(` 110 | 111 | 112 | 113 |
Click here
114 |
115 | 138 | 139 | 140 | `); 141 | 142 | const clickTarget = await page.$("#clickTarget"); 143 | assertExists(clickTarget); 144 | 145 | // Get element position 146 | const boundingBox = await clickTarget.boundingBox(); 147 | assertExists(boundingBox); 148 | 149 | // Click with 100ms delay between down and up 150 | const x = boundingBox.x + boundingBox.width / 2; 151 | const y = boundingBox.y + boundingBox.height / 2; 152 | 153 | await page.mouse.click(x, y, { delay: 100 }); 154 | 155 | // Verify the delay is approximately correct 156 | const delayIsCorrect = await page.evaluate(() => { 157 | const timings = document.getElementById("timings")?.textContent || ""; 158 | const upTiming = timings.split(",").find((t) => t.startsWith("up:")); 159 | if (!upTiming) return false; 160 | 161 | const delay = parseInt(upTiming.substring(3)); 162 | return delay >= 90; // Allow for small timing variations 163 | }); 164 | 165 | assertEquals(delayIsCorrect, true); 166 | 167 | await browser.close(); 168 | }); 169 | 170 | Deno.test("Mouse - down and up", async () => { 171 | const browser = await launch(); 172 | const page = await browser.newPage(); 173 | 174 | await page.setContent(` 175 | 176 | 177 | 178 |
Test area
179 |
180 | 192 | 193 | 194 | `); 195 | 196 | const target = await page.$("#target"); 197 | assertExists(target); 198 | 199 | // Get element position 200 | const boundingBox = await target.boundingBox(); 201 | assertExists(boundingBox); 202 | 203 | const x = boundingBox.x + boundingBox.width / 2; 204 | const y = boundingBox.y + boundingBox.height / 2; 205 | 206 | // Move to the target 207 | await page.mouse.move(x, y); 208 | 209 | // Test mouse down and up separately 210 | await page.mouse.down(); 211 | await page.mouse.up(); 212 | 213 | // Test with right button 214 | await page.mouse.down({ button: "right" }); 215 | await page.mouse.up({ button: "right" }); 216 | 217 | // Verify the events were recorded in order 218 | const events = await page.evaluate(() => 219 | document.getElementById("events")?.textContent || "" 220 | ); 221 | 222 | assertEquals(events, "down,up,down,up,"); 223 | 224 | await browser.close(); 225 | }); 226 | 227 | Deno.test("Mouse - movement", async () => { 228 | const browser = await launch(); 229 | const page = await browser.newPage(); 230 | 231 | await page.setContent(` 232 | 233 | 234 | 235 |
236 |
0,0
237 |
238 | 251 | 252 | 253 | `); 254 | 255 | const area = await page.$("#area"); 256 | assertExists(area); 257 | 258 | // Get element position 259 | const boundingBox = await area.boundingBox(); 260 | assertExists(boundingBox); 261 | 262 | // Move to specific coordinates within the area 263 | const targetX = boundingBox.x + 100; 264 | const targetY = boundingBox.y + 150; 265 | 266 | await page.mouse.move(targetX, targetY); 267 | 268 | // Verify position was updated correctly 269 | const position = await page.evaluate(() => 270 | document.getElementById("position")?.textContent || "" 271 | ); 272 | 273 | // Check if the reported position is close to our target (allowing for rounding) 274 | const [x, y] = position.split(",").map(Number); 275 | const isPositionClose = Math.abs(x - 100) <= 2 && Math.abs(y - 150) <= 2; 276 | 277 | assertEquals(isPositionClose, true); 278 | 279 | // Test movement with steps 280 | const newTargetX = boundingBox.x + 300; 281 | const newTargetY = boundingBox.y + 200; 282 | 283 | await page.mouse.move(newTargetX, newTargetY, { steps: 5 }); 284 | 285 | // Verify final position 286 | const newPosition = await page.evaluate(() => 287 | document.getElementById("position")?.textContent || "" 288 | ); 289 | 290 | const [newX, newY] = newPosition.split(",").map(Number); 291 | const isNewPositionClose = Math.abs(newX - 300) <= 2 && 292 | Math.abs(newY - 200) <= 2; 293 | 294 | assertEquals(isNewPositionClose, true); 295 | 296 | await browser.close(); 297 | }); 298 | 299 | Deno.test("Mouse - reset", async () => { 300 | const browser = await launch(); 301 | const page = await browser.newPage(); 302 | 303 | await page.setContent(` 304 | 305 | 306 | 307 |
308 |
No movement yet
309 |
310 | 318 | 319 | 320 | `); 321 | 322 | // Move mouse to a position 323 | await page.mouse.move(200, 200); 324 | 325 | // Verify position was updated 326 | let position = await page.evaluate(() => 327 | document.getElementById("position")?.textContent 328 | ); 329 | assertExists(position); 330 | 331 | // Reset mouse position 332 | await page.mouse.reset(); 333 | 334 | // Verify position was reset to (0,0) or close to it 335 | position = await page.evaluate(() => 336 | document.getElementById("position")?.textContent 337 | ); 338 | assertExists(position); 339 | 340 | const [x, y] = position.split(",").map(Number); 341 | const isAtOrigin = x <= 5 && y <= 5; // Allow small offset due to viewport considerations 342 | 343 | assertEquals(isAtOrigin, true); 344 | 345 | await browser.close(); 346 | }); 347 | 348 | // TODO(lino-levan): Reenable test, it's flaky for some reason 349 | // Deno.test("Mouse - wheel", async () => { 350 | // const browser = await launch(); 351 | // const page = await browser.newPage(); 352 | 353 | // await page.setContent(` 354 | // 355 | // 356 | // 357 | //
358 | //
359 | // Scroll area content 360 | //
361 | //
362 | //
0,0
363 | // 371 | // 372 | // 373 | // `); 374 | 375 | // const scrollArea = await page.$("#scrollArea"); 376 | // assertExists(scrollArea); 377 | 378 | // // Get element position 379 | // const boundingBox = await scrollArea.boundingBox(); 380 | // assertExists(boundingBox); 381 | 382 | // // Move to the scroll area 383 | // const x = boundingBox.x + boundingBox.width / 2; 384 | // const y = boundingBox.y + boundingBox.height / 2; 385 | // await page.mouse.move(x, y); 386 | 387 | // // Scroll vertically 388 | // await page.mouse.wheel({ deltaY: 100 }); 389 | 390 | // // Wait for scroll to complete 391 | // await page.waitForTimeout(200); 392 | 393 | // // Verify vertical scroll 394 | // let scrollPosition = await page.evaluate(() => 395 | // document.getElementById("scrollPosition")?.textContent || "" 396 | // ); 397 | 398 | // let [scrollX, scrollY] = scrollPosition.split(",").map(Number); 399 | // assertEquals(scrollX, 0); 400 | // const hasScrolledVertically = scrollY > 0; 401 | // assertEquals(hasScrolledVertically, true); 402 | 403 | // // Scroll horizontally 404 | // await page.mouse.wheel({ deltaX: 100 }); 405 | 406 | // // Wait for scroll to complete 407 | // await page.waitForTimeout(200); 408 | 409 | // // Verify horizontal scroll 410 | // scrollPosition = await page.evaluate(() => 411 | // document.getElementById("scrollPosition")?.textContent || "" 412 | // ); 413 | 414 | // [scrollX, scrollY] = scrollPosition.split(",").map(Number); 415 | // const hasScrolledHorizontally = scrollX > 0; 416 | // assertEquals(hasScrolledHorizontally, true); 417 | 418 | // await browser.close(); 419 | // }); 420 | 421 | Deno.test("Mouse - modifier keys with click", async () => { 422 | const browser = await launch(); 423 | const page = await browser.newPage(); 424 | 425 | await page.setContent(` 426 | 427 | 428 | 429 |
Click here
430 |
431 | 449 | 450 | 451 | `); 452 | 453 | const clickTarget = await page.$("#clickTarget"); 454 | assertExists(clickTarget); 455 | 456 | // Get element position 457 | const boundingBox = await clickTarget.boundingBox(); 458 | assertExists(boundingBox); 459 | 460 | const x = boundingBox.x + boundingBox.width / 2; 461 | const y = boundingBox.y + boundingBox.height / 2; 462 | 463 | // Basic click without modifiers 464 | await page.mouse.click(x, y); 465 | 466 | // Shift + Click 467 | await page.keyboard.down("ShiftLeft"); 468 | await page.mouse.click(x, y); 469 | await page.keyboard.up("ShiftLeft"); 470 | 471 | // Verify the events were recorded correctly 472 | const events = await page.evaluate(() => 473 | document.getElementById("clickEvents")?.textContent || "" 474 | ); 475 | 476 | assertEquals( 477 | events, 478 | "Click,Shift+Click,", 479 | ); 480 | 481 | await browser.close(); 482 | }); 483 | 484 | Deno.test("Mouse - drag and drop", async () => { 485 | const browser = await launch(); 486 | const page = await browser.newPage(); 487 | 488 | await page.setContent(` 489 | 490 | 491 | 492 |
493 | Drag me 494 |
495 |
496 | Drop here 497 |
498 |
Ready
499 |
0,0
500 | 545 | 546 | 547 | `); 548 | 549 | // Get draggable element 550 | const draggable = await page.$("#draggable"); 551 | assertExists(draggable); 552 | const draggableBox = await draggable.boundingBox(); 553 | assertExists(draggableBox); 554 | 555 | // Get dropzone element 556 | const dropzone = await page.$("#dropzone"); 557 | assertExists(dropzone); 558 | const dropzoneBox = await dropzone.boundingBox(); 559 | assertExists(dropzoneBox); 560 | 561 | // Start position (center of draggable) 562 | const startX = draggableBox.x + draggableBox.width / 2; 563 | const startY = draggableBox.y + draggableBox.height / 2; 564 | 565 | // End position (center of dropzone) 566 | const endX = dropzoneBox.x + dropzoneBox.width / 2; 567 | const endY = dropzoneBox.y + dropzoneBox.height / 2; 568 | 569 | // Simulate drag and drop 570 | await page.mouse.move(startX, startY); 571 | await page.mouse.down(); 572 | await page.mouse.move(endX, endY, { steps: 10 }); // Move in steps for smoother drag 573 | await page.mouse.up(); 574 | 575 | // Verify drop was successful 576 | const status = await page.evaluate(() => 577 | document.getElementById("status")?.textContent 578 | ); 579 | assertEquals(status, "Dropped in target!"); 580 | 581 | // Verify element was moved to the correct position 582 | const position = await page.evaluate(() => 583 | document.getElementById("position")?.textContent! 584 | ); 585 | 586 | const [x, y] = position.split(",").map(Number); 587 | const isNearDropzone = Math.abs( 588 | x - (dropzoneBox.x + dropzoneBox.width / 2 - draggableBox.width / 2), 589 | ) <= 50 && 590 | Math.abs( 591 | y - (dropzoneBox.y + dropzoneBox.height / 2 - draggableBox.height / 2), 592 | ) <= 50; 593 | 594 | assertEquals(isNearDropzone, true); 595 | 596 | await browser.close(); 597 | }); 598 | -------------------------------------------------------------------------------- /tests/navigation_test.ts: -------------------------------------------------------------------------------- 1 | import { assertRejects } from "@std/assert"; 2 | import { launch } from "../mod.ts"; 3 | 4 | Deno.test("Page - back and forth navigation works", async () => { 5 | const browser = await launch(); 6 | const page = await browser.newPage(); 7 | await page.goto("https://example.com"); 8 | await page.locator("a").click(); 9 | await page.goBack(); 10 | assertRejects(() => page.goBack(), "No history entry available"); 11 | await page.goForward(); 12 | assertRejects(() => page.goForward(), "No history entry available"); 13 | await page.close(); 14 | await browser.close(); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/query_test.ts: -------------------------------------------------------------------------------- 1 | import { assertExists } from "@std/assert"; 2 | import { launch } from "../mod.ts"; 3 | 4 | Deno.test("Set content", async () => { 5 | // Launch browser 6 | const browser = await launch(); 7 | const content = ` 8 | 9 | 10 | 11 | Astral 12 | 13 | 14 | Hello world 15 | 16 | `; 17 | 18 | // Open the webpage and set content 19 | const page = await browser.newPage(); 20 | await page.setContent(content); 21 | 22 | // Basic selector 23 | const body = await page.$("body"); 24 | assertExists(body); 25 | 26 | // Basic selector 27 | const nonsense = await page.$(".fake"); 28 | assertExists(!nonsense); 29 | 30 | // Set media queries 31 | await page.emulateMediaFeatures([{ 32 | name: "prefers-reduced-motion", 33 | value: "reduce", 34 | }]); 35 | await page.emulateMediaFeatures([{ 36 | name: "prefers-color-scheme", 37 | value: "dark", 38 | }]); 39 | 40 | // Close browser 41 | await browser.close(); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/reliability_test.ts: -------------------------------------------------------------------------------- 1 | import { launch } from "../mod.ts"; 2 | 3 | for (let i = 0; i < 20; i++) { 4 | Deno.test("Open browser, open page, and close browser", async () => { 5 | // Launch browser 6 | const browser = await launch(); 7 | 8 | // Open the webpage 9 | await browser.newPage("http://example.com"); 10 | 11 | // Close browser 12 | await browser.close(); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /tests/resource_test.ts: -------------------------------------------------------------------------------- 1 | import { type Browser, launch, type Page } from "../mod.ts"; 2 | import { assertRejects } from "@std/assert"; 3 | 4 | Deno.test("Page - close with 'using' keyword", async () => { 5 | const browser = await launch(); 6 | let ref: Page | null = null; 7 | { 8 | await using page = await browser.newPage(); 9 | await page.goto("https://example.com"); 10 | await page.waitForNetworkIdle(); 11 | ref = page; 12 | } 13 | 14 | assertRejects(() => ref.close(), "already been closed"); 15 | 16 | await browser.close(); 17 | }); 18 | 19 | Deno.test("Browser - close with 'using' keyword", async () => { 20 | let ref: Browser | null = null; 21 | { 22 | await using browser = await launch(); 23 | ref = browser; 24 | const page = await browser.newPage(); 25 | await page.goto("https://example.com"); 26 | await page.waitForNetworkIdle(); 27 | await page.close(); 28 | } 29 | assertRejects(() => ref.close(), "already been closed"); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/sandbox_test.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultCachePath, launch } from "../mod.ts"; 2 | import { assertStrictEquals } from "@std/assert"; 3 | import { fromFileUrl } from "@std/path/from-file-url"; 4 | import { assert } from "@std/assert/assert"; 5 | 6 | const cache = getDefaultCachePath(); 7 | 8 | const permissions = { read: [cache], write: true, run: true, env: true }; 9 | const status = 10 | "window.performance.getEntriesByType('navigation')[0].responseStatus"; 11 | 12 | Deno.test("Sandbox fulfill granted net permissions", { 13 | permissions: { ...permissions, net: ["127.0.0.1", "example.com"] }, 14 | }, async () => { 15 | const browser = await launch(); 16 | const page = await browser.newPage("http://example.com", { sandbox: true }); 17 | assertStrictEquals(await page.evaluate(status), 200); 18 | await browser.close(); 19 | }); 20 | 21 | Deno.test("Sandbox reject denied net permissions", { 22 | permissions: { ...permissions, net: ["127.0.0.1"] }, 23 | }, async () => { 24 | const browser = await launch(); 25 | const page = await browser.newPage("http://example.com", { sandbox: true }); 26 | assertStrictEquals(await page.evaluate(status), 0); 27 | await browser.close(); 28 | }); 29 | 30 | Deno.test("Sandbox fulfill granted read permissions", { 31 | permissions: { 32 | ...permissions, 33 | read: [...permissions.read, fromFileUrl(import.meta.url)], 34 | net: ["127.0.0.1"], 35 | }, 36 | }, async () => { 37 | const browser = await launch(); 38 | const page = await browser.newPage(import.meta.url, { sandbox: true }); 39 | assertStrictEquals(await page.evaluate(status), 200); 40 | await browser.close(); 41 | }); 42 | 43 | Deno.test("Sandbox reject denied read permissions", { 44 | permissions: { ...permissions, net: ["127.0.0.1"] }, 45 | }, async () => { 46 | const browser = await launch(); 47 | const page = await browser.newPage(import.meta.url, { sandbox: true }); 48 | assertStrictEquals(await page.evaluate(status), 0); 49 | await browser.close(); 50 | }); 51 | 52 | Deno.test("Sandbox cannot be escaped with redirects or scripts", { 53 | permissions: { ...permissions, net: ["127.0.0.1", "example.com"] }, 54 | }, async () => { 55 | const browser = await launch(); 56 | const page = await browser.newPage("http://example.com", { sandbox: true }); 57 | assertStrictEquals(await page.evaluate(status), 200); 58 | await page.evaluate("location = 'http://google.com'"); 59 | assertStrictEquals(await page.evaluate(status), 0); 60 | assert( 61 | await page.evaluate( 62 | "fetch('https://deno.land').then(() => false).catch(() => true)", 63 | ), 64 | ); 65 | await browser.close(); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/set_content_test.ts: -------------------------------------------------------------------------------- 1 | import { assertStrictEquals } from "@std/assert"; 2 | import { launch } from "../mod.ts"; 3 | 4 | Deno.test("Set content", async () => { 5 | // Launch browser 6 | const browser = await launch(); 7 | const content = ` 8 | 9 | 10 | 11 | Astral 12 | 13 | 14 | Hello world 15 | 16 | `; 17 | 18 | // Open the webpage and set content 19 | const page = await browser.newPage(); 20 | await page.setContent(content); 21 | 22 | // Wait for selector 23 | assertStrictEquals( 24 | content.replace(/\s/g, ""), 25 | `${await page.content()}`.replace(/\s/g, ""), 26 | ); 27 | const selected = await page.waitForSelector("span"); 28 | assertStrictEquals(await selected.innerText(), "Hello world"); 29 | 30 | // Close browser 31 | await browser.close(); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/stealth_test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { assert } from "@std/assert/assert"; 4 | 5 | import { launch } from "../mod.ts"; 6 | 7 | Deno.test("Testing stealth", async () => { 8 | // Launch browser 9 | const browser = await launch(); 10 | 11 | // Open the webpage 12 | const page = await browser.newPage("http://example.com"); 13 | 14 | // passing arguments to evaluate 15 | const userAgent = await page.evaluate(() => { 16 | return navigator.userAgent; 17 | }) as string; 18 | assert(!userAgent.toLowerCase().includes("headless")); 19 | 20 | // Close browser 21 | await browser.close(); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/wait_test.ts: -------------------------------------------------------------------------------- 1 | import { assertSnapshot } from "@std/testing/snapshot"; 2 | import { assertRejects } from "@std/assert"; 3 | 4 | import { launch } from "../mod.ts"; 5 | 6 | Deno.test("Wait for selector", async () => { 7 | // Launch browser 8 | const browser = await launch(); 9 | 10 | // Open the webpage 11 | const page = await browser.newPage("https://example.com"); 12 | 13 | // Wait for selector 14 | const selected = await page.waitForSelector("h1"); 15 | console.log(selected); 16 | 17 | // Close browser 18 | await browser.close(); 19 | }); 20 | 21 | Deno.test("Fail wait for selector", async () => { 22 | // Launch browser 23 | const browser = await launch(); 24 | 25 | // Open the webpage 26 | const page = await browser.newPage("https://example.com"); 27 | 28 | await assertRejects( 29 | () => { 30 | return page.waitForSelector(".font-bold1", { timeout: 1000 }); 31 | }, 32 | Error, 33 | "Unable to get element from selector", 34 | ); 35 | 36 | await browser.close(); 37 | }); 38 | 39 | Deno.test("Wait for function", async (t) => { 40 | // Launch browser 41 | const browser = await launch(); 42 | 43 | // Open the webpage 44 | const page = await browser.newPage("https://pyro.deno.dev"); 45 | 46 | // Wait for function (kind of a stupid case, but whatever, make it better if you see this) 47 | const fetched = await page.waitForFunction(async () => { 48 | const req = await fetch("https://pyro.deno.dev/guides/"); 49 | return await req.text(); 50 | }); 51 | assertSnapshot(t, fetched); 52 | 53 | // Close browser 54 | await browser.close(); 55 | }); 56 | --------------------------------------------------------------------------------