├── .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 |
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 The SSG documentation site framework you've been waiting for. Run the following to install Pyro :
deno run -Ar https://deno.land/x/pyro/install.ts
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!Copyright © 2023 Lino Le Van
92 | MIT Licensed
\`
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 | increment
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 | Click me
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 |
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 | //
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 |
--------------------------------------------------------------------------------