From 72949eabf3ead379f0e08368300a869ea6b0f1c4 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Tue, 30 Apr 2024 22:06:57 +0100 Subject: [PATCH 01/34] Replace assertNoConsoleErrors with consoleErrors --- deps.ts | 1 - src/page.ts | 89 +++++++++++++++++--------------------- src/protocol.ts | 36 ++++++++------- tests/server.ts | 14 ++++++ tests/unit/element_test.ts | 6 +++ tests/unit/page_test.ts | 55 +++++------------------ 6 files changed, 92 insertions(+), 109 deletions(-) diff --git a/deps.ts b/deps.ts index cd57629..d59b83d 100644 --- a/deps.ts +++ b/deps.ts @@ -2,7 +2,6 @@ import type { Protocol } from "https://unpkg.com/devtools-protocol@0.0.979918/ty export { Protocol }; export { assertEquals, - AssertionError, assertNotEquals, } from "https://deno.land/std@0.139.0/testing/asserts.ts"; export { deferred } from "https://deno.land/std@0.139.0/async/deferred.ts"; diff --git a/src/page.ts b/src/page.ts index 0b8679d..d661618 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,4 +1,4 @@ -import { AssertionError, deferred, Protocol } from "../deps.ts"; +import { deferred, Protocol } from "../deps.ts"; import { existsSync, generateTimestamp } from "./utility.ts"; import { Element } from "./element.ts"; import { Protocol as ProtocolClass } from "./protocol.ts"; @@ -31,6 +31,8 @@ export class Page { readonly client: Client; + #console_errors: string[] = []; + constructor( protocol: ProtocolClass, targetId: string, @@ -41,6 +43,35 @@ export class Page { this.target_id = targetId; this.client = client; this.#frame_id = frameId; + + const onError = (event: Event) => { + this.#console_errors.push((event as CustomEvent).detail); + }; + + addEventListener("Log.entryAdded", onError); + addEventListener("Runtime.exceptionThrow", onError); + } + + /** + * @example + * ```ts + * const waitForNewPage = page.waitFor("Page.windowOpen"); + * await elem.click(); + * await waitForNewPage + * const page2 = browser.page(2) + * ``` + * + * @param methodName + */ + public async waitFor(methodName: string): Promise { + const p = deferred(); + const listener = (event: Event) => { + p.resolve((event as CustomEvent).detail); + }; + addEventListener(methodName, listener); + const result = await p as T; + removeEventListener(methodName, listener); + return result; } public get socket() { @@ -383,56 +414,16 @@ export class Page { } /** - * Assert that there are no errors in the developer console, such as: - * - 404's (favicon for example) - * - Issues with JavaScript files - * - etc - * - * @param exceptions - A list of strings that if matched, will be ignored such as ["favicon.ico"] if you want/need to ignore a 404 error for this file - * - * @throws AssertionError + * Return the current list of console errors present in the dev tools */ - public async assertNoConsoleErrors(exceptions: string[] = []) { - const forMessages = deferred(); - let notifCount = 0; - // deno-lint-ignore no-this-alias - const self = this; - const interval = setInterval(function () { - const notifs = self.#protocol.console_errors; - // If stored notifs is greater than what we've got, then - // more notifs are being sent to us, so wait again - if (notifs.length > notifCount) { - notifCount = notifs.length; - return; - } - // Otherwise, we have not gotten anymore notifs in the last .5s - clearInterval(interval); - forMessages.resolve(); + public async consoleErrors(): Promise { + // Give it some extra time in case to pick up some more + const p = deferred(); + setTimeout(() => { + p.resolve(); }, 500); - await forMessages; - const errorNotifs = this.#protocol.console_errors; - const filteredNotifs = !exceptions.length - ? errorNotifs - : errorNotifs.filter((notif) => { - const notifCanBeIgnored = exceptions.find((exception) => { - if (notif.includes(exception)) { - return true; - } - return false; - }); - if (notifCanBeIgnored) { - return false; - } - return true; - }); - if (!filteredNotifs.length) { - return; - } - await this.client.close( - "Expected console to show no errors. Instead got:\n" + - filteredNotifs.join("\n"), - AssertionError, - ); + await p; + return this.#console_errors; } /** diff --git a/src/protocol.ts b/src/protocol.ts index a6c7529..20e5431 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -46,11 +46,6 @@ export class Protocol { } > = new Map(); - /** - * Map of notifications, where the key is the method and the value is an array of the events - */ - public console_errors: string[] = []; - constructor( socket: WebSocket, ) { @@ -58,6 +53,7 @@ export class Protocol { // Register on message listener this.socket.onmessage = (msg) => { const data = JSON.parse(msg.data); + console.log(data); this.#handleSocketMessage(data); }; } @@ -94,9 +90,10 @@ export class Protocol { #handleSocketMessage( message: MessageResponse | NotificationResponse, ) { - // TODO :: make it unique eg `.message` so say another page instance wont pick up events for the wrong websocket - dispatchEvent(new CustomEvent("message", { detail: message })); if ("id" in message) { // message response + // TODO :: make it unique eg `.message` so say another page instance wont pick up events for the wrong websocket + dispatchEvent(new CustomEvent("message", { detail: message })); + const resolvable = this.#messages.get(message.id); if (!resolvable) { return; @@ -109,24 +106,33 @@ export class Protocol { } } if ("method" in message) { // Notification response - // Store certain methods for if we need to query them later + dispatchEvent( + new CustomEvent(message.method, { + detail: message.params, + }), + ); + + // Handle console errors if (message.method === "Runtime.exceptionThrown") { const params = message .params as unknown as ProtocolTypes.Runtime.ExceptionThrownEvent; const errorMessage = params.exceptionDetails.exception?.description ?? params.exceptionDetails.text; - if (errorMessage) { - this.console_errors.push(errorMessage); - } + dispatchEvent( + new CustomEvent("consoleError", { + detail: errorMessage, + }), + ); } if (message.method === "Log.entryAdded") { const params = message .params as unknown as ProtocolTypes.Log.EntryAddedEvent; if (params.entry.level === "error") { - const errorMessage = params.entry.text; - if (errorMessage) { - this.console_errors.push(errorMessage); - } + dispatchEvent( + new CustomEvent("consoleError", { + detail: params.entry.text, + }), + ); } } diff --git a/tests/server.ts b/tests/server.ts index 57b7f18..3d4f2d8 100644 --- a/tests/server.ts +++ b/tests/server.ts @@ -41,6 +41,19 @@ class DialogsResource extends Drash.Resource { } } +class DownloadResource extends Drash.Resource { + public paths = ["/downloads", "/downloads/download"]; + + public GET(r: Drash.Request, res: Drash.Response) { + if (r.url.includes("downloads/download")) { + return res.download("./mod.ts", "application/typescript"); + } + return res.html(` + Download + `); + } +} + class InputResource extends Drash.Resource { public paths = ["/input"]; @@ -89,6 +102,7 @@ export const server = new Drash.Server({ WaitForRequestsResource, InputResource, DialogsResource, + DownloadResource, ], protocol: "http", port: 1447, diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index cef2c3a..a7e8be5 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -12,6 +12,12 @@ const serverAdd = `http://${ for (const browserItem of browserList) { Deno.test(browserItem.name, async (t) => { await t.step("click()", async (t) => { + await t.step( + "Can handle things like downloads opening new tab then closing", + async () => { + }, + ); + await t.step( "It should allow clicking of elements and update location", async () => { diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 66d006c..23a0342 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -173,7 +173,6 @@ for (const browserItem of browserList) { }); await t.step("location()", async (t) => { - // TODO await t.step( "Handles correctly and doesnt hang when invalid URL", async () => { @@ -217,71 +216,39 @@ for (const browserItem of browserList) { }); await t.step({ - name: "assertNoConsoleErrors()", + name: "consoleErrors()", fn: async (t) => { await t.step(`Should throw when errors`, async () => { server.run(); const { browser, page } = await buildFor(browserItem.name, { remote, }); - // I (ed) knows this page shows errors, but if we ever need to change it in the future, - // can always spin up a drash web app and add errors in the js to produce console errors await page.location( serverAdd, ); - let errMsg = ""; - try { - await page.assertNoConsoleErrors(); - } catch (e) { - errMsg = e.message; - } + const errors = await page.consoleErrors(); await browser.close(); await server.close(); assertEquals( - errMsg.startsWith( - `Expected console to show no errors. Instead got:\n`, - ), - true, + errors, + [ + "Failed to load resource: the server responded with a status of 404 (Not Found)", + "ReferenceError: callUser is not defined\n" + + ` at ${serverAdd}/index.js:1:1`, + ], ); - assertEquals(errMsg.includes("Not Found"), true); - assertEquals(errMsg.includes("callUser"), true); }); - await t.step(`Should not throw when no errors`, async () => { + await t.step(`Should be empty if no errors`, async () => { const { browser, page } = await buildFor(browserItem.name, { remote, }); await page.location( "https://drash.land", ); - await page.assertNoConsoleErrors(); + const errors = await page.consoleErrors(); await browser.close(); - }); - - await t.step(` Should exclude messages`, async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location( - serverAdd, - ); - let errMsg = ""; - try { - await page.assertNoConsoleErrors(["callUser"]); - } catch (e) { - errMsg = e.message; - } - await server.close(); - await browser.close(); - assertEquals( - errMsg.startsWith( - "Expected console to show no errors. Instead got", - ), - true, - ); - assertEquals(errMsg.includes("Not Found"), true); - assertEquals(errMsg.includes("callUser"), false); + assertEquals(errors, []); }); }, }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser From 7f7da2733c1770f37bc6019a6cde7e32c58ecb90 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Thu, 2 May 2024 23:11:11 +0100 Subject: [PATCH 02/34] completely refactor, huge breaking changes, simpler api --- .github/release_drafter_config.yml | 2 +- deps.ts | 5 +- mod.ts | 30 +- src/client.ts | 336 +++-------- src/element.ts | 252 ++------ src/interfaces.ts | 2 - src/page.ts | 307 ++++------ src/protocol.ts | 70 +-- src/types.ts | 1 - src/utility.ts | 45 +- tests/browser_list.ts | 63 -- .../integration/csrf_protected_pages_test.ts | 45 +- tests/integration/manipulate_page_test.ts | 94 ++- tests/unit/Screenshots/.gitkeep | 0 tests/unit/client_test.ts | 266 ++++---- tests/unit/element_test.ts | 566 +++++++---------- tests/unit/page_test.ts | 568 +++++++----------- 17 files changed, 857 insertions(+), 1795 deletions(-) delete mode 100644 src/types.ts delete mode 100644 tests/browser_list.ts delete mode 100644 tests/unit/Screenshots/.gitkeep diff --git a/.github/release_drafter_config.yml b/.github/release_drafter_config.yml index 7af2f47..96e3636 100644 --- a/.github/release_drafter_config.yml +++ b/.github/release_drafter_config.yml @@ -35,7 +35,7 @@ template: | * Import this latest release by using the following in your project(s): ```typescript - import { buildFor } from "https://deno.land/x/sinco@v$RESOLVED_VERSION/mod.ts"; + import { build } from "https://deno.land/x/sinco@v$RESOLVED_VERSION/mod.ts"; ``` __Updates__ diff --git a/deps.ts b/deps.ts index d59b83d..46aee38 100644 --- a/deps.ts +++ b/deps.ts @@ -1,8 +1,5 @@ import type { Protocol } from "https://unpkg.com/devtools-protocol@0.0.979918/types/protocol.d.ts"; export { Protocol }; -export { - assertEquals, - assertNotEquals, -} from "https://deno.land/std@0.139.0/testing/asserts.ts"; +export { assertEquals } from "https://deno.land/std@0.139.0/testing/asserts.ts"; export { deferred } from "https://deno.land/std@0.139.0/async/deferred.ts"; export type { Deferred } from "https://deno.land/std@0.139.0/async/deferred.ts"; diff --git a/mod.ts b/mod.ts index 2a2c281..01d937a 100644 --- a/mod.ts +++ b/mod.ts @@ -1,18 +1,14 @@ import { Client } from "./src/client.ts"; import { BuildOptions, Cookie, ScreenshotOptions } from "./src/interfaces.ts"; -//import type { Browsers } from "./src/types.ts"; -import { getChromeArgs } from "./src/utility.ts"; import { Page } from "./src/page.ts"; export type { BuildOptions, Cookie, ScreenshotOptions }; -export async function buildFor( - browser: "chrome", +export async function build( options: BuildOptions = { hostname: "localhost", debuggerPort: 9292, binaryPath: undefined, - remote: false, }, ): Promise<{ browser: Client; @@ -20,34 +16,10 @@ export async function buildFor( }> { if (!options.debuggerPort) options.debuggerPort = 9292; if (!options.hostname) options.hostname = "localhost"; - //if (browser === "chrome") { - const args = getChromeArgs(options.debuggerPort, options.binaryPath); return await Client.create( - args, { hostname: options.hostname, port: options.debuggerPort, - remote: !!options.remote, }, - browser, - undefined, ); - //} - // else { - // const tmpDirName = Deno.makeTempDirSync(); - // const args = getFirefoxArgs( - // tmpDirName, - // options.debuggerPort, - // options.binaryPath, - // ); - // return await Client.create( - // args, - // { - // hostname: options.hostname, - // port: options.debuggerPort, - // }, - // browser, - // tmpDirName, - // ); - //} } diff --git a/src/client.ts b/src/client.ts index 6233885..109efcd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,27 +1,12 @@ -import { Protocol as ProtocolClass } from "./protocol.ts"; -import { deferred, Protocol as ProtocolTypes } from "../deps.ts"; +import { deferred } from "../deps.ts"; import { Page } from "./page.ts"; -import type { Browsers } from "./types.ts"; -import { existsSync } from "./utility.ts"; -import { TextLineStream } from "jsr:@std/streams"; - -// https://stackoverflow.com/questions/50395719/firefox-remote-debugging-with-websockets -// FYI for reference, we can connect using websockets, but severe lack of documentation gives us NO info on how to proceed after: -/** - * $ --profile --headless --remote-debugging-port 1448 - * ```ts - * const res = await fetch("http://localhost:1448/json/list") - * const json = await res.json() - * consy url = json[json.length - 1]["webSocketDebuggerUrl"] - * const c = new WebSocket(url) - * ``` - */ +import { getChromeArgs } from "./utility.ts"; /** * A way to interact with the headless browser instance. * * This is the entrypoint API to creating and interacting with the chrome or - * firefox browser. It allows: + * browser. It allows: * - Starting the headless browser (subprocess) * - Methods to interact with the client such as: * - Visiting a page and returning a `Page` class @@ -39,38 +24,14 @@ import { TextLineStream } from "jsr:@std/streams"; */ export class Client { /** - * Whilst we won't be using this like a page would, it is used - * as a 'general' protocol, not specific to pages, but maybe - * to get targets, or general information of the overal browser + * Websocket conn for the overall browser process */ - readonly #protocol: ProtocolClass; + readonly #socket: WebSocket; /** * The sub process that runs headless chrome */ - readonly #browser_process: Deno.ChildProcess | undefined; - - /** - * Track if we've closed the sub process, so we dont try close it when it already has been - */ - #browser_process_closed = false; - - /** - * What browser are we running? - */ - readonly browser: Browsers; - - /** - * The collection of page objects for a user to interact with - */ - #pages: Page[] = []; - - /** - * Only if the browser is firefox, is this present. - * This is the path to the directory that firefox uses - * to write a profile - */ - readonly #firefox_profile_path?: string; + readonly #browser_process: Deno.ChildProcess; /** * The host and port that the websocket server is listening on @@ -81,84 +42,20 @@ export class Client { }; /** - * @param protocol - The browser protocol to interact with * @param browserProcess - The browser process to interact with - * @param browser - The name of the browser we will be running * @param wsOptions - The debugger options - * @param firefoxProfilePath - The path to the firefox dev profile (if applicable) */ constructor( - protocol: ProtocolClass, - browserProcess: Deno.ChildProcess | undefined, - browser: Browsers, + socket: WebSocket, + browserProcess: Deno.ChildProcess, wsOptions: { hostname: string; port: number; }, - firefoxProfilePath?: string, ) { - this.#protocol = protocol; + this.#socket = socket; this.#browser_process = browserProcess; - this.browser = browser; this.wsOptions = wsOptions; - this.#firefox_profile_path = firefoxProfilePath; - } - - /** - * Only for internal use. No documentation or help - * will be provided to users regarding this method - * - * This was only created so we could make `pages` property private, - * but still allow the Page class to remove a page from the list - * - * @param pageTargetId - Target id of the page to remove - */ - public _popPage(pageTargetId: string) { - this.#pages = this.#pages.filter((page) => page.target_id !== pageTargetId); - } - - /** - * For internal use. - * - * Pushed a new item to the pages array - * - * @param page - Page to push - */ - public _pushPage( - page: Page, - ): void { - this.#pages.push(page); - } - - /** - * A way to get a page. Useful if a new tab/page has opened - * - * @example - * ```js - * const { browser, page } = await buildFor("chrome"); - * console.log(await browser.page(1)); // Your initial page, exactly what `page` above is - * // You middle click an element - * console.log(await browser.page(2)); // will return a Page representation of the newly opened page - * ``` - * - * @param pageNumber - Which page to get, the first/initial page? 1. The second you just opened via a click? 2. - * @returns The page - */ - public async page(pageNumber: number): Promise { - // `i` is given to us in a way that makes the user understand exactly what page they want. - // If 1, they want the first page, so we will get the 0th index - const index = pageNumber - 1; - - if (!this.#pages[index]) { - await this.close( - "You have request to get page number " + pageNumber + ", but only " + - this.#pages.length + - " pages are opened. If the issue persists, please submit an issue.", - RangeError, - ); - } - - return this.#pages[index]; } /** @@ -171,78 +68,20 @@ export class Client { errMsg?: string, errClass: { new (message: string): Error } = Error, ) { - // Say a user calls an assertion method, and then calls close(), we make sure that if - // the subprocess is already closed, dont try close it again - if (this.#browser_process_closed === true) { - return; - } - // Close browser process (also closes the ws endpoint, which in turn closes all sockets) - if (this.#browser_process) { - this.#browser_process.stderr.cancel(); - this.#browser_process.stdout.cancel(); - this.#browser_process.kill(); - await this.#browser_process.status; - } else { - // When Working with Remote Browsers, where we don't control the Browser Process explicitly - const promise = deferred(); - this.#protocol.socket.onclose = () => promise.resolve(); - await this.#protocol.send("Browser.close"); - await promise; - } - - // Zombie processes is a thing with Windows, the firefox process on windows - // will not actually be closed using the above. - // Related Deno issue: https://github.com/denoland/deno/issues/7087 - /* if ( - this.#browser_process && this.browser === "firefox" && - Deno.build.os === "windows" - ) { - const p = Deno.run({ - cmd: ["taskkill", "/F", "/IM", "firefox.exe"], - stdout: "null", - stderr: "null", - }); - await p.status(); - p.close(); - } */ - - this.#browser_process_closed = true; - - if (this.#firefox_profile_path) { - // On windows, this block is annoying. We either get a perm denied or - // resource is in use error (classic windows). So what we're doing here is - // even if one of those errors are thrown, keep trying because what i've (ed) - // found is, it seems to need a couple seconds to realise that the dir - // isnt being used anymore. The loop shouldn't be needed for macos/unix though, so - // it will likely only run once. - while (existsSync(this.#firefox_profile_path)) { - try { - Deno.removeSync(this.#firefox_profile_path, { recursive: true }); - } catch (_e) { - // Just try removing again - } - } - } + const p = deferred(); + this.#socket.onclose = () => p.resolve(); + this.#browser_process.stderr.cancel(); + this.#browser_process.stdout.cancel(); + this.#browser_process.kill(); + await this.#browser_process.status; + await p; if (errMsg) { throw new errClass(errMsg); } } - /** - * Will close every tab/page that isn't the one passed in. - * Useful if for some reason, a site has opened multiple tabs that you will not use - * - * @param page - The page to not close - */ - public async closeAllPagesExcept(page: Page) { - const pages = this.#pages.filter((p) => p.target_id !== page.target_id); - for (const page of pages) { - await page.close(); - } - } - /** * Creates the instance and protocol to interact with, and a Page * instance, representing a placeholder page we opened for you @@ -250,113 +89,82 @@ export class Client { * @param buildArgs - Sub process args, should be ones to run chrome * @param wsOptions - Hostname and port to run the websocket server on, and whether the browser is remote * @param browser - Which browser we are building - * @param firefoxProfilePath - If firefox, the path to the temporary profile location * * @returns A client and browser instance, ready to be used */ static async create( - buildArgs: string[], wsOptions: { hostname: string; port: number; - remote: boolean; }, - browser: Browsers, - firefoxProfilePath?: string, ): Promise<{ browser: Client; page: Page; }> { - let browserProcess: Deno.ChildProcess | undefined = undefined; - let browserWsUrl = ""; - // Run the subprocess, this starts up the debugger server - if (!wsOptions.remote) { //Skip this if browser is remote - const path = buildArgs.splice(0, 1)[0]; - const command = new Deno.Command(path, { - args: buildArgs, - stderr: "piped", - stdout: "piped", - }); - browserProcess = command.spawn(); - - // Get the main ws conn for the client - this loop is needed as the ws server isn't open until we get the listeneing on. - // We could just loop on the fetch of the /json/list endpoint, but we could tank the computers resources if the endpoint - // isn't up for another 10s, meaning however many fetch requests in 10s - // Sometimes it takes a while for the "Devtools listening on ws://..." line to show on windows + firefox too - for await ( - const line of browserProcess.stderr.pipeThrough(new TextDecoderStream()) - .pipeThrough(new TextLineStream()) - ) { // Loop also needed before json endpoint is up - const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); - if (!match) { - continue; - } - browserWsUrl = line.split("on ")[1]; - break; - } - } else { //We just fetch the browser ws url on the json endpoint - //This code waits for the remote browser for 5 seconds - const waitTill = new Date().getTime() + 5000; - let jsonObj = undefined; - do { - try { - jsonObj = await (await fetch( - `http://${wsOptions.hostname}:${wsOptions.port}/json/version`, - )).json(); - break; - } catch (_ex) { - //do nothing - } - } while (new Date().getTime() < waitTill); - - browserWsUrl = jsonObj["webSocketDebuggerUrl"]; - } - - // Create the browser protocol - const mainProtocol = await ProtocolClass.create(browserWsUrl); - - // Get the connection info for the default page thats opened, that acts as our first page - // Sometimes, it isn't immediently available (eg `targets` is `[]`), so poll until it refreshes with the page - - async function getInitialPage(): Promise { - const targets = await mainProtocol.send< - null, - ProtocolTypes.Target.GetTargetsResponse - >("Target.getTargets"); - const target = targets.targetInfos.find((info) => - info.type === "page" && (info.url === "about:blank" || wsOptions.remote) - ); - - if (!target) { - return await getInitialPage(); + const buildArgs = getChromeArgs(wsOptions.port); + const path = buildArgs.splice(0, 1)[0]; + const command = new Deno.Command(path, { + args: buildArgs, + stderr: "piped", + stdout: "piped", + }); + const browserProcess = command.spawn(); + // Old approach until we discovered we can always just use fetch + // // Get the main ws conn for the client - this loop is needed as the ws server isn't open until we get the listeneing on. + // // We could just loop on the fetch of the /json/list endpoint, but we could tank the computers resources if the endpoint + // // isn't up for another 10s, meaning however many fetch requests in 10s + // // Sometimes it takes a while for the "Devtools listening on ws://..." line to show on windows + firefox too + // import { TextLineStream } from "jsr:@std/streams"; + // for await ( + // const line of browserProcess.stderr.pipeThrough(new TextDecoderStream()) + // .pipeThrough(new TextLineStream()) + // ) { // Loop also needed before json endpoint is up + // const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); + // if (!match) { + // continue; + // } + // browserWsUrl = line.split("on ")[1]; + // break; + // } + + // Wait until endpoint is ready and get a WS connection + // to the main socket + const p = deferred(); + const intervalId = setTimeout(async () => { + try { + const res = await fetch( + `http://${wsOptions.hostname}:${wsOptions.port}/json/version`, + ); + const json = await res.json(); + const socket = new WebSocket(json["webSocketDebuggerUrl"]); + const p2 = deferred(); + socket.onopen = () => p2.resolve(); + await p2; + p.resolve(socket); + clearInterval(intervalId); + } catch (_ex) { + //do nothing } - return target; - } - const pageTarget = await getInitialPage(); + }, 200); - await mainProtocol.send("Target.attachToTarget", { - targetId: pageTarget.targetId, - }); + const clientSocket = await p; - // Create protocol for the default page - const { protocol: pageProtocol, frameId } = await ProtocolClass.create( - `ws://${wsOptions.hostname}:${wsOptions.port}/devtools/page/${pageTarget.targetId}`, - true, + const listRes = await fetch( + `http://${wsOptions.hostname}:${wsOptions.port}/json/list`, ); + const targetId = (await listRes.json())[0]["id"]; - // Return a client and page instance for the user to interact with const client = new Client( - mainProtocol, + clientSocket, browserProcess, - browser, - { - hostname: wsOptions.hostname, - port: wsOptions.port, - }, - firefoxProfilePath, + wsOptions, + ); + + const page = await Page.create( + client, + targetId, ); - const page = new Page(pageProtocol, pageTarget.targetId, client, frameId); - client.#pages.push(page); + return { browser: client, page, diff --git a/src/element.ts b/src/element.ts index 5f9aa36..7059197 100644 --- a/src/element.ts +++ b/src/element.ts @@ -1,9 +1,13 @@ import { Page } from "./page.ts"; -import { Protocol } from "./protocol.ts"; import { deferred, Protocol as ProtocolTypes } from "../deps.ts"; -import { existsSync, generateTimestamp } from "./utility.ts"; -import { ScreenshotOptions, WebsocketTarget } from "./interfaces.ts"; +import { ScreenshotOptions } from "./interfaces.ts"; import { waitUntilNetworkIdle } from "./utility.ts"; + +// Eg if parameter is a string +type Click = T extends "middle" ? Page + : void; +type WaitFor = "navigation" | "newPage"; + /** * A class to represent an element on the page, providing methods * to action on that element @@ -17,42 +21,32 @@ export class Element { /** * How we select the element */ - readonly #method: "document.querySelector" | "$x"; + readonly #method = "document.querySelector"; // | "$x"; /** * The page this element belongs to */ readonly #page: Page; - /** - * Protocol to use, attached to the page - */ - readonly #protocol: Protocol; - /** * ObjectId belonging to this element */ - readonly #objectId?: string; + readonly #node: ProtocolTypes.DOM.Node; /** * @param method - The method we use for query selecting * @param selector - The CSS selector * @param page - The page this element belongs to - * @param protocol - The protocol for the page this element belongs to * @param objectId - The object id assigned to the element */ constructor( - method: "document.querySelector" | "$x", selector: string, page: Page, - protocol: Protocol, - objectId?: string, + node: ProtocolTypes.DOM.Node, ) { - this.#objectId = objectId; + this.#node = node; this.#page = page; this.#selector = selector; - this.#method = method; - this.#protocol = protocol; } /** @@ -110,58 +104,33 @@ export class Element { ); } - const { node } = await this.#protocol.send< - ProtocolTypes.DOM.DescribeNodeRequest, - ProtocolTypes.DOM.DescribeNodeResponse - >("DOM.describeNode", { - objectId: this.#objectId, - }); - await this.#protocol.send( + await this.#page.send( "DOM.setFileInputFiles", { files: files, - objectId: this.#objectId, - backendNodeId: node.backendNodeId, + nodeId: this.#node.nodeId, + backendNodeId: this.#node.backendNodeId, }, ); } - /** - * Get the value of this element, or set the value - * - * @param newValue - If not passed, will return the value, else will set the value - * - * @returns The value if getting, else if setting then an empty string - */ - public async value(newValue?: string): Promise { - if (!newValue) { - return await this.#page.evaluate( - `${this.#method}('${this.#selector}').value`, - ); - } - await this.#page.evaluate( - `${this.#method}('${this.#selector}').value = \`${newValue}\``, - ); - return ""; - } - /** * Take a screenshot of the element and save it to `filename` in `path` folder, with a `format` and `quality` (jpeg format only) * + * @example + * ```ts + * const uint8array = await element.takeScreenshot(); + * Deno.writeFileSync('./file.jpg', uint8array); + * ``` + * * @param path - The path of where to save the screenshot to * @param options * - * @returns The path to the file relative to CWD, e.g., "Screenshots/users/user_1.png" + * @returns The data */ async takeScreenshot( - path: string, options?: ScreenshotOptions, - ): Promise { - if (!existsSync(path)) { - await this.#page.client.close( - `The provided folder path "${path}" doesn't exist`, - ); - } + ): Promise { const ext = options?.format ?? "jpeg"; const rawViewportResult = await this.#page.evaluate( `JSON.stringify(${this.#method}('${this.#selector}').getBoundingClientRect())`, @@ -181,12 +150,12 @@ export class Element { ); } - //Quality should defined only if format is jpeg + // Quality should defined only if format is jpeg const quality = (ext == "jpeg") ? ((options?.quality) ? Math.abs(options.quality) : 80) : undefined; - const res = await this.#protocol.send< + const res = await this.#page.send< ProtocolTypes.Page.CaptureScreenshotRequest, ProtocolTypes.Page.CaptureScreenshotResponse >( @@ -198,20 +167,10 @@ export class Element { }, ); - //Writing the Obtained Base64 encoded string to image file - const fName = `${path}/${ - options?.fileName?.replaceAll(/.jpeg|.jpg|.png/g, "") ?? - generateTimestamp() - }.${ext}`; const B64str = res.data; const u8Arr = Uint8Array.from(atob(B64str), (c) => c.charCodeAt(0)); - try { - Deno.writeFileSync(fName, u8Arr); - } catch (e) { - await this.#page.client.close(e.message); - } - return fName; + return u8Arr; } /** @@ -229,36 +188,14 @@ export class Element { * * @example * ```js - * // Clicking an anchor tag - * await click({ - * waitFor: "navigation" - * }) - * // Clicking an anchor tag with `__BLANK` - * await click({ - * button: "middle", - * }) + * await click(); // eg button + * await click({ waitFor: 'navigation' }); // eg if link or form submit + * const newPage = await click({ waitFor: 'newPage' }); // If download button or anchor tag with _BLANK + * ``` */ - public async click(options: { - button?: "left" | "middle" | "right"; - waitFor?: "navigation"; - } = {}): Promise { - /** - * TODO :: Remember to check now and then to see if this is fixed - * This whole process doesnt work for firefox.. we get no events of a new tab opening. If you remove headless, - * and try open a new tab manually or middle clicky ourself, you get no events. Not sure if it's our fault or a CDP - * problem, but some related links are https://github.com/puppeteer/puppeteer/issues/6932 and - * https://github.com/puppeteer/puppeteer/issues/7444 - */ - if ( - this.#page.client.browser === "firefox" && options.button === "middle" - ) { - await this.#page.client.close( - "Middle clicking in Firefox doesn't work at the moment. Please mention on our Discord if you would like to discuss it.", - ); - } - - if (!options.button) options.button = "left"; - + public async click(options: { + waitFor?: WaitFor; + } = {}): Promise> { // Scroll into view await this.#page.evaluate( `${this.#method}('${this.#selector}').scrollIntoView({ @@ -269,13 +206,13 @@ export class Element { ); // Get details we need for dispatching input events on the element - const result = await this.#protocol.send< + const result = await this.#page.send< ProtocolTypes.DOM.GetContentQuadsRequest, ProtocolTypes.DOM.GetContentQuadsResponse >("DOM.getContentQuads", { - objectId: this.#objectId, + nodeId: this.#node.nodeId, }); - const layoutMetrics = await this.#protocol.send< + const layoutMetrics = await this.#page.send< null, ProtocolTypes.Page.GetLayoutMetricsResponse >("Page.getLayoutMetrics"); @@ -319,7 +256,7 @@ export class Element { await this.#page.client.close( `Unable to click the element "${this.#selector}". It could be that it is invalid HTML`, ); - return; + return undefined as Click; } for (const point of quad) { @@ -334,133 +271,62 @@ export class Element { middle: 4, }; - await this.#protocol.send("Input.dispatchMouseEvent", { + await this.#page.send("Input.dispatchMouseEvent", { type: "mouseMoved", - button: options.button, + button: "left", modifiers: 0, clickCount: 1, x: x + (x - x) * (1 / 1), y, - buttons: buttonsMap[options.button], + buttons: buttonsMap.left, }); // Creating this here because by the time we send the below events, and try wait for the notification, the protocol may have already got the message and discarded it - const middleClickHandler = options.button === "middle" + const newPageHandler = options.waitFor === "newPage" ? "Page.frameRequestedNavigation" : null; - if (middleClickHandler) { - this.#protocol.notifications.set( - middleClickHandler, + if (newPageHandler) { + this.#page.notifications.set( + newPageHandler, deferred(), ); } - await this.#protocol.send("Input.dispatchMouseEvent", { + await this.#page.send("Input.dispatchMouseEvent", { type: "mousePressed", - button: options.button, + button: "left", modifiers: 0, clickCount: 1, x, y, - buttons: buttonsMap[options.button], + buttons: buttonsMap.left, }); - await this.#protocol.send("Input.dispatchMouseEvent", { + await this.#page.send("Input.dispatchMouseEvent", { type: "mouseReleased", - button: options.button, + button: "left", modifiers: 0, clickCount: 1, x, y, - buttons: buttonsMap[options.button], + buttons: buttonsMap.left, }); - if (options.button === "middle" && middleClickHandler) { - const p1 = this.#protocol.notifications.get( - middleClickHandler, + if (newPageHandler) { + const p1 = this.#page.notifications.get( + newPageHandler, ); - const { url, frameId } = + const { frameId } = await p1 as unknown as ProtocolTypes.Page.FrameRequestedNavigationEvent; - this.#protocol.notifications.delete( - middleClickHandler, + this.#page.notifications.delete( + newPageHandler, ); - // Now, any events for the page we wont get, they will be sent thru the new targets ws connection, so we need to connect first: - // 1. Get target id of this new page - // 2. Create ws connection and protocol instance - // 3. Wait until the page has loaded properly and isnt about:blank - let targetId = ""; - while (!targetId) { // The ws endpoint might not have the item straight away, so give it a tiny bit of time - const res = await fetch( - `http://${this.#page.client.wsOptions.hostname}:${this.#page.client.wsOptions.port}/json/list`, - ); - const json = await res.json() as WebsocketTarget[]; - const item = json.find((j) => j.url === url); - if (!item) { - continue; - } - targetId = item.id; - } - await this.#protocol.send("Target.attachToTarget", { - targetId: this.#page.target_id, - }); - const newProt = await Protocol.create( - `ws://${this.#page.client.wsOptions.hostname}:${this.#page.client.wsOptions.port}/devtools/page/${targetId}`, - ); - const endpointPromise = deferred(); - const intervalId = setInterval(async () => { - const targets = await newProt.send< - null, - ProtocolTypes.Target.GetTargetsResponse - >("Target.getTargets"); - const target = targets.targetInfos.find((t) => - t.targetId === targetId - ) as ProtocolTypes.Target.TargetInfo; - if (target.title !== "about:blank") { - clearInterval(intervalId); - endpointPromise.resolve(); - } - }); - await endpointPromise; - - this.#page.client._pushPage( - new Page(newProt, targetId, this.#page.client, frameId), - ); - } else if (options.waitFor === "navigation") { // TODO :: Should we put this into its own method? waitForNavigation() to free up the maintability f this method, allowing us to add more params later but also for the mo, not need to do `.click({}, true)` OR maybe do `.click(..., waitFor: { navigation?: boolean, fetch?: boolean, ... }), because clicking needs to support: new pages, new locations, requests (any JS stuff, maybe when js is triggered it fired an event we can hook into?) + return await Page.create(this.#page.client, frameId) as Click; + } + if (options.waitFor === "navigation") { await waitUntilNetworkIdle(); } - } - /** - * Get an attribute on the element - * - * @example - * ```js - * const class = await elem.getAttribute("class"); // "form-control button" - * ``` - * - * @param name - The name of the attribute - * - * @returns The attribute value - */ - public async getAttribute(name: string): Promise { - return await this.#page.evaluate( - `${this.#method}('${this.#selector}').getAttribute('${name}')`, - ); - } - /** - * Set an attribute on the element - * - * @example - * ```js - * await elem.setAttribute("data-name", "Sinco"); - * ``` - * - * @param name - The name of the attribute - * @param value - The value to set the atrribute to - */ - public async setAttribute(name: string, value: string): Promise { - await this.#page.evaluate( - `${this.#method}('${this.#selector}').setAttribute('${name}', '${value}')`, - ); + return undefined as Click; } } diff --git a/src/interfaces.ts b/src/interfaces.ts index f16a52e..91daf7f 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -10,8 +10,6 @@ export interface BuildOptions { } export interface ScreenshotOptions { - /** Screenshot the given selector instead of the full page. Optional */ - fileName?: string; /** The Screenshot format(and hence extension). Allowed values are "jpeg" and "png" - Optional */ format?: "jpeg" | "png"; /** The image quality from 0 to 100, default 80. Applicable only if no format provided or format is "jpeg" - Optional */ diff --git a/src/page.ts b/src/page.ts index d661618..901a19b 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,81 +1,67 @@ -import { deferred, Protocol } from "../deps.ts"; -import { existsSync, generateTimestamp } from "./utility.ts"; +import { deferred, Protocol as ProtocolTypes } from "../deps.ts"; import { Element } from "./element.ts"; import { Protocol as ProtocolClass } from "./protocol.ts"; import { Cookie, ScreenshotOptions } from "./interfaces.ts"; import { Client } from "./client.ts"; -import type { Deferred } from "../deps.ts"; import { waitUntilNetworkIdle } from "./utility.ts"; /** * A representation of the page the client is on, allowing the client to action * on it, such as setting cookies, or selecting elements, or interacting with localstorage etc */ -export class Page { - /** - * The pages specific protocol to communicate on the page - */ - readonly #protocol: ProtocolClass; - +export class Page extends ProtocolClass { /** * If chrome, will look like 4174549611B216287286CA10AA78BF56 * If firefox, will look like 41745-49611-B2162-87286 (eg like a uuid) - */ - readonly target_id: string; - - /** - * If chrome, ends up being what target id is + * + * When frame ID, if chrome, ends up being what target id is * If firefox, will be something like "26" */ - readonly #frame_id: string; + readonly target_id: string; readonly client: Client; #console_errors: string[] = []; + #uuid: string; + constructor( - protocol: ProtocolClass, targetId: string, client: Client, - frameId: string, + socket: WebSocket, ) { - this.#protocol = protocol; + super(socket); this.target_id = targetId; this.client = client; - this.#frame_id = frameId; + this.#uuid = (Math.random() + 1).toString(36).substring(7); - const onError = (event: Event) => { - this.#console_errors.push((event as CustomEvent).detail); - }; - - addEventListener("Log.entryAdded", onError); - addEventListener("Runtime.exceptionThrow", onError); + this.#listenForErrors(); } /** - * @example - * ```ts - * const waitForNewPage = page.waitFor("Page.windowOpen"); - * await elem.click(); - * await waitForNewPage - * const page2 = browser.page(2) - * ``` - * - * @param methodName + * Responsible for listening to errors so we can collect them */ - public async waitFor(methodName: string): Promise { - const p = deferred(); - const listener = (event: Event) => { - p.resolve((event as CustomEvent).detail); + #listenForErrors() { + const onError = (event: Event) => { + if (event.type === "Runtime.exceptionThrown") { + const evt = event as CustomEvent< + ProtocolTypes.Runtime.ExceptionThrownEvent + >; + const msg = evt.detail.exceptionDetails.exception?.description || + evt.detail.exceptionDetails.text; + this.#console_errors.push(msg); + } + if (event.type === "Log.entryAdded") { + const evt = event as CustomEvent; + const { level, text } = evt.detail.entry; + if (level === "error") { + this.#console_errors.push(text); + } + } }; - addEventListener(methodName, listener); - const result = await p as T; - removeEventListener(methodName, listener); - return result; - } - public get socket() { - return this.#protocol.socket; + addEventListener("Log.entryAdded", onError); + addEventListener("Runtime.exceptionThrown", onError); } /** @@ -91,7 +77,7 @@ export class Page { * ``` */ public expectDialog() { - this.#protocol.notifications.set( + this.notifications.set( "Page.javascriptDialogOpening", deferred(), ); @@ -116,7 +102,7 @@ export class Page { * @param promptText - The text to enter into the dialog prompt before accepting. Used only if this is a prompt dialog. */ public async dialog(accept: boolean, promptText?: string) { - const p = this.#protocol.notifications.get("Page.javascriptDialogOpening"); + const p = this.notifications.get("Page.javascriptDialogOpening"); if (!p) { throw new Error( `Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.`, @@ -124,42 +110,21 @@ export class Page { } await p; const method = "Page.javascriptDialogClosed"; - this.#protocol.notifications.set(method, deferred()); - const body: Protocol.Page.HandleJavaScriptDialogRequest = { + this.notifications.set(method, deferred()); + const body: ProtocolTypes.Page.HandleJavaScriptDialogRequest = { accept, }; if (promptText) { body.promptText = promptText; } - await this.#protocol.send< - Protocol.Page.HandleJavaScriptDialogRequest, + await this.send< + ProtocolTypes.Page.HandleJavaScriptDialogRequest, null >("Page.handleJavaScriptDialog", body); - const closedPromise = this.#protocol.notifications.get(method); + const closedPromise = this.notifications.get(method); await closedPromise; } - /** - * Closes the page. After, you will not be able to interact with it - */ - public async close() { - // Delete page - this.#protocol.send< - Protocol.Target.CloseTargetRequest, - Protocol.Target.CloseTargetResponse - >("Target.closeTarget", { - targetId: this.target_id, - }); - - // wait for socket to close (closing page also shuts down connection to debugger url) - const p2 = deferred(); - this.#protocol.socket.onclose = () => p2.resolve(); - await p2; - - // And remove it from the pages array - this.client._popPage(this.target_id); - } - /** * Either get all cookies for the page, or set a cookie * @@ -169,17 +134,17 @@ export class Page { */ public async cookie( newCookie?: Cookie, - ): Promise { + ): Promise { if (!newCookie) { - const result = await this.#protocol.send< - Protocol.Network.GetCookiesRequest, - Protocol.Network.GetCookiesResponse + const result = await this.send< + ProtocolTypes.Network.GetCookiesRequest, + ProtocolTypes.Network.GetCookiesResponse >("Network.getCookies"); return result.cookies; } - await this.#protocol.send< - Protocol.Network.SetCookieRequest, - Protocol.Network.SetCookieResponse + await this.send< + ProtocolTypes.Network.SetCookieRequest, + ProtocolTypes.Network.SetCookieResponse >("Network.setCookie", { name: newCookie.name, value: newCookie.value, @@ -188,75 +153,18 @@ export class Page { return []; } - /** - * Tell Sinco that you will be expecting to wait for a request - */ - public expectWaitForRequest() { - const requestWillBeSendMethod = "Network.requestWillBeSent"; - this.#protocol.notifications.set(requestWillBeSendMethod, deferred()); - } - - /** - * Wait for a request to finish loading. - * - * Can be used to wait for: - * - Clicking a button that (via JS) will send a HTTO request via axios/fetch etc - * - Submitting an inline form - * - ... and many others - */ - public async waitForRequest() { - const params = await this.#protocol.notifications.get( - "Network.requestWillBeSent", - ) as { - requestId: string; - }; - if (!params) { - throw new Error( - `Unable to wait for a request because \`.expectWaitForRequest()\` was not called.`, - ); - } - const { requestId } = params; - const method = "Network.loadingFinished"; - this.#protocol.notifications.set(method, { - params: { - requestId, - }, - promise: deferred(), - }); - const result = this.#protocol.notifications.get(method) as unknown as { - promise: Deferred; - }; - await result.promise; - } - /** * Either get the href/url for the page, or set the location * - * @param newLocation - Only required if you want to set the location - * * @example * ```js - * const location = await page.location() // "https://drash.land" + * const location = await page.location("https://google.com"); // Or "http://localhost:9292" * ``` - * - * @returns The location for the page if no parameter is passed in, else an empty string */ - public async location(newLocation?: string): Promise { - if (!newLocation) { - const targets = await this.#protocol.send< - null, - Protocol.Target.GetTargetsResponse - >("Target.getTargets"); - const target = targets.targetInfos.find((target) => - target.targetId === this.target_id - ); - return target?.url ?? ""; - } - - // Send message - const res = await this.#protocol.send< - Protocol.Page.NavigateRequest, - Protocol.Page.NavigateResponse + public async location(newLocation: string): Promise { + const res = await this.send< + ProtocolTypes.Page.NavigateRequest, + ProtocolTypes.Page.NavigateResponse >( "Page.navigate", { @@ -272,15 +180,8 @@ export class Page { // for sure its an error if ("errorText" in res) { await this.client.close(res.errorText); - return ""; - } - - if (res.errorText) { - await this.client.close( - `${res.errorText}: Error for navigating to page "${newLocation}"`, - ); + return; } - return ""; } // deno-lint-ignore no-explicit-any @@ -328,7 +229,7 @@ export class Page { function convertArgument( this: Page, arg: unknown, - ): Protocol.Runtime.CallArgument { + ): ProtocolTypes.Runtime.CallArgument { if (typeof arg === "bigint") { return { unserializableValue: `${arg.toString()}n` }; } @@ -342,9 +243,9 @@ export class Page { } if (typeof pageCommand === "string") { - const result = await this.#protocol.send< - Protocol.Runtime.EvaluateRequest, - Protocol.Runtime.EvaluateResponse + const result = await this.send< + ProtocolTypes.Runtime.EvaluateRequest, + ProtocolTypes.Runtime.EvaluateResponse >("Runtime.evaluate", { expression: pageCommand, returnByValue: true, @@ -355,19 +256,19 @@ export class Page { } if (typeof pageCommand === "function") { - const { executionContextId } = await this.#protocol.send< - Protocol.Page.CreateIsolatedWorldRequest, - Protocol.Page.CreateIsolatedWorldResponse + const { executionContextId } = await this.send< + ProtocolTypes.Page.CreateIsolatedWorldRequest, + ProtocolTypes.Page.CreateIsolatedWorldResponse >( "Page.createIsolatedWorld", { - frameId: this.#frame_id, + frameId: this.target_id, }, ); - const res = await this.#protocol.send< - Protocol.Runtime.CallFunctionOnRequest, - Protocol.Runtime.CallFunctionOnResponse + const res = await this.send< + ProtocolTypes.Runtime.CallFunctionOnRequest, + ProtocolTypes.Runtime.CallFunctionOnResponse >( "Runtime.callFunctionOn", { @@ -392,9 +293,9 @@ export class Page { * @returns An element class, allowing you to take an action upon that element */ async querySelector(selector: string) { - const result = await this.#protocol.send< - Protocol.Runtime.EvaluateRequest, - Protocol.Runtime.EvaluateResponse + const result = await this.send< + ProtocolTypes.Runtime.EvaluateRequest, + ProtocolTypes.Runtime.EvaluateResponse >("Runtime.evaluate", { expression: `document.querySelector('${selector}')`, includeCommandLineAPI: true, @@ -404,12 +305,16 @@ export class Page { 'The selector "' + selector + '" does not exist inside the DOM', ); } + const { node } = await this.send< + ProtocolTypes.DOM.DescribeNodeRequest, + ProtocolTypes.DOM.DescribeNodeResponse + >("DOM.describeNode", { + objectId: result.result.objectId, + }); return new Element( - "document.querySelector", selector, this, - this.#protocol, - result.result.objectId, + node, ); } @@ -431,20 +336,22 @@ export class Page { * If `selector` is passed in, it will take a screenshot of only that element * and its children as opposed to the whole page. * - * @param path - The path of where to save the screenshot to * @param options * + * @example + * ```ts + * try { + * Deno.writeFileSync('./tets.png', await page.takeScreenshot()); + * } catch (e) { + * await browser.close(e.message); + * } + * ``` + * * @returns The path to the file relative to CWD, e.g., "Screenshots/users/user_1.png" */ - async takeScreenshot( - path: string, + public async takeScreenshot( options?: ScreenshotOptions, - ): Promise { - if (!existsSync(path)) { - await this.client.close( - `The provided folder path "${path}" doesn't exist`, - ); - } + ): Promise { const ext = options?.format ?? "jpeg"; const clip = undefined; @@ -454,14 +361,14 @@ export class Page { ); } - //Quality should defined only if format is jpeg + // Quality should defined only if format is jpeg const quality = (ext == "jpeg") ? ((options?.quality) ? Math.abs(options.quality) : 80) : undefined; - const res = await this.#protocol.send< - Protocol.Page.CaptureScreenshotRequest, - Protocol.Page.CaptureScreenshotResponse + const res = await this.send< + ProtocolTypes.Page.CaptureScreenshotRequest, + ProtocolTypes.Page.CaptureScreenshotResponse >( "Page.captureScreenshot", { @@ -471,20 +378,8 @@ export class Page { }, ); - //Writing the Obtained Base64 encoded string to image file - const fName = `${path}/${ - options?.fileName?.replaceAll(/.jpeg|.jpg|.png/g, "") ?? - generateTimestamp() - }.${ext}`; const B64str = res.data; - const u8Arr = Uint8Array.from(atob(B64str), (c) => c.charCodeAt(0)); - try { - Deno.writeFileSync(fName, u8Arr); - } catch (e) { - await this.client.close(e.message); - } - - return fName; + return Uint8Array.from(atob(B64str), (c) => c.charCodeAt(0)); } /** @@ -494,7 +389,7 @@ export class Page { * @param commandSent - The command sent to trigger the result */ async #checkForEvaluateErrorResult( - result: Protocol.Runtime.AwaitPromiseResponse, + result: ProtocolTypes.Runtime.AwaitPromiseResponse, commandSent: string, ): Promise { const exceptionDetail = result.exceptionDetails; @@ -513,4 +408,28 @@ export class Page { // any others, unsure what they'd be await this.client.close(`${errorMessage}: "${commandSent}"`); } + + public static async create(client: Client, targetId: string): Promise { + const socket = new WebSocket( + `ws://${client.wsOptions.hostname}:${client.wsOptions.port}/devtools/page/${targetId}`, + ); + const p = deferred(); + socket.onopen = () => p.resolve(); + await p; + + const page = new Page( + targetId, + client, + socket, + ); + await page.send("Target.attachToTarget", { + targetId: targetId, + }); + + for (const method of ["Page", "Log", "Runtime", "Network"]) { + await page.send(`${method}.enable`); + } + + return page; + } } diff --git a/src/protocol.ts b/src/protocol.ts index 20e5431..1ec3394 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,5 +1,4 @@ import { Deferred, deferred } from "../deps.ts"; -import { Protocol as ProtocolTypes } from "../deps.ts"; interface MessageResponse { // For when we send an event to get one back, eg running a JS expression id: number; @@ -12,18 +11,11 @@ interface NotificationResponse { // Not entirely sure when, but when we send the params: Record; } -type Create = T extends true ? { - protocol: Protocol; - frameId: string; - } - : T extends false ? Protocol - : never; - export class Protocol { /** * Our web socket connection to the remote debugging port */ - public socket: WebSocket; + protected socket: WebSocket; /** * A counter that acts as the message id we use to send as part of the event data through the websocket @@ -53,7 +45,6 @@ export class Protocol { // Register on message listener this.socket.onmessage = (msg) => { const data = JSON.parse(msg.data); - console.log(data); this.#handleSocketMessage(data); }; } @@ -112,30 +103,6 @@ export class Protocol { }), ); - // Handle console errors - if (message.method === "Runtime.exceptionThrown") { - const params = message - .params as unknown as ProtocolTypes.Runtime.ExceptionThrownEvent; - const errorMessage = params.exceptionDetails.exception?.description ?? - params.exceptionDetails.text; - dispatchEvent( - new CustomEvent("consoleError", { - detail: errorMessage, - }), - ); - } - if (message.method === "Log.entryAdded") { - const params = message - .params as unknown as ProtocolTypes.Log.EntryAddedEvent; - if (params.entry.level === "error") { - dispatchEvent( - new CustomEvent("consoleError", { - detail: params.entry.text, - }), - ); - } - } - const resolvable = this.notifications.get(message.method); if (!resolvable) { return; @@ -168,39 +135,4 @@ export class Protocol { } } } - - /** - * A builder for creating an instance of a protocol - * - * @param url - The websocket url to connect, which the protocol will use - * - * @returns A new protocol instance - */ - public static async create( - url: string, - getFrameId?: T, - ): Promise> { - const p = deferred(); - const socket = new WebSocket(url); - socket.onopen = () => p.resolve(); - await p; - const protocol = new Protocol(socket); - if (getFrameId) { - protocol.notifications.set("Runtime.executionContextCreated", deferred()); - } - for (const method of ["Page", "Log", "Runtime", "Network"]) { - await protocol.send(`${method}.enable`); - } - if (getFrameId) { - const { context: { auxData: { frameId } } } = - (await protocol.notifications.get( - "Runtime.executionContextCreated", - )) as unknown as ProtocolTypes.Runtime.ExecutionContextCreatedEvent; - return { - protocol, - frameId, - } as Create; - } - return protocol as Create; - } } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 4700957..0000000 --- a/src/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Browsers = "firefox" | "chrome"; diff --git a/src/utility.ts b/src/utility.ts index c920055..094e8b6 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -1,6 +1,6 @@ import { deferred } from "../deps.ts"; -export const existsSync = (filename: string): boolean => { +const existsSync = (filename: string): boolean => { try { Deno.statSync(filename); // successful, file or directory must exist @@ -16,13 +16,6 @@ export const existsSync = (filename: string): boolean => { } }; -export const generateTimestamp = (): string => { - const dt = new Date(); - const ts = dt.toLocaleDateString().replace(/\//g, "_") + "_" + - dt.toLocaleTimeString().replace(/:/g, "_"); - return ts; -}; - /** * Gets the full path to the chrome executable on the users filesystem * @@ -97,42 +90,6 @@ export function getChromeArgs(port: number, binaryPath?: string): string[] { ]; } -/** - * Get full path to the firefox binary on the user'ss filesystem. - * Thanks to [caspervonb](https://github.com/caspervonb/deno-web/blob/master/browser.ts) - * - * @returns the path - */ -export function getFirefoxPath(): string { - switch (Deno.build.os) { - case "darwin": - return "/Applications/Firefox.app/Contents/MacOS/firefox"; - case "linux": - return "/usr/bin/firefox"; - case "windows": - return "C:\\Program Files\\Mozilla Firefox\\firefox.exe"; - default: - throw new Error("Unhandled OS. Unsupported for " + Deno.build.os); - } -} - -export function getFirefoxArgs( - tmpDirName: string, - port: number, - binaryPath?: string, -): string[] { - return [ - binaryPath || getFirefoxPath(), - "--remote-debugging-port", - port.toString(), - "-profile", - tmpDirName, - "-headless", - "-url", - "about:blank", - ]; -} - export async function waitUntilNetworkIdle() { // Logic for waiting until zero network requests have been received for 500ms const p = deferred(); diff --git a/tests/browser_list.ts b/tests/browser_list.ts deleted file mode 100644 index 400b37c..0000000 --- a/tests/browser_list.ts +++ /dev/null @@ -1,63 +0,0 @@ -//import type { Browsers } from "../src/types.ts"; -import { getChromePath } from "../src/utility.ts"; - -export const browserList: Array<{ - name: "chrome"; - errors: { - page_not_exist_message: string; - page_name_not_resolved: string; - }; - cookies: Record[]; - getPath: () => string; -}> = [ - { - name: "chrome", - errors: { - page_not_exist_message: - 'NS_ERROR_UNKNOWN_HOST: Error for navigating to page "https://hellaDOCSWOWThispagesurelycantexist.biscuit"', - page_name_not_resolved: - 'net::ERR_NAME_NOT_RESOLVED: Error for navigating to page "https://hhh"', - }, - getPath: getChromePath, - cookies: [ - { - domain: "drash.land", - expires: -1, - httpOnly: false, - name: "user", - path: "/", - priority: "Medium", - sameParty: false, - secure: true, - session: true, - size: 6, - sourcePort: 443, - sourceScheme: "Secure", - value: "ed", - }, - ], - }, - // { - // name: "firefox", - // errors: { - // page_not_exist_message: - // 'net::ERR_NAME_NOT_RESOLVED: Error for navigating to page "https://hellaDOCSWOWThispagesurelycantexist.biscuit"', - // page_name_not_resolved: - // 'NS_ERROR_UNKNOWN_HOST: Error for navigating to page "https://hhh"', - // }, - // getPath: getFirefoxPath, - // cookies: [ - // { - // domain: "drash.land", - // expires: -1, - // httpOnly: false, - // name: "user", - // path: "/", - // secure: true, - // session: true, - // size: 6, - // value: "ed", - // }, - // ], - // }, -]; diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index f519651..0508caa 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -5,30 +5,27 @@ import { assertEquals } from "../../deps.ts"; * 1. If you have one page that gives you the token, you can goTo that, then carry on goToing your protected resources, because the cookies will carry over (assuming you've configured the cookies on your end correctly) */ -import { buildFor } from "../../mod.ts"; -import { browserList } from "../browser_list.ts"; +import { build } from "../../mod.ts"; const remote = Deno.args.includes("--remoteBrowser"); -for (const browserItem of browserList) { - Deno.test(browserItem.name, async (t) => { - await t.step( - `CSRF Protected Pages - Tutorial for this feature in the docs should work`, - async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - await page.cookie({ - name: "X-CSRF-TOKEN", - value: "hi:)", - url: "https://drash.land", - }); - await page.location("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays - const cookieVal = await page.evaluate(() => { - return document.cookie; - }); - await browser.close(); - assertEquals(cookieVal, "X-CSRF-TOKEN=hi:)"); - }, - ); - }); -} +Deno.test("csrf_protected_pages_test.ts", async (t) => { + await t.step( + `CSRF Protected Pages - Tutorial for this feature in the docs should work`, + async () => { + const { browser, page } = await build({ remote }); + await page.location("https://drash.land"); + await page.cookie({ + name: "X-CSRF-TOKEN", + value: "hi:)", + url: "https://drash.land", + }); + await page.location("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays + const cookieVal = await page.evaluate(() => { + return document.cookie; + }); + await browser.close(); + assertEquals(cookieVal, "X-CSRF-TOKEN=hi:)"); + }, + ); +}); diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index 94cf801..5bdb00a 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -1,60 +1,56 @@ import { assertEquals } from "../../deps.ts"; -import { buildFor } from "../../mod.ts"; - -import { browserList } from "../browser_list.ts"; +import { build } from "../../mod.ts"; const remote = Deno.args.includes("--remoteBrowser"); -for (const browserItem of browserList) { - Deno.test(browserItem.name, async (t) => { - await t.step("Manipulate Webpage", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); +Deno.test("manipulate_page_test.ts", async (t) => { + await t.step("Manipulate Webpage", async () => { + const { browser, page } = await build({ remote }); + await page.location("https://drash.land"); + + const updatedBody = await page.evaluate(() => { + // deno-lint-ignore no-undef + const prevBody = document.body.children.length; + // deno-lint-ignore no-undef + const newEl = document.createElement("p"); + // deno-lint-ignore no-undef + document.body.appendChild(newEl); + // deno-lint-ignore no-undef + return prevBody === document.body.children.length - 1; + }); + assertEquals(updatedBody, true); - const updatedBody = await page.evaluate(() => { + await browser.close(); + }); + + await t.step( + "Evaluating a script - Tutorial for this feature in the documentation works", + async () => { + const { browser, page } = await build({ remote }); + await page.location("https://drash.land"); + const pageTitle = await page.evaluate(() => { // deno-lint-ignore no-undef - const prevBody = document.body.children.length; + return document.querySelector("h1")?.textContent; + }); + const sum = await page.evaluate(`1 + 10`); + const oldBodyLength = await page.evaluate(() => { + // deno-lint-ignore no-undef + return document.body.children.length; + }); + const newBodyLength = await page.evaluate(() => { // deno-lint-ignore no-undef - const newEl = document.createElement("p"); + const p = document.createElement("p"); + p.textContent = "Hello world!"; // deno-lint-ignore no-undef - document.body.appendChild(newEl); + document.body.appendChild(p); // deno-lint-ignore no-undef - return prevBody === document.body.children.length - 1; + return document.body.children.length; }); - assertEquals(updatedBody, true); - await browser.close(); - }); - - await t.step( - "Evaluating a script - Tutorial for this feature in the documentation works", - async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - const pageTitle = await page.evaluate(() => { - // deno-lint-ignore no-undef - return document.querySelector("h1")?.textContent; - }); - const sum = await page.evaluate(`1 + 10`); - const oldBodyLength = await page.evaluate(() => { - // deno-lint-ignore no-undef - return document.body.children.length; - }); - const newBodyLength = await page.evaluate(() => { - // deno-lint-ignore no-undef - const p = document.createElement("p"); - p.textContent = "Hello world!"; - // deno-lint-ignore no-undef - document.body.appendChild(p); - // deno-lint-ignore no-undef - return document.body.children.length; - }); - await browser.close(); - assertEquals(pageTitle, "Drash Land"); - assertEquals(sum, 11); - assertEquals(oldBodyLength, remote ? 5 : 3); - assertEquals(newBodyLength, remote ? 6 : 4); - }, - ); - }); -} + assertEquals(pageTitle, "Drash Land"); + assertEquals(sum, 11); + assertEquals(oldBodyLength, remote ? 5 : 3); + assertEquals(newBodyLength, remote ? 6 : 4); + }, + ); +}); diff --git a/tests/unit/Screenshots/.gitkeep b/tests/unit/Screenshots/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index e424e6f..73be6d2 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,190 +1,130 @@ -import { assertEquals, assertNotEquals, deferred } from "../../deps.ts"; -import { buildFor } from "../../mod.ts"; -import { browserList } from "../browser_list.ts"; +import { deferred } from "../../deps.ts"; +import { build } from "../../mod.ts"; const remote = Deno.args.includes("--remoteBrowser"); -for (const browserItem of browserList) { - Deno.test(`${browserItem.name}`, async (t) => { - await t.step("create()", async (t) => { - await t.step( - "Uses the port when passed in to the parameters", - async () => { - const { browser } = await buildFor(browserItem.name, { - debuggerPort: 9999, +Deno.test("client_test.ts", async (t) => { + await t.step("create()", async (t) => { + await t.step( + "Uses the port when passed in to the parameters", + async () => { + const { browser } = await build({ + debuggerPort: 9999, + }); + const res = await fetch("http://localhost:9999/json/list"); + const json = await res.json(); + // Our ws client should be able to connect if the browser is running + const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); + let promise = deferred(); + client.onopen = function () { + promise.resolve(); + }; + await promise; + promise = deferred(); + client.onclose = function () { + promise.resolve(); + }; + client.close(); + await promise; + await browser.close(); + }, + ); + + await t.step( + `Will start headless as a subprocess`, + async () => { + const { browser } = await build({ remote }); + const res = await fetch("http://localhost:9292/json/list"); + const json = await res.json(); + // Our ws client should be able to connect if the browser is running + const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); + let promise = deferred(); + client.onopen = function () { + promise.resolve(); + }; + await promise; + promise = deferred(); + client.onclose = function () { + promise.resolve(); + }; + client.close(); + await promise; + await browser.close(); + }, + ); + + await t.step( + "Uses the hostname when passed in to the parameters", + async () => { + // Unable to test properly, as windows doesnt like 0.0.0.0 or localhost, so the only choice is 127.0.0.1 but this is already the default + }, + ); + + await t.step( + { + name: "Uses the binaryPath when passed in to the parameters", + fn: async () => { + const { browser } = await build({ + //binaryPath: await browserItem.getPath(), }); - const res = await fetch("http://localhost:9999/json/list"); - const json = await res.json(); - // Our ws client should be able to connect if the browser is running - const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); - let promise = deferred(); - client.onopen = function () { - promise.resolve(); - }; - await promise; - promise = deferred(); - client.onclose = function () { - promise.resolve(); - }; - client.close(); - await promise; - await browser.close(); - }, - ); - await t.step( - `Will start headless as a subprocess`, - async () => { - const { browser } = await buildFor(browserItem.name, { remote }); const res = await fetch("http://localhost:9292/json/list"); const json = await res.json(); // Our ws client should be able to connect if the browser is running const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); - let promise = deferred(); + const promise = deferred(); client.onopen = function () { - promise.resolve(); + client.close(); }; - await promise; - promise = deferred(); client.onclose = function () { promise.resolve(); }; - client.close(); await promise; await browser.close(); }, - ); - - await t.step( - "Uses the hostname when passed in to the parameters", - async () => { - // Unable to test properly, as windows doesnt like 0.0.0.0 or localhost, so the only choice is 127.0.0.1 but this is already the default - }, - ); - - await t.step( - { - name: "Uses the binaryPath when passed in to the parameters", - fn: async () => { - const { browser } = await buildFor(browserItem.name, { - //binaryPath: await browserItem.getPath(), - }); + ignore: remote, //Ignoring as binary path is not a necessisty to test for remote browsers + }, + ); + }); - const res = await fetch("http://localhost:9292/json/list"); - const json = await res.json(); - // Our ws client should be able to connect if the browser is running - const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); - const promise = deferred(); - client.onopen = function () { - client.close(); - }; - client.onclose = function () { - promise.resolve(); - }; - await promise; - await browser.close(); - }, - ignore: remote, //Ignoring as binary path is not a necessisty to test for remote browsers - }, - ); + await t.step(`close()`, async (t) => { + await t.step(`Should close all resources and not leak any`, async () => { + const { browser, page } = await build({ remote }); + await page.location("https://drash.land"); + await browser.close(); + // If resources are not closed or pending ops or leaked, this test will show it when ran }); - await t.step(`close()`, async (t) => { - await t.step(`Should close all resources and not leak any`, async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); + await t.step({ + name: `Should close all page specific resources too`, + fn: async () => { + const { browser, page } = await build({ + remote, + }); await page.location("https://drash.land"); await browser.close(); - // If resources are not closed or pending ops or leaked, this test will show it when ran - }); - - await t.step({ - name: `Should close all page specific resources too`, - fn: async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - await browser.close(); - if (!remote) { - try { - const listener = Deno.listen({ - port: 9292, - hostname: "localhost", - }); - listener.close(); - } catch (e) { - if (e instanceof Deno.errors.AddrInUse) { - throw new Error( - `Seems like the subprocess is still running: ${e.message}`, - ); - } - } - } else { - const { browser: br2, page: pg2 } = await buildFor("chrome", { - remote, - }); - await br2.close(); - assertNotEquals(pg2.socket.url, page.socket.url); - } - // If resources are not closed or pending ops or leaked, this test will show it when ran - }, - }); - }); - - await t.step("closeAllPagesExcept()", async (t) => { - if (browserItem.name === "chrome") { - await t.step( - `Should close all pages except the one passed in`, - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - const elem = await page.querySelector("a"); - await elem.click({ - button: "middle", + if (!remote) { + try { + const listener = Deno.listen({ + port: 9292, + hostname: "localhost", }); - const page2 = await browser.page(2); - await browser.closeAllPagesExcept(page2); - let errMsg = ""; - try { - await page.location(); - } catch (e) { - errMsg = e.message; + listener.close(); + } catch (e) { + if (e instanceof Deno.errors.AddrInUse) { + throw new Error( + `Seems like the subprocess is still running: ${e.message}`, + ); } - const page2location = await page2.location(); - await browser.close(); - assertEquals(errMsg, "readyState not OPEN"); - assertEquals(page2location, "https://github.com/drashland"); - }, - ); - } - }); - - await t.step("page()", async (t) => { - await t.step(`Should return the correct page`, async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - const mainPage = await browser.page(1); - await browser.close(); - assertEquals(page.target_id, mainPage.target_id); - }); - - await t.step( - `Should throw out of bounds if index doesnt exist`, - async () => { - const { browser } = await buildFor(browserItem.name, { remote }); - let threw = false; - try { - await browser.page(2); - } catch (_e) { - // As expected :) - threw = true; - } finally { - await browser.close(); - assertEquals(threw, true); } - }, - ); + } else { + const { browser: br2 } = await build({ + remote, + }); + await br2.close(); + } + // If resources are not closed or pending ops or leaked, this test will show it when ran + }, }); }); -} +}); diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index a7e8be5..bb1cbb0 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -1,404 +1,262 @@ -import { buildFor } from "../../mod.ts"; +import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; -import { browserList } from "../browser_list.ts"; const ScreenshotsFolder = "./Screenshots"; -import { existsSync } from "../../src/utility.ts"; import { server } from "../server.ts"; import { resolve } from "../deps.ts"; const remote = Deno.args.includes("--remoteBrowser"); const serverAdd = `http://${ remote ? "host.docker.internal" : "localhost" }:1447`; -for (const browserItem of browserList) { - Deno.test(browserItem.name, async (t) => { - await t.step("click()", async (t) => { - await t.step( - "Can handle things like downloads opening new tab then closing", - async () => { - }, - ); - - await t.step( - "It should allow clicking of elements and update location", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - server.run(); - await page.location(serverAdd + "/anchor-links"); - const elem = await page.querySelector( - "a#not-blank", - ); - await elem.click({ - waitFor: "navigation", - }); - const page1Location = await page.location(); - await browser.close(); - await server.close(); - assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); - }, - ); - - await t.step( - "It should error if the HTML for the element is invalid", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - server.run(); - await page.location(serverAdd + "/anchor-links"); - const elem = await page.querySelector( - "a#invalid-link", - ); - let error = null; - try { - await elem.click({ - waitFor: "navigation", - }); - } catch (e) { - error = e.message; - } - await browser.close(); - await server.close(); - assertEquals( - error, - 'Unable to click the element "a#invalid-link". It could be that it is invalid HTML', - ); - }, - ); +Deno.test("element_test.ts", async (t) => { + await t.step("click()", async (t) => { + await t.step( + "Can handle things like downloads opening new tab then closing", + async () => { + }, + ); - await t.step(`Should open a new page when middle clicked`, async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - const elem = await page.querySelector("a"); - // if (browserItem.name === "firefox") { - // let errMsg = ""; - // try { - // await elem.click({ - // button: "middle", - // }); - // } catch (e) { - // errMsg = e.message; - // } - // assertEquals( - // errMsg, - // "Middle clicking in firefox doesn't work at the moment. Please mention on our Discord if you would like to discuss it.", - // ); - // return; - // } + await t.step( + "It should allow clicking of elements and update location", + async () => { + const { browser, page } = await build({ + remote, + }); + server.run(); + await page.location(serverAdd + "/anchor-links"); + const elem = await page.querySelector( + "a#not-blank", + ); await elem.click({ - button: "middle", + waitFor: "navigation", }); - const page1Location = await page.location(); - const page2 = await browser.page(2); - const page2location = await page2.location(); + const page1Location = await page.evaluate(() => window.location.href); await browser.close(); - assertEquals(page1Location, "https://drash.land/"); - assertEquals(page2location, "https://github.com/drashland"); - }); - }); - - await t.step("takeScreenshot()", async (t) => { - await t.step( - "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", - async () => { - try { - Deno.removeSync(ScreenshotsFolder, { - recursive: true, - }); - } catch (_e) { - // if doesnt exist, no problamo - } - - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - const img = await page.querySelector("img"); - Deno.mkdirSync(ScreenshotsFolder); - const fileName = await img.takeScreenshot(ScreenshotsFolder, { - quality: 50, - }); - await browser.close(); - const exists = existsSync(fileName); - Deno.removeSync(ScreenshotsFolder, { - recursive: true, - }); - assertEquals( - exists, - true, - ); - }, - ); + await server.close(); + assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); + }, + ); - await t.step("Saves Screenshot with all options provided", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); + await t.step( + "It should error if the HTML for the element is invalid", + async () => { + const { browser, page } = await build({ + remote, + }); server.run(); await page.location(serverAdd + "/anchor-links"); - const a = await page.querySelector("a"); - Deno.mkdirSync(ScreenshotsFolder); - const filename = await a.takeScreenshot(ScreenshotsFolder, { - fileName: "AllOpts", - format: "jpeg", - quality: 100, - }); + const elem = await page.querySelector( + "a#invalid-link", + ); + let error = null; + try { + await elem.click({ + waitFor: "navigation", + }); + } catch (e) { + error = e.message; + } await browser.close(); await server.close(); - const exists = existsSync(filename); - Deno.removeSync(ScreenshotsFolder, { - recursive: true, - }); assertEquals( - exists, - true, + error, + 'Unable to click the element "a#invalid-link". It could be that it is invalid HTML', ); + }, + ); + }); + + await t.step("takeScreenshot()", async (t) => { + await t.step( + "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", + async () => { + const { browser, page } = await build({ + remote, + }); + await page.location("https://drash.land"); + const img = await page.querySelector("img"); + Deno.mkdirSync(ScreenshotsFolder); + await img.takeScreenshot({ + quality: 50, + }); + await browser.close(); + }, + ); + + await t.step("Saves Screenshot with all options provided", async () => { + const { browser, page } = await build({ remote }); + server.run(); + await page.location(serverAdd + "/anchor-links"); + const a = await page.querySelector("a"); + Deno.mkdirSync(ScreenshotsFolder); + await a.takeScreenshot({ + format: "jpeg", + quality: 100, }); + await browser.close(); + await server.close(); }); + }); - await t.step("value", async (t) => { + await t.step({ + name: "files()", + fn: async (t) => { await t.step( - "It should get the value for the given input element", + "Should throw if multiple files and input isnt multiple", async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); server.run(); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector( - 'input[type="text"]', - ); - await elem.value("hello world"); - const val = await elem.value(); - await browser.close(); - await server.close(); - assertEquals(val, "hello world"); - }, - ); - await t.step( - "Should return empty when element is not an input element", - async () => { - const { browser, page } = await buildFor(browserItem.name, { + const { browser, page } = await build({ remote, }); - server.run(); await page.location(serverAdd + "/input"); + const elem = await page.querySelector("#single-file"); let errMsg = ""; - const elem = await page.querySelector("div"); try { - await elem.value(); + await elem.files("ffff", "hhh"); } catch (e) { errMsg = e.message; + } finally { + await server.close(); + await browser.close(); } - await browser.close(); - await server.close(); assertEquals( errMsg, - "", + `Trying to set files on a file input without the 'multiple' attribute`, ); }, ); - }); - - await t.step("value()", async (t) => { - await t.step("It should set the value of the element", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); + await t.step("Should throw if element isnt an input", async () => { server.run(); + const { browser, page } = await build({ + remote, + }); await page.location(serverAdd + "/input"); - const elem = await page.querySelector('input[type="text"]'); - await elem.value("hello world"); - const val = await elem.value(); - await browser.close(); - await server.close(); - assertEquals(val, "hello world"); - }); - }); - - await t.step({ - name: "files()", - fn: async (t) => { - await t.step( - "Should throw if multiple files and input isnt multiple", - async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#single-file"); - let errMsg = ""; - try { - await elem.files("ffff", "hhh"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - `Trying to set files on a file input without the 'multiple' attribute`, - ); - }, + const elem = await page.querySelector("p"); + let errMsg = ""; + try { + await elem.files("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + "Trying to set a file on an element that isnt an input", ); - await t.step("Should throw if element isnt an input", async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("p"); - let errMsg = ""; - try { - await elem.files("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - "Trying to set a file on an element that isnt an input", - ); - }); - await t.step("Should throw if input is not of type file", async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#text"); - let errMsg = ""; - try { - await elem.files("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - 'Trying to set a file on an input that is not of type "file"', - ); + }); + await t.step("Should throw if input is not of type file", async () => { + server.run(); + const { browser, page } = await build({ + remote, }); - await t.step("Should successfully upload files", async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#multiple-file"); - try { - await elem.files( - resolve("./README.md"), - resolve("./tsconfig.json"), - ); - const files = JSON.parse( - await page.evaluate( - `JSON.stringify(document.querySelector('#multiple-file').files)`, - ), - ); - assertEquals(Object.keys(files).length, 2); - } finally { - await server.close(); - await browser.close(); - } + await page.location(serverAdd + "/input"); + const elem = await page.querySelector("#text"); + let errMsg = ""; + try { + await elem.files("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + 'Trying to set a file on an input that is not of type "file"', + ); + }); + await t.step("Should successfully upload files", async () => { + server.run(); + const { browser, page } = await build({ + remote, }); - }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser - - await t.step({ - name: "file()", - fn: async (t) => { - await t.step("Should throw if element isnt an input", async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("p"); - let errMsg = ""; - try { - await elem.file("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - "Trying to set a file on an element that isnt an input", + await page.location(serverAdd + "/input"); + const elem = await page.querySelector("#multiple-file"); + try { + await elem.files( + resolve("./README.md"), + resolve("./tsconfig.json"), ); - }); - await t.step("Should throw if input is not of type file", async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#text"); - let errMsg = ""; - try { - await elem.file("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - 'Trying to set a file on an input that is not of type "file"', + const files = JSON.parse( + await page.evaluate( + `JSON.stringify(document.querySelector('#multiple-file').files)`, + ), ); + assertEquals(Object.keys(files).length, 2); + } finally { + await server.close(); + await browser.close(); + } + }); + }, + }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser + + await t.step({ + name: "file()", + fn: async (t) => { + await t.step("Should throw if element isnt an input", async () => { + server.run(); + const { browser, page } = await build({ + remote, }); - await t.step("Should successfully upload files", async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#single-file"); - try { - await elem.file(resolve("./README.md")); - const files = JSON.parse( - await page.evaluate( - `JSON.stringify(document.querySelector('#single-file').files)`, - ), - ); - assertEquals(Object.keys(files).length, 1); - } finally { - await server.close(); - await browser.close(); - } + await page.location(serverAdd + "/input"); + const elem = await page.querySelector("p"); + let errMsg = ""; + try { + await elem.file("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + "Trying to set a file on an element that isnt an input", + ); + }); + await t.step("Should throw if input is not of type file", async () => { + server.run(); + const { browser, page } = await build({ + remote, }); - }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser - - await t.step("getAttribute()", async (t) => { - await t.step("Should get the attribute", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - const elem = await page.querySelector("a"); - const val = await elem.getAttribute("href"); - await browser.close(); - assertEquals(val, "https://github.com/drashland"); + await page.location(serverAdd + "/input"); + const elem = await page.querySelector("#text"); + let errMsg = ""; + try { + await elem.file("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + 'Trying to set a file on an input that is not of type "file"', + ); }); - }); - - await t.step("setAttribute()", async (t) => { - await t.step("Should set the attribute", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - const elem = await page.querySelector("a"); - elem.setAttribute("data-name", "Sinco"); - const val = await page.evaluate(() => { - return document.querySelector("a")?.getAttribute("data-name"); + await t.step("Should successfully upload files", async () => { + server.run(); + const { browser, page } = await build({ + remote, }); - await browser.close(); - assertEquals(val, "Sinco"); + await page.location(serverAdd + "/input"); + const elem = await page.querySelector("#single-file"); + try { + await elem.file(resolve("./README.md")); + const files = JSON.parse( + await page.evaluate( + `JSON.stringify(document.querySelector('#single-file').files)`, + ), + ); + assertEquals(Object.keys(files).length, 1); + } finally { + await server.close(); + await browser.close(); + } }); - }); - }); -} + }, + }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser +}); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 23a0342..f41943f 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,377 +1,263 @@ -import { browserList } from "../browser_list.ts"; -const ScreenshotsFolder = "./tests/unit/Screenshots"; -import { buildFor } from "../../mod.ts"; +import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; -import { existsSync } from "../../src/utility.ts"; import { server } from "../server.ts"; const remote = Deno.args.includes("--remoteBrowser"); const serverAdd = `http://${ remote ? "host.docker.internal" : "localhost" }:1447`; -for (const browserItem of browserList) { - Deno.test(browserItem.name, async (t) => { - await t.step("takeScreenshot()", async (t) => { - await t.step( - "takeScreenshot() | Throws an error if provided path doesn't exist", - async () => { - let msg = ""; - - const { page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - try { - await page.takeScreenshot("eieio"); - } catch (error) { - msg = error.message; - } - - assertEquals( - msg, - `The provided folder path "eieio" doesn't exist`, - ); - }, - ); - - await t.step( - "takeScreenshot() | Takes a Screenshot with timestamp as filename if filename is not provided", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - const fileName = await page.takeScreenshot(ScreenshotsFolder); - await browser.close(); - const exists = existsSync(fileName); - Deno.removeSync(fileName); - assertEquals( - exists, - true, - ); - }, - ); +Deno.test("page_test.ts", async (t) => { + await t.step("takeScreenshot()", async (t) => { + await t.step( + "takeScreenshot() | Takes a Screenshot", + async () => { + const { browser, page } = await build({ + remote, + }); + await page.location("https://drash.land"); + const result = await page.takeScreenshot(); + await browser.close(); + assertEquals(result instanceof Uint8Array, true); + }, + ); - await t.step( - "Throws an error when format passed is jpeg(or default) and quality > than 100", - async () => { - const { page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - let msg = ""; - try { - await page.takeScreenshot(ScreenshotsFolder, { quality: 999 }); - } catch (error) { - msg = error.message; - } - //await browser.close(); - assertEquals( - msg, - "A quality value greater than 100 is not allowed.", - ); - }, - ); + await t.step( + "Throws an error when format passed is jpeg(or default) and quality > than 100", + async () => { + const { page } = await build({ remote }); + await page.location("https://drash.land"); + let msg = ""; + try { + await page.takeScreenshot({ quality: 999 }); + } catch (error) { + msg = error.message; + } + //await browser.close(); + assertEquals( + msg, + "A quality value greater than 100 is not allowed.", + ); + }, + ); + }); - await t.step("Saves Screenshot with Given Filename", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); + await t.step("evaluate()", async (t) => { + await t.step( + "It should evaluate function on current frame", + async () => { + const { browser, page } = await build({ + remote, + }); await page.location("https://drash.land"); - const filename = await page.takeScreenshot(ScreenshotsFolder, { - fileName: "Happy", + const pageTitle = await page.evaluate(() => { + // deno-lint-ignore no-undef + return document.querySelector("h1")?.textContent; }); await browser.close(); - const exists = existsSync(filename); - Deno.removeSync(filename); - assertEquals( - exists, - true, + assertEquals(pageTitle, "Drash Land"); + }, + ); + await t.step("It should evaluate string on current frame", async () => { + const { browser, page } = await build({ remote }); + await page.location("https://drash.land"); + const parentConstructor = await page.evaluate(`1 + 2`); + await browser.close(); + assertEquals(parentConstructor, 3); + }); + await t.step( + "You should be able to pass arguments to the callback", + async () => { + const { browser, page } = await build({ + remote, + }); + await page.location("https://drash.land"); + interface User { + name: string; + age: number; + } + type Answer = "yes" | "no"; + const user: User = { + name: "Cleanup crew", + age: 9001, + }; + const answer: Answer = "yes"; + const result1 = await page.evaluate( + (user: User, answer: Answer) => { + return user.name + " " + answer; + }, + user, + answer, ); - }); + const result2 = await page.evaluate( + (user: User, answer: Answer) => { + return { + ...user, + answer, + }; + }, + user, + answer, + ); + await browser.close(); + assertEquals(result1, "Cleanup crew yes"); + assertEquals(result2, { + name: "Cleanup crew", + age: 9001, + answer: "yes", + }); + }, + ); + }); - await t.step( - "Saves Screenshot with given format (jpeg | png)", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - const fileName = await page.takeScreenshot(ScreenshotsFolder, { - format: "png", - }); - await browser.close(); - const exists = existsSync(fileName); - assertEquals( - exists, - true, - ); - Deno.removeSync(fileName); - }, - ); + await t.step("location()", async (t) => { + await t.step( + "Handles correctly and doesnt hang when invalid URL", + async () => { + const { browser, page } = await build({ + remote, + }); + let error = null; + try { + await page.location("https://google.comINPUT"); + } catch (e) { + error = e.message; + } + await browser.close(); + assertEquals(error, "net::ERR_NAME_NOT_RESOLVED"); + }, + ); + + await t.step("Sets and gets the location", async () => { + const { browser, page } = await build({ remote }); + await page.location("https://google.com"); + await page.location("https://drash.land"); + const url = await page.evaluate(() => window.location.href); + await browser.close(); + assertEquals(url, "https://drash.land/"); }); + }); - await t.step("evaluate()", async (t) => { - await t.step( - "It should evaluate function on current frame", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - const pageTitle = await page.evaluate(() => { - // deno-lint-ignore no-undef - return document.querySelector("h1")?.textContent; - }); - await browser.close(); - assertEquals(pageTitle, "Drash Land"); - }, - ); - await t.step("It should evaluate string on current frame", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - const parentConstructor = await page.evaluate(`1 + 2`); - await browser.close(); - assertEquals(parentConstructor, 3); + await t.step("cookie()", async (t) => { + await t.step("Sets and gets cookies", async () => { + const { browser, page } = await build({ remote }); + await page.location("https://drash.land"); + await page.cookie({ + name: "user", + value: "ed", + "url": "https://drash.land", }); - await t.step( - "You should be able to pass arguments to the callback", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location("https://drash.land"); - interface User { - name: string; - age: number; - } - type Answer = "yes" | "no"; - const user: User = { - name: "Cleanup crew", - age: 9001, - }; - const answer: Answer = "yes"; - const result1 = await page.evaluate( - (user: User, answer: Answer) => { - return user.name + " " + answer; - }, - user, - answer, - ); - const result2 = await page.evaluate( - (user: User, answer: Answer) => { - return { - ...user, - answer, - }; - }, - user, - answer, - ); - await browser.close(); - assertEquals(result1, "Cleanup crew yes"); - assertEquals(result2, { - name: "Cleanup crew", - age: 9001, - answer: "yes", - }); + const cookies = await page.cookie(); + await browser.close(); + assertEquals(cookies, [ + { + domain: "drash.land", + expires: -1, + httpOnly: false, + name: "user", + path: "/", + priority: "Medium", + sameParty: false, + secure: true, + session: true, + size: 6, + sourcePort: 443, + sourceScheme: "Secure", + value: "ed", }, - ); + ]); }); + }); - await t.step("location()", async (t) => { - await t.step( - "Handles correctly and doesnt hang when invalid URL", - async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - let error = null; - try { - await page.location("https://google.comINPUT"); - } catch (e) { - error = e.message; - } - await browser.close(); - assertEquals(error, "net::ERR_NAME_NOT_RESOLVED"); - }, - ); - - await t.step("Sets and gets the location", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://google.com"); - await page.location("https://drash.land"); - const location = await page.location(); + await t.step({ + name: "consoleErrors()", + fn: async (t) => { + await t.step(`Should throw when errors`, async () => { + server.run(); + const { browser, page } = await build({ + remote, + }); + await page.location( + serverAdd, + ); + const errors = await page.consoleErrors(); await browser.close(); - assertEquals(location, "https://drash.land/"); + await server.close(); + assertEquals( + errors, + [ + "Failed to load resource: the server responded with a status of 404 (Not Found)", + "ReferenceError: callUser is not defined\n" + + ` at ${serverAdd}/index.js:1:1`, + ], + ); }); - }); - await t.step("cookie()", async (t) => { - await t.step("Sets and gets cookies", async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - await page.cookie({ - name: "user", - value: "ed", - "url": "https://drash.land", + await t.step(`Should be empty if no errors`, async () => { + const { browser, page } = await build({ + remote, }); - const cookies = await page.cookie(); + await page.location( + "https://drash.land", + ); + const errors = await page.consoleErrors(); await browser.close(); - assertEquals(cookies, browserItem.cookies); + assertEquals(errors, []); }); - }); + }, + }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser - await t.step({ - name: "consoleErrors()", - fn: async (t) => { - await t.step(`Should throw when errors`, async () => { - server.run(); - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location( - serverAdd, - ); - const errors = await page.consoleErrors(); - await browser.close(); - await server.close(); - assertEquals( - errors, - [ - "Failed to load resource: the server responded with a status of 404 (Not Found)", - "ReferenceError: callUser is not defined\n" + - ` at ${serverAdd}/index.js:1:1`, - ], - ); + await t.step({ + name: "dialog()", + fn: async (t) => { + await t.step(`Accepts a dialog`, async () => { + const { browser, page } = await build({ + remote, }); - - await t.step(`Should be empty if no errors`, async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - await page.location( - "https://drash.land", - ); - const errors = await page.consoleErrors(); - await browser.close(); - assertEquals(errors, []); + server.run(); + await page.location(serverAdd + "/dialogs"); + const elem = await page.querySelector("#button"); + page.expectDialog(); + elem.click(); + await page.dialog(true, "Sinco 4eva"); + const val = await page.evaluate( + `document.querySelector("#button").textContent`, + ); + await browser.close(); + await server.close(); + assertEquals(val, "Sinco 4eva"); + }); + await t.step(`Throws if a dialog was not expected`, async () => { + const { browser, page } = await build({ + remote, }); - }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser - - await t.step("close()", async (t) => { - await t.step(`Closes the page`, async () => { - const { browser, page } = await buildFor(browserItem.name, { remote }); - await page.location("https://drash.land"); - await page.close(); let errMsg = ""; try { - await page.location(); + await page.dialog(true, "Sinco 4eva"); } catch (e) { errMsg = e.message; } await browser.close(); - assertEquals(errMsg, "readyState not OPEN"); - try { - await browser.page(1); - assertEquals(true, false); - } catch (_e) { - // do nothing, error should be thrown - } - assertEquals(page.socket.readyState, page.socket.CLOSED); + assertEquals( + errMsg, + 'Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.', + ); }); - }); - - await t.step({ - name: "dialog()", - fn: async (t) => { - await t.step(`Accepts a dialog`, async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - server.run(); - await page.location(serverAdd + "/dialogs"); - const elem = await page.querySelector("#button"); - page.expectDialog(); - elem.click(); - await page.dialog(true, "Sinco 4eva"); - const val = await page.evaluate( - `document.querySelector("#button").textContent`, - ); - await browser.close(); - await server.close(); - assertEquals(val, "Sinco 4eva"); - }); - await t.step(`Throws if a dialog was not expected`, async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - let errMsg = ""; - try { - await page.dialog(true, "Sinco 4eva"); - } catch (e) { - errMsg = e.message; - } - await browser.close(); - assertEquals( - errMsg, - 'Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.', - ); + await t.step(`Rejects a dialog`, async () => { + const { browser, page } = await build({ + remote, }); - await t.step(`Rejects a dialog`, async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - server.run(); - await page.location(serverAdd + "/dialogs"); - const elem = await page.querySelector("#button"); - page.expectDialog(); - elem.click(); - await page.dialog(false, "Sinco 4eva"); - const val = await page.evaluate( - `document.querySelector("#button").textContent`, - ); - await browser.close(); - await server.close(); - assertEquals(val, ""); - }); - }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser - - await t.step({ - name: "waitForRequest()", - fn: async (t) => { - await t.step(`Should wait for a request via JS`, async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - server.run(); - await page.location(serverAdd + "/wait-for-requests"); - const elem = await page.querySelector("#second-button"); - page.expectWaitForRequest(); - await elem.click(); - await page.waitForRequest(); - const value = await page.evaluate( - `document.querySelector("#second-button").textContent`, - ); - await page.close(); - await browser.close(); - await server.close(); - assertEquals(value, "done"); - }); - await t.step(`Should wait for a request via inline forms`, async () => { - const { browser, page } = await buildFor(browserItem.name, { - remote, - }); - server.run(); - await page.location(serverAdd + "/wait-for-requests"); - const elem = await page.querySelector(`button[type="submit"]`); - page.expectWaitForRequest(); - await elem.click(); - await page.waitForRequest(); - const value = await page.evaluate(() => { - return document.body.innerText; - }); - await page.close(); - await browser.close(); - await server.close(); - assertEquals(value, "Done!!"); - }); - }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser - }); -} + server.run(); + await page.location(serverAdd + "/dialogs"); + const elem = await page.querySelector("#button"); + page.expectDialog(); + elem.click(); + await page.dialog(false, "Sinco 4eva"); + const val = await page.evaluate( + `document.querySelector("#button").textContent`, + ); + await browser.close(); + await server.close(); + assertEquals(val, ""); + }); + }, + }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser +}); From eee446a2a0d43dd0d857489ca9aa29b21583fba8 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Fri, 3 May 2024 00:32:08 +0100 Subject: [PATCH 03/34] update chrome version --- .github/workflows/master.yml | 54 +++---------------- .../docker_test/drivers.dockerfile | 2 +- 2 files changed, 8 insertions(+), 48 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 5ccbf64..05f707a 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -14,13 +14,19 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Run Docker Tests + - name: Tests run: | cd tests/integration/docker_test docker-compose up -d drivers docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/integration docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/unit + - name: Tests (remote) + run: | + docker compose -f tests/integration/docker_test/docker-compose.yml up remotes -d + deno test -A tests/integration --config tsconfig.json --no-check=remote -- --remoteBrowser + deno test -A tests/integration --config tsconfig.json --no-check=remote -- --remoteBrowser + console-tests: runs-on: ubuntu-latest @@ -36,52 +42,6 @@ jobs: run: | deno test -A tests/console - tests: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v2 - - - name: Install Deno - uses: denoland/setup-deno@v1 - with: - deno-version: vx.x.x - - - name: Run Integration Tests - run: | - deno test -A tests/integration --config tsconfig.json --no-check=remote - - - name: Run Unit Tests - run: | - deno test -A --config tsconfig.json tests/unit --no-check=remote - - remote-tests: - #only ubuntu as docker and docker compose is preinstalled only on ubuntu - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Install Deno - uses: denoland/setup-deno@v1 - with: - deno-version: vx.x.x - - - name: Run Integration Tests (remote) - run: | - docker compose -f tests/integration/docker_test/docker-compose.yml up remotes -d - deno test -A tests/integration --config tsconfig.json --no-check=remote -- --remoteBrowser - docker compose -f tests/integration/docker_test/docker-compose.yml down - - - name: Run Unit Tests (remote) - run: | - docker compose -f tests/integration/docker_test/docker-compose.yml up remotes -d - deno test -A tests/unit --config tsconfig.json --no-check=remote -- --remoteBrowser - docker compose -f tests/integration/docker_test/docker-compose.yml down - linter: # Only one OS is required since fmt is cross platform runs-on: ubuntu-latest diff --git a/tests/integration/docker_test/drivers.dockerfile b/tests/integration/docker_test/drivers.dockerfile index d57edda..748ccec 100644 --- a/tests/integration/docker_test/drivers.dockerfile +++ b/tests/integration/docker_test/drivers.dockerfile @@ -1,6 +1,6 @@ FROM debian:stable-slim -ENV CHROME_VERSION "124.0.6367.91" +ENV CHROME_VERSION "124.0.6367.119" # Install chrome driver RUN apt update -y \ From 30fc43c26f5c56d9e4c0898bf1dfb95cc121eff8 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Fri, 3 May 2024 00:37:03 +0100 Subject: [PATCH 04/34] revert chrome upgrade --- console/bumper_ci_service.ts | 6 ++---- console/bumper_ci_service_files.ts | 8 -------- egg.json | 15 --------------- tests/integration/docker_test/drivers.dockerfile | 2 +- 4 files changed, 3 insertions(+), 28 deletions(-) delete mode 100644 egg.json diff --git a/console/bumper_ci_service.ts b/console/bumper_ci_service.ts index 21a8432..88e2cad 100644 --- a/console/bumper_ci_service.ts +++ b/console/bumper_ci_service.ts @@ -1,10 +1,8 @@ import { BumperService } from "https://raw.githubusercontent.com/drashland/services/master/ci/bumper_service.ts"; -import { bumperFiles, preReleaseFiles } from "./bumper_ci_service_files.ts"; +import { bumperFiles } from "./bumper_ci_service_files.ts"; const b = new BumperService("sinco", Deno.args); -if (b.isForPreRelease()) { - b.bump(preReleaseFiles); -} else { +if (!b.isForPreRelease()) { b.bump(bumperFiles); } diff --git a/console/bumper_ci_service_files.ts b/console/bumper_ci_service_files.ts index 55746eb..8a895d8 100644 --- a/console/bumper_ci_service_files.ts +++ b/console/bumper_ci_service_files.ts @@ -9,14 +9,6 @@ export const regexes = { yml_deno: /deno: \[".+"\]/g, }; -export const preReleaseFiles = [ - { - filename: "./egg.json", - replaceTheRegex: regexes.egg_json, - replaceWith: `"version": "{{ thisModulesLatestVersion }}"`, - }, -]; - const chromeVersionsRes = await fetch( "https://versionhistory.googleapis.com/v1/chrome/platforms/win/channels/stable/versions", ); diff --git a/egg.json b/egg.json deleted file mode 100644 index 91cfaaf..0000000 --- a/egg.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "sinco", - "description": "Browser Automation and Testing Tool for Deno.", - "version": "4.0.0", - "stable": true, - "repository": "https://github.com/drashland/sinco", - "files": [ - "./mod.ts", - "./deps.ts", - "./src/*", - "./README.md", - "./logo.svg", - "LICENSE" - ] -} diff --git a/tests/integration/docker_test/drivers.dockerfile b/tests/integration/docker_test/drivers.dockerfile index 748ccec..d57edda 100644 --- a/tests/integration/docker_test/drivers.dockerfile +++ b/tests/integration/docker_test/drivers.dockerfile @@ -1,6 +1,6 @@ FROM debian:stable-slim -ENV CHROME_VERSION "124.0.6367.119" +ENV CHROME_VERSION "124.0.6367.91" # Install chrome driver RUN apt update -y \ From e6f7db97e0699e573c6994714e0fc26fe108c2f7 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Fri, 3 May 2024 00:49:05 +0100 Subject: [PATCH 05/34] catch edge case of no object id for query selector --- src/element.ts | 8 ++++++-- src/page.ts | 7 +++++++ tests/unit/element_test.ts | 6 ------ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/element.ts b/src/element.ts index 7059197..47bd1d9 100644 --- a/src/element.ts +++ b/src/element.ts @@ -31,6 +31,8 @@ export class Element { /** * ObjectId belonging to this element */ + readonly #objectId: string; + readonly #node: ProtocolTypes.DOM.Node; /** @@ -43,8 +45,10 @@ export class Element { selector: string, page: Page, node: ProtocolTypes.DOM.Node, + objectId: string, ) { this.#node = node; + this.#objectId = objectId; this.#page = page; this.#selector = selector; } @@ -108,7 +112,7 @@ export class Element { "DOM.setFileInputFiles", { files: files, - nodeId: this.#node.nodeId, + objectId: this.#objectId, backendNodeId: this.#node.backendNodeId, }, ); @@ -210,7 +214,7 @@ export class Element { ProtocolTypes.DOM.GetContentQuadsRequest, ProtocolTypes.DOM.GetContentQuadsResponse >("DOM.getContentQuads", { - nodeId: this.#node.nodeId, + objectId: this.#objectId, }); const layoutMetrics = await this.#page.send< null, diff --git a/src/page.ts b/src/page.ts index 901a19b..a57d5d2 100644 --- a/src/page.ts +++ b/src/page.ts @@ -305,16 +305,23 @@ export class Page extends ProtocolClass { 'The selector "' + selector + '" does not exist inside the DOM', ); } + + if (!result.result.objectId) { + await this.client.close("Unable to find the object"); + } + const { node } = await this.send< ProtocolTypes.DOM.DescribeNodeRequest, ProtocolTypes.DOM.DescribeNodeResponse >("DOM.describeNode", { objectId: result.result.objectId, }); + return new Element( selector, this, node, + result.result.objectId as string, ); } diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index bb1cbb0..1a95cb5 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -9,12 +9,6 @@ const serverAdd = `http://${ }:1447`; Deno.test("element_test.ts", async (t) => { await t.step("click()", async (t) => { - await t.step( - "Can handle things like downloads opening new tab then closing", - async () => { - }, - ); - await t.step( "It should allow clicking of elements and update location", async () => { From c9d0b1d61713ecd06890f984867fcc213997f1ed Mon Sep 17 00:00:00 2001 From: ebebbington Date: Sat, 4 May 2024 23:50:55 +0100 Subject: [PATCH 06/34] fix closing an already closed process --- src/client.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/client.ts b/src/client.ts index 109efcd..796a21a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -33,6 +33,8 @@ export class Client { */ readonly #browser_process: Deno.ChildProcess; + #closed = false; + /** * The host and port that the websocket server is listening on */ @@ -68,6 +70,10 @@ export class Client { errMsg?: string, errClass: { new (message: string): Error } = Error, ) { + if (this.#closed) { + return; + } + // Close browser process (also closes the ws endpoint, which in turn closes all sockets) const p = deferred(); this.#socket.onclose = () => p.resolve(); @@ -76,6 +82,7 @@ export class Client { this.#browser_process.kill(); await this.#browser_process.status; await p; + this.#closed = true; if (errMsg) { throw new errClass(errMsg); From 99666374f74a438b317162011212415c6f5a0698 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Sat, 4 May 2024 23:53:56 +0100 Subject: [PATCH 07/34] fix tests --- .github/workflows/master.yml | 2 +- tests/deps.ts | 4 ++-- tests/unit/element_test.ts | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 05f707a..03da83b 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -9,7 +9,7 @@ on: - main jobs: - docker-tests: + tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/tests/deps.ts b/tests/deps.ts index 13a6800..112e889 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -1,3 +1,3 @@ export * as Drash from "https://deno.land/x/drash@v2.5.4/mod.ts"; -export { resolve } from "https://deno.land/std@0.136.0/path/mod.ts"; -export { delay } from "https://deno.land/std@0.126.0/async/delay.ts"; +export { resolve } from "@std/path"; +export { delay } from "@std/path"; diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 1a95cb5..c7bd563 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -68,7 +68,6 @@ Deno.test("element_test.ts", async (t) => { }); await page.location("https://drash.land"); const img = await page.querySelector("img"); - Deno.mkdirSync(ScreenshotsFolder); await img.takeScreenshot({ quality: 50, }); @@ -81,7 +80,6 @@ Deno.test("element_test.ts", async (t) => { server.run(); await page.location(serverAdd + "/anchor-links"); const a = await page.querySelector("a"); - Deno.mkdirSync(ScreenshotsFolder); await a.takeScreenshot({ format: "jpeg", quality: 100, From d2d639afd2904ae39efda2c2a63e54a5b708c0f1 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Sat, 4 May 2024 23:55:49 +0100 Subject: [PATCH 08/34] revert jsr --- tests/deps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/deps.ts b/tests/deps.ts index 112e889..13a6800 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -1,3 +1,3 @@ export * as Drash from "https://deno.land/x/drash@v2.5.4/mod.ts"; -export { resolve } from "@std/path"; -export { delay } from "@std/path"; +export { resolve } from "https://deno.land/std@0.136.0/path/mod.ts"; +export { delay } from "https://deno.land/std@0.126.0/async/delay.ts"; From c096860b44b7feb14b72ceb3663c9269cc349d60 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Sun, 5 May 2024 00:01:11 +0100 Subject: [PATCH 09/34] tidy up --- src/client.ts | 2 +- tests/unit/element_test.ts | 1 - tests/unit/page_test.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 796a21a..557116c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -73,7 +73,7 @@ export class Client { if (this.#closed) { return; } - + // Close browser process (also closes the ws endpoint, which in turn closes all sockets) const p = deferred(); this.#socket.onclose = () => p.resolve(); diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index c7bd563..d4ea32e 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -1,6 +1,5 @@ import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; -const ScreenshotsFolder = "./Screenshots"; import { server } from "../server.ts"; import { resolve } from "../deps.ts"; const remote = Deno.args.includes("--remoteBrowser"); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index f41943f..61ad91c 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -170,7 +170,7 @@ Deno.test("page_test.ts", async (t) => { await t.step({ name: "consoleErrors()", fn: async (t) => { - await t.step(`Should throw when errors`, async () => { + await t.step(`Should return expected errors`, async () => { server.run(); const { browser, page } = await build({ remote, From 2d1063971883700297c4cf335a04e77c4a2fe2f5 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 21:16:34 +0100 Subject: [PATCH 10/34] refactor more --- .github/workflows/master.yml | 8 +- README.md | 64 +++++++++++++-- mod.ts | 35 ++++++-- src/client.ts | 45 +++-------- src/interfaces.ts | 2 - .../integration/csrf_protected_pages_test.ts | 4 +- .../docker_test/docker-compose.yml | 1 + tests/integration/manipulate_page_test.ts | 11 ++- tests/integration/remote_test.ts | 79 +++++++++++++++++++ tests/unit/client_test.ts | 41 ++++------ tests/unit/element_test.ts | 51 ++++-------- tests/unit/page_test.ts | 54 ++++--------- 12 files changed, 235 insertions(+), 160 deletions(-) create mode 100644 tests/integration/remote_test.ts diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 03da83b..10e215a 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -21,11 +21,11 @@ jobs: docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/integration docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/unit - - name: Tests (remote) + - name: Remote tests run: | - docker compose -f tests/integration/docker_test/docker-compose.yml up remotes -d - deno test -A tests/integration --config tsconfig.json --no-check=remote -- --remoteBrowser - deno test -A tests/integration --config tsconfig.json --no-check=remote -- --remoteBrowser + cd tests/integration/docker_test + docker-compose up -d remotes + docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/integration/remote_test.ts console-tests: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 2ce74a1..d3c6d83 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,64 @@ Drash Land - Sinco logo -Sinco is a browser automation and testing tool for Deno. +Sinco is a browser automation and testing tool. What this means is, Sinco runs a +subprocess for Chrome, and will communicate to the process via the Chrome +Devtools Protocol, as the subprocess opens a WebSocket server that Sinco +connects to. This allows Sinco to spin up a new browser tab, go to certain +websites, click buttons and so much more, all programatically. All Sinco does is +runs a subprocess for Chrome, so you do not need to worry about it creating or +running any other processes. -View the full documentation at https://drash.land/sinco. +Sinco is used to run or test actions of a page in the browser. Similar to unit +and integration tests, Sinco can be used for "browser" tests. -In the event the documentation pages are not accessible, please view the raw -version of the documentation at -https://github.com/drashland/website-v2/tree/main/docs. +Some examples of what you can build are: + +- Browser testing for your web application +- Web scraping +- Automating interactions with a website using code + +Sinco is similar to the more well-known tools that achieve the same thing, such +as Puppeteer. What sets Sinco apart is: + +- It is the first Deno browser automation tool +- It does not try to install a specific Chrome version on your computer +- It is transparent: It will use the browser and version you already have + installed. + +Its maintainers have taken concepts from the following ... + +- [Puppeteer](https://pptr.dev/) — following a similar API and used as + inspriration ... and mixed in their own concepts and practices such as ... + +Developer UX Approachability Test-driven development Documentation-driven +development Transparency + +## Documentation + +### Getting Started + +You use Sinco to build a subprocess (client) and interact with the page that has +been opened. This defaults to "about:blank". + +```ts +import { build } from "..."; +const { browser, page } = await build(); +``` + +Be sure to always call `.close()` on the client once you've finished any actions +with it, to ensure you do not leave any hanging ops, For example, closing after +the last `browser.*` call or before assertions. + +### Visiting Pages + +You can do this by calling `.location()` on the page: + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +``` + +## Taking Screenshots + +Utilise the ` diff --git a/mod.ts b/mod.ts index 01d937a..3d32900 100644 --- a/mod.ts +++ b/mod.ts @@ -1,21 +1,46 @@ import { Client } from "./src/client.ts"; import { BuildOptions, Cookie, ScreenshotOptions } from "./src/interfaces.ts"; import { Page } from "./src/page.ts"; +import { getChromeArgs } from "./src/utility.ts"; export type { BuildOptions, Cookie, ScreenshotOptions }; +const defaultOptions = { + hostname: "localhost", + debuggerPort: 9292, + binaryPath: undefined, +}; + export async function build( - options: BuildOptions = { - hostname: "localhost", - debuggerPort: 9292, - binaryPath: undefined, - }, + options: BuildOptions = defaultOptions, ): Promise<{ browser: Client; page: Page; }> { if (!options.debuggerPort) options.debuggerPort = 9292; if (!options.hostname) options.hostname = "localhost"; + const buildArgs = getChromeArgs(options.debuggerPort); + const path = buildArgs.splice(0, 1)[0]; + const command = new Deno.Command(path, { + args: buildArgs, + stderr: "piped", + stdout: "piped", + }); + const browserProcess = command.spawn(); + + return await Client.create( + { + hostname: options.hostname, + port: options.debuggerPort, + }, + browserProcess, + ); +} + +export async function connect(options: BuildOptions = defaultOptions) { + if (!options.debuggerPort) options.debuggerPort = 9292; + if (!options.hostname) options.hostname = "localhost"; + return await Client.create( { hostname: options.hostname, diff --git a/src/client.ts b/src/client.ts index 557116c..54847ed 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,5 @@ import { deferred } from "../deps.ts"; import { Page } from "./page.ts"; -import { getChromeArgs } from "./utility.ts"; /** * A way to interact with the headless browser instance. @@ -31,7 +30,7 @@ export class Client { /** * The sub process that runs headless chrome */ - readonly #browser_process: Deno.ChildProcess; + readonly #browser_process: Deno.ChildProcess | undefined; #closed = false; @@ -49,7 +48,7 @@ export class Client { */ constructor( socket: WebSocket, - browserProcess: Deno.ChildProcess, + browserProcess: Deno.ChildProcess | undefined, wsOptions: { hostname: string; port: number; @@ -75,12 +74,17 @@ export class Client { } // Close browser process (also closes the ws endpoint, which in turn closes all sockets) + // Though if browser process isn't present (eg remote) then just close socket const p = deferred(); this.#socket.onclose = () => p.resolve(); - this.#browser_process.stderr.cancel(); - this.#browser_process.stdout.cancel(); - this.#browser_process.kill(); - await this.#browser_process.status; + if (this.#browser_process) { + this.#browser_process.stderr.cancel(); + this.#browser_process.stdout.cancel(); + this.#browser_process.kill(); + await this.#browser_process.status; + } else { + this.#socket.close(); + } await p; this.#closed = true; @@ -104,36 +108,11 @@ export class Client { hostname: string; port: number; }, + browserProcess: Deno.ChildProcess | undefined = undefined, ): Promise<{ browser: Client; page: Page; }> { - const buildArgs = getChromeArgs(wsOptions.port); - const path = buildArgs.splice(0, 1)[0]; - const command = new Deno.Command(path, { - args: buildArgs, - stderr: "piped", - stdout: "piped", - }); - const browserProcess = command.spawn(); - // Old approach until we discovered we can always just use fetch - // // Get the main ws conn for the client - this loop is needed as the ws server isn't open until we get the listeneing on. - // // We could just loop on the fetch of the /json/list endpoint, but we could tank the computers resources if the endpoint - // // isn't up for another 10s, meaning however many fetch requests in 10s - // // Sometimes it takes a while for the "Devtools listening on ws://..." line to show on windows + firefox too - // import { TextLineStream } from "jsr:@std/streams"; - // for await ( - // const line of browserProcess.stderr.pipeThrough(new TextDecoderStream()) - // .pipeThrough(new TextLineStream()) - // ) { // Loop also needed before json endpoint is up - // const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); - // if (!match) { - // continue; - // } - // browserWsUrl = line.split("on ")[1]; - // break; - // } - // Wait until endpoint is ready and get a WS connection // to the main socket const p = deferred(); diff --git a/src/interfaces.ts b/src/interfaces.ts index 91daf7f..1f725d4 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -5,8 +5,6 @@ export interface BuildOptions { hostname?: string; /** The path to the binary of the browser executable, such as specifying an alternative chromium browser */ binaryPath?: string; - /** If the Browser is a remote process */ - remote?: boolean; } export interface ScreenshotOptions { diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index 0508caa..2237c69 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -7,13 +7,11 @@ import { assertEquals } from "../../deps.ts"; import { build } from "../../mod.ts"; -const remote = Deno.args.includes("--remoteBrowser"); - Deno.test("csrf_protected_pages_test.ts", async (t) => { await t.step( `CSRF Protected Pages - Tutorial for this feature in the docs should work`, async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://drash.land"); await page.cookie({ name: "X-CSRF-TOKEN", diff --git a/tests/integration/docker_test/docker-compose.yml b/tests/integration/docker_test/docker-compose.yml index 19baf7a..ba99a03 100644 --- a/tests/integration/docker_test/docker-compose.yml +++ b/tests/integration/docker_test/docker-compose.yml @@ -13,6 +13,7 @@ services: - ../../../tsconfig.json:/var/www/docker-test/tsconfig.json command: bash -c "tail -f /dev/null" working_dir: /var/www/docker-test + remotes: container_name: remotes restart: always diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index 5bdb00a..20c8d7a 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -1,11 +1,9 @@ import { assertEquals } from "../../deps.ts"; import { build } from "../../mod.ts"; -const remote = Deno.args.includes("--remoteBrowser"); - Deno.test("manipulate_page_test.ts", async (t) => { await t.step("Manipulate Webpage", async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://drash.land"); const updatedBody = await page.evaluate(() => { @@ -26,7 +24,7 @@ Deno.test("manipulate_page_test.ts", async (t) => { await t.step( "Evaluating a script - Tutorial for this feature in the documentation works", async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://drash.land"); const pageTitle = await page.evaluate(() => { // deno-lint-ignore no-undef @@ -49,8 +47,9 @@ Deno.test("manipulate_page_test.ts", async (t) => { await browser.close(); assertEquals(pageTitle, "Drash Land"); assertEquals(sum, 11); - assertEquals(oldBodyLength, remote ? 5 : 3); - assertEquals(newBodyLength, remote ? 6 : 4); + // TODO :: Do this test but for remote as well + assertEquals(oldBodyLength, 3); + assertEquals(newBodyLength, 4); }, ); }); diff --git a/tests/integration/remote_test.ts b/tests/integration/remote_test.ts new file mode 100644 index 0000000..ad31e00 --- /dev/null +++ b/tests/integration/remote_test.ts @@ -0,0 +1,79 @@ +import { assertEquals } from "../../deps.ts"; +import { build, connect } from "../../mod.ts"; +const serverAdd = `http://host.docker.internal:1447`; + +const isRemote = Deno.args.includes("--remote"); + +Deno.test("manipulate_page_test.ts", async (t) => { + await t.step({ + name: "Remote tests (various to test different aspects)", + ignore: !isRemote, + fn: async (t) => { + await t.step("Can open and close fine", async () => { + const { browser, page } = await connect({ + hostname: "localhost", + debuggerPort: 9292, + }); + + // todo do soemthing + + await browser.close(); + }); + + await t.step("Can visit pages", async () => { + const { browser, page } = await connect({ + hostname: "localhost", + debuggerPort: 9292, + }); + + // todo do soemthing + + await browser.close(); + }); + + await t.step("Can open and close fine", async () => { + const { browser, page } = await connect({ + hostname: "localhost", + debuggerPort: 9292, + }); + + // todo do soemthing + + await browser.close(); + }); + + await t.step("Can visit pages", async () => { + const { browser, page } = await connect({ + hostname: "localhost", + debuggerPort: 9292, + }); + + // todo do soemthing + + await browser.close(); + }); + + await t.step("Can evaluate", async () => { + const { browser, page } = await connect({ + hostname: "localhost", + debuggerPort: 9292, + }); + + // todo do soemthing + + await browser.close(); + }); + + await t.step("Can click elements", async () => { + const { browser, page } = await connect({ + hostname: "localhost", + debuggerPort: 9292, + }); + + // todo do soemthing + + await browser.close(); + }); + }, + }); +}); diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 73be6d2..5ae0a03 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,8 +1,6 @@ import { deferred } from "../../deps.ts"; import { build } from "../../mod.ts"; -const remote = Deno.args.includes("--remoteBrowser"); - Deno.test("client_test.ts", async (t) => { await t.step("create()", async (t) => { await t.step( @@ -33,7 +31,7 @@ Deno.test("client_test.ts", async (t) => { await t.step( `Will start headless as a subprocess`, async () => { - const { browser } = await build({ remote }); + const { browser } = await build(); const res = await fetch("http://localhost:9292/json/list"); const json = await res.json(); // Our ws client should be able to connect if the browser is running @@ -82,14 +80,13 @@ Deno.test("client_test.ts", async (t) => { await promise; await browser.close(); }, - ignore: remote, //Ignoring as binary path is not a necessisty to test for remote browsers }, ); }); await t.step(`close()`, async (t) => { await t.step(`Should close all resources and not leak any`, async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://drash.land"); await browser.close(); // If resources are not closed or pending ops or leaked, this test will show it when ran @@ -98,32 +95,22 @@ Deno.test("client_test.ts", async (t) => { await t.step({ name: `Should close all page specific resources too`, fn: async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location("https://drash.land"); await browser.close(); - if (!remote) { - try { - const listener = Deno.listen({ - port: 9292, - hostname: "localhost", - }); - listener.close(); - } catch (e) { - if (e instanceof Deno.errors.AddrInUse) { - throw new Error( - `Seems like the subprocess is still running: ${e.message}`, - ); - } - } - } else { - const { browser: br2 } = await build({ - remote, + try { + const listener = Deno.listen({ + port: 9292, + hostname: "localhost", }); - await br2.close(); + listener.close(); + } catch (e) { + if (e instanceof Deno.errors.AddrInUse) { + throw new Error( + `Seems like the subprocess is still running: ${e.message}`, + ); + } } - // If resources are not closed or pending ops or leaked, this test will show it when ran }, }); }); diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index d4ea32e..32fb273 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -2,18 +2,13 @@ import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; import { resolve } from "../deps.ts"; -const remote = Deno.args.includes("--remoteBrowser"); -const serverAdd = `http://${ - remote ? "host.docker.internal" : "localhost" -}:1447`; +const serverAdd = `http://localhost:1447`; Deno.test("element_test.ts", async (t) => { await t.step("click()", async (t) => { await t.step( "It should allow clicking of elements and update location", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); server.run(); await page.location(serverAdd + "/anchor-links"); const elem = await page.querySelector( @@ -32,9 +27,7 @@ Deno.test("element_test.ts", async (t) => { await t.step( "It should error if the HTML for the element is invalid", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); server.run(); await page.location(serverAdd + "/anchor-links"); const elem = await page.querySelector( @@ -62,9 +55,7 @@ Deno.test("element_test.ts", async (t) => { await t.step( "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location("https://drash.land"); const img = await page.querySelector("img"); await img.takeScreenshot({ @@ -75,7 +66,7 @@ Deno.test("element_test.ts", async (t) => { ); await t.step("Saves Screenshot with all options provided", async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); server.run(); await page.location(serverAdd + "/anchor-links"); const a = await page.querySelector("a"); @@ -95,9 +86,7 @@ Deno.test("element_test.ts", async (t) => { "Should throw if multiple files and input isnt multiple", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location(serverAdd + "/input"); const elem = await page.querySelector("#single-file"); let errMsg = ""; @@ -117,9 +106,7 @@ Deno.test("element_test.ts", async (t) => { ); await t.step("Should throw if element isnt an input", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location(serverAdd + "/input"); const elem = await page.querySelector("p"); let errMsg = ""; @@ -138,9 +125,7 @@ Deno.test("element_test.ts", async (t) => { }); await t.step("Should throw if input is not of type file", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build() await page.location(serverAdd + "/input"); const elem = await page.querySelector("#text"); let errMsg = ""; @@ -159,9 +144,7 @@ Deno.test("element_test.ts", async (t) => { }); await t.step("Should successfully upload files", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(}); await page.location(serverAdd + "/input"); const elem = await page.querySelector("#multiple-file"); try { @@ -181,16 +164,14 @@ Deno.test("element_test.ts", async (t) => { } }); }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser + }); await t.step({ name: "file()", fn: async (t) => { await t.step("Should throw if element isnt an input", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location(serverAdd + "/input"); const elem = await page.querySelector("p"); let errMsg = ""; @@ -209,9 +190,7 @@ Deno.test("element_test.ts", async (t) => { }); await t.step("Should throw if input is not of type file", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location(serverAdd + "/input"); const elem = await page.querySelector("#text"); let errMsg = ""; @@ -230,9 +209,7 @@ Deno.test("element_test.ts", async (t) => { }); await t.step("Should successfully upload files", async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location(serverAdd + "/input"); const elem = await page.querySelector("#single-file"); try { @@ -249,5 +226,5 @@ Deno.test("element_test.ts", async (t) => { } }); }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser + }); }); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 61ad91c..c73ee39 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,18 +1,13 @@ import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; -const remote = Deno.args.includes("--remoteBrowser"); -const serverAdd = `http://${ - remote ? "host.docker.internal" : "localhost" -}:1447`; +const serverAdd = `http://localhost:1447`; Deno.test("page_test.ts", async (t) => { await t.step("takeScreenshot()", async (t) => { await t.step( "takeScreenshot() | Takes a Screenshot", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location("https://drash.land"); const result = await page.takeScreenshot(); await browser.close(); @@ -23,7 +18,7 @@ Deno.test("page_test.ts", async (t) => { await t.step( "Throws an error when format passed is jpeg(or default) and quality > than 100", async () => { - const { page } = await build({ remote }); + const { page } = await build(); await page.location("https://drash.land"); let msg = ""; try { @@ -44,9 +39,7 @@ Deno.test("page_test.ts", async (t) => { await t.step( "It should evaluate function on current frame", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location("https://drash.land"); const pageTitle = await page.evaluate(() => { // deno-lint-ignore no-undef @@ -57,7 +50,7 @@ Deno.test("page_test.ts", async (t) => { }, ); await t.step("It should evaluate string on current frame", async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://drash.land"); const parentConstructor = await page.evaluate(`1 + 2`); await browser.close(); @@ -66,9 +59,7 @@ Deno.test("page_test.ts", async (t) => { await t.step( "You should be able to pass arguments to the callback", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location("https://drash.land"); interface User { name: string; @@ -112,9 +103,7 @@ Deno.test("page_test.ts", async (t) => { await t.step( "Handles correctly and doesnt hang when invalid URL", async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); let error = null; try { await page.location("https://google.comINPUT"); @@ -127,7 +116,7 @@ Deno.test("page_test.ts", async (t) => { ); await t.step("Sets and gets the location", async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://google.com"); await page.location("https://drash.land"); const url = await page.evaluate(() => window.location.href); @@ -138,7 +127,7 @@ Deno.test("page_test.ts", async (t) => { await t.step("cookie()", async (t) => { await t.step("Sets and gets cookies", async () => { - const { browser, page } = await build({ remote }); + const { browser, page } = await build(); await page.location("https://drash.land"); await page.cookie({ name: "user", @@ -172,9 +161,7 @@ Deno.test("page_test.ts", async (t) => { fn: async (t) => { await t.step(`Should return expected errors`, async () => { server.run(); - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location( serverAdd, ); @@ -192,9 +179,7 @@ Deno.test("page_test.ts", async (t) => { }); await t.step(`Should be empty if no errors`, async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); await page.location( "https://drash.land", ); @@ -203,15 +188,13 @@ Deno.test("page_test.ts", async (t) => { assertEquals(errors, []); }); }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser + }); await t.step({ name: "dialog()", fn: async (t) => { await t.step(`Accepts a dialog`, async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); server.run(); await page.location(serverAdd + "/dialogs"); const elem = await page.querySelector("#button"); @@ -226,9 +209,7 @@ Deno.test("page_test.ts", async (t) => { assertEquals(val, "Sinco 4eva"); }); await t.step(`Throws if a dialog was not expected`, async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); let errMsg = ""; try { await page.dialog(true, "Sinco 4eva"); @@ -242,9 +223,7 @@ Deno.test("page_test.ts", async (t) => { ); }); await t.step(`Rejects a dialog`, async () => { - const { browser, page } = await build({ - remote, - }); + const { browser, page } = await build(); server.run(); await page.location(serverAdd + "/dialogs"); const elem = await page.querySelector("#button"); @@ -259,5 +238,4 @@ Deno.test("page_test.ts", async (t) => { assertEquals(val, ""); }); }, - }); //Ignoring until we figure out a way to run the server on a remote container accesible to the remote browser -}); + }); From 1f359c83c432ce24b1324da7d007ef84d3b06e24 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 21:52:24 +0100 Subject: [PATCH 11/34] more --- .github/workflows/master.yml | 8 +- console/bumper_ci_service_files.ts | 2 +- src/client.ts | 1 + tests/console/bumper_test.ts | 6 +- tests/docker-compose.yml | 15 + .../docker_test => }/drivers.dockerfile | 0 .../integration/csrf_protected_pages_test.ts | 33 +- .../custom_scripts/remote_chrome.sh | 4 - .../docker_test/docker-compose.yml | 30 -- tests/integration/manipulate_page_test.ts | 66 +-- tests/integration/remote_test.ts | 79 ---- tests/unit/client_test.ts | 178 ++++--- tests/unit/element_test.ts | 395 ++++++++-------- tests/unit/page_test.ts | 436 +++++++++--------- 14 files changed, 558 insertions(+), 695 deletions(-) create mode 100644 tests/docker-compose.yml rename tests/{integration/docker_test => }/drivers.dockerfile (100%) delete mode 100644 tests/integration/docker_test/custom_scripts/remote_chrome.sh delete mode 100644 tests/integration/docker_test/docker-compose.yml delete mode 100644 tests/integration/remote_test.ts diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 10e215a..5553708 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -16,17 +16,11 @@ jobs: - name: Tests run: | - cd tests/integration/docker_test + cd tests docker-compose up -d drivers docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/integration docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/unit - - name: Remote tests - run: | - cd tests/integration/docker_test - docker-compose up -d remotes - docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/integration/remote_test.ts - console-tests: runs-on: ubuntu-latest diff --git a/console/bumper_ci_service_files.ts b/console/bumper_ci_service_files.ts index 8a895d8..59baac3 100644 --- a/console/bumper_ci_service_files.ts +++ b/console/bumper_ci_service_files.ts @@ -16,7 +16,7 @@ const { versions } = await chromeVersionsRes.json(); export const bumperFiles = [ { - filename: "./tests/integration/docker_test/drivers.dockerfile", + filename: "./tests/drivers.dockerfile", replaceTheRegex: /ENV CHROME_VERSION \".*\"/, replaceWith: `ENV CHROME_VERSION "${versions[0].version}"`, }, diff --git a/src/client.ts b/src/client.ts index 54847ed..4657186 100644 --- a/src/client.ts +++ b/src/client.ts @@ -122,6 +122,7 @@ export class Client { `http://${wsOptions.hostname}:${wsOptions.port}/json/version`, ); const json = await res.json(); + console.log(json['webSocketDebuggerUrl']) const socket = new WebSocket(json["webSocketDebuggerUrl"]); const p2 = deferred(); socket.onopen = () => p2.resolve(); diff --git a/tests/console/bumper_test.ts b/tests/console/bumper_test.ts index 9629507..a257469 100644 --- a/tests/console/bumper_test.ts +++ b/tests/console/bumper_test.ts @@ -7,12 +7,12 @@ Deno.test("Updates chrome version in dockerfile", async () => { const { versions } = await chromeVersionsRes.json(); const version = versions[0].version; const originalContents = Deno.readTextFileSync( - "./tests/integration/docker_test/drivers.dockerfile", + "./tests/drivers.dockerfile", ); let newContent = originalContents; newContent.replace(/CHROME_VERSION \".*\"/, 'CHROME VERSION "123"'); Deno.writeTextFileSync( - "./tests/integration/docker_test/drivers.dockerfile", + "./tests/drivers.dockerfile", newContent, ); const p = new Deno.Command("deno", { @@ -21,7 +21,7 @@ Deno.test("Updates chrome version in dockerfile", async () => { const child = p.spawn(); await child.status; newContent = Deno.readTextFileSync( - "./tests/integration/docker_test/drivers.dockerfile", + "./tests/drivers.dockerfile", ); assertEquals(newContent.includes(`CHROME_VERSION "${version}"`), true); }); diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 0000000..f221288 --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,15 @@ +services: + drivers: + container_name: drivers + build: + context: . + dockerfile: drivers.dockerfile + volumes: + - /dev/shm:/dev/shm + - ../src:/var/www/docker-test/src + - ../tests:/var/www/docker-test/tests + - ../deps.ts:/var/www/docker-test/deps.ts + - ../mod.ts:/var/www/docker-test/mod.ts + - ../tsconfig.json:/var/www/docker-test/tsconfig.json + command: bash -c "tail -f /dev/null" + working_dir: /var/www/docker-test \ No newline at end of file diff --git a/tests/integration/docker_test/drivers.dockerfile b/tests/drivers.dockerfile similarity index 100% rename from tests/integration/docker_test/drivers.dockerfile rename to tests/drivers.dockerfile diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index 2237c69..3dd7e2b 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -7,23 +7,18 @@ import { assertEquals } from "../../deps.ts"; import { build } from "../../mod.ts"; -Deno.test("csrf_protected_pages_test.ts", async (t) => { - await t.step( - `CSRF Protected Pages - Tutorial for this feature in the docs should work`, - async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - await page.cookie({ - name: "X-CSRF-TOKEN", - value: "hi:)", - url: "https://drash.land", - }); - await page.location("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays - const cookieVal = await page.evaluate(() => { - return document.cookie; - }); - await browser.close(); - assertEquals(cookieVal, "X-CSRF-TOKEN=hi:)"); - }, - ); +Deno.test(`Tutorial for this feature in the docs should work`, async () => { + const { browser, page } = await build(); + await page.location("https://drash.land"); + await page.cookie({ + name: "X-CSRF-TOKEN", + value: "hi:)", + url: "https://drash.land", + }); + await page.location("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays + const cookieVal = await page.evaluate(() => { + return document.cookie; + }); + await browser.close(); + assertEquals(cookieVal, "X-CSRF-TOKEN=hi:)"); }); diff --git a/tests/integration/docker_test/custom_scripts/remote_chrome.sh b/tests/integration/docker_test/custom_scripts/remote_chrome.sh deleted file mode 100644 index 4d675d0..0000000 --- a/tests/integration/docker_test/custom_scripts/remote_chrome.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -while [ true ]; do - google-chrome --remote-debugging-port=9292 --remote-debugging-address=0.0.0.0 --disable-gpu --headless --no-sandbox --disable-background-networking --enable-features=NetworkService,NetworkServiceInProcess --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --force-color-profile=srgb --metrics-recording-only --no-first-run --enable-automation --password-store=basic --use-mock-keychain --window-size=1920,1080 about:blank -done \ No newline at end of file diff --git a/tests/integration/docker_test/docker-compose.yml b/tests/integration/docker_test/docker-compose.yml deleted file mode 100644 index ba99a03..0000000 --- a/tests/integration/docker_test/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - drivers: - container_name: drivers - build: - context: . - dockerfile: drivers.dockerfile - volumes: - - /dev/shm:/dev/shm - - ../../../src:/var/www/docker-test/src - - ../../../tests:/var/www/docker-test/tests - - ../../../deps.ts:/var/www/docker-test/deps.ts - - ../../../mod.ts:/var/www/docker-test/mod.ts - - ../../../tsconfig.json:/var/www/docker-test/tsconfig.json - command: bash -c "tail -f /dev/null" - working_dir: /var/www/docker-test - - remotes: - container_name: remotes - restart: always - volumes: - - ./custom_scripts:/var/www/docker-test/custom_scripts - build: - context: . - dockerfile: drivers.dockerfile - ports: - - "9292:9292" - extra_hosts: - - "host.docker.internal:host-gateway" - command: bash ./custom_scripts/remote_chrome.sh - working_dir: /var/www/docker-test \ No newline at end of file diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index 20c8d7a..46b7ff9 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -1,55 +1,33 @@ import { assertEquals } from "../../deps.ts"; import { build } from "../../mod.ts"; -Deno.test("manipulate_page_test.ts", async (t) => { - await t.step("Manipulate Webpage", async () => { +Deno.test( + "Evaluating a script - Tutorial for this feature in the documentation works", + async () => { const { browser, page } = await build(); await page.location("https://drash.land"); - - const updatedBody = await page.evaluate(() => { + const pageTitle = await page.evaluate(() => { + // deno-lint-ignore no-undef + return document.querySelector("h1")?.textContent; + }); + const sum = await page.evaluate(`1 + 10`); + const oldBodyLength = await page.evaluate(() => { // deno-lint-ignore no-undef - const prevBody = document.body.children.length; + return document.body.children.length; + }); + const newBodyLength = await page.evaluate(() => { // deno-lint-ignore no-undef - const newEl = document.createElement("p"); + const p = document.createElement("p"); + p.textContent = "Hello world!"; // deno-lint-ignore no-undef - document.body.appendChild(newEl); + document.body.appendChild(p); // deno-lint-ignore no-undef - return prevBody === document.body.children.length - 1; + return document.body.children.length; }); - assertEquals(updatedBody, true); - await browser.close(); - }); - - await t.step( - "Evaluating a script - Tutorial for this feature in the documentation works", - async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - const pageTitle = await page.evaluate(() => { - // deno-lint-ignore no-undef - return document.querySelector("h1")?.textContent; - }); - const sum = await page.evaluate(`1 + 10`); - const oldBodyLength = await page.evaluate(() => { - // deno-lint-ignore no-undef - return document.body.children.length; - }); - const newBodyLength = await page.evaluate(() => { - // deno-lint-ignore no-undef - const p = document.createElement("p"); - p.textContent = "Hello world!"; - // deno-lint-ignore no-undef - document.body.appendChild(p); - // deno-lint-ignore no-undef - return document.body.children.length; - }); - await browser.close(); - assertEquals(pageTitle, "Drash Land"); - assertEquals(sum, 11); - // TODO :: Do this test but for remote as well - assertEquals(oldBodyLength, 3); - assertEquals(newBodyLength, 4); - }, - ); -}); + assertEquals(pageTitle, "Drash Land"); + assertEquals(sum, 11); + assertEquals(oldBodyLength, 3); + assertEquals(newBodyLength, 4); + }, +); diff --git a/tests/integration/remote_test.ts b/tests/integration/remote_test.ts deleted file mode 100644 index ad31e00..0000000 --- a/tests/integration/remote_test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { assertEquals } from "../../deps.ts"; -import { build, connect } from "../../mod.ts"; -const serverAdd = `http://host.docker.internal:1447`; - -const isRemote = Deno.args.includes("--remote"); - -Deno.test("manipulate_page_test.ts", async (t) => { - await t.step({ - name: "Remote tests (various to test different aspects)", - ignore: !isRemote, - fn: async (t) => { - await t.step("Can open and close fine", async () => { - const { browser, page } = await connect({ - hostname: "localhost", - debuggerPort: 9292, - }); - - // todo do soemthing - - await browser.close(); - }); - - await t.step("Can visit pages", async () => { - const { browser, page } = await connect({ - hostname: "localhost", - debuggerPort: 9292, - }); - - // todo do soemthing - - await browser.close(); - }); - - await t.step("Can open and close fine", async () => { - const { browser, page } = await connect({ - hostname: "localhost", - debuggerPort: 9292, - }); - - // todo do soemthing - - await browser.close(); - }); - - await t.step("Can visit pages", async () => { - const { browser, page } = await connect({ - hostname: "localhost", - debuggerPort: 9292, - }); - - // todo do soemthing - - await browser.close(); - }); - - await t.step("Can evaluate", async () => { - const { browser, page } = await connect({ - hostname: "localhost", - debuggerPort: 9292, - }); - - // todo do soemthing - - await browser.close(); - }); - - await t.step("Can click elements", async () => { - const { browser, page } = await connect({ - hostname: "localhost", - debuggerPort: 9292, - }); - - // todo do soemthing - - await browser.close(); - }); - }, - }); -}); diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 5ae0a03..fd2d5fa 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,117 +1,115 @@ import { deferred } from "../../deps.ts"; import { build } from "../../mod.ts"; -Deno.test("client_test.ts", async (t) => { - await t.step("create()", async (t) => { - await t.step( - "Uses the port when passed in to the parameters", - async () => { +Deno.test("create()", async (t) => { + await t.step( + "Uses the port when passed in to the parameters", + async () => { + const { browser } = await build({ + debuggerPort: 9999, + }); + const res = await fetch("http://localhost:9999/json/list"); + const json = await res.json(); + // Our ws client should be able to connect if the browser is running + const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); + let promise = deferred(); + client.onopen = function () { + promise.resolve(); + }; + await promise; + promise = deferred(); + client.onclose = function () { + promise.resolve(); + }; + client.close(); + await promise; + await browser.close(); + }, + ); + + await t.step( + `Will start headless as a subprocess`, + async () => { + const { browser } = await build(); + const res = await fetch("http://localhost:9292/json/list"); + const json = await res.json(); + // Our ws client should be able to connect if the browser is running + const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); + let promise = deferred(); + client.onopen = function () { + promise.resolve(); + }; + await promise; + promise = deferred(); + client.onclose = function () { + promise.resolve(); + }; + client.close(); + await promise; + await browser.close(); + }, + ); + + await t.step( + "Uses the hostname when passed in to the parameters", + async () => { + // Unable to test properly, as windows doesnt like 0.0.0.0 or localhost, so the only choice is 127.0.0.1 but this is already the default + }, + ); + + await t.step( + { + name: "Uses the binaryPath when passed in to the parameters", + fn: async () => { const { browser } = await build({ - debuggerPort: 9999, + //binaryPath: await browserItem.getPath(), }); - const res = await fetch("http://localhost:9999/json/list"); - const json = await res.json(); - // Our ws client should be able to connect if the browser is running - const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); - let promise = deferred(); - client.onopen = function () { - promise.resolve(); - }; - await promise; - promise = deferred(); - client.onclose = function () { - promise.resolve(); - }; - client.close(); - await promise; - await browser.close(); - }, - ); - await t.step( - `Will start headless as a subprocess`, - async () => { - const { browser } = await build(); const res = await fetch("http://localhost:9292/json/list"); const json = await res.json(); // Our ws client should be able to connect if the browser is running const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); - let promise = deferred(); + const promise = deferred(); client.onopen = function () { - promise.resolve(); + client.close(); }; - await promise; - promise = deferred(); client.onclose = function () { promise.resolve(); }; - client.close(); await promise; await browser.close(); }, - ); - - await t.step( - "Uses the hostname when passed in to the parameters", - async () => { - // Unable to test properly, as windows doesnt like 0.0.0.0 or localhost, so the only choice is 127.0.0.1 but this is already the default - }, - ); - - await t.step( - { - name: "Uses the binaryPath when passed in to the parameters", - fn: async () => { - const { browser } = await build({ - //binaryPath: await browserItem.getPath(), - }); + }, + ); +}); - const res = await fetch("http://localhost:9292/json/list"); - const json = await res.json(); - // Our ws client should be able to connect if the browser is running - const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); - const promise = deferred(); - client.onopen = function () { - client.close(); - }; - client.onclose = function () { - promise.resolve(); - }; - await promise; - await browser.close(); - }, - }, - ); +Deno.test(`close()`, async (t) => { + await t.step(`Should close all resources and not leak any`, async () => { + const { browser, page } = await build(); + await page.location("https://drash.land"); + await browser.close(); + // If resources are not closed or pending ops or leaked, this test will show it when ran }); - await t.step(`close()`, async (t) => { - await t.step(`Should close all resources and not leak any`, async () => { + await t.step({ + name: `Should close all page specific resources too`, + fn: async () => { const { browser, page } = await build(); await page.location("https://drash.land"); await browser.close(); - // If resources are not closed or pending ops or leaked, this test will show it when ran - }); - - await t.step({ - name: `Should close all page specific resources too`, - fn: async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - await browser.close(); - try { - const listener = Deno.listen({ - port: 9292, - hostname: "localhost", - }); - listener.close(); - } catch (e) { - if (e instanceof Deno.errors.AddrInUse) { - throw new Error( - `Seems like the subprocess is still running: ${e.message}`, - ); - } + try { + const listener = Deno.listen({ + port: 9292, + hostname: "localhost", + }); + listener.close(); + } catch (e) { + if (e instanceof Deno.errors.AddrInUse) { + throw new Error( + `Seems like the subprocess is still running: ${e.message}`, + ); } - }, - }); + } + }, }); }); diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 32fb273..4c43054 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -2,116 +2,94 @@ import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; import { resolve } from "../deps.ts"; -const serverAdd = `http://localhost:1447`; -Deno.test("element_test.ts", async (t) => { - await t.step("click()", async (t) => { - await t.step( - "It should allow clicking of elements and update location", - async () => { - const { browser, page } = await build(); - server.run(); - await page.location(serverAdd + "/anchor-links"); - const elem = await page.querySelector( - "a#not-blank", - ); +Deno.test("click()", async (t) => { + await t.step( + "It should allow clicking of elements and update location", + async () => { + const { browser, page } = await build(); + server.run(); + await page.location(server.address + "/anchor-links"); + const elem = await page.querySelector( + "a#not-blank", + ); + await elem.click({ + waitFor: "navigation", + }); + const page1Location = await page.evaluate(() => window.location.href); + await browser.close(); + await server.close(); + assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); + }, + ); + + await t.step( + "It should error if the HTML for the element is invalid", + async () => { + const { browser, page } = await build(); + server.run(); + await page.location(server.address + "/anchor-links"); + const elem = await page.querySelector( + "a#invalid-link", + ); + let error = null; + try { await elem.click({ waitFor: "navigation", }); - const page1Location = await page.evaluate(() => window.location.href); - await browser.close(); - await server.close(); - assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); - }, - ); - - await t.step( - "It should error if the HTML for the element is invalid", - async () => { - const { browser, page } = await build(); - server.run(); - await page.location(serverAdd + "/anchor-links"); - const elem = await page.querySelector( - "a#invalid-link", - ); - let error = null; - try { - await elem.click({ - waitFor: "navigation", - }); - } catch (e) { - error = e.message; - } - await browser.close(); - await server.close(); - assertEquals( - error, - 'Unable to click the element "a#invalid-link". It could be that it is invalid HTML', - ); - }, - ); - }); - - await t.step("takeScreenshot()", async (t) => { - await t.step( - "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", - async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - const img = await page.querySelector("img"); - await img.takeScreenshot({ - quality: 50, - }); - await browser.close(); - }, - ); + } catch (e) { + error = e.message; + } + await browser.close(); + await server.close(); + assertEquals( + error, + 'Unable to click the element "a#invalid-link". It could be that it is invalid HTML', + ); + }, + ); +}); - await t.step("Saves Screenshot with all options provided", async () => { +Deno.test("takeScreenshot()", async (t) => { + await t.step( + "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", + async () => { const { browser, page } = await build(); - server.run(); - await page.location(serverAdd + "/anchor-links"); - const a = await page.querySelector("a"); - await a.takeScreenshot({ - format: "jpeg", - quality: 100, + await page.location("https://drash.land"); + const img = await page.querySelector("img"); + await img.takeScreenshot({ + quality: 50, }); await browser.close(); - await server.close(); + }, + ); + + await t.step("Saves Screenshot with all options provided", async () => { + const { browser, page } = await build(); + server.run(); + await page.location(server.address + "/anchor-links"); + const a = await page.querySelector("a"); + await a.takeScreenshot({ + format: "jpeg", + quality: 100, }); + await browser.close(); + await server.close(); }); +}); - await t.step({ - name: "files()", - fn: async (t) => { - await t.step( - "Should throw if multiple files and input isnt multiple", - async () => { - server.run(); - const { browser, page } = await build(); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#single-file"); - let errMsg = ""; - try { - await elem.files("ffff", "hhh"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - `Trying to set files on a file input without the 'multiple' attribute`, - ); - }, - ); - await t.step("Should throw if element isnt an input", async () => { +Deno.test({ + name: "files()", + fn: async (t) => { + await t.step( + "Should throw if multiple files and input isnt multiple", + async () => { server.run(); const { browser, page } = await build(); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("p"); + await page.location(server.address + "/input"); + const elem = await page.querySelector("#single-file"); let errMsg = ""; try { - await elem.files("ffff"); + await elem.files("ffff", "hhh"); } catch (e) { errMsg = e.message; } finally { @@ -120,111 +98,130 @@ Deno.test("element_test.ts", async (t) => { } assertEquals( errMsg, - "Trying to set a file on an element that isnt an input", + `Trying to set files on a file input without the 'multiple' attribute`, ); - }); - await t.step("Should throw if input is not of type file", async () => { - server.run(); - const { browser, page } = await build() - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#text"); - let errMsg = ""; - try { - await elem.files("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - 'Trying to set a file on an input that is not of type "file"', + }, + ); + await t.step("Should throw if element isnt an input", async () => { + server.run(); + const { browser, page } = await build(); + await page.location(server.address + "/input"); + const elem = await page.querySelector("p"); + let errMsg = ""; + try { + await elem.files("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + "Trying to set a file on an element that isnt an input", + ); + }); + await t.step("Should throw if input is not of type file", async () => { + server.run(); + const { browser, page } = await build(); + await page.location(server.address + "/input"); + const elem = await page.querySelector("#text"); + let errMsg = ""; + try { + await elem.files("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + 'Trying to set a file on an input that is not of type "file"', + ); + }); + await t.step("Should successfully upload files", async () => { + server.run(); + const { browser, page } = await build(); + await page.location(server.address + "/input"); + const elem = await page.querySelector("#multiple-file"); + try { + await elem.files( + resolve("./README.md"), + resolve("./tsconfig.json"), ); - }); - await t.step("Should successfully upload files", async () => { - server.run(); - const { browser, page } = await build(}); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#multiple-file"); - try { - await elem.files( - resolve("./README.md"), - resolve("./tsconfig.json"), - ); - const files = JSON.parse( - await page.evaluate( - `JSON.stringify(document.querySelector('#multiple-file').files)`, - ), - ); - assertEquals(Object.keys(files).length, 2); - } finally { - await server.close(); - await browser.close(); - } - }); - }, - }); - - await t.step({ - name: "file()", - fn: async (t) => { - await t.step("Should throw if element isnt an input", async () => { - server.run(); - const { browser, page } = await build(); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("p"); - let errMsg = ""; - try { - await elem.file("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - "Trying to set a file on an element that isnt an input", + const files = JSON.parse( + await page.evaluate( + `JSON.stringify(document.querySelector('#multiple-file').files)`, + ), ); - }); - await t.step("Should throw if input is not of type file", async () => { - server.run(); - const { browser, page } = await build(); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#text"); - let errMsg = ""; - try { - await elem.file("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - 'Trying to set a file on an input that is not of type "file"', + assertEquals(Object.keys(files).length, 2); + } finally { + await server.close(); + await browser.close(); + } + }); + }, +}); + +Deno.test({ + name: "file()", + fn: async (t) => { + await t.step("Should throw if element isnt an input", async () => { + server.run(); + const { browser, page } = await build(); + await page.location(server.address + "/input"); + const elem = await page.querySelector("p"); + let errMsg = ""; + try { + await elem.file("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + "Trying to set a file on an element that isnt an input", + ); + }); + await t.step("Should throw if input is not of type file", async () => { + server.run(); + const { browser, page } = await build(); + await page.location(server.address + "/input"); + const elem = await page.querySelector("#text"); + let errMsg = ""; + try { + await elem.file("ffff"); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + 'Trying to set a file on an input that is not of type "file"', + ); + }); + await t.step("Should successfully upload files", async () => { + server.run(); + const { browser, page } = await build(); + await page.location(server.address + "/input"); + const elem = await page.querySelector("#single-file"); + try { + await elem.file(resolve("./README.md")); + const files = JSON.parse( + await page.evaluate( + `JSON.stringify(document.querySelector('#single-file').files)`, + ), ); - }); - await t.step("Should successfully upload files", async () => { - server.run(); - const { browser, page } = await build(); - await page.location(serverAdd + "/input"); - const elem = await page.querySelector("#single-file"); - try { - await elem.file(resolve("./README.md")); - const files = JSON.parse( - await page.evaluate( - `JSON.stringify(document.querySelector('#single-file').files)`, - ), - ); - assertEquals(Object.keys(files).length, 1); - } finally { - await server.close(); - await browser.close(); - } - }); - }, - }); + assertEquals(Object.keys(files).length, 1); + } finally { + await server.close(); + await browser.close(); + } + }); + }, }); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index c73ee39..56b16e2 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,241 +1,239 @@ import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; -const serverAdd = `http://localhost:1447`; -Deno.test("page_test.ts", async (t) => { - await t.step("takeScreenshot()", async (t) => { - await t.step( - "takeScreenshot() | Takes a Screenshot", - async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - const result = await page.takeScreenshot(); - await browser.close(); - assertEquals(result instanceof Uint8Array, true); - }, - ); - - await t.step( - "Throws an error when format passed is jpeg(or default) and quality > than 100", - async () => { - const { page } = await build(); - await page.location("https://drash.land"); - let msg = ""; - try { - await page.takeScreenshot({ quality: 999 }); - } catch (error) { - msg = error.message; - } - //await browser.close(); - assertEquals( - msg, - "A quality value greater than 100 is not allowed.", - ); - }, - ); - }); - - await t.step("evaluate()", async (t) => { - await t.step( - "It should evaluate function on current frame", - async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - const pageTitle = await page.evaluate(() => { - // deno-lint-ignore no-undef - return document.querySelector("h1")?.textContent; - }); - await browser.close(); - assertEquals(pageTitle, "Drash Land"); - }, - ); - await t.step("It should evaluate string on current frame", async () => { +Deno.test("takeScreenshot()", async (t) => { + await t.step( + "takeScreenshot() | Takes a Screenshot", + async () => { const { browser, page } = await build(); await page.location("https://drash.land"); - const parentConstructor = await page.evaluate(`1 + 2`); + const result = await page.takeScreenshot(); await browser.close(); - assertEquals(parentConstructor, 3); - }); - await t.step( - "You should be able to pass arguments to the callback", - async () => { - const { browser, page } = await build(); - await page.location("https://drash.land"); - interface User { - name: string; - age: number; - } - type Answer = "yes" | "no"; - const user: User = { - name: "Cleanup crew", - age: 9001, - }; - const answer: Answer = "yes"; - const result1 = await page.evaluate( - (user: User, answer: Answer) => { - return user.name + " " + answer; - }, - user, - answer, - ); - const result2 = await page.evaluate( - (user: User, answer: Answer) => { - return { - ...user, - answer, - }; - }, - user, - answer, - ); - await browser.close(); - assertEquals(result1, "Cleanup crew yes"); - assertEquals(result2, { - name: "Cleanup crew", - age: 9001, - answer: "yes", - }); - }, - ); - }); + assertEquals(result instanceof Uint8Array, true); + }, + ); - await t.step("location()", async (t) => { - await t.step( - "Handles correctly and doesnt hang when invalid URL", - async () => { - const { browser, page } = await build(); - let error = null; - try { - await page.location("https://google.comINPUT"); - } catch (e) { - error = e.message; - } - await browser.close(); - assertEquals(error, "net::ERR_NAME_NOT_RESOLVED"); - }, - ); + await t.step( + "Throws an error when format passed is jpeg(or default) and quality > than 100", + async () => { + const { page } = await build(); + await page.location("https://drash.land"); + let msg = ""; + try { + await page.takeScreenshot({ quality: 999 }); + } catch (error) { + msg = error.message; + } + //await browser.close(); + assertEquals( + msg, + "A quality value greater than 100 is not allowed.", + ); + }, + ); +}); - await t.step("Sets and gets the location", async () => { +Deno.test("evaluate()", async (t) => { + await t.step( + "It should evaluate function on current frame", + async () => { const { browser, page } = await build(); - await page.location("https://google.com"); await page.location("https://drash.land"); - const url = await page.evaluate(() => window.location.href); + const pageTitle = await page.evaluate(() => { + // deno-lint-ignore no-undef + return document.querySelector("h1")?.textContent; + }); await browser.close(); - assertEquals(url, "https://drash.land/"); - }); + assertEquals(pageTitle, "Drash Land"); + }, + ); + await t.step("It should evaluate string on current frame", async () => { + const { browser, page } = await build(); + await page.location("https://drash.land"); + const parentConstructor = await page.evaluate(`1 + 2`); + await browser.close(); + assertEquals(parentConstructor, 3); }); - - await t.step("cookie()", async (t) => { - await t.step("Sets and gets cookies", async () => { + await t.step( + "You should be able to pass arguments to the callback", + async () => { const { browser, page } = await build(); await page.location("https://drash.land"); - await page.cookie({ - name: "user", - value: "ed", - "url": "https://drash.land", + interface User { + name: string; + age: number; + } + type Answer = "yes" | "no"; + const user: User = { + name: "Cleanup crew", + age: 9001, + }; + const answer: Answer = "yes"; + const result1 = await page.evaluate( + (user: User, answer: Answer) => { + return user.name + " " + answer; + }, + user, + answer, + ); + const result2 = await page.evaluate( + (user: User, answer: Answer) => { + return { + ...user, + answer, + }; + }, + user, + answer, + ); + await browser.close(); + assertEquals(result1, "Cleanup crew yes"); + assertEquals(result2, { + name: "Cleanup crew", + age: 9001, + answer: "yes", }); - const cookies = await page.cookie(); + }, + ); +}); + +Deno.test("location()", async (t) => { + await t.step( + "Handles correctly and doesnt hang when invalid URL", + async () => { + const { browser, page } = await build(); + let error = null; + try { + await page.location("https://google.comINPUT"); + } catch (e) { + error = e.message; + } await browser.close(); - assertEquals(cookies, [ - { - domain: "drash.land", - expires: -1, - httpOnly: false, - name: "user", - path: "/", - priority: "Medium", - sameParty: false, - secure: true, - session: true, - size: 6, - sourcePort: 443, - sourceScheme: "Secure", - value: "ed", - }, - ]); + assertEquals(error, "net::ERR_NAME_NOT_RESOLVED"); + }, + ); + + await t.step("Sets and gets the location", async () => { + const { browser, page } = await build(); + await page.location("https://google.com"); + await page.location("https://drash.land"); + const url = await page.evaluate(() => window.location.href); + await browser.close(); + assertEquals(url, "https://drash.land/"); + }); +}); + +Deno.test("cookie()", async (t) => { + await t.step("Sets and gets cookies", async () => { + const { browser, page } = await build(); + await page.location("https://drash.land"); + await page.cookie({ + name: "user", + value: "ed", + "url": "https://drash.land", }); + const cookies = await page.cookie(); + await browser.close(); + assertEquals(cookies, [ + { + domain: "drash.land", + expires: -1, + httpOnly: false, + name: "user", + path: "/", + priority: "Medium", + sameParty: false, + secure: true, + session: true, + size: 6, + sourcePort: 443, + sourceScheme: "Secure", + value: "ed", + }, + ]); }); +}); - await t.step({ - name: "consoleErrors()", - fn: async (t) => { - await t.step(`Should return expected errors`, async () => { - server.run(); - const { browser, page } = await build(); - await page.location( - serverAdd, - ); - const errors = await page.consoleErrors(); - await browser.close(); - await server.close(); - assertEquals( - errors, - [ - "Failed to load resource: the server responded with a status of 404 (Not Found)", - "ReferenceError: callUser is not defined\n" + - ` at ${serverAdd}/index.js:1:1`, - ], - ); - }); +Deno.test({ + name: "consoleErrors()", + fn: async (t) => { + await t.step(`Should return expected errors`, async () => { + server.run(); + const { browser, page } = await build(); + await page.location( + server.address, + ); + const errors = await page.consoleErrors(); + await browser.close(); + await server.close(); + assertEquals( + errors, + [ + "Failed to load resource: the server responded with a status of 404 (Not Found)", + "ReferenceError: callUser is not defined\n" + + ` at ${server.address}/index.js:1:1`, + ], + ); + }); - await t.step(`Should be empty if no errors`, async () => { - const { browser, page } = await build(); - await page.location( - "https://drash.land", - ); - const errors = await page.consoleErrors(); - await browser.close(); - assertEquals(errors, []); - }); - }, - }); + await t.step(`Should be empty if no errors`, async () => { + const { browser, page } = await build(); + await page.location( + "https://drash.land", + ); + const errors = await page.consoleErrors(); + await browser.close(); + assertEquals(errors, []); + }); + }, +}); - await t.step({ - name: "dialog()", - fn: async (t) => { - await t.step(`Accepts a dialog`, async () => { - const { browser, page } = await build(); - server.run(); - await page.location(serverAdd + "/dialogs"); - const elem = await page.querySelector("#button"); - page.expectDialog(); - elem.click(); +Deno.test({ + name: "dialog()", + fn: async (t) => { + await t.step(`Accepts a dialog`, async () => { + const { browser, page } = await build(); + server.run(); + await page.location(server.address + "/dialogs"); + const elem = await page.querySelector("#button"); + page.expectDialog(); + elem.click(); + await page.dialog(true, "Sinco 4eva"); + const val = await page.evaluate( + `document.querySelector("#button").textContent`, + ); + await browser.close(); + await server.close(); + assertEquals(val, "Sinco 4eva"); + }); + await t.step(`Throws if a dialog was not expected`, async () => { + const { browser, page } = await build(); + let errMsg = ""; + try { await page.dialog(true, "Sinco 4eva"); - const val = await page.evaluate( - `document.querySelector("#button").textContent`, - ); - await browser.close(); - await server.close(); - assertEquals(val, "Sinco 4eva"); - }); - await t.step(`Throws if a dialog was not expected`, async () => { - const { browser, page } = await build(); - let errMsg = ""; - try { - await page.dialog(true, "Sinco 4eva"); - } catch (e) { - errMsg = e.message; - } - await browser.close(); - assertEquals( - errMsg, - 'Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.', - ); - }); - await t.step(`Rejects a dialog`, async () => { - const { browser, page } = await build(); - server.run(); - await page.location(serverAdd + "/dialogs"); - const elem = await page.querySelector("#button"); - page.expectDialog(); - elem.click(); - await page.dialog(false, "Sinco 4eva"); - const val = await page.evaluate( - `document.querySelector("#button").textContent`, - ); - await browser.close(); - await server.close(); - assertEquals(val, ""); - }); - }, - }); + } catch (e) { + errMsg = e.message; + } + await browser.close(); + assertEquals( + errMsg, + 'Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.', + ); + }); + await t.step(`Rejects a dialog`, async () => { + const { browser, page } = await build(); + server.run(); + await page.location(server.address + "/dialogs"); + const elem = await page.querySelector("#button"); + page.expectDialog(); + elem.click(); + await page.dialog(false, "Sinco 4eva"); + const val = await page.evaluate( + `document.querySelector("#button").textContent`, + ); + await browser.close(); + await server.close(); + assertEquals(val, ""); + }); + }, +}); From d3e65165ca2756f1adcfcb346d757b722205b398 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:18:50 +0100 Subject: [PATCH 12/34] update readme --- src/client.ts | 2 +- src/element.ts | 4 ++-- src/page.ts | 6 +++--- tests/unit/element_test.ts | 6 +++--- tests/unit/page_test.ts | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4657186..c9061b2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -122,7 +122,7 @@ export class Client { `http://${wsOptions.hostname}:${wsOptions.port}/json/version`, ); const json = await res.json(); - console.log(json['webSocketDebuggerUrl']) + console.log(json["webSocketDebuggerUrl"]); const socket = new WebSocket(json["webSocketDebuggerUrl"]); const p2 = deferred(); socket.onopen = () => p2.resolve(); diff --git a/src/element.ts b/src/element.ts index 47bd1d9..7100d97 100644 --- a/src/element.ts +++ b/src/element.ts @@ -123,7 +123,7 @@ export class Element { * * @example * ```ts - * const uint8array = await element.takeScreenshot(); + * const uint8array = await element.screenshot(); * Deno.writeFileSync('./file.jpg', uint8array); * ``` * @@ -132,7 +132,7 @@ export class Element { * * @returns The data */ - async takeScreenshot( + async screenshot( options?: ScreenshotOptions, ): Promise { const ext = options?.format ?? "jpeg"; diff --git a/src/page.ts b/src/page.ts index a57d5d2..a927b57 100644 --- a/src/page.ts +++ b/src/page.ts @@ -348,7 +348,7 @@ export class Page extends ProtocolClass { * @example * ```ts * try { - * Deno.writeFileSync('./tets.png', await page.takeScreenshot()); + * Deno.writeFileSync('./tets.png', await page.screenshot()); * } catch (e) { * await browser.close(e.message); * } @@ -356,7 +356,7 @@ export class Page extends ProtocolClass { * * @returns The path to the file relative to CWD, e.g., "Screenshots/users/user_1.png" */ - public async takeScreenshot( + public async screenshot( options?: ScreenshotOptions, ): Promise { const ext = options?.format ?? "jpeg"; @@ -369,7 +369,7 @@ export class Page extends ProtocolClass { } // Quality should defined only if format is jpeg - const quality = (ext == "jpeg") + const quality = (ext === "jpeg") ? ((options?.quality) ? Math.abs(options.quality) : 80) : undefined; diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 4c43054..2d048c9 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -49,14 +49,14 @@ Deno.test("click()", async (t) => { ); }); -Deno.test("takeScreenshot()", async (t) => { +Deno.test("screenshot()", async (t) => { await t.step( "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", async () => { const { browser, page } = await build(); await page.location("https://drash.land"); const img = await page.querySelector("img"); - await img.takeScreenshot({ + await img.screenshot({ quality: 50, }); await browser.close(); @@ -68,7 +68,7 @@ Deno.test("takeScreenshot()", async (t) => { server.run(); await page.location(server.address + "/anchor-links"); const a = await page.querySelector("a"); - await a.takeScreenshot({ + await a.screenshot({ format: "jpeg", quality: 100, }); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 56b16e2..d9cb4f6 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,13 +1,13 @@ import { build } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; -Deno.test("takeScreenshot()", async (t) => { +Deno.test("screenshot()", async (t) => { await t.step( - "takeScreenshot() | Takes a Screenshot", + "screenshot() | Takes a Screenshot", async () => { const { browser, page } = await build(); await page.location("https://drash.land"); - const result = await page.takeScreenshot(); + const result = await page.screenshot(); await browser.close(); assertEquals(result instanceof Uint8Array, true); }, @@ -20,7 +20,7 @@ Deno.test("takeScreenshot()", async (t) => { await page.location("https://drash.land"); let msg = ""; try { - await page.takeScreenshot({ quality: 999 }); + await page.screenshot({ quality: 999 }); } catch (error) { msg = error.message; } From 388e4f38c61b4623cec48ce77cf4b67ca7b4f622 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:26:11 +0100 Subject: [PATCH 13/34] add bumper --- .github/workflows/bumper.yml | 22 +++++ README.md | 146 ++++++++++++++++++++++++++++- console/bumper.ts | 9 ++ console/bumper_ci_service.ts | 8 -- console/bumper_ci_service_files.ts | 23 ----- 5 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/bumper.yml create mode 100644 console/bumper.ts delete mode 100644 console/bumper_ci_service.ts delete mode 100644 console/bumper_ci_service_files.ts diff --git a/.github/workflows/bumper.yml b/.github/workflows/bumper.yml new file mode 100644 index 0000000..9fd6305 --- /dev/null +++ b/.github/workflows/bumper.yml @@ -0,0 +1,22 @@ +on: + schedule: + - cron: "0 0 * * 1" + +jobs: + console-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install Deno + uses: denoland/setup-deno@v1 + with: + deno-version: vx.x.x + + - name: Check latest versions + run: | + deno run -A console/bumper.ts + deno run --allow-read --allow-write --allow-net https://deno.land/x/dmm/mod.ts update + cd tests + deno run --allow-read --allow-write --allow-net https://deno.land/x/dmm/mod.ts update \ No newline at end of file diff --git a/README.md b/README.md index d3c6d83..34276fb 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,28 @@ import { build } from "..."; const { browser, page } = await build(); ``` +The hostname and port of the subprocess and debugger default to `localhost` and +`9292` respectively. If you wish to customise this, you can: + +```ts +await build({ + hostname: "127.0.0.1", + debuggerPort: 1000, +}); +``` + Be sure to always call `.close()` on the client once you've finished any actions with it, to ensure you do not leave any hanging ops, For example, closing after the last `browser.*` call or before assertions. +You can also use `connect()` if you wish to connect to an existing remote +process and not run a new subprocess yourself. + +````ts +import { connect } from "..."; +const { browser, page } = await connect(); +``` + ### Visiting Pages You can do this by calling `.location()` on the page: @@ -62,8 +80,132 @@ You can do this by calling `.location()` on the page: ```ts const { browser, page } = await build(); await page.location("https://some-url.com"); +```` + +### Taking Screenshots + +Utilise the `.screenshotMethod()` on a page or element: + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +const uint8array = await page.screenshot({ // Options are optional + format: "jpeg", // or png. Defaults to jpeg + quality: 50, // 0-100, only applicable if format is optional. Defaults to 80 +}); +const elem = await page.querySelector("div"); +const uint8array = await elem.screenshot(); // Same options as above +``` + +### Dialogs + +You're able to interact with dialogs (prompt, alert). + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +await page.dialog(false); // Decline the dialog +await page.dialog(true); // Accept it +await page.dialog(true, "I will be joining on 20/03/2024"); // Accept and provide prompt text +``` + +### Cookies + +You can get or set cookies + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +await page.cookie(); // Get the cookies, [ { ... }, { ... } ] +await page.cookie({ + name: "x-csrf-token", + value: "1234", + url: "/", +}); +``` + +### Evaluating (full DOM or dev console access) + +Evaluating will evaluate a command and you can use this to make any query to the +DOM, think of it like you've got the devtools open. Maybe you want to create an +element and add it to a list, or get the `innerHTML` of an element, or get the +page title. + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +await page.evaluate("1 + 1"); // 2 +await page.evaluate(() => { + return document.title; +}); // "Some title" +await page.evaluate(() => { + return document.cookie; +}); // "cxsrf=hello;john=doe" + +// You can reference variables inside the callback but you must pass them as parameters, and you can pass as many as you like. See how im using `username` and `greeting` in the callback, so I can pass these in as parameters to `.evaluate()` and also access them from the callback. +await page.evaluate( + (username, greeting) => { + document.body.innerHTML = `${greeting}, ${username}`; + }, + "Sinco", + "Hello", +); +``` + +### Retreiving console errors + +This could be useful if you would like to quickly assert the page has no console +errors. `.consoleErrors()` will return any console errors that have appeared up +until this point. + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +await page.evaluate("1 + 1"); // 2 +const errors = await page.consoleErrors(); +``` + +### Working with Elements (clicking, inputs) + +We provide ways to set files on a file input and click elements. + +To create a reference to an element, use +`await page.querySelector("")`, just like how you would use it in +the browser. + +#### File operations + +We provide an easier way to set a file on a file input element. + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +const input = await page.querySelector('input[type="file"]'); +await input.file("./users.png"); +const multipleInput = await page.querySelector('input[type="file"]'); +await multipleInput.files(["./users.png", "./company.pdf"]); ``` -## Taking Screenshots +#### Clicking -Utilise the ` +You can also click elements, such as buttons or anchor tags. + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com"); +const button = await page.querySelector('button[type="button"]'); +await button.click(); +// .. Do something else now button has been clicked + +// `navigation` is used if you need to wait for some kind of HTTP request, such as going to a different URL, or clicking a button that makes an API request +const anchor = await page.querySelector("a"); +await anchor.click({ + waitFor: "navigation", +}); + +const anchor = await page.querySelector('a[target="_BLANK"]'); +const newPage = await anchor.click({ + waitFor: "newPage", +}); +// ... Now `newPage` is a reference to the new tab that just opened +``` diff --git a/console/bumper.ts b/console/bumper.ts new file mode 100644 index 0000000..2da1337 --- /dev/null +++ b/console/bumper.ts @@ -0,0 +1,9 @@ +const chromeVersionsRes = await fetch( + "https://versionhistory.googleapis.com/v1/chrome/platforms/win/channels/stable/versions", +); +const { versions } = await chromeVersionsRes.json(); + +const dockerfile = Deno.readTextFileSync("./tests/drivers.dockerfile") + .replace(/ENV CHROME_VERSION \".*\"/, `ENV CHROME_VERSION "${versions[0].version}"`); + +Deno.writeTextFileSync('./tests/drivers.dockerfile', dockerfile); \ No newline at end of file diff --git a/console/bumper_ci_service.ts b/console/bumper_ci_service.ts deleted file mode 100644 index 88e2cad..0000000 --- a/console/bumper_ci_service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BumperService } from "https://raw.githubusercontent.com/drashland/services/master/ci/bumper_service.ts"; -import { bumperFiles } from "./bumper_ci_service_files.ts"; - -const b = new BumperService("sinco", Deno.args); - -if (!b.isForPreRelease()) { - b.bump(bumperFiles); -} diff --git a/console/bumper_ci_service_files.ts b/console/bumper_ci_service_files.ts deleted file mode 100644 index 59baac3..0000000 --- a/console/bumper_ci_service_files.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const regexes = { - // deno-lint-ignore camelcase - const_statements: /version = ".+"/g, - // deno-lint-ignore camelcase - egg_json: /"version": ".+"/, - // deno-lint-ignore camelcase - import_export_statements: /sinco@v[0-9\.]+[0-9\.]+[0-9\.]/g, - // deno-lint-ignore camelcase - yml_deno: /deno: \[".+"\]/g, -}; - -const chromeVersionsRes = await fetch( - "https://versionhistory.googleapis.com/v1/chrome/platforms/win/channels/stable/versions", -); -const { versions } = await chromeVersionsRes.json(); - -export const bumperFiles = [ - { - filename: "./tests/drivers.dockerfile", - replaceTheRegex: /ENV CHROME_VERSION \".*\"/, - replaceWith: `ENV CHROME_VERSION "${versions[0].version}"`, - }, -]; From 8e62dfbd6a181de0ed014563a5510aebd837d5a9 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:28:13 +0100 Subject: [PATCH 14/34] emove console tests --- .github/workflows/master.yml | 15 --------------- tests/console/bumper_test.ts | 27 --------------------------- 2 files changed, 42 deletions(-) delete mode 100644 tests/console/bumper_test.ts diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 5553708..c05be57 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -21,21 +21,6 @@ jobs: docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/integration docker exec drivers deno test -A --config tsconfig.json --no-check=remote tests/unit - console-tests: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Install Deno - uses: denoland/setup-deno@v1 - with: - deno-version: vx.x.x - - - name: Run Console Tests - run: | - deno test -A tests/console - linter: # Only one OS is required since fmt is cross platform runs-on: ubuntu-latest diff --git a/tests/console/bumper_test.ts b/tests/console/bumper_test.ts deleted file mode 100644 index a257469..0000000 --- a/tests/console/bumper_test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { assertEquals } from "../../deps.ts"; - -Deno.test("Updates chrome version in dockerfile", async () => { - const chromeVersionsRes = await fetch( - "https://versionhistory.googleapis.com/v1/chrome/platforms/win/channels/stable/versions", - ); - const { versions } = await chromeVersionsRes.json(); - const version = versions[0].version; - const originalContents = Deno.readTextFileSync( - "./tests/drivers.dockerfile", - ); - let newContent = originalContents; - newContent.replace(/CHROME_VERSION \".*\"/, 'CHROME VERSION "123"'); - Deno.writeTextFileSync( - "./tests/drivers.dockerfile", - newContent, - ); - const p = new Deno.Command("deno", { - args: ["run", "-A", "console/bumper_ci_service.ts"], - }); - const child = p.spawn(); - await child.status; - newContent = Deno.readTextFileSync( - "./tests/drivers.dockerfile", - ); - assertEquals(newContent.includes(`CHROME_VERSION "${version}"`), true); -}); From 2b7766b53c60711f5ee06665372ead88d27db49f Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:30:26 +0100 Subject: [PATCH 15/34] fmt --- console/bumper.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/console/bumper.ts b/console/bumper.ts index 2da1337..bf99706 100644 --- a/console/bumper.ts +++ b/console/bumper.ts @@ -4,6 +4,9 @@ const chromeVersionsRes = await fetch( const { versions } = await chromeVersionsRes.json(); const dockerfile = Deno.readTextFileSync("./tests/drivers.dockerfile") - .replace(/ENV CHROME_VERSION \".*\"/, `ENV CHROME_VERSION "${versions[0].version}"`); + .replace( + /ENV CHROME_VERSION \".*\"/, + `ENV CHROME_VERSION "${versions[0].version}"`, + ); -Deno.writeTextFileSync('./tests/drivers.dockerfile', dockerfile); \ No newline at end of file +Deno.writeTextFileSync("./tests/drivers.dockerfile", dockerfile); From 666026e7fd7a292ded380abd5f669b724ebc8831 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:38:24 +0100 Subject: [PATCH 16/34] Add catch and more tests --- src/element.ts | 27 ++++++++++++++++++++------- tests/unit/element_test.ts | 23 +++++++++++++++++++++++ tests/unit/page_test.ts | 16 +++++++++++----- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/element.ts b/src/element.ts index 7100d97..858c628 100644 --- a/src/element.ts +++ b/src/element.ts @@ -201,13 +201,19 @@ export class Element { waitFor?: WaitFor; } = {}): Promise> { // Scroll into view - await this.#page.evaluate( - `${this.#method}('${this.#selector}').scrollIntoView({ - block: 'center', - inline: 'center', - behavior: 'instant' - })`, - ); + try { + await this.#page.evaluate( + `${this.#method}('${this.#selector}').scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'instant' + })`, + ); + } catch (e) { + await this.#page.client.close( + "It might be that the element is no longer in the DOM:" + e.message, + ); + } // Get details we need for dispatching input events on the element const result = await this.#page.send< @@ -220,6 +226,13 @@ export class Element { null, ProtocolTypes.Page.GetLayoutMetricsResponse >("Page.getLayoutMetrics"); + + if (!result || !result.quads.length) { + await this.#page.client.close( + `Node is either not clickable or not an HTMLElement`, + ); + } + // Ignoring because cssLayoutMetrics is present on chrome, but not firefox // deno-lint-ignore ban-ts-comment // @ts-ignore diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 2d048c9..4c9e002 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -3,6 +3,29 @@ import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; import { resolve } from "../deps.ts"; Deno.test("click()", async (t) => { + await t.step( + "It should fail if the element is no longer present in the DOM", + async () => { + server.run(); + const { page } = await build(); + await page.location(server.address + "/anchor-links"); + // Need to make the element either not clickable or not a HTMLElement + const elem = await page.querySelector( + "a", + ); + await page.location("https://google.com"); + let errMsg = ""; + try { + await elem.click(); + } catch (e) { + errMsg = e.message; + } + assertEquals( + errMsg, + `todo`, + ); + }, + ); await t.step( "It should allow clicking of elements and update location", async () => { diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index d9cb4f6..5b3d1ac 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -166,13 +166,19 @@ Deno.test({ const errors = await page.consoleErrors(); await browser.close(); await server.close(); + assertEquals(errors.length, 2); assertEquals( - errors, - [ - "Failed to load resource: the server responded with a status of 404 (Not Found)", + errors.includes( "ReferenceError: callUser is not defined\n" + - ` at ${server.address}/index.js:1:1`, - ], + ` at ${server.address}/index.js:1:1`, + ), + true, + ); + assertEquals( + errors.includes( + "Failed to load resource: the server responded with a status of 404 (Not Found)", + ), + true, ); }); From 46ddf12fad4db82aa17b76a6958f6ebb5c744eae Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:41:52 +0100 Subject: [PATCH 17/34] fix src --- README.md | 10 ++++++++++ src/element.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 34276fb..82cea61 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,16 @@ await page.evaluate("1 + 1"); // 2 const errors = await page.consoleErrors(); ``` +Due to race conditions with console errors, it's best not to assert the whole +array, and instead assert the length or if it contains, for example: + +```ts +const errors = await page.consoleErrors(); // ["user not defined", "undefined property company"]; +// It could be that on another test run, "user not defined" is the 2nd item in the array, so instead do the below. +assertEquals(errors.length, 2); +assertEquals(errors.includes("user not defined")); +``` + ### Working with Elements (clicking, inputs) We provide ways to set files on a file input and click elements. diff --git a/src/element.ts b/src/element.ts index 858c628..0acdbf6 100644 --- a/src/element.ts +++ b/src/element.ts @@ -227,7 +227,7 @@ export class Element { ProtocolTypes.Page.GetLayoutMetricsResponse >("Page.getLayoutMetrics"); - if (!result || !result.quads.length) { + if (!result || !result.quads?.length) { await this.#page.client.close( `Node is either not clickable or not an HTMLElement`, ); From c7231e9232fec835f10872c4222fcf51a5942118 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:43:58 +0100 Subject: [PATCH 18/34] fix tests --- tests/unit/element_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 4c9e002..f3c46c0 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -22,7 +22,7 @@ Deno.test("click()", async (t) => { } assertEquals( errMsg, - `todo`, + "Node is either not clickable or not an HTMLElement", ); }, ); From 6367c622d01662b403f866b5211b900d087d53a1 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 22:54:19 +0100 Subject: [PATCH 19/34] Fix tests --- src/client.ts | 1 - src/element.ts | 12 +++--------- tests/unit/element_test.ts | 30 ++++++++++++++++++------------ 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/client.ts b/src/client.ts index c9061b2..54847ed 100644 --- a/src/client.ts +++ b/src/client.ts @@ -122,7 +122,6 @@ export class Client { `http://${wsOptions.hostname}:${wsOptions.port}/json/version`, ); const json = await res.json(); - console.log(json["webSocketDebuggerUrl"]); const socket = new WebSocket(json["webSocketDebuggerUrl"]); const p2 = deferred(); socket.onopen = () => p2.resolve(); diff --git a/src/element.ts b/src/element.ts index 0acdbf6..5f1611a 100644 --- a/src/element.ts +++ b/src/element.ts @@ -201,19 +201,13 @@ export class Element { waitFor?: WaitFor; } = {}): Promise> { // Scroll into view - try { - await this.#page.evaluate( - `${this.#method}('${this.#selector}').scrollIntoView({ + await this.#page.evaluate( + `${this.#method}('${this.#selector}').scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })`, - ); - } catch (e) { - await this.#page.client.close( - "It might be that the element is no longer in the DOM:" + e.message, - ); - } + ); // Get details we need for dispatching input events on the element const result = await this.#page.send< diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index f3c46c0..6473d6c 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -20,6 +20,7 @@ Deno.test("click()", async (t) => { } catch (e) { errMsg = e.message; } + server.close(); assertEquals( errMsg, "Node is either not clickable or not an HTMLElement", @@ -30,18 +31,23 @@ Deno.test("click()", async (t) => { "It should allow clicking of elements and update location", async () => { const { browser, page } = await build(); - server.run(); - await page.location(server.address + "/anchor-links"); - const elem = await page.querySelector( - "a#not-blank", - ); - await elem.click({ - waitFor: "navigation", - }); - const page1Location = await page.evaluate(() => window.location.href); - await browser.close(); - await server.close(); - assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); + try { + server.run(); + await page.location(server.address + "/anchor-links"); + const elem = await page.querySelector( + "a#not-blank", + ); + await elem.click({ + waitFor: "navigation", + }); + const page1Location = await page.evaluate(() => window.location.href); + await browser.close(); + await server.close(); + assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); + } catch (e) { + console.log(e); + await browser.close(); + } }, ); From eddd961120d1461b6b0ef699cb1d3a913df2c4dc Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 23:01:48 +0100 Subject: [PATCH 20/34] update readme --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 82cea61..421cb09 100644 --- a/README.md +++ b/README.md @@ -219,3 +219,39 @@ const newPage = await anchor.click({ }); // ... Now `newPage` is a reference to the new tab that just opened ``` + +### Authenticating + +One way of authenticating, say if there is a website that is behind a login, is +to manually set some cookies e.g., `X-CSRF-TOKEN`: + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com/login"); +const token = await page.evaluate(() => + document.querySelector('meta[name="token"]').value +); +await page.cookie({ + name: "X-CSRF-TOKEN", + value: token, +}); +await page.location("https://some-url.com/api/users"); +``` + +Another approach would be to manually submit a login form: + +```ts +const { browser, page } = await build(); +await page.location("https://some-url.com/login"); +const button = await page.querySelector('[type="submit"]'); +await page.evaluate(() => + document.querySelector('[type="email]').value = "..." +); +await page.evaluate(() => + document.querySelector('[type="password]').value = "..." +); +await button.click({ + waitFor: "navigation", +}); +await page.evaluate("window.location.href"); // "https://some-url.com/dashboard" +``` From 74c3cf69441ab82f3655f82ac08073f43e15bec2 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 23:12:38 +0100 Subject: [PATCH 21/34] add timeout handler --- src/client.ts | 12 ++++++++++++ src/protocol.ts | 15 +++++++++++++++ tests/unit/client_test.ts | 15 +++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/client.ts b/src/client.ts index 54847ed..5e0451d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -146,6 +146,18 @@ export class Client { wsOptions, ); + // Handle CTRL+C for example + // TODO :: Even if we remove this in the timeout callback, we still get leaking ops + // const onSIGINT = async () => { + // await client.close(); + // Deno.exit(1); + // } + // Deno.addSignalListener("SIGINT", onSIGINT); + // Handle a timeout from our protocol + addEventListener("timeout", async (e) => { + await client.close((e as CustomEvent).detail); + }); + const page = await Page.create( client, targetId, diff --git a/src/protocol.ts b/src/protocol.ts index 1ec3394..0809b22 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -73,7 +73,22 @@ export class Protocol { const promise = deferred(); this.#messages.set(data.id, promise); this.socket.send(JSON.stringify(data)); + + let count = 0; + const maxDuration = 30; + const intervalId = setInterval(() => { + count++; + if (count === maxDuration) { + const event = new CustomEvent("timeout", { + detail: "Timed out as action took longer than 30s.", + }); + dispatchEvent(event); + clearInterval(intervalId); + } + }, 1000); + const result = await promise; + clearInterval(intervalId); this.#messages.delete(data.id); return result; } diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index fd2d5fa..84dd3cf 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -2,6 +2,21 @@ import { deferred } from "../../deps.ts"; import { build } from "../../mod.ts"; Deno.test("create()", async (t) => { + await t.step("Registers close listener", async () => { + await build(); + const res = await fetch("http://localhost:9292/json/list"); + const json = await res.json(); + const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); + let promise = deferred(); + client.onopen = function () { + promise.resolve(); + }; + await promise; + promise = deferred(); + client.onclose = () => promise.resolve(); + dispatchEvent(new CustomEvent("timeout")); + await promise; + }); await t.step( "Uses the port when passed in to the parameters", async () => { From 9a73d39d1af4e46ab46e70ed00ce839255e912e0 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 23:14:00 +0100 Subject: [PATCH 22/34] update --- .github/workflows/master.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index c05be57..654faaa 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -12,7 +12,7 @@ jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Tests run: | @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install Deno uses: denoland/setup-deno@v1 From ec329f0a7a0dc604402c14abfe98752ec7168c80 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Wed, 15 May 2024 23:17:50 +0100 Subject: [PATCH 23/34] rm bumper --- .github/workflows/bumper.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/bumper.yml diff --git a/.github/workflows/bumper.yml b/.github/workflows/bumper.yml deleted file mode 100644 index 9fd6305..0000000 --- a/.github/workflows/bumper.yml +++ /dev/null @@ -1,22 +0,0 @@ -on: - schedule: - - cron: "0 0 * * 1" - -jobs: - console-tests: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Install Deno - uses: denoland/setup-deno@v1 - with: - deno-version: vx.x.x - - - name: Check latest versions - run: | - deno run -A console/bumper.ts - deno run --allow-read --allow-write --allow-net https://deno.land/x/dmm/mod.ts update - cd tests - deno run --allow-read --allow-write --allow-net https://deno.land/x/dmm/mod.ts update \ No newline at end of file From d031cd8f192dc2339a64cae1a4fa713a66bbe597 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Thu, 16 May 2024 14:14:07 +0100 Subject: [PATCH 24/34] cleanup mod.ts --- mod.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/mod.ts b/mod.ts index 3d32900..67fb7fa 100644 --- a/mod.ts +++ b/mod.ts @@ -12,14 +12,16 @@ const defaultOptions = { }; export async function build( - options: BuildOptions = defaultOptions, + { + hostname = "localhost", + debuggerPort = 9292, + binaryPath, + }: BuildOptions = defaultOptions, ): Promise<{ browser: Client; page: Page; }> { - if (!options.debuggerPort) options.debuggerPort = 9292; - if (!options.hostname) options.hostname = "localhost"; - const buildArgs = getChromeArgs(options.debuggerPort); + const buildArgs = getChromeArgs(debuggerPort, binaryPath); const path = buildArgs.splice(0, 1)[0]; const command = new Deno.Command(path, { args: buildArgs, @@ -30,21 +32,22 @@ export async function build( return await Client.create( { - hostname: options.hostname, - port: options.debuggerPort, + hostname, + port: debuggerPort, }, browserProcess, ); } -export async function connect(options: BuildOptions = defaultOptions) { - if (!options.debuggerPort) options.debuggerPort = 9292; - if (!options.hostname) options.hostname = "localhost"; - +export async function connect({ + hostname = "localhost", + debuggerPort = 9292, +}: BuildOptions = defaultOptions) { + console.log(hostname, debuggerPort); return await Client.create( { - hostname: options.hostname, - port: options.debuggerPort, + hostname, + port: debuggerPort, }, ); } From 7424cdc94a1a82cd03b15bdc36c6998ef13ad584 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Thu, 16 May 2024 14:53:46 +0100 Subject: [PATCH 25/34] rm dead code --- src/page.ts | 2 -- src/protocol.ts | 24 +----------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/src/page.ts b/src/page.ts index a927b57..3ff2a6b 100644 --- a/src/page.ts +++ b/src/page.ts @@ -176,8 +176,6 @@ export class Page extends ProtocolClass { // Usually if an invalid URL is given, the WS never gets a notification // but we get a message with the id associated with the msg we sent - // TODO :: Ideally the protocol class would throw and we could catch it so we know - // for sure its an error if ("errorText" in res) { await this.client.close(res.errorText); return; diff --git a/src/protocol.ts b/src/protocol.ts index 0809b22..fb5bdba 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -69,6 +69,7 @@ export class Protocol { id: this.#next_message_id++, method: method, }; + if (params) data.params = params; const promise = deferred(); this.#messages.set(data.id, promise); @@ -125,29 +126,6 @@ export class Protocol { if ("resolve" in resolvable && "reject" in resolvable) { resolvable.resolve(message.params); } - if ("params" in resolvable && "promise" in resolvable) { - let allMatch = false; - Object.keys(resolvable.params).forEach((paramName) => { - if ( - allMatch === true && - (message.params[paramName] as string | number).toString() !== - (resolvable.params[paramName] as string | number).toString() - ) { - allMatch = false; - return; - } - if ( - (message.params[paramName] as string | number).toString() === - (resolvable.params[paramName] as string | number).toString() - ) { - allMatch = true; - } - }); - if (allMatch) { - resolvable.promise.resolve(message.params); - } - return; - } } } } From 627472ea57575679b04418bf1802c5b4cc2d9bd1 Mon Sep 17 00:00:00 2001 From: ebebbington Date: Thu, 16 May 2024 15:04:00 +0100 Subject: [PATCH 26/34] make mod.ts simpler --- README.md | 40 ++++++++++++++++++++------------------ mod.ts | 49 +---------------------------------------------- src/client.ts | 43 ++++++++++++++++++++++++++++++++--------- src/interfaces.ts | 2 ++ 4 files changed, 58 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 421cb09..fdf69cf 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,15 @@ You use Sinco to build a subprocess (client) and interact with the page that has been opened. This defaults to "about:blank". ```ts -import { build } from "..."; -const { browser, page } = await build(); +import { Client } from "..."; +const { browser, page } = await Client.create(); ``` The hostname and port of the subprocess and debugger default to `localhost` and `9292` respectively. If you wish to customise this, you can: ```ts -await build({ +await Client.create({ hostname: "127.0.0.1", debuggerPort: 1000, }); @@ -65,12 +65,14 @@ Be sure to always call `.close()` on the client once you've finished any actions with it, to ensure you do not leave any hanging ops, For example, closing after the last `browser.*` call or before assertions. -You can also use `connect()` if you wish to connect to an existing remote -process and not run a new subprocess yourself. +You can also use an existing remote process and not run a new subprocess +yourself. -````ts -import { connect } from "..."; -const { browser, page } = await connect(); +```ts +import { Client } from "..."; +const { browser, page } = await Client.create({ + remote: true, +}); ``` ### Visiting Pages @@ -78,16 +80,16 @@ const { browser, page } = await connect(); You can do this by calling `.location()` on the page: ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); -```` +``` ### Taking Screenshots Utilise the `.screenshotMethod()` on a page or element: ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); const uint8array = await page.screenshot({ // Options are optional format: "jpeg", // or png. Defaults to jpeg @@ -102,7 +104,7 @@ const uint8array = await elem.screenshot(); // Same options as above You're able to interact with dialogs (prompt, alert). ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); await page.dialog(false); // Decline the dialog await page.dialog(true); // Accept it @@ -114,7 +116,7 @@ await page.dialog(true, "I will be joining on 20/03/2024"); // Accept and provid You can get or set cookies ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); await page.cookie(); // Get the cookies, [ { ... }, { ... } ] await page.cookie({ @@ -132,7 +134,7 @@ element and add it to a list, or get the `innerHTML` of an element, or get the page title. ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); await page.evaluate("1 + 1"); // 2 await page.evaluate(() => { @@ -159,7 +161,7 @@ errors. `.consoleErrors()` will return any console errors that have appeared up until this point. ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); await page.evaluate("1 + 1"); // 2 const errors = await page.consoleErrors(); @@ -188,7 +190,7 @@ the browser. We provide an easier way to set a file on a file input element. ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); const input = await page.querySelector('input[type="file"]'); await input.file("./users.png"); @@ -201,7 +203,7 @@ await multipleInput.files(["./users.png", "./company.pdf"]); You can also click elements, such as buttons or anchor tags. ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com"); const button = await page.querySelector('button[type="button"]'); await button.click(); @@ -226,7 +228,7 @@ One way of authenticating, say if there is a website that is behind a login, is to manually set some cookies e.g., `X-CSRF-TOKEN`: ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com/login"); const token = await page.evaluate(() => document.querySelector('meta[name="token"]').value @@ -241,7 +243,7 @@ await page.location("https://some-url.com/api/users"); Another approach would be to manually submit a login form: ```ts -const { browser, page } = await build(); +const { browser, page } = await Client.create(); await page.location("https://some-url.com/login"); const button = await page.querySelector('[type="submit"]'); await page.evaluate(() => diff --git a/mod.ts b/mod.ts index 67fb7fa..c274e3d 100644 --- a/mod.ts +++ b/mod.ts @@ -1,53 +1,6 @@ import { Client } from "./src/client.ts"; import { BuildOptions, Cookie, ScreenshotOptions } from "./src/interfaces.ts"; -import { Page } from "./src/page.ts"; -import { getChromeArgs } from "./src/utility.ts"; export type { BuildOptions, Cookie, ScreenshotOptions }; -const defaultOptions = { - hostname: "localhost", - debuggerPort: 9292, - binaryPath: undefined, -}; - -export async function build( - { - hostname = "localhost", - debuggerPort = 9292, - binaryPath, - }: BuildOptions = defaultOptions, -): Promise<{ - browser: Client; - page: Page; -}> { - const buildArgs = getChromeArgs(debuggerPort, binaryPath); - const path = buildArgs.splice(0, 1)[0]; - const command = new Deno.Command(path, { - args: buildArgs, - stderr: "piped", - stdout: "piped", - }); - const browserProcess = command.spawn(); - - return await Client.create( - { - hostname, - port: debuggerPort, - }, - browserProcess, - ); -} - -export async function connect({ - hostname = "localhost", - debuggerPort = 9292, -}: BuildOptions = defaultOptions) { - console.log(hostname, debuggerPort); - return await Client.create( - { - hostname, - port: debuggerPort, - }, - ); -} +export { Client }; diff --git a/src/client.ts b/src/client.ts index 5e0451d..32d8bb4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,14 @@ import { deferred } from "../deps.ts"; +import { BuildOptions, WebsocketTarget } from "./interfaces.ts"; import { Page } from "./page.ts"; +import { getChromeArgs } from "./utility.ts"; + +const defaultOptions = { + hostname: "localhost", + debuggerPort: 9292, + binaryPath: undefined, + remote: false, +}; /** * A way to interact with the headless browser instance. @@ -104,24 +113,37 @@ export class Client { * @returns A client and browser instance, ready to be used */ static async create( - wsOptions: { - hostname: string; - port: number; - }, - browserProcess: Deno.ChildProcess | undefined = undefined, + { + hostname = "localhost", + debuggerPort = 9292, + binaryPath, + remote, + }: BuildOptions = defaultOptions, ): Promise<{ browser: Client; page: Page; }> { + let browserProcess: Deno.ChildProcess | undefined = undefined; + + if (!remote) { + const buildArgs = getChromeArgs(debuggerPort, binaryPath); + const path = buildArgs.splice(0, 1)[0]; + const command = new Deno.Command(path, { + args: buildArgs, + stderr: "piped", + stdout: "piped", + }); + browserProcess = command.spawn(); + } // Wait until endpoint is ready and get a WS connection // to the main socket const p = deferred(); const intervalId = setTimeout(async () => { try { const res = await fetch( - `http://${wsOptions.hostname}:${wsOptions.port}/json/version`, + `http://${hostname}:${debuggerPort}/json/version`, ); - const json = await res.json(); + const json = await res.json() as WebsocketTarget; const socket = new WebSocket(json["webSocketDebuggerUrl"]); const p2 = deferred(); socket.onopen = () => p2.resolve(); @@ -136,14 +158,17 @@ export class Client { const clientSocket = await p; const listRes = await fetch( - `http://${wsOptions.hostname}:${wsOptions.port}/json/list`, + `http://${hostname}:${debuggerPort}/json/list`, ); const targetId = (await listRes.json())[0]["id"]; const client = new Client( clientSocket, browserProcess, - wsOptions, + { + hostname, + port: debuggerPort, + }, ); // Handle CTRL+C for example diff --git a/src/interfaces.ts b/src/interfaces.ts index 1f725d4..38c60c9 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -5,6 +5,8 @@ export interface BuildOptions { hostname?: string; /** The path to the binary of the browser executable, such as specifying an alternative chromium browser */ binaryPath?: string; + /** If true, will not run a subprocess but will still connect to the ws endpoint using above info */ + remote?: boolean; } export interface ScreenshotOptions { From 5d2a7ba56987e221fbbda0e1d1662c3013783b0b Mon Sep 17 00:00:00 2001 From: ebebbington Date: Thu, 16 May 2024 15:07:34 +0100 Subject: [PATCH 27/34] correct tests --- .github/release_drafter_config.yml | 2 +- .../integration/csrf_protected_pages_test.ts | 4 +-- tests/integration/manipulate_page_test.ts | 4 +-- tests/unit/client_test.ts | 14 +++++----- tests/unit/element_test.ts | 26 ++++++++--------- tests/unit/page_test.ts | 28 +++++++++---------- 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/.github/release_drafter_config.yml b/.github/release_drafter_config.yml index 96e3636..77188f2 100644 --- a/.github/release_drafter_config.yml +++ b/.github/release_drafter_config.yml @@ -35,7 +35,7 @@ template: | * Import this latest release by using the following in your project(s): ```typescript - import { build } from "https://deno.land/x/sinco@v$RESOLVED_VERSION/mod.ts"; + import { Client } from "https://deno.land/x/sinco@v$RESOLVED_VERSION/mod.ts"; ``` __Updates__ diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index 3dd7e2b..1a2e586 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -5,10 +5,10 @@ import { assertEquals } from "../../deps.ts"; * 1. If you have one page that gives you the token, you can goTo that, then carry on goToing your protected resources, because the cookies will carry over (assuming you've configured the cookies on your end correctly) */ -import { build } from "../../mod.ts"; +import { Client } from "../../mod.ts"; Deno.test(`Tutorial for this feature in the docs should work`, async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); await page.cookie({ name: "X-CSRF-TOKEN", diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index 46b7ff9..5cac636 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -1,10 +1,10 @@ import { assertEquals } from "../../deps.ts"; -import { build } from "../../mod.ts"; +import { Client } from "../../mod.ts"; Deno.test( "Evaluating a script - Tutorial for this feature in the documentation works", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); const pageTitle = await page.evaluate(() => { // deno-lint-ignore no-undef diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 84dd3cf..1caad76 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,9 +1,9 @@ import { deferred } from "../../deps.ts"; -import { build } from "../../mod.ts"; +import { Client } from "../../mod.ts"; Deno.test("create()", async (t) => { await t.step("Registers close listener", async () => { - await build(); + await Client.create(); const res = await fetch("http://localhost:9292/json/list"); const json = await res.json(); const client = new WebSocket(json[0]["webSocketDebuggerUrl"]); @@ -20,7 +20,7 @@ Deno.test("create()", async (t) => { await t.step( "Uses the port when passed in to the parameters", async () => { - const { browser } = await build({ + const { browser } = await Client.create({ debuggerPort: 9999, }); const res = await fetch("http://localhost:9999/json/list"); @@ -45,7 +45,7 @@ Deno.test("create()", async (t) => { await t.step( `Will start headless as a subprocess`, async () => { - const { browser } = await build(); + const { browser } = await Client.create(); const res = await fetch("http://localhost:9292/json/list"); const json = await res.json(); // Our ws client should be able to connect if the browser is running @@ -76,7 +76,7 @@ Deno.test("create()", async (t) => { { name: "Uses the binaryPath when passed in to the parameters", fn: async () => { - const { browser } = await build({ + const { browser } = await Client.create({ //binaryPath: await browserItem.getPath(), }); @@ -100,7 +100,7 @@ Deno.test("create()", async (t) => { Deno.test(`close()`, async (t) => { await t.step(`Should close all resources and not leak any`, async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); await browser.close(); // If resources are not closed or pending ops or leaked, this test will show it when ran @@ -109,7 +109,7 @@ Deno.test(`close()`, async (t) => { await t.step({ name: `Should close all page specific resources too`, fn: async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); await browser.close(); try { diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 6473d6c..d98521c 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -1,4 +1,4 @@ -import { build } from "../../mod.ts"; +import { Client } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; import { resolve } from "../deps.ts"; @@ -7,7 +7,7 @@ Deno.test("click()", async (t) => { "It should fail if the element is no longer present in the DOM", async () => { server.run(); - const { page } = await build(); + const { page } = await Client.create(); await page.location(server.address + "/anchor-links"); // Need to make the element either not clickable or not a HTMLElement const elem = await page.querySelector( @@ -30,7 +30,7 @@ Deno.test("click()", async (t) => { await t.step( "It should allow clicking of elements and update location", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); try { server.run(); await page.location(server.address + "/anchor-links"); @@ -54,7 +54,7 @@ Deno.test("click()", async (t) => { await t.step( "It should error if the HTML for the element is invalid", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); server.run(); await page.location(server.address + "/anchor-links"); const elem = await page.querySelector( @@ -82,7 +82,7 @@ Deno.test("screenshot()", async (t) => { await t.step( "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); const img = await page.querySelector("img"); await img.screenshot({ @@ -93,7 +93,7 @@ Deno.test("screenshot()", async (t) => { ); await t.step("Saves Screenshot with all options provided", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); server.run(); await page.location(server.address + "/anchor-links"); const a = await page.querySelector("a"); @@ -113,7 +113,7 @@ Deno.test({ "Should throw if multiple files and input isnt multiple", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("#single-file"); let errMsg = ""; @@ -133,7 +133,7 @@ Deno.test({ ); await t.step("Should throw if element isnt an input", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("p"); let errMsg = ""; @@ -152,7 +152,7 @@ Deno.test({ }); await t.step("Should throw if input is not of type file", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("#text"); let errMsg = ""; @@ -171,7 +171,7 @@ Deno.test({ }); await t.step("Should successfully upload files", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("#multiple-file"); try { @@ -198,7 +198,7 @@ Deno.test({ fn: async (t) => { await t.step("Should throw if element isnt an input", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("p"); let errMsg = ""; @@ -217,7 +217,7 @@ Deno.test({ }); await t.step("Should throw if input is not of type file", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("#text"); let errMsg = ""; @@ -236,7 +236,7 @@ Deno.test({ }); await t.step("Should successfully upload files", async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location(server.address + "/input"); const elem = await page.querySelector("#single-file"); try { diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 5b3d1ac..5009e86 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,11 +1,11 @@ -import { build } from "../../mod.ts"; +import { Client } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; Deno.test("screenshot()", async (t) => { await t.step( "screenshot() | Takes a Screenshot", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); const result = await page.screenshot(); await browser.close(); @@ -16,7 +16,7 @@ Deno.test("screenshot()", async (t) => { await t.step( "Throws an error when format passed is jpeg(or default) and quality > than 100", async () => { - const { page } = await build(); + const { page } = await Client.create(); await page.location("https://drash.land"); let msg = ""; try { @@ -37,7 +37,7 @@ Deno.test("evaluate()", async (t) => { await t.step( "It should evaluate function on current frame", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); const pageTitle = await page.evaluate(() => { // deno-lint-ignore no-undef @@ -48,7 +48,7 @@ Deno.test("evaluate()", async (t) => { }, ); await t.step("It should evaluate string on current frame", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); const parentConstructor = await page.evaluate(`1 + 2`); await browser.close(); @@ -57,7 +57,7 @@ Deno.test("evaluate()", async (t) => { await t.step( "You should be able to pass arguments to the callback", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); interface User { name: string; @@ -101,7 +101,7 @@ Deno.test("location()", async (t) => { await t.step( "Handles correctly and doesnt hang when invalid URL", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); let error = null; try { await page.location("https://google.comINPUT"); @@ -114,7 +114,7 @@ Deno.test("location()", async (t) => { ); await t.step("Sets and gets the location", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://google.com"); await page.location("https://drash.land"); const url = await page.evaluate(() => window.location.href); @@ -125,7 +125,7 @@ Deno.test("location()", async (t) => { Deno.test("cookie()", async (t) => { await t.step("Sets and gets cookies", async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location("https://drash.land"); await page.cookie({ name: "user", @@ -159,7 +159,7 @@ Deno.test({ fn: async (t) => { await t.step(`Should return expected errors`, async () => { server.run(); - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location( server.address, ); @@ -183,7 +183,7 @@ Deno.test({ }); await t.step(`Should be empty if no errors`, async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); await page.location( "https://drash.land", ); @@ -198,7 +198,7 @@ Deno.test({ name: "dialog()", fn: async (t) => { await t.step(`Accepts a dialog`, async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); server.run(); await page.location(server.address + "/dialogs"); const elem = await page.querySelector("#button"); @@ -213,7 +213,7 @@ Deno.test({ assertEquals(val, "Sinco 4eva"); }); await t.step(`Throws if a dialog was not expected`, async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); let errMsg = ""; try { await page.dialog(true, "Sinco 4eva"); @@ -227,7 +227,7 @@ Deno.test({ ); }); await t.step(`Rejects a dialog`, async () => { - const { browser, page } = await build(); + const { browser, page } = await Client.create(); server.run(); await page.location(server.address + "/dialogs"); const elem = await page.querySelector("#button"); From a7cdd0f31cfeb68e87e64907b195f3816802f96c Mon Sep 17 00:00:00 2001 From: ebebbington Date: Thu, 16 May 2024 15:16:37 +0100 Subject: [PATCH 28/34] rm unused property --- src/page.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/page.ts b/src/page.ts index 3ff2a6b..1a36b14 100644 --- a/src/page.ts +++ b/src/page.ts @@ -23,8 +23,6 @@ export class Page extends ProtocolClass { #console_errors: string[] = []; - #uuid: string; - constructor( targetId: string, client: Client, @@ -33,7 +31,6 @@ export class Page extends ProtocolClass { super(socket); this.target_id = targetId; this.client = client; - this.#uuid = (Math.random() + 1).toString(36).substring(7); this.#listenForErrors(); } From 2fad7a9f0a164f098283af3c3614fbcff181feca Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Fri, 24 May 2024 22:15:32 +0100 Subject: [PATCH 29/34] update readme --- README.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fdf69cf..579cb22 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,30 @@ Its maintainers have taken concepts from the following ... Developer UX Approachability Test-driven development Documentation-driven development Transparency +## Table of Contents + +1. [Documentation](#documentation) + + 1.1. [Getting Started](#getting-started) + + 1.2. [Waiting For Actions](#waiting-for-actions-page-to-be-loaded-requests-to-finish) + + 1.3. [Visiting Pages](#visiting-pages) + + 1.4. [Taking Screenshots](#taking-screenshots) + + 1.5. [Dialogs](#dialogs) + + 1.6. [Cookies](#cookies) + + 1.7. [Evaluating](#evaluating-full-dom-or-dev-console-access) + + 1.8. [Retrieving Console Errors](#retreiving-console-errors) + + 1.9. [Working With Elements](#working-with-elements-clicking-inputs) + + 1.10. [Authenticating](#authenticating) + ## Documentation ### Getting Started @@ -75,6 +99,52 @@ const { browser, page } = await Client.create({ }); ``` +### Waiting for Actions (page to be loaded, requests to finish) + +There is only so much we can really do on our end. Whilst we try out best to wait for the correct events from the websocket, +websites load in various ways. Some examples might be: + +- A basic website that may take 200ms to load and the DOM is fully ready by then +- A Vue site that uses Inertia or Vite. The load has loaded but the page/network is still fetching components + +There are simple ways to handle this though + +#### Wait until a specific element is visible in the page + +For example if you're testing your login page, you may wait until the email field is visible to start typing into it + +```ts +import { Client } from "..."; +import { delay } from "..."; // deno std + +const { browser, page } = await Client.create(); +const until = async (cb: () => Promise) => { + while (!(await cb())) { + await delay(100); + } +} +await page.location("http://localhost/login"); +await until(() => await page.evaluate('document.querySelector("[input=email]")')); +await page.evaluate(() => document.querySelector("[type=email]").value = '...'); +``` + +What this will do is you will keep calling `until`, until the result of `evaluate` is truthy. + +#### Wait until the network is idle + +We use this approach when clicking buttons that result in a navigation as well! + +```ts +import { Client } from "..."; +import { waitUntilNetworkIdle } from ".../src/utility.ts"; + +const { browser, page } = await Client.create(); +await page.location("http://localhost/login"); +await waitUntilNetworkIdle(); +``` + +This method will wait until there have been 0 network requests in a 500ms timeframe. + ### Visiting Pages You can do this by calling `.location()` on the page: @@ -243,17 +313,23 @@ await page.location("https://some-url.com/api/users"); Another approach would be to manually submit a login form: ```ts +const login = async (page: Page) => { + await page.location("http://localhost/login"); + await until(async () => (await page.evaluate('document.querySelector("input[type=email]")'))) + await page.evaluate(() => { + document.querySelector('input[type="email"]').value = "admin@example.com"; + document.querySelector('input[type="password"]').value = 'secret' + }) + const submit = await page.querySelector("button[type=submit]"); + await submit.click({ + waitFor: "navigation", + }); +}; const { browser, page } = await Client.create(); -await page.location("https://some-url.com/login"); -const button = await page.querySelector('[type="submit"]'); -await page.evaluate(() => - document.querySelector('[type="email]').value = "..." -); -await page.evaluate(() => - document.querySelector('[type="password]').value = "..." -); -await button.click({ - waitFor: "navigation", -}); -await page.evaluate("window.location.href"); // "https://some-url.com/dashboard" +await login(page); +// Visit the required page +await page.location(url); +// Wait for the page to property load so the dom is ready +await waitUntilNetworkIdle(); +// ... Assertions or actions ``` From 3b63f2bfd079b20dca1332c7f8284d544f9079a5 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Fri, 24 May 2024 22:17:48 +0100 Subject: [PATCH 30/34] fmt --- README.md | 56 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 579cb22..e5b8b77 100644 --- a/README.md +++ b/README.md @@ -43,25 +43,26 @@ development Transparency 1. [Documentation](#documentation) - 1.1. [Getting Started](#getting-started) + 1.1. [Getting Started](#getting-started) - 1.2. [Waiting For Actions](#waiting-for-actions-page-to-be-loaded-requests-to-finish) + 1.2. + [Waiting For Actions](#waiting-for-actions-page-to-be-loaded-requests-to-finish) - 1.3. [Visiting Pages](#visiting-pages) + 1.3. [Visiting Pages](#visiting-pages) - 1.4. [Taking Screenshots](#taking-screenshots) + 1.4. [Taking Screenshots](#taking-screenshots) - 1.5. [Dialogs](#dialogs) + 1.5. [Dialogs](#dialogs) - 1.6. [Cookies](#cookies) + 1.6. [Cookies](#cookies) - 1.7. [Evaluating](#evaluating-full-dom-or-dev-console-access) + 1.7. [Evaluating](#evaluating-full-dom-or-dev-console-access) - 1.8. [Retrieving Console Errors](#retreiving-console-errors) + 1.8. [Retrieving Console Errors](#retreiving-console-errors) - 1.9. [Working With Elements](#working-with-elements-clicking-inputs) + 1.9. [Working With Elements](#working-with-elements-clicking-inputs) - 1.10. [Authenticating](#authenticating) + 1.10. [Authenticating](#authenticating) ## Documentation @@ -101,17 +102,20 @@ const { browser, page } = await Client.create({ ### Waiting for Actions (page to be loaded, requests to finish) -There is only so much we can really do on our end. Whilst we try out best to wait for the correct events from the websocket, -websites load in various ways. Some examples might be: +There is only so much we can really do on our end. Whilst we try out best to +wait for the correct events from the websocket, websites load in various ways. +Some examples might be: - A basic website that may take 200ms to load and the DOM is fully ready by then -- A Vue site that uses Inertia or Vite. The load has loaded but the page/network is still fetching components +- A Vue site that uses Inertia or Vite. The load has loaded but the page/network + is still fetching components There are simple ways to handle this though #### Wait until a specific element is visible in the page -For example if you're testing your login page, you may wait until the email field is visible to start typing into it +For example if you're testing your login page, you may wait until the email +field is visible to start typing into it ```ts import { Client } from "..."; @@ -122,13 +126,16 @@ const until = async (cb: () => Promise) => { while (!(await cb())) { await delay(100); } -} +}; await page.location("http://localhost/login"); -await until(() => await page.evaluate('document.querySelector("[input=email]")')); -await page.evaluate(() => document.querySelector("[type=email]").value = '...'); +await until(() => + await page.evaluate('document.querySelector("[input=email]")') +); +await page.evaluate(() => document.querySelector("[type=email]").value = "..."); ``` -What this will do is you will keep calling `until`, until the result of `evaluate` is truthy. +What this will do is you will keep calling `until`, until the result of +`evaluate` is truthy. #### Wait until the network is idle @@ -143,7 +150,8 @@ await page.location("http://localhost/login"); await waitUntilNetworkIdle(); ``` -This method will wait until there have been 0 network requests in a 500ms timeframe. +This method will wait until there have been 0 network requests in a 500ms +timeframe. ### Visiting Pages @@ -315,11 +323,15 @@ Another approach would be to manually submit a login form: ```ts const login = async (page: Page) => { await page.location("http://localhost/login"); - await until(async () => (await page.evaluate('document.querySelector("input[type=email]")'))) + await until( + async () => (await page.evaluate( + 'document.querySelector("input[type=email]")', + )), + ); await page.evaluate(() => { document.querySelector('input[type="email"]').value = "admin@example.com"; - document.querySelector('input[type="password"]').value = 'secret' - }) + document.querySelector('input[type="password"]').value = "secret"; + }); const submit = await page.querySelector("button[type=submit]"); await submit.click({ waitFor: "navigation", From 9ed8171c20a5f4868a351efc0213ca0ee1a3eb81 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 25 May 2024 09:31:18 +0100 Subject: [PATCH 31/34] refactor more --- README.md | 88 +++++----- src/element.ts | 343 ------------------------------------- src/interfaces.ts | 2 + src/page.ts | 206 ++++++++++++++-------- tests/unit/client_test.ts | 33 ++-- tests/unit/element_test.ts | 256 --------------------------- tests/unit/page_test.ts | 172 ++++++++++++++++--- 7 files changed, 344 insertions(+), 756 deletions(-) delete mode 100644 src/element.ts delete mode 100644 tests/unit/element_test.ts diff --git a/README.md b/README.md index e5b8b77..9917227 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,6 @@ What this will do is you will keep calling `until`, until the result of #### Wait until the network is idle -We use this approach when clicking buttons that result in a navigation as well! - ```ts import { Client } from "..."; import { waitUntilNetworkIdle } from ".../src/utility.ts"; @@ -164,17 +162,23 @@ await page.location("https://some-url.com"); ### Taking Screenshots -Utilise the `.screenshotMethod()` on a page or element: +Utilise the `.screenshot()` method on a page: ```ts const { browser, page } = await Client.create(); await page.location("https://some-url.com"); -const uint8array = await page.screenshot({ // Options are optional - format: "jpeg", // or png. Defaults to jpeg +// Defaults to jpeg, with a quality of 80 +const uint8array = await page.screenshot(); + +const uint8array = await page.screenshot({ + format: "jpeg", // or png quality: 50, // 0-100, only applicable if format is optional. Defaults to 80 }); -const elem = await page.querySelector("div"); -const uint8array = await elem.screenshot(); // Same options as above + +// Pass in a selector to only screenshot that +const uint8array = await page.screenshot({ + element: "div#users", +}); ``` ### Dialogs @@ -189,6 +193,18 @@ await page.dialog(true); // Accept it await page.dialog(true, "I will be joining on 20/03/2024"); // Accept and provide prompt text ``` +Be sure that if you're evaluating for example then calling `.dialog`, do not +await `.evaluate`, this is to ensure that the `dialog` method can get the event +of a dialog opening: + +```ts +page.evaluate("..."); +await page.dialog(true); +``` + +The method waits for av enet to tell us a dialog is opening, and once it's +opened, we will either accept or reject it with any given text if appicable + ### Cookies You can get or set cookies @@ -255,49 +271,46 @@ assertEquals(errors.length, 2); assertEquals(errors.includes("user not defined")); ``` -### Working with Elements (clicking, inputs) +### Working with file inputs + +We provide a simple way to set files on a file input. -We provide ways to set files on a file input and click elements. +```ts +const { browser, page } = await Client.create(); +await page.location("https://some-url.com"); +await page.setInputFiles({ + selector: "#upload", + files: ["/home/user/images/logo.png"], +}); +``` -To create a reference to an element, use -`await page.querySelector("")`, just like how you would use it in -the browser. +Pass in a list of files to attach to the input. You can only send 1 if your file +input sin't a multiple input -#### File operations +### Clicking -We provide an easier way to set a file on a file input element. +To click elements, you can achieve it by doing what you might do in the devtools +console: ```ts const { browser, page } = await Client.create(); await page.location("https://some-url.com"); -const input = await page.querySelector('input[type="file"]'); -await input.file("./users.png"); -const multipleInput = await page.querySelector('input[type="file"]'); -await multipleInput.files(["./users.png", "./company.pdf"]); +await page.evaluate(`document.querySelector('button[type="button"]')`); ``` -#### Clicking +If the button you click makes some form of HTTP request, be sure to utilise +`waitUntilNetworkIdle` to wait until the new page has finished loading. -You can also click elements, such as buttons or anchor tags. +If you are clicking an element that opens a new tab, be sure to use +`.newPageClick()`. This insures that we can expect a new tab to be opened and +return a new instance of `Page` for you to work with: ```ts const { browser, page } = await Client.create(); await page.location("https://some-url.com"); -const button = await page.querySelector('button[type="button"]'); -await button.click(); -// .. Do something else now button has been clicked - -// `navigation` is used if you need to wait for some kind of HTTP request, such as going to a different URL, or clicking a button that makes an API request -const anchor = await page.querySelector("a"); -await anchor.click({ - waitFor: "navigation", -}); - -const anchor = await page.querySelector('a[target="_BLANK"]'); -const newPage = await anchor.click({ - waitFor: "newPage", -}); -// ... Now `newPage` is a reference to the new tab that just opened +const newPage = await page.newPageClick( + `document.querySelector('button[type="button"]')`, +); ``` ### Authenticating @@ -332,10 +345,7 @@ const login = async (page: Page) => { document.querySelector('input[type="email"]').value = "admin@example.com"; document.querySelector('input[type="password"]').value = "secret"; }); - const submit = await page.querySelector("button[type=submit]"); - await submit.click({ - waitFor: "navigation", - }); + await page.evaluate(`document.querySelector('button[type=submit]')`); }; const { browser, page } = await Client.create(); await login(page); diff --git a/src/element.ts b/src/element.ts deleted file mode 100644 index 5f1611a..0000000 --- a/src/element.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { Page } from "./page.ts"; -import { deferred, Protocol as ProtocolTypes } from "../deps.ts"; -import { ScreenshotOptions } from "./interfaces.ts"; -import { waitUntilNetworkIdle } from "./utility.ts"; - -// Eg if parameter is a string -type Click = T extends "middle" ? Page - : void; -type WaitFor = "navigation" | "newPage"; - -/** - * A class to represent an element on the page, providing methods - * to action on that element - */ -export class Element { - /** - * The css selector for the element - */ - readonly #selector: string; // eg "#user" or "div > #name" or "//h1" - - /** - * How we select the element - */ - readonly #method = "document.querySelector"; // | "$x"; - - /** - * The page this element belongs to - */ - readonly #page: Page; - - /** - * ObjectId belonging to this element - */ - readonly #objectId: string; - - readonly #node: ProtocolTypes.DOM.Node; - - /** - * @param method - The method we use for query selecting - * @param selector - The CSS selector - * @param page - The page this element belongs to - * @param objectId - The object id assigned to the element - */ - constructor( - selector: string, - page: Page, - node: ProtocolTypes.DOM.Node, - objectId: string, - ) { - this.#node = node; - this.#objectId = objectId; - this.#page = page; - this.#selector = selector; - } - - /** - * Sets a file for a file input - * - * @param path - The remote path of the file to attach - * - * @example - * ```js - * import { resolve } from "https://deno.land/std@0.136.0/path/mod.ts"; - * const fileInput = await page.querySelector("input[type='file']"); - * await fileInput.file(resolve("./logo.png")); - * ``` - */ - public async file(path: string): Promise { - return await this.files(path); - } - - /** - * Sets many files for a file input - * - * @param files - The list of remote files to attach - * - * @example - * ```js - * import { resolve } from "https://deno.land/std@0.136.0/path/mod.ts"; - * const fileInput = await page.querySelector("input[type='file']"); - * await fileInput.files(resolve("./logo.png")); - * ``` - */ - public async files(...files: string[]) { - if (files.length > 1) { - const isMultiple = await this.#page.evaluate( - `${this.#method}('${this.#selector}').hasAttribute('multiple')`, - ); - if (!isMultiple) { - throw new Error( - "Trying to set files on a file input without the 'multiple' attribute", - ); - } - } - - const name = await this.#page.evaluate( - `${this.#method}('${this.#selector}').nodeName`, - ); - if (name !== "INPUT") { - throw new Error("Trying to set a file on an element that isnt an input"); - } - const type = await this.#page.evaluate( - `${this.#method}('${this.#selector}').type`, - ); - if (type !== "file") { - throw new Error( - 'Trying to set a file on an input that is not of type "file"', - ); - } - - await this.#page.send( - "DOM.setFileInputFiles", - { - files: files, - objectId: this.#objectId, - backendNodeId: this.#node.backendNodeId, - }, - ); - } - - /** - * Take a screenshot of the element and save it to `filename` in `path` folder, with a `format` and `quality` (jpeg format only) - * - * @example - * ```ts - * const uint8array = await element.screenshot(); - * Deno.writeFileSync('./file.jpg', uint8array); - * ``` - * - * @param path - The path of where to save the screenshot to - * @param options - * - * @returns The data - */ - async screenshot( - options?: ScreenshotOptions, - ): Promise { - const ext = options?.format ?? "jpeg"; - const rawViewportResult = await this.#page.evaluate( - `JSON.stringify(${this.#method}('${this.#selector}').getBoundingClientRect())`, - ); - const jsonViewportResult = JSON.parse(rawViewportResult); - const clip = { - x: jsonViewportResult.x, - y: jsonViewportResult.y, - width: jsonViewportResult.width, - height: jsonViewportResult.height, - scale: 2, - }; - - if (options?.quality && Math.abs(options.quality) > 100 && ext == "jpeg") { - await this.#page.client.close( - "A quality value greater than 100 is not allowed.", - ); - } - - // Quality should defined only if format is jpeg - const quality = (ext == "jpeg") - ? ((options?.quality) ? Math.abs(options.quality) : 80) - : undefined; - - const res = await this.#page.send< - ProtocolTypes.Page.CaptureScreenshotRequest, - ProtocolTypes.Page.CaptureScreenshotResponse - >( - "Page.captureScreenshot", - { - format: ext, - quality: quality, - clip: clip, - }, - ); - - const B64str = res.data; - const u8Arr = Uint8Array.from(atob(B64str), (c) => c.charCodeAt(0)); - - return u8Arr; - } - - /** - * Click the element - * - * If clicking something that will open a new tab, you should use `button: "middle"`. This will - * also wait until the new page has opened, and you can then retrieve it: const page2 = browser.pages[1] - * - * If clicking something that will update the location of the page, pass true as the second parameter - * to wait until this new location loads - * - * @param options - * @param options.button - If you should left, mdidle, or right click the element. Defaults to left. If middle, will wait until the new page has loaded - * @param options.waitFor - "navigation". If clicking an element that will change the page location, set to true. Will wait for the new location to load - * - * @example - * ```js - * await click(); // eg button - * await click({ waitFor: 'navigation' }); // eg if link or form submit - * const newPage = await click({ waitFor: 'newPage' }); // If download button or anchor tag with _BLANK - * ``` - */ - public async click(options: { - waitFor?: WaitFor; - } = {}): Promise> { - // Scroll into view - await this.#page.evaluate( - `${this.#method}('${this.#selector}').scrollIntoView({ - block: 'center', - inline: 'center', - behavior: 'instant' - })`, - ); - - // Get details we need for dispatching input events on the element - const result = await this.#page.send< - ProtocolTypes.DOM.GetContentQuadsRequest, - ProtocolTypes.DOM.GetContentQuadsResponse - >("DOM.getContentQuads", { - objectId: this.#objectId, - }); - const layoutMetrics = await this.#page.send< - null, - ProtocolTypes.Page.GetLayoutMetricsResponse - >("Page.getLayoutMetrics"); - - if (!result || !result.quads?.length) { - await this.#page.client.close( - `Node is either not clickable or not an HTMLElement`, - ); - } - - // Ignoring because cssLayoutMetrics is present on chrome, but not firefox - // deno-lint-ignore ban-ts-comment - // @ts-ignore - const { clientWidth, clientHeight } = layoutMetrics.csslayoutViewport ?? - layoutMetrics.layoutViewport; - const quads = result.quads.map((quad) => { - return [ - { x: quad[0], y: quad[1] }, - { x: quad[2], y: quad[3] }, - { x: quad[4], y: quad[5] }, - { x: quad[6], y: quad[7] }, - ]; - }).map((quad) => { - return quad.map((point) => ({ - x: Math.min(Math.max(point.x, 0), clientWidth), - y: Math.min(Math.max(point.y, 0), clientHeight), - })); - }).filter((quad) => { - let area = 0; - for (let i = 0; i < quad.length; ++i) { - const p1 = quad[i]; - const p2 = quad[(i + 1) % quad.length]; - area += (p1.x * p2.y - p2.x * p1.y) / 2; - } - return Math.abs(area) > 1; - }); - const quad = quads[0]; - let x = 0; - let y = 0; - - /** - * It could be that the element isn't clickable. Once - * instance i've found this is when i've tried to click - * an element `` eg self closing. - * Could be more reasons though - */ - if (!quad) { - await this.#page.client.close( - `Unable to click the element "${this.#selector}". It could be that it is invalid HTML`, - ); - return undefined as Click; - } - - for (const point of quad) { - x += point.x; - y += point.y; - } - x = x / 4; - y = y / 4; - const buttonsMap = { - left: 1, - right: 2, - middle: 4, - }; - - await this.#page.send("Input.dispatchMouseEvent", { - type: "mouseMoved", - button: "left", - modifiers: 0, - clickCount: 1, - x: x + (x - x) * (1 / 1), - y, - buttons: buttonsMap.left, - }); - - // Creating this here because by the time we send the below events, and try wait for the notification, the protocol may have already got the message and discarded it - const newPageHandler = options.waitFor === "newPage" - ? "Page.frameRequestedNavigation" - : null; - if (newPageHandler) { - this.#page.notifications.set( - newPageHandler, - deferred(), - ); - } - - await this.#page.send("Input.dispatchMouseEvent", { - type: "mousePressed", - button: "left", - modifiers: 0, - clickCount: 1, - x, - y, - buttons: buttonsMap.left, - }); - await this.#page.send("Input.dispatchMouseEvent", { - type: "mouseReleased", - button: "left", - modifiers: 0, - clickCount: 1, - x, - y, - buttons: buttonsMap.left, - }); - - if (newPageHandler) { - const p1 = this.#page.notifications.get( - newPageHandler, - ); - const { frameId } = - await p1 as unknown as ProtocolTypes.Page.FrameRequestedNavigationEvent; - this.#page.notifications.delete( - newPageHandler, - ); - - return await Page.create(this.#page.client, frameId) as Click; - } - if (options.waitFor === "navigation") { - await waitUntilNetworkIdle(); - } - - return undefined as Click; - } -} diff --git a/src/interfaces.ts b/src/interfaces.ts index 38c60c9..f9d59a0 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -14,6 +14,8 @@ export interface ScreenshotOptions { format?: "jpeg" | "png"; /** The image quality from 0 to 100, default 80. Applicable only if no format provided or format is "jpeg" - Optional */ quality?: number; + /** The css selector should you wish to screenshot an element */ + element?: string; } /** diff --git a/src/page.ts b/src/page.ts index 1a36b14..4dbe6ec 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,5 +1,4 @@ import { deferred, Protocol as ProtocolTypes } from "../deps.ts"; -import { Element } from "./element.ts"; import { Protocol as ProtocolClass } from "./protocol.ts"; import { Cookie, ScreenshotOptions } from "./interfaces.ts"; import { Client } from "./client.ts"; @@ -61,37 +60,11 @@ export class Page extends ProtocolClass { addEventListener("Runtime.exceptionThrown", onError); } - /** - * Tells Sinco you are expecting a dialog, so Sinco can listen for the event, - * and when `.dialog()` is called, Sinco can accept or decline it at the right time - * - * @example - * ```js - * // Note that if `.click()` produces a dialog, do not await it. - * await page.expectDialog(); - * await elem.click(); - * await page.dialog(true, "my username is Sinco"); - * ``` - */ - public expectDialog() { - this.notifications.set( - "Page.javascriptDialogOpening", - deferred(), - ); - } - /** * Interact with a dialog. * - * Will throw if `.expectDialog()` was not called before. - * This is so Sino doesn't try to accept/decline a dialog before - * it opens. - * * @example * ```js - * // Note that if `.click()` produces a dialog, do not await it. - * await page.expectDialog(); - * elem.click(); * await page.dialog(true, "my username is Sinco"); * ``` * @@ -99,13 +72,8 @@ export class Page extends ProtocolClass { * @param promptText - The text to enter into the dialog prompt before accepting. Used only if this is a prompt dialog. */ public async dialog(accept: boolean, promptText?: string) { - const p = this.notifications.get("Page.javascriptDialogOpening"); - if (!p) { - throw new Error( - `Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.`, - ); - } - await p; + this.notifications.set("Page.javascriptDialogOpening", deferred()); + await this.notifications.get("Page.javascriptDialogOpening"); const method = "Page.javascriptDialogClosed"; this.notifications.set(method, deferred()); const body: ProtocolTypes.Page.HandleJavaScriptDialogRequest = { @@ -122,6 +90,88 @@ export class Page extends ProtocolClass { await closedPromise; } + /** + * Sets files for a file input + * + * @param files - The list of remote files to attach + * + * @example + * ```js + * await page.setInputFiles({ + * selector: "input[type='file']", + * files: ["./logo.png"], + * }); + * ``` + */ + public async setInputFiles(options: { + selector: string; + files: string[]; + }) { + if (options.files.length > 1) { + const isMultiple = await this.evaluate( + `document.querySelector('${options.selector}').hasAttribute('multiple')`, + ); + if (!isMultiple) { + throw new Error( + "Trying to set files on a file input without the 'multiple' attribute", + ); + } + } + + const name = await this.evaluate( + `document.querySelector('${options.selector}').nodeName`, + ); + if (name !== "INPUT") { + throw new Error("Trying to set a file on an element that isnt an input"); + } + const type = await this.evaluate( + `document.querySelector('${options.selector}').type`, + ); + if (type !== "file") { + throw new Error( + 'Trying to set a file on an input that is not of type "file"', + ); + } + + const { + result: { + value, + objectId, + }, + } = await this.send< + ProtocolTypes.Runtime.EvaluateRequest, + ProtocolTypes.Runtime.EvaluateResponse + >("Runtime.evaluate", { + expression: `document.querySelector('${options.selector}')`, + includeCommandLineAPI: true, + }); + if (value === null) { + await this.client.close( + 'The selector "' + options.selector + '" does not exist inside the DOM', + ); + } + + if (!objectId) { + await this.client.close("Unable to find the object"); + } + + const { node } = await this.send< + ProtocolTypes.DOM.DescribeNodeRequest, + ProtocolTypes.DOM.DescribeNodeResponse + >("DOM.describeNode", { + objectId: objectId, + }); + + await this.send( + "DOM.setFileInputFiles", + { + files: options.files, + objectId: objectId, + backendNodeId: node.backendNodeId, + }, + ); + } + /** * Either get all cookies for the page, or set a cookie * @@ -280,46 +330,6 @@ export class Page extends ProtocolClass { } } - /** - * Representation of the Browser's `document.querySelector` - * - * @param selector - The selector for the element - * - * @returns An element class, allowing you to take an action upon that element - */ - async querySelector(selector: string) { - const result = await this.send< - ProtocolTypes.Runtime.EvaluateRequest, - ProtocolTypes.Runtime.EvaluateResponse - >("Runtime.evaluate", { - expression: `document.querySelector('${selector}')`, - includeCommandLineAPI: true, - }); - if (result.result.value === null) { - await this.client.close( - 'The selector "' + selector + '" does not exist inside the DOM', - ); - } - - if (!result.result.objectId) { - await this.client.close("Unable to find the object"); - } - - const { node } = await this.send< - ProtocolTypes.DOM.DescribeNodeRequest, - ProtocolTypes.DOM.DescribeNodeResponse - >("DOM.describeNode", { - objectId: result.result.objectId, - }); - - return new Element( - selector, - this, - node, - result.result.objectId as string, - ); - } - /** * Return the current list of console errors present in the dev tools */ @@ -333,6 +343,39 @@ export class Page extends ProtocolClass { return this.#console_errors; } + /** + * Click an element and expect a new page to be opened. + * + * @param selector - The selector for the element + * + * @returns A new Page instance referencing the new tab + */ + public async newPageClick(selector: string): Promise { + const newPageMethod = "Page.windowOpen"; + this.notifications.set(newPageMethod, deferred()); + + await this.evaluate( + `document.querySelector('${selector}').click()`, + ); + + const p = this.notifications.get(newPageMethod); + const { url } = await p as unknown as ProtocolTypes.Page.WindowOpenEvent; + const res = await fetch( + `http://${this.client.wsOptions.hostname}:${this.client.wsOptions.port}/json/list`, + ); + const page = (await res.json()).find((p: Record) => + p.url === url + ); + + if (!page) { + await this.client.close( + `Internal error. Could not find a new page`, + ); + } + + return await Page.create(this.client, page.id); + } + /** * Take a screenshot of the page and save it to `filename` in `path` folder, with a `format` and `quality` (jpeg format only) * If `selector` is passed in, it will take a screenshot of only that element @@ -355,7 +398,20 @@ export class Page extends ProtocolClass { options?: ScreenshotOptions, ): Promise { const ext = options?.format ?? "jpeg"; - const clip = undefined; + let clip: ProtocolTypes.Page.Viewport | undefined = undefined; + if (options?.element) { + const rawViewportResult = await this.evaluate( + `JSON.stringify(document.querySelector('${options.element}').getBoundingClientRect())`, + ); + const jsonViewportResult = JSON.parse(rawViewportResult); + clip = { + x: jsonViewportResult.x, + y: jsonViewportResult.y, + width: jsonViewportResult.width, + height: jsonViewportResult.height, + scale: 2, + }; + } if (options?.quality && Math.abs(options.quality) > 100 && ext == "jpeg") { await this.client.close( diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 1caad76..10ac5df 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,4 +1,4 @@ -import { deferred } from "../../deps.ts"; +import { assertEquals, deferred } from "../../deps.ts"; import { Client } from "../../mod.ts"; Deno.test("create()", async (t) => { @@ -99,32 +99,19 @@ Deno.test("create()", async (t) => { }); Deno.test(`close()`, async (t) => { - await t.step(`Should close all resources and not leak any`, async () => { - const { browser, page } = await Client.create(); - await page.location("https://drash.land"); - await browser.close(); - // If resources are not closed or pending ops or leaked, this test will show it when ran - }); - - await t.step({ - name: `Should close all page specific resources too`, - fn: async () => { + await t.step( + `Should close all resources, and throw if speciified`, + async () => { const { browser, page } = await Client.create(); await page.location("https://drash.land"); - await browser.close(); + let errMsg = ""; try { - const listener = Deno.listen({ - port: 9292, - hostname: "localhost", - }); - listener.close(); + await browser.close("Some error message"); } catch (e) { - if (e instanceof Deno.errors.AddrInUse) { - throw new Error( - `Seems like the subprocess is still running: ${e.message}`, - ); - } + errMsg = e.message; } + assertEquals(errMsg, "Some error message"); + // If resources are not closed or pending ops or leaked, this test will show it when ran }, - }); + ); }); diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts deleted file mode 100644 index d98521c..0000000 --- a/tests/unit/element_test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { Client } from "../../mod.ts"; -import { assertEquals } from "../../deps.ts"; -import { server } from "../server.ts"; -import { resolve } from "../deps.ts"; -Deno.test("click()", async (t) => { - await t.step( - "It should fail if the element is no longer present in the DOM", - async () => { - server.run(); - const { page } = await Client.create(); - await page.location(server.address + "/anchor-links"); - // Need to make the element either not clickable or not a HTMLElement - const elem = await page.querySelector( - "a", - ); - await page.location("https://google.com"); - let errMsg = ""; - try { - await elem.click(); - } catch (e) { - errMsg = e.message; - } - server.close(); - assertEquals( - errMsg, - "Node is either not clickable or not an HTMLElement", - ); - }, - ); - await t.step( - "It should allow clicking of elements and update location", - async () => { - const { browser, page } = await Client.create(); - try { - server.run(); - await page.location(server.address + "/anchor-links"); - const elem = await page.querySelector( - "a#not-blank", - ); - await elem.click({ - waitFor: "navigation", - }); - const page1Location = await page.evaluate(() => window.location.href); - await browser.close(); - await server.close(); - assertEquals(page1Location, "https://discord.com/invite/RFsCSaHRWK"); - } catch (e) { - console.log(e); - await browser.close(); - } - }, - ); - - await t.step( - "It should error if the HTML for the element is invalid", - async () => { - const { browser, page } = await Client.create(); - server.run(); - await page.location(server.address + "/anchor-links"); - const elem = await page.querySelector( - "a#invalid-link", - ); - let error = null; - try { - await elem.click({ - waitFor: "navigation", - }); - } catch (e) { - error = e.message; - } - await browser.close(); - await server.close(); - assertEquals( - error, - 'Unable to click the element "a#invalid-link". It could be that it is invalid HTML', - ); - }, - ); -}); - -Deno.test("screenshot()", async (t) => { - await t.step( - "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", - async () => { - const { browser, page } = await Client.create(); - await page.location("https://drash.land"); - const img = await page.querySelector("img"); - await img.screenshot({ - quality: 50, - }); - await browser.close(); - }, - ); - - await t.step("Saves Screenshot with all options provided", async () => { - const { browser, page } = await Client.create(); - server.run(); - await page.location(server.address + "/anchor-links"); - const a = await page.querySelector("a"); - await a.screenshot({ - format: "jpeg", - quality: 100, - }); - await browser.close(); - await server.close(); - }); -}); - -Deno.test({ - name: "files()", - fn: async (t) => { - await t.step( - "Should throw if multiple files and input isnt multiple", - async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("#single-file"); - let errMsg = ""; - try { - await elem.files("ffff", "hhh"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - `Trying to set files on a file input without the 'multiple' attribute`, - ); - }, - ); - await t.step("Should throw if element isnt an input", async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("p"); - let errMsg = ""; - try { - await elem.files("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - "Trying to set a file on an element that isnt an input", - ); - }); - await t.step("Should throw if input is not of type file", async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("#text"); - let errMsg = ""; - try { - await elem.files("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - 'Trying to set a file on an input that is not of type "file"', - ); - }); - await t.step("Should successfully upload files", async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("#multiple-file"); - try { - await elem.files( - resolve("./README.md"), - resolve("./tsconfig.json"), - ); - const files = JSON.parse( - await page.evaluate( - `JSON.stringify(document.querySelector('#multiple-file').files)`, - ), - ); - assertEquals(Object.keys(files).length, 2); - } finally { - await server.close(); - await browser.close(); - } - }); - }, -}); - -Deno.test({ - name: "file()", - fn: async (t) => { - await t.step("Should throw if element isnt an input", async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("p"); - let errMsg = ""; - try { - await elem.file("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - "Trying to set a file on an element that isnt an input", - ); - }); - await t.step("Should throw if input is not of type file", async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("#text"); - let errMsg = ""; - try { - await elem.file("ffff"); - } catch (e) { - errMsg = e.message; - } finally { - await server.close(); - await browser.close(); - } - assertEquals( - errMsg, - 'Trying to set a file on an input that is not of type "file"', - ); - }); - await t.step("Should successfully upload files", async () => { - server.run(); - const { browser, page } = await Client.create(); - await page.location(server.address + "/input"); - const elem = await page.querySelector("#single-file"); - try { - await elem.file(resolve("./README.md")); - const files = JSON.parse( - await page.evaluate( - `JSON.stringify(document.querySelector('#single-file').files)`, - ), - ); - assertEquals(Object.keys(files).length, 1); - } finally { - await server.close(); - await browser.close(); - } - }); - }, -}); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 5009e86..68d55b6 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,9 +1,10 @@ import { Client } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; +import { resolve } from "../deps.ts"; Deno.test("screenshot()", async (t) => { await t.step( - "screenshot() | Takes a Screenshot", + "Takes a Screenshot", async () => { const { browser, page } = await Client.create(); await page.location("https://drash.land"); @@ -13,6 +14,19 @@ Deno.test("screenshot()", async (t) => { }, ); + await t.step( + "Takes a Screenshot of an element", + async () => { + const { browser, page } = await Client.create(); + await page.location("https://drash.land"); + const result = await page.screenshot({ + element: "div", + }); + await browser.close(); + assertEquals(result instanceof Uint8Array, true); + }, + ); + await t.step( "Throws an error when format passed is jpeg(or default) and quality > than 100", async () => { @@ -154,6 +168,24 @@ Deno.test("cookie()", async (t) => { }); }); +Deno.test({ + name: "newPageClick()", + fn: async (t) => { + await t.step("Should click on a link and open a new page", async () => { + const { browser, page } = await Client.create(); + server.run(); + await page.location(server.address + "/anchor-links"); + const newPage = await page.newPageClick("a#blank"); + const url = await newPage.evaluate(() => window.location.href); + const originalUrl = await page.evaluate(() => window.location.href); + await browser.close(); + await server.close(); + assertEquals(url, "https://drash.land/"); + assertEquals(originalUrl, server.address + "/anchor-links"); + }); + }, +}); + Deno.test({ name: "consoleErrors()", fn: async (t) => { @@ -201,9 +233,7 @@ Deno.test({ const { browser, page } = await Client.create(); server.run(); await page.location(server.address + "/dialogs"); - const elem = await page.querySelector("#button"); - page.expectDialog(); - elem.click(); + page.evaluate(`document.querySelector("#button").click()`); await page.dialog(true, "Sinco 4eva"); const val = await page.evaluate( `document.querySelector("#button").textContent`, @@ -212,34 +242,136 @@ Deno.test({ await server.close(); assertEquals(val, "Sinco 4eva"); }); - await t.step(`Throws if a dialog was not expected`, async () => { + await t.step(`Rejects a dialog`, async () => { + const { browser, page } = await Client.create(); + server.run(); + await page.location(server.address + "/dialogs"); + page.evaluate(`document.querySelector("#button").click()`); + await page.dialog(false, "Sinco 4eva"); + const val = await page.evaluate( + `document.querySelector("#button").textContent`, + ); + await browser.close(); + await server.close(); + assertEquals(val, ""); + }); + }, +}); + +Deno.test({ + name: "files()", + fn: async (t) => { + await t.step( + "Should throw if multiple files and input isnt multiple", + async () => { + server.run(); + const { browser, page } = await Client.create(); + await page.location(server.address + "/input"); + let errMsg = ""; + try { + await page.setInputFiles({ + selector: "#single-file", + files: ["ffff", "hhh"], + }); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + `Trying to set files on a file input without the 'multiple' attribute`, + ); + }, + ); + await t.step("Should throw if element isnt an input", async () => { + server.run(); + const { browser, page } = await Client.create(); + await page.location(server.address + "/input"); + let errMsg = ""; + try { + await page.setInputFiles({ + selector: "p", + files: ["ffff"], + }); + } catch (e) { + errMsg = e.message; + } finally { + await server.close(); + await browser.close(); + } + assertEquals( + errMsg, + "Trying to set a file on an element that isnt an input", + ); + }); + await t.step("Should throw if input is not of type file", async () => { + server.run(); const { browser, page } = await Client.create(); + await page.location(server.address + "/input"); let errMsg = ""; try { - await page.dialog(true, "Sinco 4eva"); + await page.setInputFiles({ + selector: "#text", + files: ["ffff"], + }); } catch (e) { errMsg = e.message; + } finally { + await server.close(); + await browser.close(); } - await browser.close(); assertEquals( errMsg, - 'Trying to accept or decline a dialog without you expecting one. ".expectDialog()" was not called beforehand.', + 'Trying to set a file on an input that is not of type "file"', ); }); - await t.step(`Rejects a dialog`, async () => { + await t.step("Should successfully upload files", async () => { + server.run(); const { browser, page } = await Client.create(); + await page.location(server.address + "/input"); + try { + await page.setInputFiles({ + selector: "#multiple-file", + files: [ + resolve("./README.md"), + resolve("./tsconfig.json"), + ], + }); + const files = JSON.parse( + await page.evaluate( + `JSON.stringify(document.querySelector('#multiple-file').files)`, + ), + ); + assertEquals(Object.keys(files).length, 2); + } finally { + await server.close(); + await browser.close(); + } + }); + + await t.step("Should successfully upload a file", async () => { server.run(); - await page.location(server.address + "/dialogs"); - const elem = await page.querySelector("#button"); - page.expectDialog(); - elem.click(); - await page.dialog(false, "Sinco 4eva"); - const val = await page.evaluate( - `document.querySelector("#button").textContent`, - ); - await browser.close(); - await server.close(); - assertEquals(val, ""); + const { browser, page } = await Client.create(); + await page.location(server.address + "/input"); + try { + await page.setInputFiles({ + selector: "#single-file", + files: [ + resolve("./tsconfig.json"), + ], + }); + const files = JSON.parse( + await page.evaluate( + `JSON.stringify(document.querySelector('#single-file').files)`, + ), + ); + assertEquals(Object.keys(files).length, 1); + } finally { + await server.close(); + await browser.close(); + } }); }, }); From 7ef05f79b22056c13acb14f8e84deb434895a889 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 25 May 2024 09:34:21 +0100 Subject: [PATCH 32/34] update Readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9917227..28bb49c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Drash Land - Sinco logo -Sinco is a browser automation and testing tool. What this means is, Sinco runs a +Sinco is a very lightweight browser automation and testing tool to act fast, simple, and get the job done. What this means is, Sinco runs a subprocess for Chrome, and will communicate to the process via the Chrome Devtools Protocol, as the subprocess opens a WebSocket server that Sinco connects to. This allows Sinco to spin up a new browser tab, go to certain @@ -39,6 +39,8 @@ Its maintainers have taken concepts from the following ... Developer UX Approachability Test-driven development Documentation-driven development Transparency +We've tried to keep Sinco as minimal as possible, only exposing methods that will make it easier to action upon a page. For example, setting files on a file input. This is not a simple action so we provide a method to do this that ends up sending multiple websocket messages. + ## Table of Contents 1. [Documentation](#documentation) From ade97ac98d9a6e896a50994f9b4d7fb8a044f649 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 25 May 2024 09:35:20 +0100 Subject: [PATCH 33/34] fmt --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 28bb49c..ddea2c0 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Drash Land - Sinco logo -Sinco is a very lightweight browser automation and testing tool to act fast, simple, and get the job done. What this means is, Sinco runs a -subprocess for Chrome, and will communicate to the process via the Chrome -Devtools Protocol, as the subprocess opens a WebSocket server that Sinco -connects to. This allows Sinco to spin up a new browser tab, go to certain -websites, click buttons and so much more, all programatically. All Sinco does is -runs a subprocess for Chrome, so you do not need to worry about it creating or -running any other processes. +Sinco is a very lightweight browser automation and testing tool to act fast, +simple, and get the job done. What this means is, Sinco runs a subprocess for +Chrome, and will communicate to the process via the Chrome Devtools Protocol, as +the subprocess opens a WebSocket server that Sinco connects to. This allows +Sinco to spin up a new browser tab, go to certain websites, click buttons and so +much more, all programatically. All Sinco does is runs a subprocess for Chrome, +so you do not need to worry about it creating or running any other processes. Sinco is used to run or test actions of a page in the browser. Similar to unit and integration tests, Sinco can be used for "browser" tests. @@ -39,7 +39,10 @@ Its maintainers have taken concepts from the following ... Developer UX Approachability Test-driven development Documentation-driven development Transparency -We've tried to keep Sinco as minimal as possible, only exposing methods that will make it easier to action upon a page. For example, setting files on a file input. This is not a simple action so we provide a method to do this that ends up sending multiple websocket messages. +We've tried to keep Sinco as minimal as possible, only exposing methods that +will make it easier to action upon a page. For example, setting files on a file +input. This is not a simple action so we provide a method to do this that ends +up sending multiple websocket messages. ## Table of Contents From 204f1a8a1a54cec048459137ad474264c73e5fda Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 25 May 2024 10:14:29 +0100 Subject: [PATCH 34/34] update some deps --- deps.ts | 3 +-- tests/deps.ts | 5 +++-- tests/integration/csrf_protected_pages_test.ts | 2 +- tests/integration/manipulate_page_test.ts | 2 +- tests/unit/client_test.ts | 3 ++- tests/unit/page_test.ts | 3 +-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/deps.ts b/deps.ts index 46aee38..b64ea8a 100644 --- a/deps.ts +++ b/deps.ts @@ -1,5 +1,4 @@ -import type { Protocol } from "https://unpkg.com/devtools-protocol@0.0.979918/types/protocol.d.ts"; +import type { Protocol } from "https://unpkg.com/devtools-protocol@0.0.1305504/types/protocol.d.ts"; export { Protocol }; -export { assertEquals } from "https://deno.land/std@0.139.0/testing/asserts.ts"; export { deferred } from "https://deno.land/std@0.139.0/async/deferred.ts"; export type { Deferred } from "https://deno.land/std@0.139.0/async/deferred.ts"; diff --git a/tests/deps.ts b/tests/deps.ts index 13a6800..7538eb5 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -1,3 +1,4 @@ export * as Drash from "https://deno.land/x/drash@v2.5.4/mod.ts"; -export { resolve } from "https://deno.land/std@0.136.0/path/mod.ts"; -export { delay } from "https://deno.land/std@0.126.0/async/delay.ts"; +export { resolve } from "https://deno.land/std@0.224.0/path/mod.ts"; +export { delay } from "https://deno.land/std@0.224.0/async/delay.ts"; +export { assertEquals } from "https://deno.land/std@0.224.0/assert/assert_equals.ts"; diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index 1a2e586..2d1072f 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "../../deps.ts"; +import { assertEquals } from "../deps.ts"; /** * Other ways you can achieve this are: * diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index 5cac636..1c230e0 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "../../deps.ts"; +import { assertEquals } from "../deps.ts"; import { Client } from "../../mod.ts"; Deno.test( diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 10ac5df..7070b6e 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,4 +1,5 @@ -import { assertEquals, deferred } from "../../deps.ts"; +import { assertEquals } from "../deps.ts"; +import { deferred } from "../../deps.ts"; import { Client } from "../../mod.ts"; Deno.test("create()", async (t) => { diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 68d55b6..10e276f 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,7 +1,6 @@ import { Client } from "../../mod.ts"; -import { assertEquals } from "../../deps.ts"; import { server } from "../server.ts"; -import { resolve } from "../deps.ts"; +import { assertEquals, resolve } from "../deps.ts"; Deno.test("screenshot()", async (t) => { await t.step( "Takes a Screenshot",