Skip to content

Commit

Permalink
feat: ability to run unit tests in browser
Browse files Browse the repository at this point in the history
  • Loading branch information
DudaGod committed Mar 21, 2024
1 parent 3d7cbab commit 1aeb516
Showing 58 changed files with 3,603 additions and 430 deletions.
1,675 changes: 1,407 additions & 268 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
"typings"
],
"scripts": {
"build": "tsc && npm run copy-static && npm run build-bundle -- --minify",
"build": "tsc --build && npm run copy-static && npm run build-bundle -- --minify",
"build-bundle": "esbuild ./src/bundle/index.ts --outdir=./build/src/bundle --bundle --format=cjs --platform=node --target=ES2021",
"copy-static": "copyfiles 'src/browser/client-scripts/*' build",
"check-types": "tsc --project tsconfig.spec.json",
@@ -25,7 +25,7 @@
"commitmsg": "commitlint -e",
"release": "standard-version",
"watch": "npm run copy-static && concurrently -c 'auto' 'npm:watch:src' 'npm:watch:bundle'",
"watch:src": "tsc --watch",
"watch:src": "tsc --build --watch",
"watch:bundle": "npm run build-bundle -- --watch"
},
"repository": {
@@ -62,7 +62,9 @@
"fastq": "1.13.0",
"fs-extra": "5.0.0",
"gemini-configparser": "1.3.0",
"get-port": "5.1.1",
"glob-extra": "5.0.2",
"import-meta-resolve": "4.0.0",
"lodash": "4.17.21",
"looks-same": "9.0.0",
"micromatch": "4.0.5",
@@ -77,6 +79,7 @@
"uglifyify": "3.0.4",
"urijs": "1.19.11",
"url-join": "4.0.1",
"vite": "5.1.6",
"webdriverio": "8.21.0",
"worker-farm": "1.7.0",
"yallist": "3.1.1"
@@ -95,12 +98,14 @@
"@types/bluebird": "3.5.38",
"@types/chai": "4.3.4",
"@types/chai-as-promised": "7.1.5",
"@types/debug": "4.1.12",
"@types/lodash": "4.14.191",
"@types/node": "18.19.3",
"@types/proxyquire": "1.3.28",
"@types/sharp": "0.31.1",
"@types/sinon": "4.3.3",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/urijs": "1.19.25",
"@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.12.0",
"app-module-path": "2.2.0",
2 changes: 2 additions & 0 deletions src/browser/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AssertViewCommand, AssertViewElementCommand } from "./commands/types";
import type { BrowserConfig } from "./../config/browser-config";
import type { AssertViewResult, RunnerTest, RunnerHook } from "../types";
import Callstack from "./history/callstack";

export interface BrowserMeta {
pid: number;
@@ -13,6 +14,7 @@ export interface Browser {
config: BrowserConfig;
state: Record<string, unknown>;
applyState: (state: Record<string, unknown>) => void;
callstackHistory: Callstack;
}

declare global {
3 changes: 2 additions & 1 deletion src/config/defaults.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";

const { WEBDRIVER_PROTOCOL, SAVE_HISTORY_MODE } = require("../constants/config");
const { WEBDRIVER_PROTOCOL, SAVE_HISTORY_MODE, NODEJS_TEST_RUN_ENV } = require("../constants/config");

module.exports = {
baseUrl: "http://localhost",
@@ -93,6 +93,7 @@ module.exports = {
region: null,
headless: null,
isolation: null,
testRunEnv: NODEJS_TEST_RUN_ENV,
};

module.exports.configPaths = [".hermione.conf.ts", ".hermione.conf.js"];
46 changes: 46 additions & 0 deletions src/config/options.js
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ const { root, section, map, option } = require("gemini-configparser");
const browserOptions = require("./browser-options");
const defaults = require("./defaults");
const optionsBuilder = require("./options-builder");
const { NODEJS_TEST_RUN_ENV, BROWSER_TEST_RUN_ENV } = require("../constants/config");

const options = optionsBuilder(_.propertyOf(defaults));

@@ -57,6 +58,51 @@ const rootSection = section(
}
},
}),

testRunEnv: option({
defaultValue: defaults.testRunEnv,
validate: value => {
if (!_.isArray(value) && !_.isString(value)) {
throw new Error(`"testRunEnv" must be an array or string but got ${JSON.stringify(value)}`);
}

if (_.isString(value)) {
if (value !== NODEJS_TEST_RUN_ENV && value !== BROWSER_TEST_RUN_ENV) {
throw new Error(
`"testRunEnv" specified as string must be "${NODEJS_TEST_RUN_ENV}" or "${BROWSER_TEST_RUN_ENV}" but got "${value}"`,
);
}

return;
}

const [testRunEnv, options] = value;

if (testRunEnv === NODEJS_TEST_RUN_ENV) {
throw new Error(
`"testRunEnv" with "${NODEJS_TEST_RUN_ENV}" value must be specified as string but got ${JSON.stringify(
value,
)}`,
);
}

if (testRunEnv === BROWSER_TEST_RUN_ENV && !options) {
throw new Error(
`"testRunEnv" specified as array must also contain options as second argument but got ${JSON.stringify(
value,
)}`,
);
}

if (testRunEnv !== BROWSER_TEST_RUN_ENV) {
throw new Error(
`"testRunEnv" specified as array must be in format ["${BROWSER_TEST_RUN_ENV}", <options>] but got ${JSON.stringify(
value,
)}`,
);
}
},
}),
}),

