From f704ade4c3cf87ad8c8aa244ade11fcd25697990 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Thu, 4 Nov 2021 21:00:50 +0000 Subject: [PATCH 01/22] new api? --- src/client.ts | 17 +++++++++++++---- src/element.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 src/element.ts diff --git a/src/client.ts b/src/client.ts index 9028b04f..48658eac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import { assertEquals, Deferred, deferred, readLines } from "../deps.ts"; import { existsSync, generateTimestamp } from "./utility.ts"; +import { Element } from "./element.ts" export interface BuildOptions { debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 @@ -183,6 +184,14 @@ export class Client { } } + public querySelector(selector: string) { + return new Element('document.querySelector', selector, this.socket, this.browser_process, this.browser, this.firefox_profile_path) + } + + public $x(selector: string) { + return new Element('$x', selector, this.socket, this.browser_process, this.browser, this.firefox_profile_path) + } + /** * Clicks a button with the given selector * @@ -494,7 +503,7 @@ export class Client { * @param selector - The selector for the element to capture * @returns ViewPort object - Which contains the dimensions of the element captured */ - private async getViewport(selector: string): Promise { + protected async getViewport(selector: string): Promise { const res = await this.sendWebSocketMessage("Runtime.evaluate", { expression: `JSON.stringify(document.querySelector('${selector}').getBoundingClientRect())`, @@ -528,7 +537,7 @@ export class Client { }; } - private handleSocketMessage(message: MessageResponse | NotificationResponse) { + protected handleSocketMessage(message: MessageResponse | NotificationResponse) { if ("id" in message) { // message response const resolvable = this.resolvables.get(message.id); if (resolvable) { @@ -562,7 +571,7 @@ export class Client { * * @returns */ - private async sendWebSocketMessage( + protected async sendWebSocketMessage( method: string, params?: { [key: string]: unknown }, // because we return a packet @@ -591,7 +600,7 @@ export class Client { * @param result - The DOM result response, after writing to stdin and getting by stdout of the process * @param commandSent - The command sent to trigger the result */ - private checkForErrorResult(result: DOMOutput, commandSent: string): void { + protected checkForErrorResult(result: DOMOutput, commandSent: string): void { // Is an error if (result.exceptionDetails) { // Error with the sent command, maybe there is a syntax error const exceptionDetail = result.exceptionDetails; diff --git a/src/element.ts b/src/element.ts new file mode 100644 index 00000000..e68c9887 --- /dev/null +++ b/src/element.ts @@ -0,0 +1,29 @@ +import { Client } from "./client.ts" +export class Element extends Client { + public selector: string + public method: string + constructor(method: string, selector: string, socket: WebSocket, process: Deno.Process, browser: "chrome" | "firefox", firefoxPath?: string) { + super(socket, process, browser, firefoxPath) + this.selector = selector + this.method = method + } + + private formatQuery() { + let cmd = `${this.method}('${this.selector}')` + if (this.method === '$x'){ // todo problem is, $x rerturns an array, eg [h1] as opposed to queryselector: h1 + cmd += '[0]' + } + return cmd + } + + public async value(newValue?: string) { + let cmd = this.formatQuery() + cmd += '.value' + if (newValue) { + cmd += ` = '${newValue}` + } + return await this.evaluatePage(cmd) + } + + // todo add most methods from client here +} \ No newline at end of file From 2d478eec0bb4ddcc10838ab3ded3b603b4ded051 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 5 Dec 2021 16:32:29 +0000 Subject: [PATCH 02/22] wip for adding onto new api --- src/client.ts | 105 ++++++------------------------------------------- src/element.ts | 43 ++++++++++---------- 2 files changed, 34 insertions(+), 114 deletions(-) diff --git a/src/client.ts b/src/client.ts index 48658eac..af2a437d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -184,38 +184,20 @@ export class Client { } } - public querySelector(selector: string) { - return new Element('document.querySelector', selector, this.socket, this.browser_process, this.browser, this.firefox_profile_path) + public async querySelector(selector: string) { + const result = await this.evaluatePage(`document.querySelector('${selector}')`) + console.log(result) + if (result === null) { + console.log('dont exist') + // todo call done with erro cause selecotor doesnt exist + } + return new Element('document.querySelector', selector, this) } public $x(selector: string) { - return new Element('$x', selector, this.socket, this.browser_process, this.browser, this.firefox_profile_path) - } - - /** - * Clicks a button with the given selector - * - * await this.click("#username"); - * await this.click('button[type="submit"]') - * - * @param selector - The tag name, id or class - */ - public async click(selector: string): Promise { - const command = `document.querySelector('${selector}').click()`; - const result = await this.sendWebSocketMessage("Runtime.evaluate", { - expression: command, - }) as { - // If all went ok and an elem was clicked - result: { - type: "undefined"; - }; - } | { // else a other error, eg no elem exists with the selector, or `selector` is `">>"` - result: Exception; - exceptionDetails: ExceptionDetails; - }; - if ("exceptionDetails" in result) { - this.checkForErrorResult(result, command); - } + throw new Error('Client#$x not impelemented') + // todo check the element exists first + //return new Element('$x', selector, this) } /** @@ -229,6 +211,7 @@ export class Client { if (typeof pageCommand === "string") { const result = await this.sendWebSocketMessage("Runtime.evaluate", { expression: pageCommand, + includeCommandLineAPI: true, }); return result.result.value; } @@ -266,42 +249,6 @@ export class Client { this.notification_resolvables.delete(method); } - /** - * Gets the text for the given selector - * Must be an input element - * - * @param selector - eg input[type="submit"] or #submit - * - * @throws When: - * - Error with the element (using selector) - * - * @returns The text inside the selector, eg could be "" or "Edward" - */ - public async getInputValue(selector: string): Promise { - const command = `document.querySelector('${selector}').value`; - const res = await this.sendWebSocketMessage("Runtime.evaluate", { - expression: command, - }) as { - result: { - type: "undefined" | "string"; - value?: string; - }; - } | { // Present if we get a `cannot read property 'value' of null`, eg if `selector` is `input[name="fff']` - result: Exception; - exceptionDetails: ExceptionDetails; - }; - if ("exceptionDetails" in res) { - this.checkForErrorResult(res, command); - } - const type = (res as DOMOutput).result.type; - if (type === "undefined") { // not an input elem - return "undefined"; - } - // Tried and tested, value and type are a string and `res.result.value` definitely exists at this stage - const value = (res.result as { value: string }).value; - return value || ""; - } - /** * Close/stop the sub process, and close the ws connection. Must be called when finished with all your testing * @@ -387,34 +334,6 @@ export class Client { } } - /** - * Type into an input element, by the given selector - * - * - * - * await this.type('input[name="city"]', "Stockholm") - * - * @param selector - The value for the name attribute of the input to type into - * @param value - The value to set the input to - */ - public async type(selector: string, value: string): Promise { - const command = `document.querySelector('${selector}').value = "${value}"`; - const res = await this.sendWebSocketMessage("Runtime.evaluate", { - expression: command, - }) as { - result: Exception; - exceptionDetails: ExceptionDetails; - } | { - result: { - type: string; - value: string; - }; - }; - if ("exceptionDetails" in res) { - this.checkForErrorResult(res, command); - } - } - /** * 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 diff --git a/src/element.ts b/src/element.ts index e68c9887..0197fb8d 100644 --- a/src/element.ts +++ b/src/element.ts @@ -1,29 +1,30 @@ import { Client } from "./client.ts" -export class Element extends Client { +export class Element { public selector: string - public method: string - constructor(method: string, selector: string, socket: WebSocket, process: Deno.Process, browser: "chrome" | "firefox", firefoxPath?: string) { - super(socket, process, browser, firefoxPath) + public method: "document.querySelector" | "$x" + private client: Client + public value: string|null = null + + + constructor(method: "document.querySelector" | "$x", selector: string, client: Client) { + this.client = client this.selector = selector this.method = method + const self = this; + Object.defineProperty(this, 'value', { + async set(value: string) { + await self.client.evaluatePage(`${self.method}('${self.selector}').value = '${value}'`) + }, + async get() { + const value = await self.client.evaluatePage(`${self.method}('${self.selector}').value`) + return value + }, + configurable: true, + enumerable: true + }) } - private formatQuery() { - let cmd = `${this.method}('${this.selector}')` - if (this.method === '$x'){ // todo problem is, $x rerturns an array, eg [h1] as opposed to queryselector: h1 - cmd += '[0]' - } - return cmd - } - - public async value(newValue?: string) { - let cmd = this.formatQuery() - cmd += '.value' - if (newValue) { - cmd += ` = '${newValue}` - } - return await this.evaluatePage(cmd) + public async click() { + await this.client.evaluatePage(`${this.method}('${this.selector}').click()`) } - - // todo add most methods from client here } \ No newline at end of file From 40b8b71d90e8fb9a810c75bdac88efea9e4a1705 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 5 Dec 2021 22:17:40 +0000 Subject: [PATCH 03/22] fix typo --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index ed22ec96..b595f9b6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -109,7 +109,7 @@ export class Client { */ public async assertSee(text: string): Promise { const command = `document.body.innerText.indexOf('${text}') >= 0`; - const exists = await this.sendWebSocketMessage(command); + const exists = await this.evaluatePage(command); if (exists !== true) { // We know it's going to fail, so before an assertion error is thrown, cleanupup await this.done(); } From 51414f4d165a88237171c64bad01192c1f6e8d69 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 5 Dec 2021 22:36:07 +0000 Subject: [PATCH 04/22] fix ci --- .github/workflows/master.yml | 4 +--- examples/a.ts | 16 ---------------- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 examples/a.ts diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 0ba029be..32b029e2 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -44,9 +44,7 @@ jobs: - name: Run Unit Tests run: | - deno test -A --config tsconfig.json tests/unit/chrome_client_test.ts - deno test -A --config tsconfig.json tests/unit/firefox_client_test.ts - deno test -A --config tsconfig.json tests/unit/mod_test.ts + deno test -A --config tsconfig.json tests/unit linter: # Only one OS is required since fmt is cross platform diff --git a/examples/a.ts b/examples/a.ts deleted file mode 100644 index ab7ddb16..00000000 --- a/examples/a.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ChromeClient } from "../mod.ts"; - -const Sinco = await ChromeClient.build(); -await Sinco.goTo("https://drash.land"); -//await Sinco.evaluatePage(`document.querySelector('a').getBoundingClientRect()`) -await Sinco.evaluatePage(() => { - return window.location.href; -}); -await Sinco.goTo("https://deno.land"); -//await Sinco.evaluatePage(`document.querySelector('a').getBoundingClientRect()`) -console.log( - await Sinco.evaluatePage(() => { - return window.location.href; - }), -); -await Sinco.done(); From d7b9e49a73e6e3d5aa736f7518d642577de3703d Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 5 Dec 2021 22:58:02 +0000 Subject: [PATCH 05/22] fix tests --- .github/workflows/master.yml | 4 +- src/client.ts | 2 - src/element.ts | 5 +- tests/browser_list.ts | 7 --- tests/unit/client_test.ts | 11 ++--- tests/unit/element_test.ts | 92 ++++++++++++++++++------------------ tests/unit/mod_test.ts | 26 +++++----- 7 files changed, 66 insertions(+), 81 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 32b029e2..b70ee0c6 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -19,9 +19,7 @@ jobs: cd tests/integration/docker_test docker-compose up -d docker exec drivers deno test -A --config tsconfig.json tests/integration - docker exec drivers deno test -A --config tsconfig.json tests/unit/chrome_client_test.ts - docker exec drivers deno test -A --config tsconfig.json tests/unit/firefox_client_test.ts - docker exec drivers deno test -A --config tsconfig.json tests/unit/mod_test.ts + docker exec drivers deno test -A --config tsconfig.json tests/unit tests: diff --git a/src/client.ts b/src/client.ts index b595f9b6..d80619ca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -147,7 +147,6 @@ export class Client { `document.querySelector('${selector}')`, ); if (result === null) { - console.log("dont exist"); await this.done( 'The selector "' + selector + '" does not exist inside the DOM', ); @@ -303,7 +302,6 @@ export class Client { value, url, }); - console.log(res); if ("success" in res === false && "message" in res) { //await this.done(res.message); } diff --git a/src/element.ts b/src/element.ts index f88437fc..4fb31f91 100644 --- a/src/element.ts +++ b/src/element.ts @@ -14,16 +14,17 @@ export class Element { this.client = client; this.selector = selector; this.method = method; + // deno-lint-ignore no-this-alias const self = this; Object.defineProperty(this, "value", { async set(value: string) { await self.client.evaluatePage( - `${self.method}('${self.selector}').value = '${value}'`, + `${method}('${selector}').value = '${value}'`, ); }, async get() { const value = await self.client.evaluatePage( - `${self.method}('${self.selector}').value`, + `${method}('${selector}').value`, ); return value; }, diff --git a/tests/browser_list.ts b/tests/browser_list.ts index b28ec91f..a112c904 100644 --- a/tests/browser_list.ts +++ b/tests/browser_list.ts @@ -11,11 +11,4 @@ export const browserList: Array<{ 'NS_ERROR_UNKNOWN_HOST: Error for navigating to page "https://hellaDOCSWOWThispagesurelycantexist.biscuit"', }, }, - { - name: "firefox", - errors: { - page_not_exist_message: - 'net::ERR_NAME_NOT_RESOLVED: Error for navigating to page "https://hellaDOCSWOWThispagesurelycantexist.biscuit"', - }, - }, ]; diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 75e3a68b..57db425b 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -18,9 +18,8 @@ Rhum.testPlan("tests/unit/client.ts", () => { errored: false, msg: "", }; - const elem = await Sinco.querySelector("hkkkjgjkgk"); try { - await elem.click(); + await Sinco.querySelector("hkkkjgjkgk"); } catch (err) { error.errored = true; error.msg = err.message; @@ -28,7 +27,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { await Sinco.done(); Rhum.asserts.assertEquals(error, { errored: true, - msg: `todo`, + msg: `The selector "hkkkjgjkgk" does not exist inside the DOM`, }); }, ); @@ -41,9 +40,8 @@ Rhum.testPlan("tests/unit/client.ts", () => { errored: false, msg: "", }; - const elem = await Sinco.querySelector("a#dont-exist"); try { - await elem.click(); + await Sinco.querySelector("a#dont-exist"); } catch (err) { error.errored = true; error.msg = err.message; @@ -51,8 +49,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { await Sinco.done(); Rhum.asserts.assertEquals(error, { errored: true, - msg: - `TypeError: Cannot read properties of null (reading 'click')\n at :1:39: "document.querySelector('a#dont-exist').click()"`, + msg: `The selector "hkkkjgjkgk" does not exist inside the DOM`, }); }, ); diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index babf0e7c..1e869515 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -2,55 +2,53 @@ import { buildFor } from "../../mod.ts"; import { assertEquals } from "../../deps.ts"; import { browserList } from "../browser_list.ts"; -Deno.test("tests/unit/element_test.ts", () => { - for (const browserItem of browserList) { - Deno.test("click() | It should allow clicking of elements", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); - const elem = await Sinco.querySelector('a[href="/roadmap"]'); - await elem.click(); - await Sinco.waitForPageChange(); - await Sinco.assertSee("Roadmap"); - await Sinco.done(); - }); +for (const browserItem of browserList) { + Deno.test("tests/unit/element_test.ts | click() | It should allow clicking of elements", async () => { + const Sinco = await buildFor(browserItem.name); + await Sinco.location("https://chromestatus.com"); + const elem = await Sinco.querySelector('a[href="/roadmap"]'); + await elem.click(); + await Sinco.waitForPageChange(); + await Sinco.assertSee("Roadmap"); + await Sinco.done(); + }); - Deno.test("value | It should get the value for the given input element", async () => { + Deno.test("tests/unit/element_test.ts | value | It should get the value for the given input element", async () => { + const Sinco = await buildFor(browserItem.name); + await Sinco.location("https://chromestatus.com"); + const elem = await Sinco.querySelector('input[placeholder="Filter"]'); + elem.value = "hello world"; + const val = await elem.value; + assertEquals(val, "hello world"); + await Sinco.done(); + }); + Deno.test( + "tests/unit/element_test.ts | value | Should return empty when element is not an input element", + async () => { const Sinco = await buildFor(browserItem.name); await Sinco.location("https://chromestatus.com"); - const elem = await Sinco.querySelector('input[placeholder="Filter"]'); - elem.value = "hello world"; - const val = await elem.value; - assertEquals(val, "hello world"); + let errMsg = ""; + const elem = await Sinco.querySelector("div"); + try { + await elem.value; + } catch (e) { + errMsg = e.message; + } await Sinco.done(); - }); - Deno.test( - "value | Should return undefined when element is not an input element", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); - let errMsg = ""; - const elem = await Sinco.querySelector('a[href="/roadmap"]'); - try { - await elem.value; - } catch (e) { - errMsg = e.message; - } - await Sinco.done(); - assertEquals( - errMsg, - 'a[href="/roadmap"] is either not an input element, or does not exist', - ); - }, - ); + assertEquals( + errMsg, + "", + ); + }, + ); - Deno.test("value() | It should set the value of the element", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); - const elem = await Sinco.querySelector('input[placeholder="Filter"]'); - elem.value = "hello world"; - const val = await elem.value; - await Sinco.done(); - assertEquals(val, "hello world"); - }); - } -}); + Deno.test("tests/unit/element_test.ts | value() | It should set the value of the element", async () => { + const Sinco = await buildFor(browserItem.name); + await Sinco.location("https://chromestatus.com"); + const elem = await Sinco.querySelector('input[placeholder="Filter"]'); + elem.value = "hello world"; + const val = await elem.value; + await Sinco.done(); + assertEquals(val, "hello world"); + }); +} diff --git a/tests/unit/mod_test.ts b/tests/unit/mod_test.ts index 804f90a5..38fabf05 100644 --- a/tests/unit/mod_test.ts +++ b/tests/unit/mod_test.ts @@ -1,21 +1,21 @@ import { Rhum } from "../deps.ts"; import { buildFor } from "../../mod.ts"; +import { browserList } from "../browser_list.ts"; Rhum.testPlan("tests/unit/mod_test.ts", () => { - Rhum.testSuite("buildFor()", () => { - Rhum.testCase("Builds for firefox correctly", async () => { - const Sinco = await buildFor("firefox"); - await Sinco.location("https://drash.land"); // Go to this page - await Sinco.assertUrlIs("https://drash.land"); - await Sinco.done(); + for (const browserItem of browserList) { + Rhum.testSuite("buildFor()", () => { + Rhum.testCase( + "Builds for " + browserItem.name + " correctly", + async () => { + const Sinco = await buildFor(browserItem.name); + await Sinco.location("https://drash.land"); // Go to this page + await Sinco.assertUrlIs("https://drash.land/"); + await Sinco.done(); + }, + ); }); - Rhum.testCase("Builds for chrome correctly", async () => { - const Sinco = await buildFor("chrome"); - await Sinco.location("https://drash.land"); // Go to this page - await Sinco.assertUrlIs("https://drash.land"); - await Sinco.done(); - }); - }); + } }); Rhum.run(); From f0ae0fb45abf4167ba71ae069a8a1273d0024e4d Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 5 Dec 2021 23:00:30 +0000 Subject: [PATCH 06/22] fix tests --- tests/unit/client_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 57db425b..a74ec9f3 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -49,7 +49,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { await Sinco.done(); Rhum.asserts.assertEquals(error, { errored: true, - msg: `The selector "hkkkjgjkgk" does not exist inside the DOM`, + msg: `The selector "a#dont-exist" does not exist inside the DOM`, }); }, ); From ba7baee7cd81695a90d65b189c7501ac5784d5c1 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Tue, 7 Dec 2021 17:31:42 +0000 Subject: [PATCH 07/22] add baseline for new page class --- src/client.ts | 26 +- src/element.ts | 10 +- src/page.ts | 243 ++++++++++++++++++ .../assert_methods_clean_up_on_fail_test.ts | 4 +- tests/integration/clicking_elements_test.ts | 2 +- .../integration/csrf_protected_pages_test.ts | 4 +- tests/integration/custom_assertions_test.ts | 2 +- .../get_and_set_input_value_test.ts | 2 +- tests/integration/getting_started_test.ts | 2 +- tests/integration/manipulate_page_test.ts | 4 +- tests/integration/screenshots_test.ts | 2 +- tests/integration/visit_pages_test.ts | 2 +- tests/integration/waiting_test.ts | 2 +- tests/unit/client_test.ts | 36 +-- tests/unit/element_test.ts | 8 +- tests/unit/mod_test.ts | 2 +- tests/unit/page_test.ts | 7 + 17 files changed, 308 insertions(+), 50 deletions(-) create mode 100644 src/page.ts create mode 100644 tests/unit/page_test.ts diff --git a/src/client.ts b/src/client.ts index d80619ca..028b0ec1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7,6 +7,7 @@ import { } from "../deps.ts"; import { existsSync, generateTimestamp } from "./utility.ts"; import { Element } from "./element.ts"; +import { Page } from "./page.ts"; export interface BuildOptions { debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 @@ -41,12 +42,12 @@ export class Client { */ private next_message_id = 1; - private frame_id: string; + protected frame_id: string; /** * To keep hold of promises waiting for a notification from the websocket */ - private notification_resolvables: Map> = new Map(); + protected notification_resolvables: Map> = new Map(); /** * Track if we've closed the sub process, so we dont try close it when it already has been @@ -94,6 +95,7 @@ export class Client { * * @param expectedUrl - The expected url, eg `https://google.com/hello` */ + // todo :: no need for this when we use page as user can do assertEquals(..., await page.location) public async assertUrlIs(expectedUrl: string): Promise { const actualUrl = await this.evaluatePage(`window.location.href`); if (actualUrl !== expectedUrl) { // Before we know the test will fail, close everything @@ -121,7 +123,7 @@ export class Client { * * @param urlToVisit - The page to go to */ - public async location(urlToVisit: string): Promise { + public async goTo(urlToVisit: string): Promise { const method = "Page.loadEventFired"; this.notification_resolvables.set(method, deferred()); const notificationPromise = this.notification_resolvables.get(method); @@ -140,6 +142,13 @@ export class Client { `${res.errorText}: Error for navigating to page "${urlToVisit}"`, ); } + return new Page( + this.socket, + this.browser_process, + this.browser, + this.frame_id, + this.firefox_profile_path, + ); } public async querySelector(selector: string) { @@ -338,8 +347,7 @@ export class Client { : undefined; if (options?.quality && Math.abs(options.quality) > 100 && ext == "jpeg") { - await this.done(); - throw new Error("A quality value greater than 100 is not allowed."); + await this.done("A quality value greater than 100 is not allowed."); } //Quality should defined only if format is jpeg @@ -397,7 +405,9 @@ export class Client { * @param selector - The selector for the element to capture * @returns ViewPort object - Which contains the dimensions of the element captured */ - private async getViewport(selector: string): Promise { + protected async getViewport( + selector: string, + ): Promise { const res = await this.evaluatePage( `JSON.stringify(document.querySelector('${selector}').getBoundingClientRect())`, ); @@ -447,7 +457,7 @@ export class Client { * * @returns */ - private async sendWebSocketMessage( + protected async sendWebSocketMessage( method: string, params?: RequestType, ): Promise { @@ -474,7 +484,7 @@ export class Client { * @param result - The DOM result response, after writing to stdin and getting by stdout of the process * @param commandSent - The command sent to trigger the result */ - private async checkForErrorResult( + protected async checkForErrorResult( result: Protocol.Runtime.AwaitPromiseResponse, commandSent: string, ): Promise { diff --git a/src/element.ts b/src/element.ts index 4fb31f91..bf6da041 100644 --- a/src/element.ts +++ b/src/element.ts @@ -14,16 +14,14 @@ export class Element { this.client = client; this.selector = selector; this.method = method; - // deno-lint-ignore no-this-alias - const self = this; Object.defineProperty(this, "value", { - async set(value: string) { - await self.client.evaluatePage( + async set(value: string): Promise { + await client.evaluatePage( `${method}('${selector}').value = '${value}'`, ); }, - async get() { - const value = await self.client.evaluatePage( + async get(): Promise { + const value = await client.evaluatePage( `${method}('${selector}').value`, ); return value; diff --git a/src/page.ts b/src/page.ts new file mode 100644 index 00000000..4c626a42 --- /dev/null +++ b/src/page.ts @@ -0,0 +1,243 @@ +import { Client } from "./client.ts"; +import { Element } from "./element.ts"; +import { assertEquals, deferred, Protocol } from "../deps.ts"; +import { existsSync, generateTimestamp } from "./utility.ts"; + +export class Page extends Client { + public location = ""; + public cookie = ""; + + constructor( + socket: WebSocket, + browserProcess: Deno.Process, + browser: "firefox" | "chrome", + frameId: string, + firefoxProfilePath?: string, + ) { + super(socket, browserProcess, browser, frameId, firefoxProfilePath); + // deno-lint-ignore no-this-alias + const self = this; + Object.defineProperty(this, "location", { + async set(value: string): Promise { + await self.goTo(value); + }, + async get(): Promise { + const value = await self.evaluatePage( + `window.location.href`, + ); + return value; + }, + configurable: true, + enumerable: true, + }); + Object.defineProperty(this, "cookie", { + async set(data: { + name: string; + value: string; + url: string; + }): Promise { + await self.sendWebSocketMessage< + Protocol.Network.SetCookieRequest, + Protocol.Network.SetCookieResponse + >("Network.setCookie", { + name: data.name, + value: data.value, + url: data.url, + }); + }, + async get(): Promise { + const cookies = await self.sendWebSocketMessage< + Protocol.Network.GetCookiesRequest, + Protocol.Network.GetCookiesResponse + >("Network.getCookies", { + urls: [await self.location], + }); + return cookies; + }, + configurable: true, + enumerable: true, + }); + } + + public async querySelector(selector: string): Promise { + const result = await this.evaluatePage( + `document.querySelector('${selector}')`, + ); + if (result === null) { + await this.done( + 'The selector "' + selector + '" does not exist inside the DOM', + ); + } + return new Element("document.querySelector", selector, this); + } + + /** + * Invoke a function or string expression on the current frame. + * + * @param pageCommand - The function to be called or the line of code to execute. + * + * @returns The result of the evaluation + */ + public async evaluate( + pageCommand: (() => unknown) | string, + // As defined by the protocol, the `value` is `any` + // deno-lint-ignore no-explicit-any + ): Promise { + if (typeof pageCommand === "string") { + const result = await this.sendWebSocketMessage< + Protocol.Runtime.EvaluateRequest, + Protocol.Runtime.EvaluateResponse + >("Runtime.evaluate", { + expression: pageCommand, + includeCommandLineAPI: true, // sopprts things like $x + }); + await this.checkForErrorResult(result, pageCommand); + return result.result.value; + } + + if (typeof pageCommand === "function") { + const { executionContextId } = await this.sendWebSocketMessage< + Protocol.Page.CreateIsolatedWorldRequest, + Protocol.Page.CreateIsolatedWorldResponse + >( + "Page.createIsolatedWorld", + { + frameId: this.frame_id, + }, + ); + + const res = await this.sendWebSocketMessage< + Protocol.Runtime.CallFunctionOnRequest, + Protocol.Runtime.CallFunctionOnResponse + >( + "Runtime.callFunctionOn", + { + functionDeclaration: pageCommand.toString(), + executionContextId: executionContextId, + returnByValue: true, + awaitPromise: true, + userGesture: true, + }, + ); + await this.checkForErrorResult(res, pageCommand.toString()); + return res.result.value; + } + } + + /** + * Check if the given text exists on the dom + * + * @param text - The text to check for + */ + public async assertSee(text: string): Promise { + const command = `document.body.innerText.indexOf('${text}') >= 0`; + const exists = await this.evaluatePage(command); + if (exists !== true) { // We know it's going to fail, so before an assertion error is thrown, cleanupup + await this.done(); + } + assertEquals(exists, true); + } + + /** + * Wait for the page to change. Can be used with `click()` if clicking a button or anchor tag that redirects the user + */ + public async waitForPageChange(): Promise { + const method = "Page.loadEventFired"; + this.notification_resolvables.set(method, deferred()); + const notificationPromise = this.notification_resolvables.get(method); + await notificationPromise; + this.notification_resolvables.delete(method); + } + + /** + * 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 + * and its children as opposed to the whole page. + * + * @param path - The path of where to save the screenshot to + * @param options - options + * @param options.filename - Name to be given to the screenshot. Optional + * @param options.selector - Screenshot the given selector instead of the full page. Optional + * @param options.format - The Screenshot format(and hence extension). Allowed values are "jpeg" and "png" - Optional + * @param options.quality - The image quality from 0 to 100, default 80. Applicable only if no format provided or format is "jpeg" - Optional + */ + public async takeScreenshot( + path: string, + options?: { + selector?: string; + fileName?: string; + format?: "jpeg" | "png"; + quality?: number; + }, + ): Promise { + if (!existsSync(path)) { + await this.done(); + throw new Error(`The provided folder path - ${path} doesn't exist`); + } + const ext = options?.format ?? "jpeg"; + const clip = (options?.selector) + ? await this.getViewport(options.selector) + : undefined; + + if (options?.quality && Math.abs(options.quality) > 100 && ext == "jpeg") { + await this.done("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.sendWebSocketMessage< + Protocol.Page.CaptureScreenshotRequest, + Protocol.Page.CaptureScreenshotResponse + >( + "Page.captureScreenshot", + { + format: ext, + quality: quality, + clip: clip, + }, + ) as { + data: string; + }; + + //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.done(); + throw new Error(e.message); + } + + return fName; + } + + /** + * This method is used internally to calculate the element Viewport (Dimensions) + * executes getBoundingClientRect of the obtained element + * @param selector - The selector for the element to capture + * @returns ViewPort object - Which contains the dimensions of the element captured + */ + protected async getViewport( + selector: string, + ): Promise { + const res = await this.evaluatePage( + `JSON.stringify(document.querySelector('${selector}').getBoundingClientRect())`, + ); + const values = JSON.parse(res); + return { + x: values.x, + y: values.y, + width: values.width, + height: values.height, + scale: 2, + }; + } +} diff --git a/tests/integration/assert_methods_clean_up_on_fail_test.ts b/tests/integration/assert_methods_clean_up_on_fail_test.ts index ad09c6cc..eaa99d3d 100644 --- a/tests/integration/assert_methods_clean_up_on_fail_test.ts +++ b/tests/integration/assert_methods_clean_up_on_fail_test.ts @@ -31,7 +31,7 @@ for (const browserItem of browserList) { browserItem.name + ": Assertion methods cleanup when an assertion fails", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); await Sinco.assertUrlIs("https://chromestatus.com/features"); let gotError = false; let errMsg = ""; @@ -53,7 +53,7 @@ for (const browserItem of browserList) { ); // Now we should be able to run tests again without it hanging const Sinco2 = await buildFor(browserItem.name); - await Sinco2.location("https://chromestatus.com"); + await Sinco2.goTo("https://chromestatus.com"); await Sinco2.assertUrlIs("https://chromestatus.com/features"); try { await Sinco2.assertSee("Chrome Versions"); diff --git a/tests/integration/clicking_elements_test.ts b/tests/integration/clicking_elements_test.ts index 7eae360a..df0c9036 100644 --- a/tests/integration/clicking_elements_test.ts +++ b/tests/integration/clicking_elements_test.ts @@ -7,7 +7,7 @@ for (const browserItem of browserList) { ": Clicking elements - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); const elem = await Sinco.querySelector( 'a[href="https://discord.gg/RFsCSaHRWK"]', ); diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index e7138d91..e487064d 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -14,9 +14,9 @@ import { browserList } from "../browser_list.ts"; for (const browserItem of browserList) { Deno.test("Chrome: " + title, async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); await Sinco.setCookie("X-CSRF-TOKEN", "hi:)", "https://drash.land"); - await Sinco.location("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays + await Sinco.goTo("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays const cookieVal = await Sinco.evaluatePage(() => { return document.cookie; }); diff --git a/tests/integration/custom_assertions_test.ts b/tests/integration/custom_assertions_test.ts index 690fe5d6..396c5513 100644 --- a/tests/integration/custom_assertions_test.ts +++ b/tests/integration/custom_assertions_test.ts @@ -7,7 +7,7 @@ for (const browserItem of browserList) { ": Assertions - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); await Sinco.assertUrlIs("https://drash.land/"); await Sinco.assertSee("Develop With Confidence"); await Sinco.done(); diff --git a/tests/integration/get_and_set_input_value_test.ts b/tests/integration/get_and_set_input_value_test.ts index faf29d23..364a64c4 100644 --- a/tests/integration/get_and_set_input_value_test.ts +++ b/tests/integration/get_and_set_input_value_test.ts @@ -8,7 +8,7 @@ for (const browserItem of browserList) { ": Get and set input value - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const elem = await Sinco.querySelector('input[placeholder="Filter"]'); elem.value = "hello world"; const val = await elem.value; diff --git a/tests/integration/getting_started_test.ts b/tests/integration/getting_started_test.ts index f0c49944..fea8c969 100644 --- a/tests/integration/getting_started_test.ts +++ b/tests/integration/getting_started_test.ts @@ -7,7 +7,7 @@ for (const browserItem of browserList) { async () => { // Setup const Sinco = await buildFor(browserItem.name); // also supports firefox - await Sinco.location("https://drash.land"); // Go to this page + await Sinco.goTo("https://drash.land"); // Go to this page // Do any actions and assertions, in any order await Sinco.assertUrlIs("https://drash.land/"); diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index 6804848a..cca0ba71 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -6,7 +6,7 @@ import { browserList } from "../browser_list.ts"; for (const browserItem of browserList) { Deno.test(browserItem.name + ": Manipulate Webpage", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); const updatedBody = await Sinco.evaluatePage(() => { // deno-lint-ignore no-undef @@ -28,7 +28,7 @@ for (const browserItem of browserList) { ": Evaluating a script - Tutorial for this feature in the documentation works", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); const pageTitle = await Sinco.evaluatePage(() => { // deno-lint-ignore no-undef return document.title; diff --git a/tests/integration/screenshots_test.ts b/tests/integration/screenshots_test.ts index 66ca0471..60ae4e4c 100644 --- a/tests/integration/screenshots_test.ts +++ b/tests/integration/screenshots_test.ts @@ -8,7 +8,7 @@ for (const browserItem of browserList) { " - Tutorial for taking screenshots in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); const screenshotsFolder = "./screenshots"; Deno.mkdirSync(screenshotsFolder); // Ensure you create the directory your screenshots will be put within await Sinco.takeScreenshot(screenshotsFolder); // Will take a screenshot of the whole page, and write it to `./screenshots/dd_mm_yyyy_hh_mm_ss.jpeg` diff --git a/tests/integration/visit_pages_test.ts b/tests/integration/visit_pages_test.ts index b5e00ee7..34b890c8 100644 --- a/tests/integration/visit_pages_test.ts +++ b/tests/integration/visit_pages_test.ts @@ -7,7 +7,7 @@ for (const browserItem of browserList) { ": Visit pages - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); await Sinco.assertUrlIs("https://drash.land/"); await Sinco.done(); }, diff --git a/tests/integration/waiting_test.ts b/tests/integration/waiting_test.ts index e9e61292..23b15e09 100644 --- a/tests/integration/waiting_test.ts +++ b/tests/integration/waiting_test.ts @@ -8,7 +8,7 @@ for (const browserItem of browserList) { ": Waiting - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); await Sinco.assertUrlIs("https://drash.land/"); const elem = await Sinco.querySelector( 'a[href="https://discord.gg/RFsCSaHRWK"]', diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index a74ec9f3..85bf53dd 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -13,7 +13,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Should throw an error when selector is invalid", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const error = { errored: false, msg: "", @@ -35,7 +35,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "It should throw an error when no element exists for the selector", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const error = { errored: false, msg: "", @@ -129,13 +129,13 @@ Rhum.testPlan("tests/unit/client.ts", () => { Rhum.testSuite("assertUrlIs()", () => { Rhum.testCase("Works when an assertion is true", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com/features"); + await Sinco.goTo("https://chromestatus.com/features"); await Sinco.assertUrlIs("https://chromestatus.com/features"); await Sinco.done(); }); Rhum.testCase("Will fail when an assertion fails", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); let originalErrMsg = ""; try { await Sinco.assertUrlIs("https://hella.com"); @@ -161,7 +161,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { Rhum.testSuite("location()", () => { Rhum.testCase("Successfully navigates when url is correct", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com/roadmap"); + await Sinco.goTo("https://chromestatus.com/roadmap"); await Sinco.assertUrlIs("https://chromestatus.com/roadmap"); await Sinco.done(); }); @@ -171,7 +171,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { const Sinco = await buildFor(browserItem.name); let msg = ""; try { - await Sinco.location( + await Sinco.goTo( "https://hellaDOCSWOWThispagesurelycantexist.biscuit", ); } catch (err) { @@ -191,7 +191,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Assertion should work when text is present on page", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com/features"); + await Sinco.goTo("https://chromestatus.com/features"); await Sinco.assertSee("Chrome Platform Status"); await Sinco.done(); }, @@ -200,7 +200,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Assertion should NOT work when text is NOT present on page", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); let errorMsg = ""; try { await Sinco.assertSee("Crumpets and tea init?"); @@ -223,7 +223,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "It should evaluate function on current frame", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); + await Sinco.goTo("https://drash.land"); const pageTitle = await Sinco.evaluatePage(() => { // deno-lint-ignore no-undef return document.title; @@ -234,7 +234,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { ); Rhum.testCase("It should evaluate string on current frame", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const parentConstructor = await Sinco.evaluatePage(`1 + 2`); await Sinco.done(); Rhum.asserts.assertEquals(parentConstructor, 3); @@ -255,7 +255,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Waits for a page to change before continuing", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); await Sinco.assertUrlIs("https://chromestatus.com/features"); const elem = await Sinco.querySelector('a[href="/roadmap"]'); await elem.click(); @@ -284,7 +284,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { async () => { let msg = ""; const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); try { await Sinco.takeScreenshot("eieio"); } catch (error) { @@ -302,7 +302,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Takes a Screenshot with timestamp as filename if filename is not provided", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const fileName = await Sinco.takeScreenshot(ScreenshotsFolder); await Sinco.done(); Rhum.asserts.assertEquals( @@ -318,7 +318,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const fileName = await Sinco.takeScreenshot(ScreenshotsFolder, { selector: "span", quality: 50, @@ -337,7 +337,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Throws an error when format passed is jpeg(or default) and quality > than 100", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); let msg = ""; try { await Sinco.takeScreenshot(ScreenshotsFolder, { quality: 999 }); @@ -354,7 +354,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { Rhum.testCase("Saves Screenshot with Given Filename", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); await Sinco.takeScreenshot(ScreenshotsFolder, { fileName: "Happy" }); await Sinco.done(); Rhum.asserts.assertEquals( @@ -367,7 +367,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { "Saves Screenshot with given format (jpeg | png)", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const fileName = await Sinco.takeScreenshot(ScreenshotsFolder, { format: "png", }); @@ -383,7 +383,7 @@ Rhum.testPlan("tests/unit/client.ts", () => { Rhum.testCase("Saves Screenshot with all options provided", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); await Sinco.takeScreenshot(ScreenshotsFolder, { fileName: "AllOpts", selector: "span", diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index 1e869515..e25512da 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -5,7 +5,7 @@ import { browserList } from "../browser_list.ts"; for (const browserItem of browserList) { Deno.test("tests/unit/element_test.ts | click() | It should allow clicking of elements", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const elem = await Sinco.querySelector('a[href="/roadmap"]'); await elem.click(); await Sinco.waitForPageChange(); @@ -15,7 +15,7 @@ for (const browserItem of browserList) { Deno.test("tests/unit/element_test.ts | value | It should get the value for the given input element", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const elem = await Sinco.querySelector('input[placeholder="Filter"]'); elem.value = "hello world"; const val = await elem.value; @@ -26,7 +26,7 @@ for (const browserItem of browserList) { "tests/unit/element_test.ts | value | Should return empty when element is not an input element", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); let errMsg = ""; const elem = await Sinco.querySelector("div"); try { @@ -44,7 +44,7 @@ for (const browserItem of browserList) { Deno.test("tests/unit/element_test.ts | value() | It should set the value of the element", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://chromestatus.com"); + await Sinco.goTo("https://chromestatus.com"); const elem = await Sinco.querySelector('input[placeholder="Filter"]'); elem.value = "hello world"; const val = await elem.value; diff --git a/tests/unit/mod_test.ts b/tests/unit/mod_test.ts index 38fabf05..43918c97 100644 --- a/tests/unit/mod_test.ts +++ b/tests/unit/mod_test.ts @@ -9,7 +9,7 @@ Rhum.testPlan("tests/unit/mod_test.ts", () => { "Builds for " + browserItem.name + " correctly", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.location("https://drash.land"); // Go to this page + await Sinco.goTo("https://drash.land"); // Go to this page await Sinco.assertUrlIs("https://drash.land/"); await Sinco.done(); }, diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts new file mode 100644 index 00000000..4ce1fa81 --- /dev/null +++ b/tests/unit/page_test.ts @@ -0,0 +1,7 @@ +import { browserList } from "../browser_list.ts"; + +for (const browserItem of browserList) { + + + +} \ No newline at end of file From 4f6eba764e6c3045a0d459f20ac4d2b4c516399c Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Fri, 10 Dec 2021 21:57:37 +0000 Subject: [PATCH 08/22] properly finish --- mod.ts | 49 +- src/chrome_client.ts | 84 --- src/client.ts | 529 ++---------------- src/element.ts | 60 +- src/firefox_client.ts | 96 ---- src/page.ts | 293 ++++++---- src/protocol.ts | 222 ++++++++ src/types.ts | 1 + src/utility.ts | 86 +++ tests/browser_list.ts | 19 +- .../assert_methods_clean_up_on_fail_test.ts | 12 +- tests/integration/clicking_elements_test.ts | 12 +- .../integration/csrf_protected_pages_test.ts | 10 +- tests/integration/custom_assertions_test.ts | 7 +- .../get_and_set_input_value_test.ts | 8 +- tests/integration/getting_started_test.ts | 12 +- tests/integration/manipulate_page_test.ts | 14 +- tests/integration/screenshots_test.ts | 8 +- tests/integration/visit_pages_test.ts | 6 +- tests/integration/waiting_test.ts | 11 +- tests/unit/Screenshots/.gitkeep | 0 tests/unit/client_test.ts | 513 ++++------------- tests/unit/element_test.ts | 36 +- tests/unit/mod_test.ts | 21 - tests/unit/page_test.ts | 248 +++++++- 25 files changed, 1033 insertions(+), 1324 deletions(-) delete mode 100644 src/chrome_client.ts delete mode 100644 src/firefox_client.ts create mode 100644 src/protocol.ts create mode 100644 src/types.ts create mode 100644 tests/unit/Screenshots/.gitkeep delete mode 100644 tests/unit/mod_test.ts diff --git a/mod.ts b/mod.ts index 0c9dec57..f5d84f26 100644 --- a/mod.ts +++ b/mod.ts @@ -1,18 +1,43 @@ -import { ChromeClient } from "./src/chrome_client.ts"; -import { FirefoxClient } from "./src/firefox_client.ts"; -import { BuildOptions } from "./src/client.ts"; - -export { ChromeClient, FirefoxClient }; - -type Browsers = "firefox" | "chrome"; +import { BuildOptions, Client } from "./src/client.ts"; +import type { Browsers } from "./src/types.ts"; +import { getChromeArgs, getFirefoxArgs } from "./src/utility.ts"; export async function buildFor( browser: Browsers, - options?: BuildOptions, -): Promise { - if (browser === "firefox") { - return await FirefoxClient.build(options); + options: BuildOptions = { + hostname: "localhost", + debuggerPort: 9292, + binaryPath: undefined, + }, +): Promise { + 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, + }, + browser, + undefined, + ); } else { - return await ChromeClient.build(options); + 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/chrome_client.ts b/src/chrome_client.ts deleted file mode 100644 index 5938931d..00000000 --- a/src/chrome_client.ts +++ /dev/null @@ -1,84 +0,0 @@ -// https://peter.sh/experiments/chromium-command-line-switches/ -// https://chromedevtools.github.io/devtools-protocol/tot/Network/ - -import { BuildOptions, Client } from "./client.ts"; -import { existsSync } from "./utility.ts"; - -/** - * Gets the full path to the chrome executable on the users filesystem - * - * @returns The path to chrome - */ -export function getChromePath(): string { - const paths = { - // deno-lint-ignore camelcase - windows_chrome_exe: - "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", - // deno-lint-ignore camelcase - windows_chrome_exe_x86: - "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", - darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - linux: "/usr/bin/google-chrome", - }; - let chromePath = ""; - switch (Deno.build.os) { - case "darwin": - chromePath = paths.darwin; - break; - case "windows": - if (existsSync(paths.windows_chrome_exe)) { - chromePath = paths.windows_chrome_exe; - break; - } - if (existsSync(paths.windows_chrome_exe_x86)) { - chromePath = paths.windows_chrome_exe_x86; - break; - } - - throw new Error( - "Cannot find path for chrome in windows. Submit an issue if you encounter this error", - ); - case "linux": - chromePath = paths.linux; - break; - } - return chromePath; -} - -export class ChromeClient extends Client { - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Entry point for creating a headless chrome browser. - * - * @param buildOptions - Any extra options you wish to provide to customise how the headless browser sub process is ran - * - hostname: Defaults to localhost - * - port: Defaults to 9293 - * - * @returns An instance of ChromeClient, that is now ready. - */ - public static async build(options: BuildOptions = {}): Promise { - // Setup build options - if (!options.debuggerPort) { - options.debuggerPort = 9292; - } - if (!options.hostname) { - options.hostname = "localhost"; - } - - // Create the sub process - const args = [ - options.binaryPath || getChromePath(), - "--headless", - "--remote-debugging-port=" + options.debuggerPort, - "--disable-gpu", - "--no-sandbox", - ]; - return await Client.create(args, { - hostname: options.hostname, - port: options.debuggerPort, - }, "chrome"); - } -} diff --git a/src/client.ts b/src/client.ts index 028b0ec1..54189a3a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,13 +1,7 @@ -import { - assertEquals, - Deferred, - deferred, - Protocol, - readLines, -} from "../deps.ts"; -import { existsSync, generateTimestamp } from "./utility.ts"; -import { Element } from "./element.ts"; +import { Protocol as ProtocolClass } from "./protocol.ts"; +import { deferred, Protocol as ProtocolTypes, readLines } from "../deps.ts"; import { Page } from "./page.ts"; +import type { Browsers } from "./types.ts"; export interface BuildOptions { debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 @@ -15,121 +9,37 @@ export interface BuildOptions { binaryPath?: string; //The Full Path to the browser binary. If using an alternative chromium based browser, this field is necessary. } -interface MessageResponse { // For when we send an event to get one back, eg running a JS expression - id: number; - result?: Record; // Present on success, OR for example if we use goTo and the url doesnt exist (in firefox) - error?: unknown; // Present on error -} - -interface NotificationResponse { // Not entirely sure when, but when we send the `Network.enable` method - method: string; - params: unknown; -} - export class Client { - /** - * The sub process that runs headless chrome - */ - private readonly browser_process: Deno.Process; - - /** - * Our web socket connection to the remote debugging port - */ - private readonly socket: WebSocket; - - /** - * A counter that acts as the message id we use to send as part of the event data through the websocket - */ - private next_message_id = 1; - - protected frame_id: string; - - /** - * To keep hold of promises waiting for a notification from the websocket - */ - protected notification_resolvables: Map> = new Map(); - - /** - * Track if we've closed the sub process, so we dont try close it when it already has been - */ - private browser_process_closed = false; - - /** - * To keep hold of our promises waiting for messages from the websocket - */ - private resolvables: Map> = new Map(); - - private browser: "firefox" | "chrome"; - - /** - * Only if the browser is firefox, is this present. - * This is the path to the directory that firefox uses - * to write a profile - */ - private firefox_profile_path: string | undefined = undefined; - + #protocol: ProtocolClass; constructor( - socket: WebSocket, - browserProcess: Deno.Process, - browser: "firefox" | "chrome", - frameId: string, - firefoxProfilePath?: string, + protocol: ProtocolClass, ) { - this.browser = browser; - this.socket = socket; - this.browser_process = browserProcess; - this.firefox_profile_path = firefoxProfilePath; - this.frame_id = frameId; - // Register on message listener - this.socket.onmessage = (msg) => { - const data = JSON.parse(msg.data); - if (data.method === "Page.frameStartedLoading") { - this.frame_id = data.params.frameId; - } - this.handleSocketMessage(data); - }; + this.#protocol = protocol; } /** - * Asserts a given url matches the current - * - * @param expectedUrl - The expected url, eg `https://google.com/hello` - */ - // todo :: no need for this when we use page as user can do assertEquals(..., await page.location) - public async assertUrlIs(expectedUrl: string): Promise { - const actualUrl = await this.evaluatePage(`window.location.href`); - if (actualUrl !== expectedUrl) { // Before we know the test will fail, close everything - await this.done(); - } - assertEquals(actualUrl, expectedUrl); - } - - /** - * Check if the given text exists on the dom - * - * @param text - The text to check for + * Close/stop the sub process, and close the ws connection. Must be called when finished with all your testing */ - public async assertSee(text: string): Promise { - const command = `document.body.innerText.indexOf('${text}') >= 0`; - const exists = await this.evaluatePage(command); - if (exists !== true) { // We know it's going to fail, so before an assertion error is thrown, cleanupup - await this.done(); - } - assertEquals(exists, true); + public async done() { + await this.#protocol.done(); } /** * Go to a specific page * * @param urlToVisit - The page to go to + * + * @returns A page instance, with methods to help you directly interact with the page */ public async goTo(urlToVisit: string): Promise { const method = "Page.loadEventFired"; - this.notification_resolvables.set(method, deferred()); - const notificationPromise = this.notification_resolvables.get(method); - const res = await this.sendWebSocketMessage< - Protocol.Page.NavigateRequest, - Protocol.Page.NavigateResponse + this.#protocol.notification_resolvables.set(method, deferred()); + const notificationPromise = this.#protocol.notification_resolvables.get( + method, + ); + const res = await this.#protocol.sendWebSocketMessage< + ProtocolTypes.Page.NavigateRequest, + ProtocolTypes.Page.NavigateResponse >( "Page.navigate", { @@ -138,253 +48,13 @@ export class Client { ); await notificationPromise; if (res.errorText) { - await this.done( + await this.#protocol.done( `${res.errorText}: Error for navigating to page "${urlToVisit}"`, ); } - return new Page( - this.socket, - this.browser_process, - this.browser, - this.frame_id, - this.firefox_profile_path, - ); - } - - public async querySelector(selector: string) { - const result = await this.evaluatePage( - `document.querySelector('${selector}')`, - ); - if (result === null) { - await this.done( - 'The selector "' + selector + '" does not exist inside the DOM', - ); - } - return new Element("document.querySelector", selector, this); - } - - public $x(_selector: string) { - throw new Error("Client#$x not impelemented"); - // todo check the element exists first - //return new Element('$x', selector, this) + return new Page(this.#protocol); } - /** - * Invoke a function or string expression on the current frame. - * - * @param pageCommand - The function to be called or the line of code to execute. - * - * @returns The result of the evaluation - */ - public async evaluatePage( - pageCommand: (() => unknown) | string, - // As defined by the protocol, the `value` is `any` - // deno-lint-ignore no-explicit-any - ): Promise { - if (typeof pageCommand === "string") { - const result = await this.sendWebSocketMessage< - Protocol.Runtime.EvaluateRequest, - Protocol.Runtime.EvaluateResponse - >("Runtime.evaluate", { - expression: pageCommand, - includeCommandLineAPI: true, // sopprts things like $x - }); - await this.checkForErrorResult(result, pageCommand); - return result.result.value; - } - - if (typeof pageCommand === "function") { - const { executionContextId } = await this.sendWebSocketMessage< - Protocol.Page.CreateIsolatedWorldRequest, - Protocol.Page.CreateIsolatedWorldResponse - >( - "Page.createIsolatedWorld", - { - frameId: this.frame_id, - }, - ); - - const res = await this.sendWebSocketMessage< - Protocol.Runtime.CallFunctionOnRequest, - Protocol.Runtime.CallFunctionOnResponse - >( - "Runtime.callFunctionOn", - { - functionDeclaration: pageCommand.toString(), - executionContextId: executionContextId, - returnByValue: true, - awaitPromise: true, - userGesture: true, - }, - ); - await this.checkForErrorResult(res, pageCommand.toString()); - return res.result.value; - } - } - - /** - * Wait for the page to change. Can be used with `click()` if clicking a button or anchor tag that redirects the user - */ - public async waitForPageChange(): Promise { - const method = "Page.loadEventFired"; - this.notification_resolvables.set(method, deferred()); - const notificationPromise = this.notification_resolvables.get(method); - await notificationPromise; - this.notification_resolvables.delete(method); - } - - /** - * Close/stop the sub process, and close the ws connection. Must be called when finished with all your testing - * - * @param errMsg - If supplied, will finally throw an error with the message after closing all processes - */ - public async done(errMsg?: string): Promise { - // Say a user calls an assertion method, and then calls done(), we make sure that if - // the subprocess is already closed, dont try close it again - if (this.browser_process_closed === true) { - return; - } - const clientIsClosed = deferred(); - this.socket.onclose = () => clientIsClosed.resolve(); - // cloing subprocess will also close the ws endpoint - this.browser_process.stderr!.close(); - this.browser_process.stdout!.close(); - this.browser_process.close(); - this.browser_process_closed = true; - // 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 === "firefox" && Deno.build.os === "windows") { - const p = Deno.run({ - cmd: ["taskkill", "/F", "/IM", "firefox.exe"], - stdout: "null", - stderr: "null", - }); - await p.status(); - p.close(); - } - await clientIsClosed; // done AFTER the above conditional because the process is still running, so the client is never closed - 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 - } - } - } - if (errMsg) { - throw new Error(errMsg); - } - } - - /** - * Set a cookie for the `url`. Will be passed across 'sessions' based - * from the `url` - * - * @param name - Name of the cookie, eg X-CSRF-TOKEN - * @param value - Value to assign to the cookie name, eg "some cryptic token" - * @param url - The domain to assign the cookie to, eg "https://drash.land" - * - * @example - * ```ts - * await Sinco.setCookie("X-CSRF-TOKEN", "abc123", "https://drash.land") - * const result = await Sinco.evaluatePage(`document.cookie`) // "X-CSRF-TOKEN=abc123" - * ``` - */ - public async setCookie( - name: string, - value: string, - url: string, - ): Promise { - const res = await this.sendWebSocketMessage< - Protocol.Network.SetCookieRequest, - Protocol.Network.SetCookieResponse - >("Network.setCookie", { - name, - value, - url, - }); - if ("success" in res === false && "message" in res) { - //await this.done(res.message); - } - } - - /** - * 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 - * and its children as opposed to the whole page. - * - * @param path - The path of where to save the screenshot to - * @param options - options - * @param options.filename - Name to be given to the screenshot. Optional - * @param options.selector - Screenshot the given selector instead of the full page. Optional - * @param options.format - The Screenshot format(and hence extension). Allowed values are "jpeg" and "png" - Optional - * @param options.quality - The image quality from 0 to 100, default 80. Applicable only if no format provided or format is "jpeg" - Optional - */ - public async takeScreenshot( - path: string, - options?: { - selector?: string; - fileName?: string; - format?: "jpeg" | "png"; - quality?: number; - }, - ): Promise { - if (!existsSync(path)) { - await this.done(); - throw new Error(`The provided folder path - ${path} doesn't exist`); - } - const ext = options?.format ?? "jpeg"; - const clip = (options?.selector) - ? await this.getViewport(options.selector) - : undefined; - - if (options?.quality && Math.abs(options.quality) > 100 && ext == "jpeg") { - await this.done("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.sendWebSocketMessage< - Protocol.Page.CaptureScreenshotRequest, - Protocol.Page.CaptureScreenshotResponse - >( - "Page.captureScreenshot", - { - format: ext, - quality: quality, - clip: clip, - }, - ) as { - data: string; - }; - - //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.done(); - throw new Error(e.message); - } - - return fName; - } /** * Wait for anchor navigation. Usually used when typing into an input field */ @@ -395,124 +65,23 @@ export class Client { // delete this.notification_resolvables["Page.navigatedWithinDocument"]; // } - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - /** - * This method is used internally to calculate the element Viewport (Dimensions) - * executes getBoundingClientRect of the obtained element - * @param selector - The selector for the element to capture - * @returns ViewPort object - Which contains the dimensions of the element captured - */ - protected async getViewport( - selector: string, - ): Promise { - const res = await this.evaluatePage( - `JSON.stringify(document.querySelector('${selector}').getBoundingClientRect())`, - ); - const values = JSON.parse(res); - return { - x: values.x, - y: values.y, - width: values.width, - height: values.height, - scale: 2, - }; - } - - protected handleSocketMessage( - message: MessageResponse | NotificationResponse, - ) { - if ("id" in message) { // message response - const resolvable = this.resolvables.get(message.id); - if (resolvable) { - if ("result" in message) { // success response - if ("errorText" in message.result!) { - const r = this.notification_resolvables.get("Page.loadEventFired"); - if (r) { - r.resolve(); - } - } - resolvable.resolve(message.result); - } - if ("error" in message) { // error response - resolvable.resolve(message.error); - } - } - } - if ("method" in message) { // Notification response - const resolvable = this.notification_resolvables.get(message.method); - if (resolvable) { - resolvable.resolve(); - } - } - } - - /** - * Main method to handle sending messages/events to the websocket endpoint. + * Creates the instant and protocol to interact with * - * @param method - Any DOMAIN, see sidebar at https://chromedevtools.github.io/devtools-protocol/tot/, eg Runtime.evaluate, or DOM.getDocument - * @param params - Parameters required for the domain method + * @param buildArgs - Sub process args, should be ones to run chrome + * @param wsOptions - Hostname and port to run the websocket server on + * @param browser - Which browser we are building + * @param firefoxProfilePath - If firefox, the path to the temporary profile location * - * @returns + * @returns A client instance, ready to be used */ - protected async sendWebSocketMessage( - method: string, - params?: RequestType, - ): Promise { - const data: { - id: number; - method: string; - params?: RequestType; - } = { - id: this.next_message_id++, - method: method, - }; - if (params) data.params = params; - const promise = deferred(); - this.resolvables.set(data.id, promise); - this.socket.send(JSON.stringify(data)); - const result = await promise; - this.resolvables.delete(data.id); - return result; - } - - /** - * Checks if the result is an error - * - * @param result - The DOM result response, after writing to stdin and getting by stdout of the process - * @param commandSent - The command sent to trigger the result - */ - protected async checkForErrorResult( - result: Protocol.Runtime.AwaitPromiseResponse, - commandSent: string, - ): Promise { - const exceptionDetail = result.exceptionDetails; - if (!exceptionDetail) { - return; - } - if (exceptionDetail.text && !exceptionDetail.exception) { // specific for firefox - await this.done(exceptionDetail.text); - } - const errorMessage = exceptionDetail.exception!.description ?? - exceptionDetail.text; - if (errorMessage.includes("SyntaxError")) { // a syntax error - const message = errorMessage.replace("SyntaxError: ", ""); - await this.done(); - throw new SyntaxError(message + ": `" + commandSent + "`"); - } - // any others, unsure what they'd be - await this.done(`${errorMessage}: "${commandSent}"`); - } - - protected static async create( + static async create( buildArgs: string[], wsOptions: { hostname: string; port: number; }, - browser: "firefox" | "chrome", + browser: Browsers, firefoxProfilePath?: string, ): Promise { const browserProcess = Deno.run({ @@ -529,7 +98,7 @@ export class Client { } break; } - const { debugUrl: wsUrl, frameId } = await Client.getWebSocketInfo( + const { debugUrl: wsUrl, frameId } = await ProtocolClass.getWebSocketInfo( wsOptions.hostname, wsOptions.port, ); @@ -537,47 +106,15 @@ export class Client { const promise = deferred(); websocket.onopen = () => promise.resolve(); await promise; - const TempClient = new Client(websocket, browserProcess, browser, frameId); - await TempClient.sendWebSocketMessage("Page.enable"); - await TempClient.sendWebSocketMessage("Runtime.enable"); - return new Client( + const protocol = new ProtocolClass( websocket, browserProcess, browser, frameId, firefoxProfilePath, ); - } - - /** - * Gets the websocket url we use to create a ws client with. - * Requires the headless chrome process to be running, as - * this is what actually starts the remote debugging url - * - * @param hostname - The hostname to fetch from - * @param port - The port for the hostname to fetch from - * - * @returns The url to connect to - */ - private static async getWebSocketInfo( - hostname: string, - port: number, - ): Promise<{ debugUrl: string; frameId: string }> { - let debugUrl = ""; - let frameId = ""; - while (debugUrl === "") { - try { - const res = await fetch(`http://${hostname}:${port}/json/list`); - const json = await res.json(); - debugUrl = json[0]["webSocketDebuggerUrl"]; - frameId = json[0]["id"]; - } catch (_err) { - // do nothing, loop again until the endpoint is ready - } - } - return { - debugUrl, - frameId, - }; + await protocol.sendWebSocketMessage("Page.enable"); + await protocol.sendWebSocketMessage("Runtime.enable"); + return new Client(protocol); } } diff --git a/src/element.ts b/src/element.ts index bf6da041..9300020c 100644 --- a/src/element.ts +++ b/src/element.ts @@ -1,38 +1,54 @@ -import { Client } from "./client.ts"; +import { Page } from "./page.ts"; export class Element { - public selector: string; + /** + * The css selector for the element + */ + public selector: string; // eg "#user" or "div > #name" or "//h1" + + /** + * How we select the element + */ public method: "document.querySelector" | "$x"; - private client: Client; - public value = ""; + + /** + * The page this element belongs to + */ + private page: Page; constructor( method: "document.querySelector" | "$x", selector: string, - client: Client, + page: Page, ) { - this.client = client; + this.page = page; this.selector = selector; this.method = method; - Object.defineProperty(this, "value", { - async set(value: string): Promise { - await client.evaluatePage( - `${method}('${selector}').value = '${value}'`, - ); - }, - async get(): Promise { - const value = await client.evaluatePage( - `${method}('${selector}').value`, - ); - return value; - }, - configurable: true, - enumerable: true, - }); } + /** + * 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 setting, else void if not + */ + public async value(newValue?: string) { + if (!newValue) { + return await this.page.evaluate( + `${this.method}('${this.selector}').value`, + ); + } + await this.page.evaluate( + `${this.method}('${this.selector}').value = \`${newValue}\``, + ); + } + + /** + * Click the element + */ public async click(): Promise { - await this.client.evaluatePage( + await this.page.evaluate( `${this.method}('${this.selector}').click()`, ); } diff --git a/src/firefox_client.ts b/src/firefox_client.ts deleted file mode 100644 index 826b2642..00000000 --- a/src/firefox_client.ts +++ /dev/null @@ -1,96 +0,0 @@ -// 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 { BuildOptions, Client } from "./client.ts"; - -export const defaultBuildOptions = { - hostname: Deno.build.os === "windows" ? "127.0.0.1" : "localhost", - debuggerServerPort: 9293, - defaultUrl: "https://developer.mozilla.org/", -}; - -/** - * 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"; - } -} - -/** - * @example - * - * const Firefox = await FirefoxClient.build() - * await Firefox. - */ -export class FirefoxClient extends Client { - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Entry point for creating a headless firefox browser. - * Creates a dev profile to be used by Firefox, creates the headless browser and sets up the connection to - * - * @param buildOptions - Any extra options you wish to provide to customise how the headless browser sub process is ran - * - hostname: Defaults to 0.0.0.0 for macos/linux, 127.0.0.1 for windows - * - port: Defaults to 9293 - * - * @returns An instance of FirefoxClient, that is now ready. - */ - public static async build( - buildOptions: BuildOptions = {}, - ): Promise { - // Setup the options to defaults if required - if (!buildOptions.hostname) { - buildOptions.hostname = defaultBuildOptions.hostname; - } - if (!buildOptions.debuggerPort) { - buildOptions.debuggerPort = defaultBuildOptions.debuggerServerPort; - } - - // Create the profile the browser will use. Create a test one so we can enable required options to enable communication with it - const tmpDirName = Deno.makeTempDirSync(); - - // Create the arguments we will use when spawning the headless browser - const args = [ - buildOptions.binaryPath || getFirefoxPath(), - "--start-debugger-server", - buildOptions.debuggerPort.toString(), - "-headless", - "--remote-debugging-port", - buildOptions.debuggerPort.toString(), - "-profile", - tmpDirName, - "about:blank", - ]; - // Create the sub process to start the browser - return await Client.create( - args, - { - hostname: buildOptions.hostname, - port: buildOptions.debuggerPort, - }, - "firefox", - tmpDirName, - ); - } -} diff --git a/src/page.ts b/src/page.ts index 4c626a42..76f1b868 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,74 +1,97 @@ -import { Client } from "./client.ts"; -import { Element } from "./element.ts"; import { assertEquals, deferred, Protocol } from "../deps.ts"; import { existsSync, generateTimestamp } from "./utility.ts"; +import { Element } from "./element.ts"; +import { Protocol as ProtocolClass } from "./protocol.ts"; -export class Page extends Client { - public location = ""; - public cookie = ""; - - constructor( - socket: WebSocket, - browserProcess: Deno.Process, - browser: "firefox" | "chrome", - frameId: string, - firefoxProfilePath?: string, - ) { - super(socket, browserProcess, browser, frameId, firefoxProfilePath); - // deno-lint-ignore no-this-alias - const self = this; - Object.defineProperty(this, "location", { - async set(value: string): Promise { - await self.goTo(value); - }, - async get(): Promise { - const value = await self.evaluatePage( - `window.location.href`, - ); - return value; - }, - configurable: true, - enumerable: true, - }); - Object.defineProperty(this, "cookie", { - async set(data: { - name: string; - value: string; - url: string; - }): Promise { - await self.sendWebSocketMessage< - Protocol.Network.SetCookieRequest, - Protocol.Network.SetCookieResponse - >("Network.setCookie", { - name: data.name, - value: data.value, - url: data.url, - }); - }, - async get(): Promise { - const cookies = await self.sendWebSocketMessage< - Protocol.Network.GetCookiesRequest, - Protocol.Network.GetCookiesResponse - >("Network.getCookies", { - urls: [await self.location], - }); - return cookies; - }, - configurable: true, - enumerable: true, +export interface ScreenshotOptions { + selector?: string; + fileName?: string; + format?: "jpeg" | "png"; + quality?: number; +} + +export type Cookie = { + name: string; + value: string; + url: string; +}; + +export class Page { + readonly #protocol: ProtocolClass; + + constructor(protocol: ProtocolClass) { + this.#protocol = protocol; + } + + /** + * Either get all cookies for the page, or set a cookie + * + * @param newCookie - Only required if you want to set a cookie + * + * @returns All cookies for the page if no parameter is passed in, else an empty array + */ + public async cookie( + newCookie?: Cookie, + ): Promise { + if (!newCookie) { + const result = await this.#protocol.sendWebSocketMessage< + Protocol.Network.GetCookiesRequest, + Protocol.Network.GetCookiesResponse + >("Network.getCookies"); + return result.cookies; + } + await this.#protocol.sendWebSocketMessage< + Protocol.Network.SetCookieRequest, + Protocol.Network.SetCookieResponse + >("Network.setCookie", { + name: newCookie.name, + value: newCookie.value, + url: newCookie.url, }); + return []; } - public async querySelector(selector: string): Promise { - const result = await this.evaluatePage( - `document.querySelector('${selector}')`, + /** + * 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" + * ``` + * + * @returns The location for the page if no parameter is passed in, else an empty array + */ + public async location(newLocation?: string): Promise { + if (!newLocation) { + const document = await this.#protocol.sendWebSocketMessage< + Protocol.DOM.GetDocumentRequest, + Protocol.DOM.GetDocumentResponse + >("DOM.getDocument"); + return document.root.documentURL ?? ""; + } + const method = "Page.loadEventFired"; + this.#protocol.notification_resolvables.set(method, deferred()); + const notificationPromise = this.#protocol.notification_resolvables.get( + method, ); - if (result === null) { - await this.done( - 'The selector "' + selector + '" does not exist inside the DOM', + const res = await this.#protocol.sendWebSocketMessage< + Protocol.Page.NavigateRequest, + Protocol.Page.NavigateResponse + >( + "Page.navigate", + { + url: newLocation, + }, + ); + await notificationPromise; + if (res.errorText) { + await this.#protocol.done( + `${res.errorText}: Error for navigating to page "${newLocation}"`, ); } - return new Element("document.querySelector", selector, this); + return ""; } /** @@ -78,35 +101,35 @@ export class Page extends Client { * * @returns The result of the evaluation */ - public async evaluate( + async evaluate( pageCommand: (() => unknown) | string, - // As defined by the protocol, the `value` is `any` + // As defined by the #protocol, the `value` is `any` // deno-lint-ignore no-explicit-any ): Promise { if (typeof pageCommand === "string") { - const result = await this.sendWebSocketMessage< + const result = await this.#protocol.sendWebSocketMessage< Protocol.Runtime.EvaluateRequest, Protocol.Runtime.EvaluateResponse >("Runtime.evaluate", { expression: pageCommand, - includeCommandLineAPI: true, // sopprts things like $x + includeCommandLineAPI: true, // supports things like $x }); - await this.checkForErrorResult(result, pageCommand); + await this.#checkForErrorResult(result, pageCommand); return result.result.value; } if (typeof pageCommand === "function") { - const { executionContextId } = await this.sendWebSocketMessage< + const { executionContextId } = await this.#protocol.sendWebSocketMessage< Protocol.Page.CreateIsolatedWorldRequest, Protocol.Page.CreateIsolatedWorldResponse >( "Page.createIsolatedWorld", { - frameId: this.frame_id, + frameId: this.#protocol.frame_id, }, ); - const res = await this.sendWebSocketMessage< + const res = await this.#protocol.sendWebSocketMessage< Protocol.Runtime.CallFunctionOnRequest, Protocol.Runtime.CallFunctionOnResponse >( @@ -119,34 +142,62 @@ export class Page extends Client { userGesture: true, }, ); - await this.checkForErrorResult(res, pageCommand.toString()); + await this.#checkForErrorResult(res, pageCommand.toString()); return res.result.value; } } + /** + * Wait for the page to change. Can be used with `click()` if clicking a button or anchor tag that redirects the user + */ + async waitForPageChange(): Promise { + const method = "Page.loadEventFired"; + this.#protocol.notification_resolvables.set(method, deferred()); + const notificationPromise = this.#protocol.notification_resolvables.get( + method, + ); + await notificationPromise; + this.#protocol.notification_resolvables.delete(method); + } + /** * Check if the given text exists on the dom * * @param text - The text to check for */ - public async assertSee(text: string): Promise { - const command = `document.body.innerText.indexOf('${text}') >= 0`; - const exists = await this.evaluatePage(command); + async assertSee(text: string): Promise { + const command = `document.body.innerText.includes('${text}')`; + const exists = await this.evaluate(command); if (exists !== true) { // We know it's going to fail, so before an assertion error is thrown, cleanupup - await this.done(); + await this.#protocol.done(); } assertEquals(exists, true); } + // deno-lint-ignore require-await + async #$x(_selector: string) { + throw new Error("Client#$x not impelemented"); + // todo check the element exists first + //return new Element('$x', selector, this) + } + /** - * Wait for the page to change. Can be used with `click()` if clicking a button or anchor tag that redirects the user + * Representation of the Browsers `document.querySelector` + * + * @param selector - The selector for the element + * + * @returns An element class, allowing you to action upon that element */ - public async waitForPageChange(): Promise { - const method = "Page.loadEventFired"; - this.notification_resolvables.set(method, deferred()); - const notificationPromise = this.notification_resolvables.get(method); - await notificationPromise; - this.notification_resolvables.delete(method); + async querySelector(selector: string) { + const result = await this.evaluate( + `document.querySelector('${selector}')`, + ); + if (result === null) { + await this.#protocol.done( + 'The selector "' + selector + '" does not exist inside the DOM', + ); + } + return new Element("document.querySelector", selector, this); } /** @@ -161,26 +212,34 @@ export class Page extends Client { * @param options.format - The Screenshot format(and hence extension). Allowed values are "jpeg" and "png" - Optional * @param options.quality - The image quality from 0 to 100, default 80. Applicable only if no format provided or format is "jpeg" - Optional */ - public async takeScreenshot( + async takeScreenshot( path: string, - options?: { - selector?: string; - fileName?: string; - format?: "jpeg" | "png"; - quality?: number; - }, + options?: ScreenshotOptions, ): Promise { if (!existsSync(path)) { - await this.done(); + await this.#protocol.done(); throw new Error(`The provided folder path - ${path} doesn't exist`); } const ext = options?.format ?? "jpeg"; - const clip = (options?.selector) - ? await this.getViewport(options.selector) - : undefined; + const rawViewportResult = options?.selector + ? await this.evaluate( + `JSON.stringify(document.querySelector('${options.selector}').getBoundingClientRect())`, + ) + : "{}"; + const jsonViewportResult = JSON.parse(rawViewportResult); + const viewPort = { + x: jsonViewportResult.x, + y: jsonViewportResult.y, + width: jsonViewportResult.width, + height: jsonViewportResult.height, + scale: 2, + }; + const clip = (options?.selector) ? viewPort : undefined; if (options?.quality && Math.abs(options.quality) > 100 && ext == "jpeg") { - await this.done("A quality value greater than 100 is not allowed."); + await this.#protocol.done( + "A quality value greater than 100 is not allowed.", + ); } //Quality should defined only if format is jpeg @@ -188,7 +247,7 @@ export class Page extends Client { ? ((options?.quality) ? Math.abs(options.quality) : 80) : undefined; - const res = await this.sendWebSocketMessage< + const res = await this.#protocol.sendWebSocketMessage< Protocol.Page.CaptureScreenshotRequest, Protocol.Page.CaptureScreenshotResponse >( @@ -212,7 +271,7 @@ export class Page extends Client { try { Deno.writeFileSync(fName, u8Arr); } catch (e) { - await this.done(); + await this.#protocol.done(); throw new Error(e.message); } @@ -220,24 +279,30 @@ export class Page extends Client { } /** - * This method is used internally to calculate the element Viewport (Dimensions) - * executes getBoundingClientRect of the obtained element - * @param selector - The selector for the element to capture - * @returns ViewPort object - Which contains the dimensions of the element captured + * Checks if the result is an error + * + * @param result - The DOM result response, after writing to stdin and getting by stdout of the process + * @param commandSent - The command sent to trigger the result */ - protected async getViewport( - selector: string, - ): Promise { - const res = await this.evaluatePage( - `JSON.stringify(document.querySelector('${selector}').getBoundingClientRect())`, - ); - const values = JSON.parse(res); - return { - x: values.x, - y: values.y, - width: values.width, - height: values.height, - scale: 2, - }; + async #checkForErrorResult( + result: Protocol.Runtime.AwaitPromiseResponse, + commandSent: string, + ): Promise { + const exceptionDetail = result.exceptionDetails; + if (!exceptionDetail) { + return; + } + if (exceptionDetail.text && !exceptionDetail.exception) { // specific for firefox + await this.#protocol.done(exceptionDetail.text); + } + const errorMessage = exceptionDetail.exception!.description ?? + exceptionDetail.text; + if (errorMessage.includes("SyntaxError")) { // a syntax error + const message = errorMessage.replace("SyntaxError: ", ""); + await this.#protocol.done(); + throw new SyntaxError(message + ": `" + commandSent + "`"); + } + // any others, unsure what they'd be + await this.#protocol.done(`${errorMessage}: "${commandSent}"`); } } diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 00000000..77a6e269 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,222 @@ +import { Deferred, deferred } from "../deps.ts"; +import { existsSync } from "./utility.ts"; +import type { Browsers } from "./types.ts"; + +interface MessageResponse { // For when we send an event to get one back, eg running a JS expression + id: number; + result?: Record; // Present on success, OR for example if we use goTo and the url doesnt exist (in firefox) + error?: unknown; // Present on error +} + +interface NotificationResponse { // Not entirely sure when, but when we send the `Network.enable` method + method: string; + params: unknown; +} + +export class Protocol { + /** + * Our web socket connection to the remote debugging port + */ + public socket: WebSocket; + + /** + * The sub process that runs headless chrome + */ + public browser_process: Deno.Process; + + /** + * What browser we running? + */ + public browser: Browsers; + + public frame_id: string; + + /** + * A counter that acts as the message id we use to send as part of the event data through the websocket + */ + public next_message_id = 1; + + /** + * To keep hold of our promises waiting for messages from the websocket + */ + public resolvables: Map> = new Map(); + + /** + * To keep hold of promises waiting for a notification from the websocket + */ + public notification_resolvables: Map> = new Map(); + + /** + * Only if the browser is firefox, is this present. + * This is the path to the directory that firefox uses + * to write a profile + */ + public firefox_profile_path: string | undefined = undefined; + + /** + * Track if we've closed the sub process, so we dont try close it when it already has been + */ + public browser_process_closed = false; + + constructor( + socket: WebSocket, + browserProcess: Deno.Process, + browser: Browsers, + frameId: string, + firefoxProfilePath: string | undefined, + ) { + this.socket = socket; + this.browser_process = browserProcess; + this.browser = browser; + this.frame_id = frameId; + this.firefox_profile_path = firefoxProfilePath; + // Register on message listener + this.socket.onmessage = (msg) => { + const data = JSON.parse(msg.data); + if (data.method === "Page.frameStartedLoading") { + this.frame_id = data.params.frameId; + } + this.#handleSocketMessage(data); + }; + } + + /** + * Close/stop the sub process, and close the ws connection. Must be called when finished with all your testing + * + * @param errMsg - If supplied, will finally throw an error with the message after closing all processes + */ + public async done(errMsg?: string): Promise { + // Say a user calls an assertion method, and then calls done(), we make sure that if + // the subprocess is already closed, dont try close it again + if (this.browser_process_closed === true) { + return; + } + const clientIsClosed = deferred(); + this.socket.onclose = () => clientIsClosed.resolve(); + // cloing subprocess will also close the ws endpoint + this.browser_process.stderr!.close(); + this.browser_process.stdout!.close(); + this.browser_process.close(); + this.browser_process_closed = true; + // 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 === "firefox" && Deno.build.os === "windows") { + const p = Deno.run({ + cmd: ["taskkill", "/F", "/IM", "firefox.exe"], + stdout: "null", + stderr: "null", + }); + await p.status(); + p.close(); + } + await clientIsClosed; // done AFTER the above conditional because the process is still running, so the client is never closed + 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 + } + } + } + if (errMsg) { + throw new Error(errMsg); + } + } + + /** + * Main method to handle sending messages/events to the websocket endpoint. + * + * @param method - Any DOMAIN, see sidebar at https://chromedevtools.github.io/devtools-protocol/tot/, eg Runtime.evaluate, or DOM.getDocument + * @param params - Parameters required for the domain method + * + * @returns + */ + public async sendWebSocketMessage( + method: string, + params?: RequestType, + ): Promise { + const data: { + id: number; + method: string; + params?: RequestType; + } = { + id: this.next_message_id++, + method: method, + }; + if (params) data.params = params; + const promise = deferred(); + this.resolvables.set(data.id, promise); + this.socket.send(JSON.stringify(data)); + const result = await promise; + this.resolvables.delete(data.id); + return result; + } + + /** + * Gets the websocket url we use to create a ws client with. + * Requires the headless chrome process to be running, as + * this is what actually starts the remote debugging url + * + * @param hostname - The hostname to fetch from + * @param port - The port for the hostname to fetch from + * + * @returns The url to connect to + */ + public static async getWebSocketInfo( + hostname: string, + port: number, + ): Promise<{ debugUrl: string; frameId: string }> { + let debugUrl = ""; + let frameId = ""; + while (debugUrl === "") { + try { + const res = await fetch(`http://${hostname}:${port}/json/list`); + const json = await res.json(); + debugUrl = json[0]["webSocketDebuggerUrl"]; + frameId = json[0]["id"]; + } catch (_err) { + // do nothing, loop again until the endpoint is ready + } + } + return { + debugUrl, + frameId, + }; + } + + #handleSocketMessage( + message: MessageResponse | NotificationResponse, + ) { + if ("id" in message) { // message response + const resolvable = this.resolvables.get(message.id); + if (resolvable) { + if ("result" in message) { // success response + if ("errorText" in message.result!) { + const r = this.notification_resolvables.get("Page.loadEventFired"); + if (r) { + r.resolve(); + } + } + resolvable.resolve(message.result); + } + if ("error" in message) { // error response + resolvable.resolve(message.error); + } + } + } + if ("method" in message) { // Notification response + const resolvable = this.notification_resolvables.get(message.method); + if (resolvable) { + resolvable.resolve(); + } + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..47009571 --- /dev/null +++ b/src/types.ts @@ -0,0 +1 @@ +export type Browsers = "firefox" | "chrome"; diff --git a/src/utility.ts b/src/utility.ts index 6ed6c452..3193a413 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -20,3 +20,89 @@ export const generateTimestamp = (): string => { dt.toLocaleTimeString().replace(/:/g, "_"); return ts; }; + +/** + * Gets the full path to the chrome executable on the users filesystem + * + * @returns The path to chrome + */ +export function getChromePath(): string { + const paths = { + // deno-lint-ignore camelcase + windows_chrome_exe: + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + // deno-lint-ignore camelcase + windows_chrome_exe_x86: + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + linux: "/usr/bin/google-chrome", + }; + let chromePath = ""; + switch (Deno.build.os) { + case "darwin": + chromePath = paths.darwin; + break; + case "windows": + if (existsSync(paths.windows_chrome_exe)) { + chromePath = paths.windows_chrome_exe; + break; + } + if (existsSync(paths.windows_chrome_exe_x86)) { + chromePath = paths.windows_chrome_exe_x86; + break; + } + + throw new Error( + "Cannot find path for chrome in windows. Submit an issue if you encounter this error", + ); + case "linux": + chromePath = paths.linux; + break; + } + return chromePath; +} + +export function getChromeArgs(port: number, binaryPath?: string): string[] { + return [ + binaryPath || getChromePath(), + "--headless", + "--remote-debugging-port=" + port, + "--disable-gpu", + "--no-sandbox", + ]; +} + +/** + * 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"; + } +} + +export function getFirefoxArgs( + tmpDirName: string, + port: number, + binaryPath?: string, +): string[] { + return [ + binaryPath || getFirefoxPath(), + "--start-debugger-server", + port.toString(), + "-headless", + "--remote-debugging-port", + port.toString(), + "-profile", + tmpDirName, + "about:blank", + ]; +} diff --git a/tests/browser_list.ts b/tests/browser_list.ts index a112c904..f066114d 100644 --- a/tests/browser_list.ts +++ b/tests/browser_list.ts @@ -1,14 +1,31 @@ +import type { Browsers } from "../src/types.ts"; +import { getChromePath, getFirefoxPath } from "../src/utility.ts"; + export const browserList: Array<{ - name: "chrome" | "firefox"; + name: Browsers; errors: { page_not_exist_message: string; + page_name_not_resolved: string; }; + 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, + }, + { + name: "firefox", + errors: { + page_not_exist_message: + 'net::ERR_NAME_NOT_RESOLVED: Error for navigating to page "https://hellaDOCSWOWThispagesurelycantexist.biscuit"', + page_name_not_resolved: "todo", + }, + getPath: getFirefoxPath, }, ]; diff --git a/tests/integration/assert_methods_clean_up_on_fail_test.ts b/tests/integration/assert_methods_clean_up_on_fail_test.ts index eaa99d3d..52ec5adb 100644 --- a/tests/integration/assert_methods_clean_up_on_fail_test.ts +++ b/tests/integration/assert_methods_clean_up_on_fail_test.ts @@ -31,12 +31,12 @@ for (const browserItem of browserList) { browserItem.name + ": Assertion methods cleanup when an assertion fails", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - await Sinco.assertUrlIs("https://chromestatus.com/features"); + const page = await Sinco.goTo("https://chromestatus.com"); + assertEquals(await page.location(), "https://chromestatus.com/features"); let gotError = false; let errMsg = ""; try { - await Sinco.assertSee("Chrome Versions"); // Does not exist on the page (the `V` is lowercase, whereas here we use an uppercase) + await page.assertSee("Chrome Versions"); // Does not exist on the page (the `V` is lowercase, whereas here we use an uppercase) } catch (err) { gotError = true; errMsg = err.message @@ -53,10 +53,10 @@ for (const browserItem of browserList) { ); // Now we should be able to run tests again without it hanging const Sinco2 = await buildFor(browserItem.name); - await Sinco2.goTo("https://chromestatus.com"); - await Sinco2.assertUrlIs("https://chromestatus.com/features"); + const page2 = await Sinco2.goTo("https://chromestatus.com"); + assertEquals(await page2.location(), "https://chromestatus.com/features"); try { - await Sinco2.assertSee("Chrome Versions"); + await page2.assertSee("Chrome Versions"); } catch (_err) { // } diff --git a/tests/integration/clicking_elements_test.ts b/tests/integration/clicking_elements_test.ts index df0c9036..cb54a079 100644 --- a/tests/integration/clicking_elements_test.ts +++ b/tests/integration/clicking_elements_test.ts @@ -1,5 +1,6 @@ import { buildFor } from "../../mod.ts"; import { browserList } from "../browser_list.ts"; +import { assertEquals } from "../../deps.ts"; for (const browserItem of browserList) { Deno.test( @@ -7,13 +8,16 @@ for (const browserItem of browserList) { ": Clicking elements - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - const elem = await Sinco.querySelector( + const page = await Sinco.goTo("https://drash.land"); + const elem = await page.querySelector( 'a[href="https://discord.gg/RFsCSaHRWK"]', ); await elem.click(); - await Sinco.waitForPageChange(); - await Sinco.assertUrlIs("https://discord.com/invite/RFsCSaHRWK"); + await page.waitForPageChange(); + assertEquals( + await page.location(), + "https://discord.com/invite/RFsCSaHRWK", + ); await Sinco.done(); }, ); diff --git a/tests/integration/csrf_protected_pages_test.ts b/tests/integration/csrf_protected_pages_test.ts index e487064d..d8b69f54 100644 --- a/tests/integration/csrf_protected_pages_test.ts +++ b/tests/integration/csrf_protected_pages_test.ts @@ -14,10 +14,14 @@ import { browserList } from "../browser_list.ts"; for (const browserItem of browserList) { Deno.test("Chrome: " + title, async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - await Sinco.setCookie("X-CSRF-TOKEN", "hi:)", "https://drash.land"); + const page = await Sinco.goTo("https://drash.land"); + await page.cookie({ + name: "X-CSRF-TOKEN", + value: "hi:)", + url: "https://drash.land", + }); await Sinco.goTo("https://drash.land/drash/v1.x/#/"); // Going here to ensure the cookie stays - const cookieVal = await Sinco.evaluatePage(() => { + const cookieVal = await page.evaluate(() => { return document.cookie; }); await Sinco.done(); diff --git a/tests/integration/custom_assertions_test.ts b/tests/integration/custom_assertions_test.ts index 396c5513..b45a5aed 100644 --- a/tests/integration/custom_assertions_test.ts +++ b/tests/integration/custom_assertions_test.ts @@ -1,5 +1,6 @@ import { buildFor } from "../../mod.ts"; import { browserList } from "../browser_list.ts"; +import { assertEquals } from "../../deps.ts"; for (const browserItem of browserList) { Deno.test( @@ -7,9 +8,9 @@ for (const browserItem of browserList) { ": Assertions - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - await Sinco.assertUrlIs("https://drash.land/"); - await Sinco.assertSee("Develop With Confidence"); + const page = await Sinco.goTo("https://drash.land"); + assertEquals(await page.location(), "https://drash.land/"); + await page.assertSee("Develop With Confidence"); await Sinco.done(); }, ); diff --git a/tests/integration/get_and_set_input_value_test.ts b/tests/integration/get_and_set_input_value_test.ts index 364a64c4..8df870c3 100644 --- a/tests/integration/get_and_set_input_value_test.ts +++ b/tests/integration/get_and_set_input_value_test.ts @@ -8,10 +8,10 @@ for (const browserItem of browserList) { ": Get and set input value - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const elem = await Sinco.querySelector('input[placeholder="Filter"]'); - elem.value = "hello world"; - const val = await elem.value; + const page = await Sinco.goTo("https://chromestatus.com"); + const elem = await page.querySelector('input[placeholder="Filter"]'); + await elem.value("hello world"); + const val = await elem.value(); assertEquals(val, "hello world"); await Sinco.done(); }, diff --git a/tests/integration/getting_started_test.ts b/tests/integration/getting_started_test.ts index fea8c969..ad43890a 100644 --- a/tests/integration/getting_started_test.ts +++ b/tests/integration/getting_started_test.ts @@ -1,5 +1,6 @@ import { buildFor } from "../../mod.ts"; import { browserList } from "../browser_list.ts"; +import { assertEquals } from "../../deps.ts"; for (const browserItem of browserList) { Deno.test( @@ -7,19 +8,20 @@ for (const browserItem of browserList) { async () => { // Setup const Sinco = await buildFor(browserItem.name); // also supports firefox - await Sinco.goTo("https://drash.land"); // Go to this page + const page = await Sinco.goTo("https://drash.land"); // Go to this page // Do any actions and assertions, in any order - await Sinco.assertUrlIs("https://drash.land/"); - const elem = await Sinco.querySelector( + assertEquals(await page.location(), "https://drash.land/"); + const elem = await page.querySelector( 'a[href="https://discord.gg/RFsCSaHRWK"]', ); await elem.click(); // This element will take the user to Sinco's documentation - await Sinco.waitForPageChange(); - await Sinco.assertUrlIs("https://discord.com/invite/RFsCSaHRWK"); + await page.waitForPageChange(); + const location = await page.location(); // Once finished, close to clean up any processes await Sinco.done(); + assertEquals(location, "https://discord.com/invite/RFsCSaHRWK"); }, ); } diff --git a/tests/integration/manipulate_page_test.ts b/tests/integration/manipulate_page_test.ts index cca0ba71..944bc817 100644 --- a/tests/integration/manipulate_page_test.ts +++ b/tests/integration/manipulate_page_test.ts @@ -6,9 +6,9 @@ import { browserList } from "../browser_list.ts"; for (const browserItem of browserList) { Deno.test(browserItem.name + ": Manipulate Webpage", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); + const page = await Sinco.goTo("https://drash.land"); - const updatedBody = await Sinco.evaluatePage(() => { + const updatedBody = await page.evaluate(() => { // deno-lint-ignore no-undef const prevBody = document.body.children.length; // deno-lint-ignore no-undef @@ -28,17 +28,17 @@ for (const browserItem of browserList) { ": Evaluating a script - Tutorial for this feature in the documentation works", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - const pageTitle = await Sinco.evaluatePage(() => { + const page = await Sinco.goTo("https://drash.land"); + const pageTitle = await page.evaluate(() => { // deno-lint-ignore no-undef return document.title; }); - const sum = await Sinco.evaluatePage(`1 + 10`); - const oldBodyLength = await Sinco.evaluatePage(() => { + 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 Sinco.evaluatePage(() => { + const newBodyLength = await page.evaluate(() => { // deno-lint-ignore no-undef const p = document.createElement("p"); p.textContent = "Hello world!"; diff --git a/tests/integration/screenshots_test.ts b/tests/integration/screenshots_test.ts index 60ae4e4c..4a947b8d 100644 --- a/tests/integration/screenshots_test.ts +++ b/tests/integration/screenshots_test.ts @@ -8,15 +8,15 @@ for (const browserItem of browserList) { " - Tutorial for taking screenshots in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); + const page = await Sinco.goTo("https://drash.land"); const screenshotsFolder = "./screenshots"; Deno.mkdirSync(screenshotsFolder); // Ensure you create the directory your screenshots will be put within - await Sinco.takeScreenshot(screenshotsFolder); // Will take a screenshot of the whole page, and write it to `./screenshots/dd_mm_yyyy_hh_mm_ss.jpeg` - await Sinco.takeScreenshot(screenshotsFolder, { + await page.takeScreenshot(screenshotsFolder); // Will take a screenshot of the whole page, and write it to `./screenshots/dd_mm_yyyy_hh_mm_ss.jpeg` + await page.takeScreenshot(screenshotsFolder, { fileName: "drash_land.png", format: "png", }); // Specify filename and format. Will be saved as `./screenshots/drash_land.png` - await Sinco.takeScreenshot(screenshotsFolder, { + await page.takeScreenshot(screenshotsFolder, { fileName: "modules.jpeg", selector: 'a[href="https://github.com/drashland"]', }); // Will screenshot only the GitHub icon section, and write it to `./screenshots/dd_mm_yyyy_hh_mm_ss.jpeg` diff --git a/tests/integration/visit_pages_test.ts b/tests/integration/visit_pages_test.ts index 34b890c8..a9cb09b9 100644 --- a/tests/integration/visit_pages_test.ts +++ b/tests/integration/visit_pages_test.ts @@ -1,5 +1,6 @@ import { buildFor } from "../../mod.ts"; import { browserList } from "../browser_list.ts"; +import { assertEquals } from "../../deps.ts"; for (const browserItem of browserList) { Deno.test( @@ -7,9 +8,10 @@ for (const browserItem of browserList) { ": Visit pages - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - await Sinco.assertUrlIs("https://drash.land/"); + const page = await Sinco.goTo("https://drash.land"); + const location = await page.location(); await Sinco.done(); + assertEquals(location, "https://drash.land/"); }, ); } diff --git a/tests/integration/waiting_test.ts b/tests/integration/waiting_test.ts index 23b15e09..406dc5d1 100644 --- a/tests/integration/waiting_test.ts +++ b/tests/integration/waiting_test.ts @@ -1,6 +1,7 @@ import { buildFor } from "../../mod.ts"; import { browserList } from "../browser_list.ts"; +import { assertEquals } from "../../deps.ts"; for (const browserItem of browserList) { Deno.test( @@ -8,15 +9,15 @@ for (const browserItem of browserList) { ": Waiting - Tutorial for this feature in the docs should work", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - await Sinco.assertUrlIs("https://drash.land/"); - const elem = await Sinco.querySelector( + const page = await Sinco.goTo("https://drash.land"); + const elem = await page.querySelector( 'a[href="https://discord.gg/RFsCSaHRWK"]', ); await elem.click(); - await Sinco.waitForPageChange(); - await Sinco.assertUrlIs("https://discord.com/invite/RFsCSaHRWK"); + await page.waitForPageChange(); + const location = await page.location(); await Sinco.done(); + assertEquals(location, "https://discord.com/invite/RFsCSaHRWK"); }, ); } diff --git a/tests/unit/Screenshots/.gitkeep b/tests/unit/Screenshots/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/client_test.ts b/tests/unit/client_test.ts index 85bf53dd..0f32f8bd 100644 --- a/tests/unit/client_test.ts +++ b/tests/unit/client_test.ts @@ -1,416 +1,109 @@ -import { existsSync } from "./../../src/utility.ts"; -import { Rhum } from "../deps.ts"; -import { deferred } from "../../deps.ts"; -import { getChromePath } from "../../src/chrome_client.ts"; +import { assertEquals, deferred } from "../../deps.ts"; import { buildFor } from "../../mod.ts"; import { browserList } from "../browser_list.ts"; -Rhum.testPlan("tests/unit/client.ts", () => { - for (const browserItem of browserList) { - Rhum.testSuite("querySelector()", () => { - // TODO :: Tets if selector doesnt exist or is invalid etc - Rhum.testCase( - "Should throw an error when selector is invalid", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const error = { - errored: false, - msg: "", - }; - try { - await Sinco.querySelector("hkkkjgjkgk"); - } catch (err) { - error.errored = true; - error.msg = err.message; - } - await Sinco.done(); - Rhum.asserts.assertEquals(error, { - errored: true, - msg: `The selector "hkkkjgjkgk" does not exist inside the DOM`, - }); - }, - ); - Rhum.testCase( - "It should throw an error when no element exists for the selector", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const error = { - errored: false, - msg: "", - }; - try { - await Sinco.querySelector("a#dont-exist"); - } catch (err) { - error.errored = true; - error.msg = err.message; - } - await Sinco.done(); - Rhum.asserts.assertEquals(error, { - errored: true, - msg: `The selector "a#dont-exist" does not exist inside the DOM`, - }); - }, - ); - }); - - Rhum.testSuite("build()", () => { - Rhum.testCase( - `Will start ${browserItem.name} headless as a subprocess`, - async () => { - const Sinco = await buildFor(browserItem.name); - 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 Sinco.done(); - }, - ); - Rhum.testCase( - "Uses the port when passed in to the parameters", - async () => { - const Sinco = await buildFor(browserItem.name, { - 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"]); - const promise = deferred(); - client.onopen = function () { - client.close(); - }; - client.onclose = function () { - promise.resolve(); - }; - await promise; - await Sinco.done(); - }, - ); - Rhum.testCase( - "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 - }, - ); - Rhum.testCase( - "Uses the binaryPath when passed in to the parameters", - async () => { - const Sinco = await buildFor(browserItem.name, { - binaryPath: await getChromePath(), - }); - - 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 Sinco.done(); - }, - ); - }); - - Rhum.testSuite("assertUrlIs()", () => { - Rhum.testCase("Works when an assertion is true", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com/features"); - await Sinco.assertUrlIs("https://chromestatus.com/features"); - await Sinco.done(); - }); - Rhum.testCase("Will fail when an assertion fails", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - let originalErrMsg = ""; - try { - await Sinco.assertUrlIs("https://hella.com"); - } catch (error) { - originalErrMsg = error.message; - } - await Sinco.done(); - const msgArr = originalErrMsg.split("\n").filter((line) => { - return !!line === true && line.indexOf(" ") !== 0 && - line.indexOf("Values") < 0; - }); - Rhum.asserts.assertEquals( - msgArr[0], - "\x1b[31m\x1b[1m- https://\x1b[41m\x1b[37mchromestatus\x1b[31m\x1b[49m.com\x1b[41m\x1b[37m/\x1b[31m\x1b[49m\x1b[41m\x1b[37mfeatures\x1b[31m\x1b[49m", - ); - Rhum.asserts.assertEquals( - msgArr[1], - "\x1b[22m\x1b[39m\x1b[32m\x1b[1m+ https://\x1b[42m\x1b[37mhella\x1b[32m\x1b[49m.com", - ); - }); - }); - - Rhum.testSuite("location()", () => { - Rhum.testCase("Successfully navigates when url is correct", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com/roadmap"); - await Sinco.assertUrlIs("https://chromestatus.com/roadmap"); - await Sinco.done(); - }); - Rhum.testCase( - "Throws an error when there was an error navving to the page", - async () => { - const Sinco = await buildFor(browserItem.name); - let msg = ""; - try { - await Sinco.goTo( - "https://hellaDOCSWOWThispagesurelycantexist.biscuit", - ); - } catch (err) { - msg = err.message; - } - await Sinco.done(); - Rhum.asserts.assertEquals( - msg, - 'net::ERR_NAME_NOT_RESOLVED: Error for navigating to page "https://hellaDOCSWOWThispagesurelycantexist.biscuit"', - ); - }, - ); - }); - - Rhum.testSuite("assertSee()", () => { - Rhum.testCase( - "Assertion should work when text is present on page", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com/features"); - await Sinco.assertSee("Chrome Platform Status"); - await Sinco.done(); - }, - ); - Rhum.testCase( - "Assertion should NOT work when text is NOT present on page", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - let errorMsg = ""; - try { - await Sinco.assertSee("Crumpets and tea init?"); - } catch (err) { - errorMsg = err.message; - } - await Sinco.done(); - const msgArr = errorMsg.split("\n").filter((line) => { - return !!line === true && line.indexOf(" ") !== 0 && - line.indexOf("Values") < 0; - }); - Rhum.asserts.assertEquals(msgArr[0].indexOf("- false") > -1, true); - Rhum.asserts.assertEquals(msgArr[1].indexOf("+ true") > -1, true); - }, - ); - }); - - Rhum.testSuite("evaluatePage()", () => { - Rhum.testCase( - "It should evaluate function on current frame", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); - const pageTitle = await Sinco.evaluatePage(() => { - // deno-lint-ignore no-undef - return document.title; - }); - await Sinco.done(); - Rhum.asserts.assertEquals(pageTitle, "Drash Land"); - }, - ); - Rhum.testCase("It should evaluate string on current frame", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const parentConstructor = await Sinco.evaluatePage(`1 + 2`); - await Sinco.done(); - Rhum.asserts.assertEquals(parentConstructor, 3); - }); - }); - - Rhum.testSuite("done()", () => { - Rhum.testCase( - "Should close the sub process eg pid on users machine", - async () => { - // TODO(any): How do we do this? We could return the browser process rid and pid in the done() method, but what can we do with it? Eg checking `ps -a`, should not have the process process once we call `done()` - }, - ); - }); - - Rhum.testSuite("waitForPageChange()", () => { - Rhum.testCase( - "Waits for a page to change before continuing", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - await Sinco.assertUrlIs("https://chromestatus.com/features"); - const elem = await Sinco.querySelector('a[href="/roadmap"]'); - await elem.click(); - await Sinco.waitForPageChange(); - await Sinco.assertUrlIs("https://chromestatus.com/roadmap"); - await Sinco.done(); - }, - ); - }); - - Rhum.testSuite("takeScreenshot()", () => { - const ScreenshotsFolder = "Screenshots"; - - Rhum.beforeAll(() => { - try { - Deno.removeSync(ScreenshotsFolder, { recursive: true }); - } catch (_e) { - // - } finally { - Deno.mkdirSync(ScreenshotsFolder); - } - }); - - Rhum.testCase( - "Throws an error if provided path doesn't exist", - async () => { - let msg = ""; - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - try { - await Sinco.takeScreenshot("eieio"); - } catch (error) { - msg = error.message; - } - - Rhum.asserts.assertEquals( - msg, - `The provided folder path - eieio doesn't exist`, - ); - }, - ); - - Rhum.testCase( - "Takes a Screenshot with timestamp as filename if filename is not provided", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const fileName = await Sinco.takeScreenshot(ScreenshotsFolder); - await Sinco.done(); - Rhum.asserts.assertEquals( - existsSync( - fileName, - ), - true, - ); - }, - ); - - Rhum.testCase( - "Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const fileName = await Sinco.takeScreenshot(ScreenshotsFolder, { - selector: "span", - quality: 50, - }); - await Sinco.done(); - Rhum.asserts.assertEquals( - existsSync( - fileName, - ), - true, - ); - }, - ); - - Rhum.testCase( - "Throws an error when format passed is jpeg(or default) and quality > than 100", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - let msg = ""; - try { - await Sinco.takeScreenshot(ScreenshotsFolder, { quality: 999 }); - } catch (error) { - msg = error.message; - } - await Sinco.done(); - Rhum.asserts.assertEquals( - msg, - "A quality value greater than 100 is not allowed.", - ); - }, - ); - - Rhum.testCase("Saves Screenshot with Given Filename", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - await Sinco.takeScreenshot(ScreenshotsFolder, { fileName: "Happy" }); - await Sinco.done(); - Rhum.asserts.assertEquals( - existsSync(`${ScreenshotsFolder}/Happy.jpeg`), - true, - ); +for (const browserItem of browserList) { + Deno.test( + "goTo() | Should go to the page", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://drash.land"); + const location = await page.location(); + await Sinco.done(); + assertEquals(location, "https://drash.land/"); + }, + ); + Deno.test( + "goTo() | Should error when page is invalid", + async () => { + const Sinco = await buildFor(browserItem.name); + let errMsg = ""; + try { + await Sinco.goTo("https://hhh"); + } catch (e) { + errMsg = e.message; + } + await Sinco.done(); + assertEquals(errMsg, browserItem.errors.page_name_not_resolved); + }, + ); + Deno.test( + `create() | Will start ${browserItem.name} headless as a subprocess`, + async () => { + const Sinco = await buildFor(browserItem.name); + 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 Sinco.done(); + }, + ); + Deno.test( + "create() | Uses the port when passed in to the parameters", + async () => { + const Sinco = await buildFor(browserItem.name, { + debuggerPort: 9999, }); - - Rhum.testCase( - "Saves Screenshot with given format (jpeg | png)", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const fileName = await Sinco.takeScreenshot(ScreenshotsFolder, { - format: "png", - }); - await Sinco.done(); - Rhum.asserts.assertEquals( - existsSync( - fileName, - ), - true, - ); - }, - ); - - Rhum.testCase("Saves Screenshot with all options provided", async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - await Sinco.takeScreenshot(ScreenshotsFolder, { - fileName: "AllOpts", - selector: "span", - format: "jpeg", - quality: 100, - }); - await Sinco.done(); - Rhum.asserts.assertEquals( - existsSync(`${ScreenshotsFolder}/AllOpts.jpeg`), - true, - ); + 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"]); + const promise = deferred(); + client.onopen = function () { + client.close(); + }; + client.onclose = function () { + promise.resolve(); + }; + await promise; + await Sinco.done(); + }, + ); + Deno.test( + "create() | 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 + }, + ); + Deno.test( + "create() | Uses the binaryPath when passed in to the parameters", + async () => { + const Sinco = await buildFor(browserItem.name, { + //binaryPath: await browserItem.getPath(), }); - Rhum.afterAll(() => { - Deno.removeSync(ScreenshotsFolder, { recursive: true }); - }); - }); - // Rhum.testSuite("waitForAnchorChange()", () => { - // Rhum.testCase("Waits for any anchor changes after an action", async () => { - // const Sinco = await ChromeClient.build(); - // await Sinco.goTo("https://chromestatus.com"); - // await Sinco.type('input[placeholder="Filter"]', "Gday"); - // await Sinco.waitForAnchorChange(); - // await Sinco.assertUrlIs("https://chromestatus.com/features#Gday"); - // await Sinco.done(); - // }); - // }); - } -}); -Rhum.run(); + 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 Sinco.done(); + }, + ); + + // Rhum.testSuite("waitForAnchorChange()", () => { + // Rhum.testCase("Waits for any anchor changes after an action", async () => { + // const Sinco = await ChromeClient.build(); + // await Sinco.goTo("https://chromestatus.com"); + // await Sinco.type('input[placeholder="Filter"]', "Gday"); + // await Sinco.waitForAnchorChange(); + // await Sinco.assertUrlIs("https://chromestatus.com/features#Gday"); + // await Sinco.done(); + // }); + // }); +} diff --git a/tests/unit/element_test.ts b/tests/unit/element_test.ts index e25512da..58298438 100644 --- a/tests/unit/element_test.ts +++ b/tests/unit/element_test.ts @@ -3,32 +3,32 @@ import { assertEquals } from "../../deps.ts"; import { browserList } from "../browser_list.ts"; for (const browserItem of browserList) { - Deno.test("tests/unit/element_test.ts | click() | It should allow clicking of elements", async () => { + Deno.test("click() | It should allow clicking of elements", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const elem = await Sinco.querySelector('a[href="/roadmap"]'); + const page = await Sinco.goTo("https://chromestatus.com"); + const elem = await page.querySelector('a[href="/roadmap"]'); await elem.click(); - await Sinco.waitForPageChange(); - await Sinco.assertSee("Roadmap"); + await page.waitForPageChange(); + await page.assertSee("Roadmap"); await Sinco.done(); }); - Deno.test("tests/unit/element_test.ts | value | It should get the value for the given input element", async () => { + Deno.test("value | It should get the value for the given input element", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const elem = await Sinco.querySelector('input[placeholder="Filter"]'); - elem.value = "hello world"; - const val = await elem.value; + const page = await Sinco.goTo("https://chromestatus.com"); + const elem = await page.querySelector('input[placeholder="Filter"]'); + await elem.value("hello world"); + const val = await elem.value(); assertEquals(val, "hello world"); await Sinco.done(); }); Deno.test( - "tests/unit/element_test.ts | value | Should return empty when element is not an input element", + "value | Should return empty when element is not an input element", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); + const page = await Sinco.goTo("https://chromestatus.com"); let errMsg = ""; - const elem = await Sinco.querySelector("div"); + const elem = await page.querySelector("div"); try { await elem.value; } catch (e) { @@ -42,12 +42,12 @@ for (const browserItem of browserList) { }, ); - Deno.test("tests/unit/element_test.ts | value() | It should set the value of the element", async () => { + Deno.test("value() | It should set the value of the element", async () => { const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://chromestatus.com"); - const elem = await Sinco.querySelector('input[placeholder="Filter"]'); - elem.value = "hello world"; - const val = await elem.value; + const page = await Sinco.goTo("https://chromestatus.com"); + const elem = await page.querySelector('input[placeholder="Filter"]'); + await elem.value("hello world"); + const val = await elem.value(); await Sinco.done(); assertEquals(val, "hello world"); }); diff --git a/tests/unit/mod_test.ts b/tests/unit/mod_test.ts deleted file mode 100644 index 43918c97..00000000 --- a/tests/unit/mod_test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Rhum } from "../deps.ts"; -import { buildFor } from "../../mod.ts"; -import { browserList } from "../browser_list.ts"; - -Rhum.testPlan("tests/unit/mod_test.ts", () => { - for (const browserItem of browserList) { - Rhum.testSuite("buildFor()", () => { - Rhum.testCase( - "Builds for " + browserItem.name + " correctly", - async () => { - const Sinco = await buildFor(browserItem.name); - await Sinco.goTo("https://drash.land"); // Go to this page - await Sinco.assertUrlIs("https://drash.land/"); - await Sinco.done(); - }, - ); - }); - } -}); - -Rhum.run(); diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index 4ce1fa81..d7da62d6 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -1,7 +1,241 @@ -import { browserList } from "../browser_list.ts"; - -for (const browserItem of browserList) { - - - -} \ No newline at end of file +import { browserList } from "../browser_list.ts"; +const ScreenshotsFolder = "./tests/unit/Screenshots"; +import { buildFor } from "../../mod.ts"; +import { assertEquals } from "../../deps.ts"; +import { existsSync } from "../../src/utility.ts"; + +for (const browserItem of browserList) { + Deno.test( + "takeScreenshot() | Throws an error if provided path doesn't exist", + async () => { + let msg = ""; + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + try { + await page.takeScreenshot("eieio"); + } catch (error) { + msg = error.message; + } + + assertEquals( + msg, + `The provided folder path - eieio doesn't exist`, + ); + }, + ); + + Deno.test( + "takeScreenshot() | Takes a Screenshot with timestamp as filename if filename is not provided", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + const fileName = await page.takeScreenshot(ScreenshotsFolder); + await Sinco.done(); + const exists = existsSync(fileName); + Deno.removeSync(fileName); + assertEquals( + exists, + true, + ); + }, + ); + + Deno.test( + "takeScreenshot() | Takes Screenshot of only the element passed as selector and also quality(only if the image is jpeg)", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + const fileName = await page.takeScreenshot(ScreenshotsFolder, { + selector: "span", + quality: 50, + }); + await Sinco.done(); + const exists = existsSync(fileName); + Deno.removeSync(fileName); + assertEquals( + exists, + true, + ); + }, + ); + + Deno.test( + "Throws an error when format passed is jpeg(or default) and quality > than 100", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + let msg = ""; + try { + await page.takeScreenshot(ScreenshotsFolder, { quality: 999 }); + } catch (error) { + msg = error.message; + } + //await Sinco.done(); + assertEquals( + msg, + "A quality value greater than 100 is not allowed.", + ); + }, + ); + + Deno.test("Saves Screenshot with Given Filename", async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + const filename = await page.takeScreenshot(ScreenshotsFolder, { + fileName: "Happy", + }); + await Sinco.done(); + const exists = existsSync(filename); + Deno.removeSync(filename); + assertEquals( + exists, + true, + ); + }); + + Deno.test( + "takeScreenshot() | Saves Screenshot with given format (jpeg | png)", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + const fileName = await page.takeScreenshot(ScreenshotsFolder, { + format: "png", + }); + await Sinco.done(); + const exists = existsSync(fileName); + assertEquals( + exists, + true, + ); + Deno.removeSync(fileName); + }, + ); + + Deno.test("takeScreenshot() | Saves Screenshot with all options provided", async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + const filename = await page.takeScreenshot(ScreenshotsFolder, { + fileName: "AllOpts", + selector: "span", + format: "jpeg", + quality: 100, + }); + await Sinco.done(); + const exists = existsSync(filename); + assertEquals( + exists, + true, + ); + Deno.removeSync(filename); + }); + + Deno.test("click() | It should allow clicking of elements", async () => { + }); + + Deno.test( + "waitForPageChange() | Waits for a page to change before continuing", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + assertEquals( + await page.location(), + "https://chromestatus.com/features", + ); + const elem = await page.querySelector('a[href="/roadmap"]'); + await elem.click(); + await page.waitForPageChange(); + assertEquals(await page.location(), "https://chromestatus.com/roadmap"); + await Sinco.done(); + }, + ); + + Deno.test( + "assertSee() | Assertion should work when text is present on page", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com/features"); + await page.assertSee("Chrome Platform Status"); + await Sinco.done(); + }, + ); + Deno.test( + "assertSee() | Assertion should NOT work when text is NOT present on page", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + let errorMsg = ""; + // test fails because page is its own instance, so page prop is true, but clients is still false + try { + await page.assertSee("Crumpets and tea init?"); + } catch (err) { + errorMsg = err.message; + } + await Sinco.done(); + const msgArr = errorMsg.split("\n").filter((line) => { + return !!line === true && line.indexOf(" ") !== 0 && + line.indexOf("Values") < 0; + }); + assertEquals(msgArr[0].indexOf("- false") > -1, true); + assertEquals(msgArr[1].indexOf("+ true") > -1, true); + }, + ); + + Deno.test( + "evaluate() | It should evaluate function on current frame", + async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://drash.land"); + const pageTitle = await page.evaluate(() => { + // deno-lint-ignore no-undef + return document.title; + }); + await Sinco.done(); + assertEquals(pageTitle, "Drash Land"); + }, + ); + Deno.test("evaluate() | It should evaluate string on current frame", async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://chromestatus.com"); + const parentConstructor = await page.evaluate(`1 + 2`); + await Sinco.done(); + assertEquals(parentConstructor, 3); + }); + + Deno.test("location() | Sets and gets the location", async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://google.com"); + await page.location("https://drash.land"); + const location = await page.location(); + await Sinco.done(); + assertEquals(location, "https://drash.land/"); + }); + + Deno.test("cookie() | Sets and gets cookies", async () => { + const Sinco = await buildFor(browserItem.name); + const page = await Sinco.goTo("https://drash.land"); + await page.cookie({ + name: "user", + value: "ed", + "url": "https://drash.land", + }); + const cookies = await page.cookie(); + await Sinco.done(); + 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", + }, + ]); + }); +} From fec0716acecf09edd787ef062ab44af6eedf16e2 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 11 Dec 2021 14:44:37 +0000 Subject: [PATCH 09/22] try fix ci --- src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client.ts b/src/client.ts index 54189a3a..2aa7ac5c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -115,6 +115,7 @@ export class Client { ); await protocol.sendWebSocketMessage("Page.enable"); await protocol.sendWebSocketMessage("Runtime.enable"); + await protocol.sendWebSocketMessage("DOM.enable") return new Client(protocol); } } From b245385c07b740a9171e6e048b0c15af1466aa86 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 11 Dec 2021 14:45:53 +0000 Subject: [PATCH 10/22] try fix ci --- src/page.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/page.ts b/src/page.ts index 76f1b868..188c4659 100644 --- a/src/page.ts +++ b/src/page.ts @@ -69,6 +69,7 @@ export class Page { Protocol.DOM.GetDocumentRequest, Protocol.DOM.GetDocumentResponse >("DOM.getDocument"); + console.log(document) return document.root.documentURL ?? ""; } const method = "Page.loadEventFired"; From a7cefe38e3cd45d66ad7b308413f5a2386a994a0 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 11 Dec 2021 14:54:53 +0000 Subject: [PATCH 11/22] fix ci --- src/client.ts | 13 ++++++++++++- src/page.ts | 11 +++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/client.ts b/src/client.ts index 2aa7ac5c..c4667aaf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,6 +3,18 @@ import { deferred, Protocol as ProtocolTypes, readLines } from "../deps.ts"; import { Page } from "./page.ts"; import type { Browsers } from "./types.ts"; +// 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) + * ``` + */ + export interface BuildOptions { debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 hostname?: string; // The hostname the browser process starts on. If on host machine, this will be "localhost", if in docker, it will bee the container name. Defaults to localhost @@ -115,7 +127,6 @@ export class Client { ); await protocol.sendWebSocketMessage("Page.enable"); await protocol.sendWebSocketMessage("Runtime.enable"); - await protocol.sendWebSocketMessage("DOM.enable") return new Client(protocol); } } diff --git a/src/page.ts b/src/page.ts index 188c4659..aa1f5e87 100644 --- a/src/page.ts +++ b/src/page.ts @@ -65,12 +65,11 @@ export class Page { */ public async location(newLocation?: string): Promise { if (!newLocation) { - const document = await this.#protocol.sendWebSocketMessage< - Protocol.DOM.GetDocumentRequest, - Protocol.DOM.GetDocumentResponse - >("DOM.getDocument"); - console.log(document) - return document.root.documentURL ?? ""; + const targets = await this.#protocol.sendWebSocketMessage< + null, + Protocol.Target.GetTargetsResponse + >("Target.getTargets"); + return targets.targetInfos[0].url; } const method = "Page.loadEventFired"; this.#protocol.notification_resolvables.set(method, deferred()); From fb7da9187f5eb30ab43b803f5da20a347a45d8b6 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 11 Dec 2021 15:04:01 +0000 Subject: [PATCH 12/22] fix tests --- tests/browser_list.ts | 34 +++++++++++++++++++++++++++++++++- tests/unit/page_test.ts | 18 +----------------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/tests/browser_list.ts b/tests/browser_list.ts index f066114d..f861be76 100644 --- a/tests/browser_list.ts +++ b/tests/browser_list.ts @@ -7,6 +7,7 @@ export const browserList: Array<{ page_not_exist_message: string; page_name_not_resolved: string; }; + cookies: Record[]; getPath: () => string; }> = [ { @@ -18,14 +19,45 @@ export const browserList: Array<{ '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: "/", + secure: true, + session: true, + size: 6, + 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: "todo", + page_name_not_resolved: + 'NS_ERROR_UNKNOWN_HOST:Errorfornavigatingtopage"https://hhh"', }, getPath: getFirefoxPath, + 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", + }, + ], }, ]; diff --git a/tests/unit/page_test.ts b/tests/unit/page_test.ts index d7da62d6..13c0d538 100644 --- a/tests/unit/page_test.ts +++ b/tests/unit/page_test.ts @@ -220,22 +220,6 @@ for (const browserItem of browserList) { }); const cookies = await page.cookie(); await Sinco.done(); - 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(cookies, browserItem.cookies); }); } From d502833f6dc0f34e206b52eefc3b068837e9f336 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sat, 11 Dec 2021 15:12:20 +0000 Subject: [PATCH 13/22] fix tests --- tests/browser_list.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/browser_list.ts b/tests/browser_list.ts index f861be76..9d30a88d 100644 --- a/tests/browser_list.ts +++ b/tests/browser_list.ts @@ -26,9 +26,13 @@ export const browserList: Array<{ httpOnly: false, name: "user", path: "/", + priority: "Medium", + sameParty: false, secure: true, session: true, size: 6, + sourcePort: 443, + sourceScheme: "Secure", value: "ed", }, ], @@ -39,7 +43,7 @@ export const browserList: Array<{ 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:Errorfornavigatingtopage"https://hhh"', + 'NS_ERROR_UNKNOWN_HOST: Error for navigating to page "https://hhh"', }, getPath: getFirefoxPath, cookies: [ @@ -49,13 +53,9 @@ export const browserList: Array<{ httpOnly: false, name: "user", path: "/", - priority: "Medium", - sameParty: false, secure: true, session: true, size: 6, - sourcePort: 443, - sourceScheme: "Secure", value: "ed", }, ], From e783f6b8d6a92bdf1cd126492e7a25e9d0ffb90b Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 12 Dec 2021 20:23:03 +0000 Subject: [PATCH 14/22] ocument client class --- src/client.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index c4667aaf..b4437886 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,12 +15,27 @@ import type { Browsers } from "./types.ts"; * ``` */ -export interface BuildOptions { - debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 - hostname?: string; // The hostname the browser process starts on. If on host machine, this will be "localhost", if in docker, it will bee the container name. Defaults to localhost - binaryPath?: string; //The Full Path to the browser binary. If using an alternative chromium based browser, this field is necessary. -} - +/** + * 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: + * - Starting the headless browser (subprocess) + * - Methods to interact with the client such as: + * - Visiting a page and returning a `Page` class + * to interact with that page. + * + * @example + * ```js + * const client = await Client.create([ + * "chrome", "--headless", + * ], { + * hostname: "localhost", + * port: 1234 + * }, "chrome", undefined); + * const page = await client.goTo("https://drash.land"); + * ``` + */ export class Client { #protocol: ProtocolClass; constructor( From 532fe39b8ec22b2ae05564f2366c152d87fa8073 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 12 Dec 2021 20:23:52 +0000 Subject: [PATCH 15/22] move build options into interface file --- mod.ts | 5 ++++- src/interfaces.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/interfaces.ts diff --git a/mod.ts b/mod.ts index f5d84f26..2a6e1595 100644 --- a/mod.ts +++ b/mod.ts @@ -1,7 +1,10 @@ -import { BuildOptions, Client } from "./src/client.ts"; +import { Client } from "./src/client.ts"; +import { BuildOptions } from "./src/interfaces.ts" import type { Browsers } from "./src/types.ts"; import { getChromeArgs, getFirefoxArgs } from "./src/utility.ts"; +export type { BuildOptions } + export async function buildFor( browser: Browsers, options: BuildOptions = { diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 00000000..265da8db --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,5 @@ +export interface BuildOptions { +debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 +hostname?: string; // The hostname the browser process starts on. If on host machine, this will be "localhost", if in docker, it will bee the container name. Defaults to localhost +binaryPath?: string; //The Full Path to the browser binary. If using an alternative chromium based browser, this field is necessary. +} \ No newline at end of file From cec56e6c427c14b14b50156d17c9fedaadae08c5 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 12 Dec 2021 20:26:18 +0000 Subject: [PATCH 16/22] put all interfaces into one file --- mod.ts | 4 ++-- src/interfaces.ts | 15 ++++++++++++++- src/page.ts | 14 +------------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/mod.ts b/mod.ts index 2a6e1595..e2ca10c8 100644 --- a/mod.ts +++ b/mod.ts @@ -1,9 +1,9 @@ import { Client } from "./src/client.ts"; -import { BuildOptions } from "./src/interfaces.ts" +import { BuildOptions, ScreenshotOptions, Cookie } from "./src/interfaces.ts" import type { Browsers } from "./src/types.ts"; import { getChromeArgs, getFirefoxArgs } from "./src/utility.ts"; -export type { BuildOptions } +export type { BuildOptions, ScreenshotOptions, Cookie } export async function buildFor( browser: Browsers, diff --git a/src/interfaces.ts b/src/interfaces.ts index 265da8db..5ac5c0e9 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -2,4 +2,17 @@ export interface BuildOptions { debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 hostname?: string; // The hostname the browser process starts on. If on host machine, this will be "localhost", if in docker, it will bee the container name. Defaults to localhost binaryPath?: string; //The Full Path to the browser binary. If using an alternative chromium based browser, this field is necessary. -} \ No newline at end of file +} + +export interface ScreenshotOptions { + selector?: string; + fileName?: string; + format?: "jpeg" | "png"; + quality?: number; + } + + export type Cookie = { + name: string; + value: string; + url: string; + }; \ No newline at end of file diff --git a/src/page.ts b/src/page.ts index aa1f5e87..42d47b33 100644 --- a/src/page.ts +++ b/src/page.ts @@ -2,19 +2,7 @@ import { assertEquals, deferred, Protocol } from "../deps.ts"; import { existsSync, generateTimestamp } from "./utility.ts"; import { Element } from "./element.ts"; import { Protocol as ProtocolClass } from "./protocol.ts"; - -export interface ScreenshotOptions { - selector?: string; - fileName?: string; - format?: "jpeg" | "png"; - quality?: number; -} - -export type Cookie = { - name: string; - value: string; - url: string; -}; +import { ScreenshotOptions, Cookie } from "./interfaces.ts" export class Page { readonly #protocol: ProtocolClass; From cbfbb6d01b42227b3c4429b18105522867d58720 Mon Sep 17 00:00:00 2001 From: Edward Bebbington <47337480+ebebbington@users.noreply.github.com> Date: Sun, 12 Dec 2021 20:26:50 +0000 Subject: [PATCH 17/22] Update src/page.ts Co-authored-by: Eric Crooks --- src/page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/page.ts b/src/page.ts index aa1f5e87..0949d4aa 100644 --- a/src/page.ts +++ b/src/page.ts @@ -61,7 +61,7 @@ export class Page { * const location = await page.location() // "https://drash.land" * ``` * - * @returns The location for the page if no parameter is passed in, else an empty array + * @returns The location for the page if no parameter is passed in, else an empty string */ public async location(newLocation?: string): Promise { if (!newLocation) { From a152318f127469f4544e78c77c3ea03b13940a14 Mon Sep 17 00:00:00 2001 From: Edward Bebbington <47337480+ebebbington@users.noreply.github.com> Date: Sun, 12 Dec 2021 20:26:55 +0000 Subject: [PATCH 18/22] Update src/page.ts Co-authored-by: Eric Crooks --- src/page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/page.ts b/src/page.ts index 0949d4aa..71b3e339 100644 --- a/src/page.ts +++ b/src/page.ts @@ -161,7 +161,7 @@ export class Page { } /** - * Check if the given text exists on the dom + * Check if the given text exists on the DOM * * @param text - The text to check for */ From 88c3eb29a983e0bb04dc6e7c71c9c6ca6abfce24 Mon Sep 17 00:00:00 2001 From: Edward Bebbington <47337480+ebebbington@users.noreply.github.com> Date: Sun, 12 Dec 2021 20:27:08 +0000 Subject: [PATCH 19/22] Update src/page.ts Co-authored-by: Eric Crooks --- src/page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/page.ts b/src/page.ts index 71b3e339..9de24273 100644 --- a/src/page.ts +++ b/src/page.ts @@ -182,7 +182,7 @@ export class Page { } /** - * Representation of the Browsers `document.querySelector` + * Representation of the Browser's `document.querySelector` * * @param selector - The selector for the element * From 3987476055de14acbe69900060234e88ca008d3e Mon Sep 17 00:00:00 2001 From: Edward Bebbington <47337480+ebebbington@users.noreply.github.com> Date: Sun, 12 Dec 2021 20:27:15 +0000 Subject: [PATCH 20/22] Update src/page.ts Co-authored-by: Eric Crooks --- src/page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/page.ts b/src/page.ts index 9de24273..80459c7f 100644 --- a/src/page.ts +++ b/src/page.ts @@ -186,7 +186,7 @@ export class Page { * * @param selector - The selector for the element * - * @returns An element class, allowing you to action upon that element + * @returns An element class, allowing you to take an action upon that element */ async querySelector(selector: string) { const result = await this.evaluate( From 267d421daf517b5aab8fb42432bde576e77ddd0b Mon Sep 17 00:00:00 2001 From: Edward Bebbington <47337480+ebebbington@users.noreply.github.com> Date: Sun, 12 Dec 2021 20:27:23 +0000 Subject: [PATCH 21/22] Update src/utility.ts Co-authored-by: Eric Crooks --- src/utility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utility.ts b/src/utility.ts index 3193a413..ab398a53 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -53,7 +53,7 @@ export function getChromePath(): string { } throw new Error( - "Cannot find path for chrome in windows. Submit an issue if you encounter this error", + "Cannot find path for chrome in Windows. Submit an issue if you encounter this error.", ); case "linux": chromePath = paths.linux; From 2677d073c825403e4cc2abc59f001955f68335b3 Mon Sep 17 00:00:00 2001 From: Edward Bebbington Date: Sun, 12 Dec 2021 20:48:55 +0000 Subject: [PATCH 22/22] add docblocks --- mod.ts | 4 ++-- src/client.ts | 4 ++-- src/element.ts | 4 ++++ src/interfaces.ts | 46 ++++++++++++++++++++++++++++------------------ src/page.ts | 21 ++++++++------------- 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/mod.ts b/mod.ts index e2ca10c8..3e7c13d7 100644 --- a/mod.ts +++ b/mod.ts @@ -1,9 +1,9 @@ import { Client } from "./src/client.ts"; -import { BuildOptions, ScreenshotOptions, Cookie } from "./src/interfaces.ts" +import { BuildOptions, Cookie, ScreenshotOptions } from "./src/interfaces.ts"; import type { Browsers } from "./src/types.ts"; import { getChromeArgs, getFirefoxArgs } from "./src/utility.ts"; -export type { BuildOptions, ScreenshotOptions, Cookie } +export type { BuildOptions, Cookie, ScreenshotOptions }; export async function buildFor( browser: Browsers, diff --git a/src/client.ts b/src/client.ts index b4437886..a66bea03 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,14 +17,14 @@ import type { Browsers } from "./types.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: * - Starting the headless browser (subprocess) * - Methods to interact with the client such as: * - Visiting a page and returning a `Page` class * to interact with that page. - * + * * @example * ```js * const client = await Client.create([ diff --git a/src/element.ts b/src/element.ts index 9300020c..708008f3 100644 --- a/src/element.ts +++ b/src/element.ts @@ -1,5 +1,9 @@ import { Page } from "./page.ts"; +/** + * 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 diff --git a/src/interfaces.ts b/src/interfaces.ts index 5ac5c0e9..e5b34a44 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,18 +1,28 @@ -export interface BuildOptions { -debuggerPort?: number; // The port to start the debugger on for Chrome, so that we can connect to it. Defaults to 9292 -hostname?: string; // The hostname the browser process starts on. If on host machine, this will be "localhost", if in docker, it will bee the container name. Defaults to localhost -binaryPath?: string; //The Full Path to the browser binary. If using an alternative chromium based browser, this field is necessary. -} - -export interface ScreenshotOptions { - selector?: string; - fileName?: string; - format?: "jpeg" | "png"; - quality?: number; - } - - export type Cookie = { - name: string; - value: string; - url: string; - }; \ No newline at end of file +export interface BuildOptions { + /** The port that the WebSocket server should run on when starting the headless client */ + debuggerPort?: number; + /** The hostname that the WebSocket server starts on when starting the headless client */ + hostname?: string; + /** The path to the binary of the browser executable, such as specifying an alternative chromium browser */ + binaryPath?: string; +} + +export interface ScreenshotOptions { + /** Name to be given to the screenshot. Optional */ + selector?: string; + /** 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 */ + quality?: number; +} + +export type Cookie = { + /** The name of the cookie */ + name: string; + /** The value to set the cookie to */ + value: string; + /** The domain that the cookie shoudl belong to */ + url: string; +}; diff --git a/src/page.ts b/src/page.ts index f89e8b63..d6ed5312 100644 --- a/src/page.ts +++ b/src/page.ts @@ -2,8 +2,12 @@ import { assertEquals, deferred, Protocol } from "../deps.ts"; import { existsSync, generateTimestamp } from "./utility.ts"; import { Element } from "./element.ts"; import { Protocol as ProtocolClass } from "./protocol.ts"; -import { ScreenshotOptions, Cookie } from "./interfaces.ts" +import { Cookie, ScreenshotOptions } from "./interfaces.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 { readonly #protocol: ProtocolClass; @@ -162,13 +166,6 @@ export class Page { assertEquals(exists, true); } - // deno-lint-ignore require-await - async #$x(_selector: string) { - throw new Error("Client#$x not impelemented"); - // todo check the element exists first - //return new Element('$x', selector, this) - } - /** * Representation of the Browser's `document.querySelector` * @@ -194,11 +191,9 @@ export class Page { * and its children as opposed to the whole page. * * @param path - The path of where to save the screenshot to - * @param options - options - * @param options.filename - Name to be given to the screenshot. Optional - * @param options.selector - Screenshot the given selector instead of the full page. Optional - * @param options.format - The Screenshot format(and hence extension). Allowed values are "jpeg" and "png" - Optional - * @param options.quality - The image quality from 0 to 100, default 80. Applicable only if no format provided or format is "jpeg" - Optional + * @param options + * + * @returns The path to the file relative to CWD, e.g., "Screenshots/users/user_1.png" */ async takeScreenshot( path: string,