Skip to content

Commit

Permalink
Fixes tests, correctly wait for page loads and remove windows from CI
Browse files Browse the repository at this point in the history
* Fix tests

* Replace deno.run with deno.command

* fmt

* possible fix for windows hanging

* attempt to figure out why test is hanging on windows

* uncomment tests

* fmt

* comment out windows for now

* Update page.ts

* Finally fix issue of not being able to wait until pages have fully loaded

* fix tests
  • Loading branch information
ebebbington authored Apr 29, 2024
1 parent 0d870c9 commit 4208bb5
Show file tree
Hide file tree
Showing 20 changed files with 205 additions and 285 deletions.
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/std@0.139.0/testing/asserts.ts";
export { readLines } from "https://deno.land/std@0.139.0/io/mod.ts";
export { deferred } from "https://deno.land/std@0.139.0/async/deferred.ts";
export type { Deferred } from "https://deno.land/std@0.139.0/async/deferred.ts";
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

0 comments on commit 4208bb5

Please sign in to comment.