plugins: options.anyObject(),
7 changes: 7 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SetRequired } from "type-fest";
import type { BrowserConfig } from "./browser-config";
import type { BrowserTestRunEnvOptions } from "../runner/browser-env/vite/types";
import type { Test } from "../types";

export interface CompareOptsConfig {
@@ -71,6 +72,7 @@ export interface SystemConfig {
tempDir: string;
parallelLimit: number;
fileExtensions: Array<string>;
testRunEnv: "nodejs" | "browser" | ["browser", BrowserTestRunEnvOptions];
}

export interface CommonConfig {
@@ -144,6 +146,11 @@ export type ConfigInput = {
prepareEnvironment?: () => void | null;
};

export interface RuntimeConfig {
extend: (data: unknown) => this;
[key: string]: unknown;
}

declare module "." {
export interface Config extends CommonConfig {
browsers: Record<string, BrowserConfig>;
2 changes: 2 additions & 0 deletions src/constants/config.js
Original file line number Diff line number Diff line change
@@ -8,4 +8,6 @@ module.exports = {
NONE: "none",
ONLY_FAILED: "onlyFailed",
},
NODEJS_TEST_RUN_ENV: "nodejs",
BROWSER_TEST_RUN_ENV: "browser",
};
11 changes: 8 additions & 3 deletions src/hermione.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,8 @@ import { CommanderStatic } from "@gemini-testing/commander";
import * as _ from "lodash";
import { Stats as RunnerStats } from "./stats";
import { BaseHermione } from "./base-hermione";
import { MainRunner } from "./runner";
import { MainRunner as NodejsEnvRunner } from "./runner";
import { MainRunner as BrowserEnvRunner } from "./runner/browser-env";
import RuntimeConfig from "./config/runtime-config";
import { MasterAsyncEvents, MasterEvents, MasterSyncEvents } from "./events";
import eventsUtils from "./events/utils";
@@ -12,6 +13,7 @@ import { TestCollection } from "./test-collection";
import { validateUnknownBrowsers } from "./validators";
import { initReporters } from "./reporters";
import logger from "./utils/logger";
import { isRunInNodeJsEnv } from "./utils/config";
import { ConfigInput } from "./config/types";
import { MasterEventHandler, Test } from "./types";

@@ -47,7 +49,7 @@ export interface Hermione {

export class Hermione extends BaseHermione {
protected failed: boolean;
protected runner: MainRunner | null;
protected runner: NodejsEnvRunner | BrowserEnvRunner | null;

constructor(config?: string | ConfigInput) {
super(config);
@@ -82,7 +84,10 @@ export class Hermione extends BaseHermione {
this._config.system.mochaOpts.timeout = 0;
}

const runner = MainRunner.create(this._config, this._interceptors);
const runner = (isRunInNodeJsEnv(this._config) ? NodejsEnvRunner : BrowserEnvRunner).create(
this._config,
this._interceptors,
);
this.runner = runner;

this.on(MasterEvents.TEST_FAIL, () => this._fail()).on(MasterEvents.ERROR, (err: Error) => this.halt(err));
42 changes: 42 additions & 0 deletions src/runner/browser-env/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ViteServer } from "./vite/server";
import { MainRunner as NodejsEnvRunner } from "..";
import { TestCollection } from "../../test-collection";
import { Config } from "../../config";
import { Interceptor } from "../../events";
import type { Stats as RunnerStats } from "../../stats";

export class MainRunner extends NodejsEnvRunner {
#viteServer: ViteServer;

constructor(config: Config, interceptors: Interceptor[]) {
super(config, interceptors);

this.#viteServer = ViteServer.create(config);
}

async run(testCollection: TestCollection, stats: RunnerStats): Promise<void> {
try {
await this.#viteServer.start();
} catch (err) {
throw new Error(`Vite server failed to start: ${(err as Error).message}`);
}

this.useBaseUrlFromVite();
await super.run(testCollection, stats);
}

private useBaseUrlFromVite(): void {
const viteBaseUrl = this.#viteServer.baseUrl!;

this.config.baseUrl = viteBaseUrl;
for (const broConfig of Object.values(this.config.browsers)) {
broConfig.baseUrl = viteBaseUrl;
}
}

cancel(): void {
super.cancel();

this.#viteServer.close();
}
}
26 changes: 26 additions & 0 deletions src/runner/browser-env/vite/browser-modules/communicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { prepareError } from "./errors/index.js";
import { BrowserEventNames, BrowserMessage, WorkerEventNames, WorkerMessage } from "./types.js";

export class ViteBrowserCommunicator {
static create<T extends ViteBrowserCommunicator>(this: new () => T): T {
return new this();
}

subscribeOnMessage(event: WorkerEventNames, handler: (msg: WorkerMessage) => Promise<void>): void {
import.meta.hot?.on(event, handler);
}

sendMessage(event: BrowserEventNames, msg?: Partial<BrowserMessage>): void {
if (msg && msg.errors) {
msg.errors = msg.errors.map(prepareError);
}

import.meta.hot?.send(event, {
pid: window.__hermione__.pid,
runUuid: window.__hermione__.runUuid,
cmdUuid: window.__hermione__.cmdUuid,
errors: [],
...msg,
});
}
}
14 changes: 14 additions & 0 deletions src/runner/browser-env/vite/browser-modules/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const DOCUMENT_TITLE = "Hermione Browser Test";
export const VITE_OVERLAY_SELECTOR = "vite-error-overlay";

export const VITE_SELECTORS = {
overlay: "vite-error-overlay",
overlayMessage: ".message",
overlayStack: ".stack",
overlayFile: ".file",
overlayFrame: ".frame",
overlayTip: ".tip",
};

export const HERMIONE_BROWSER_EVENT_SUFFIX = "hermione:browser";
export const HERMIONE_WORKER_EVENT_SUFFIX = "hermione:worker";
11 changes: 11 additions & 0 deletions src/runner/browser-env/vite/browser-modules/errors/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class BaseError extends Error {
constructor({ message, stack }: { message: string; stack?: string }) {
super(message);

this.name = this.constructor.name;

if (stack) {
this.stack = stack;
}
}
}
23 changes: 23 additions & 0 deletions src/runner/browser-env/vite/browser-modules/errors/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BaseError } from "./base.js";

interface BrowserErrorData {
message: string;
stack?: string;
file?: string;
}

export class BrowserError extends BaseError {
file?: string;

static create<T extends BrowserError>(this: new (opts: BrowserErrorData) => T, opts: BrowserErrorData): T {
return new this(opts);
}

constructor({ message, stack, file }: BrowserErrorData) {
super({ message, stack });

if (file) {
this.file = file;
}
}
}
58 changes: 58 additions & 0 deletions src/runner/browser-env/vite/browser-modules/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { BrowserError } from "./browser.js";
import { LoadPageError } from "./load-page.js";
import { ViteError } from "./vite.js";
import { getSelectorTextFromShadowRoot } from "../utils/index.js";
import { DOCUMENT_TITLE, VITE_SELECTORS } from "../constants.js";

export type ErrorOnPageLoad = LoadPageError | ViteError | BrowserError;
export type ErrorOnRunRunnable = ViteError | BrowserError | Error;
export type AvailableError = ErrorOnPageLoad | Error;

const getLoadPageErrors = (): LoadPageError[] => {
if (document.title === DOCUMENT_TITLE && window.__hermione__) {
return [];
}

return [LoadPageError.create()];
};

const getViteErrors = (): ViteError[] => {
const viteErrorElem = document.querySelector(VITE_SELECTORS.overlay);

if (!viteErrorElem || !viteErrorElem.shadowRoot) {
return [];
}

const shadowRoot = viteErrorElem.shadowRoot;

const message = getSelectorTextFromShadowRoot(VITE_SELECTORS.overlayMessage, shadowRoot);
const stack = getSelectorTextFromShadowRoot(VITE_SELECTORS.overlayStack, shadowRoot);
const file = getSelectorTextFromShadowRoot(VITE_SELECTORS.overlayFile, shadowRoot);
const frame = getSelectorTextFromShadowRoot(VITE_SELECTORS.overlayFrame, shadowRoot);
const tip = getSelectorTextFromShadowRoot(VITE_SELECTORS.overlayTip, shadowRoot);

return [ViteError.create({ message, stack, file, frame, tip })];
};

const getBrowserErrors = (): BrowserError[] => {
return window.__hermione__.errors;
};

const findErrors = (errors: AvailableError | AvailableError[] = []): AvailableError[] => {
return [errors, getViteErrors(), getBrowserErrors()].flat().filter(Boolean);
};

export const findErrorsOnPageLoad = (): ErrorOnPageLoad[] => {
return findErrors(getLoadPageErrors());
};

export const findErrorsOnRunRunnable = (runnableError?: Error): AvailableError[] => {
return findErrors(runnableError);
};

export const prepareError = (error: Error): Error => {
// in order to correctly pass errors through websocket
return JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error)));
};

export { BrowserError, LoadPageError, ViteError };
Loading

0 comments on commit 1aeb516

Please sign in to comment.