Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix tests #143

Merged
merged 12 commits into from
Apr 29, 2024
4 changes: 1 addition & 3 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
tests:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}

steps:
Expand All @@ -53,8 +53,6 @@ jobs:
- name: Run Integration Tests
run: |
deno test -A tests/integration --config tsconfig.json --no-check=remote



- name: Run Unit Tests
run: |
Expand Down
1 change: 0 additions & 1 deletion deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@ export {
AssertionError,
assertNotEquals,
} from "https://deno.land/[email protected]/testing/asserts.ts";
export { readLines } from "https://deno.land/[email protected]/io/mod.ts";
export { deferred } from "https://deno.land/[email protected]/async/deferred.ts";
export type { Deferred } from "https://deno.land/[email protected]/async/deferred.ts";
61 changes: 22 additions & 39 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Protocol as ProtocolClass } from "./protocol.ts";
import { deferred, Protocol as ProtocolTypes, readLines } from "../deps.ts";
import { deferred, Protocol as ProtocolTypes } from "../deps.ts";
import { Page } from "./page.ts";
import type { Browsers } from "./types.ts";
import { existsSync } from "./utility.ts";
import { TextLineStream } from "jsr:@std/streams";

// https://stackoverflow.com/questions/50395719/firefox-remote-debugging-with-websockets
// FYI for reference, we can connect using websockets, but severe lack of documentation gives us NO info on how to proceed after:
Expand Down Expand Up @@ -47,7 +48,7 @@ export class Client {
/**
* The sub process that runs headless chrome
*/
readonly #browser_process: Deno.Process | undefined;
readonly #browser_process: Deno.ChildProcess | undefined;

/**
* Track if we've closed the sub process, so we dont try close it when it already has been
Expand Down Expand Up @@ -88,7 +89,7 @@ export class Client {
*/
constructor(
protocol: ProtocolClass,
browserProcess: Deno.Process | undefined,
browserProcess: Deno.ChildProcess | undefined,
browser: Browsers,
wsOptions: {
hostname: string;
Expand Down Expand Up @@ -176,22 +177,18 @@ export class Client {
return;
}

// Collect all promises we need to wait for due to the browser and any page websockets
const pList = this.#pages.map((_page) => deferred());
pList.push(deferred());
this.#pages.forEach((page, i) => {
page.socket.onclose = () => pList[i].resolve();
});
this.#protocol.socket.onclose = () => pList.at(-1)?.resolve();

// Close browser process (also closes the ws endpoint, which in turn closes all sockets)
if (this.#browser_process) {
this.#browser_process.stderr!.close();
this.#browser_process.stdout!.close();
this.#browser_process.close();
this.#browser_process.stderr.cancel();
this.#browser_process.stdout.cancel();
this.#browser_process.kill();
await this.#browser_process.status;
} else {
//When Working with Remote Browsers, where we don't control the Browser Process explicitly
// When Working with Remote Browsers, where we don't control the Browser Process explicitly
const promise = deferred();
this.#protocol.socket.onclose = () => promise.resolve();
await this.#protocol.send("Browser.close");
await promise;
}

// Zombie processes is a thing with Windows, the firefox process on windows
Expand All @@ -210,25 +207,6 @@ export class Client {
p.close();
} */

// Wait until all ws clients are closed, so we aren't leaking any ops
await Promise.all(pList);

// Wait until we know for sure that the process is gone and the port is freed up
function listen(wsOptions: { port: number; hostname: string }) {
try {
const listener = Deno.listen({
hostname: wsOptions.hostname,
port: wsOptions.port,
});
listener.close();
} catch (_e) {
listen(wsOptions);
}
}
if (this.#browser_process) {
listen(this.wsOptions);
}

this.#browser_process_closed = true;

if (this.#firefox_profile_path) {
Expand Down Expand Up @@ -289,21 +267,26 @@ export class Client {
browser: Client;
page: Page;
}> {
let browserProcess: Deno.Process | undefined = undefined;
let browserProcess: Deno.ChildProcess | undefined = undefined;
let browserWsUrl = "";
// Run the subprocess, this starts up the debugger server
if (!wsOptions.remote) { //Skip this if browser is remote
browserProcess = Deno.run({
cmd: buildArgs,
const path = buildArgs.splice(0, 1)[0];
const command = new Deno.Command(path, {
args: buildArgs,
stderr: "piped",
stdout: "piped",
});
browserProcess = command.spawn();

// Get the main ws conn for the client - this loop is needed as the ws server isn't open until we get the listeneing on.
// We could just loop on the fetch of the /json/list endpoint, but we could tank the computers resources if the endpoint
// isn't up for another 10s, meaning however many fetch requests in 10s
// Sometimes it takes a while for the "Devtools listening on ws://..." line to show on windows + firefox too

for await (const line of readLines(browserProcess.stderr!)) { // Loop also needed before json endpoint is up
for await (
const line of browserProcess.stderr.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream())
) { // Loop also needed before json endpoint is up
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
if (!match) {
continue;
Expand Down
26 changes: 16 additions & 10 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Protocol } from "./protocol.ts";
import { deferred, Protocol as ProtocolTypes } from "../deps.ts";
import { existsSync, generateTimestamp } from "./utility.ts";
import { ScreenshotOptions, WebsocketTarget } from "./interfaces.ts";
import { waitUntilNetworkIdle } from "./utility.ts";
/**
* A class to represent an element on the page, providing methods
* to action on that element
Expand Down Expand Up @@ -307,6 +308,20 @@ export class Element {
const quad = quads[0];
let x = 0;
let y = 0;

/**
* It could be that the element isn't clickable. Once
* instance i've found this is when i've tried to click
* an element `<a id=... href=... />` eg self closing.
* Could be more reasons though
*/
if (!quad) {
await this.#page.client.close(
`Unable to click the element "${this.#selector}". It could be that it is invalid HTML`,
);
return;
}

for (const point of quad) {
x += point.x;
y += point.y;
Expand Down Expand Up @@ -411,16 +426,7 @@ export class Element {
new Page(newProt, targetId, this.#page.client, frameId),
);
} else if (options.waitFor === "navigation") { // TODO :: Should we put this into its own method? waitForNavigation() to free up the maintability f this method, allowing us to add more params later but also for the mo, not need to do `.click({}, true)` OR maybe do `.click(..., waitFor: { navigation?: boolean, fetch?: boolean, ... }), because clicking needs to support: new pages, new locations, requests (any JS stuff, maybe when js is triggered it fired an event we can hook into?)
const method2 = "Page.frameStoppedLoading";
this.#protocol.notifications.set(
method2,
deferred(),
);
const notificationPromise2 = this.#protocol.notifications.get(
method2,
);
await notificationPromise2;
this.#protocol.notifications.delete(method2);
await waitUntilNetworkIdle();
}
}

Expand Down
23 changes: 16 additions & 7 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Protocol as ProtocolClass } from "./protocol.ts";
import { Cookie, ScreenshotOptions } from "./interfaces.ts";
import { Client } from "./client.ts";
import type { Deferred } from "../deps.ts";
import { waitUntilNetworkIdle } from "./utility.ts";

/**
* A representation of the page the client is on, allowing the client to action
Expand Down Expand Up @@ -220,11 +221,8 @@ export class Page {
);
return target?.url ?? "";
}
const method = "Page.loadEventFired";
this.#protocol.notifications.set(method, deferred());
const notificationPromise = this.#protocol.notifications.get(
method,
);

// Send message
const res = await this.#protocol.send<
Protocol.Page.NavigateRequest,
Protocol.Page.NavigateResponse
Expand All @@ -234,7 +232,18 @@ export class Page {
url: newLocation,
},
);
await notificationPromise;

await waitUntilNetworkIdle();

// Usually if an invalid URL is given, the WS never gets a notification
// but we get a message with the id associated with the msg we sent
// TODO :: Ideally the protocol class would throw and we could catch it so we know
// for sure its an error
if ("errorText" in res) {
await this.client.close(res.errorText);
return "";
}

if (res.errorText) {
await this.client.close(
`${res.errorText}: Error for navigating to page "${newLocation}"`,
Expand Down Expand Up @@ -399,7 +408,7 @@ export class Page {
// Otherwise, we have not gotten anymore notifs in the last .5s
clearInterval(interval);
forMessages.resolve();
}, 1000);
}, 500);
await forMessages;
const errorNotifs = this.#protocol.console_errors;
const filteredNotifs = !exceptions.length
Expand Down
8 changes: 5 additions & 3 deletions src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ interface NotificationResponse { // Not entirely sure when, but when we send the
}

type Create<T> = T extends true ? {
protocol: Protocol;
frameId: string;
}
protocol: Protocol;
frameId: string;
}
: T extends false ? Protocol
: never;

Expand Down Expand Up @@ -94,6 +94,8 @@ export class Protocol {
#handleSocketMessage(
message: MessageResponse | NotificationResponse,
) {
// TODO :: make it unique eg `<frame-id>.message` so say another page instance wont pick up events for the wrong websocket
dispatchEvent(new CustomEvent("message", { detail: message }));
if ("id" in message) { // message response
const resolvable = this.#messages.get(message.id);
if (!resolvable) {
Expand Down
33 changes: 32 additions & 1 deletion src/utility.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { deferred } from "../deps.ts";

export const existsSync = (filename: string): boolean => {
try {
Deno.statSync(filename);
Expand Down Expand Up @@ -70,7 +72,6 @@ export function getChromeArgs(port: number, binaryPath?: string): string[] {
"--headless",
"--no-sandbox",
"--disable-background-networking",
"--enable-features=NetworkService,NetworkServiceInProcess",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
Expand Down Expand Up @@ -110,6 +111,8 @@ export function getFirefoxPath(): string {
return "/usr/bin/firefox";
case "windows":
return "C:\\Program Files\\Mozilla Firefox\\firefox.exe";
default:
throw new Error("Unhandled OS. Unsupported for " + Deno.build.os);
}
}

Expand All @@ -129,3 +132,31 @@ export function getFirefoxArgs(
"about:blank",
];
}

export async function waitUntilNetworkIdle() {
// Logic for waiting until zero network requests have been received for 500ms
const p = deferred();
let interval = 0;
const startInterval = () => {
interval = setInterval(() => {
p.resolve();
clearInterval(interval);
}, 500);
};

// Event listener to restart interval
const eventListener = () => {
clearInterval(interval);
startInterval();
};

// On message, restart interval
addEventListener("message", eventListener);

// Start the interval and wait
startInterval();
await p;

// Unregister event listener
removeEventListener("message", eventListener);
}
8 changes: 4 additions & 4 deletions tests/console/bumper_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ Deno.test("Updates chrome version in dockerfile", async () => {
"./tests/integration/docker_test/drivers.dockerfile",
newContent,
);
const p = Deno.run({
cmd: ["deno", "run", "-A", "console/bumper_ci_service.ts"],
const p = new Deno.Command("deno", {
args: ["run", "-A", "console/bumper_ci_service.ts"],
});
await p.status();
p.close();
const child = p.spawn();
await child.status;
newContent = Deno.readTextFileSync(
"./tests/integration/docker_test/drivers.dockerfile",
);
Expand Down
65 changes: 0 additions & 65 deletions tests/integration/clicking_elements_test.ts

This file was deleted.

Loading
Loading