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 Apr 11, 2024
1 parent 02af7e2 commit 5a76453
Show file tree
Hide file tree
Showing 55 changed files with 4,160 additions and 432 deletions.
2,281 changes: 2,012 additions & 269 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down Expand Up @@ -64,7 +64,9 @@
"fastq": "1.13.0",
"fs-extra": "5.0.0",
"gemini-configparser": "1.4.1",
"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",
Expand All @@ -73,12 +75,15 @@
"png-validator": "1.1.0",
"sharp": "0.32.6",
"sizzle": "2.3.6",
"socket.io": "4.7.5",
"socket.io-client": "4.7.5",
"strftime": "0.10.2",
"strip-ansi": "6.0.1",
"temp": "0.8.3",
"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"
Expand All @@ -97,12 +102,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",
Expand Down
2 changes: 2 additions & 0 deletions src/browser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BrowserConfig } from "./../config/browser-config";
import type { RunnerTest, RunnerHook, ExecutionThreadToolCtx } from "../types";
import { MoveCursorToCommand } from "./commands/moveCursorTo";
import { OpenAndWaitCommand } from "./commands/openAndWait";
import Callstack from "./history/callstack";

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

type FunctionProperties<T> = Exclude<
Expand Down
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",
Expand Down Expand Up @@ -93,6 +93,7 @@ module.exports = {
region: null,
headless: null,
isolation: null,
testRunEnv: NODEJS_TEST_RUN_ENV,
};

module.exports.configPaths = [".testplane.conf.ts", ".testplane.conf.js", ".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
Expand Up @@ -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));

Expand Down Expand Up @@ -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(),
Expand Down
9 changes: 8 additions & 1 deletion 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 {
Expand Down Expand Up @@ -71,12 +72,13 @@ export interface SystemConfig {
tempDir: string;
parallelLimit: number;
fileExtensions: Array<string>;
testRunEnv: "nodejs" | "browser" | ["browser", BrowserTestRunEnvOptions];
}

export interface CommonConfig {
configPath?: string;
automationProtocol: "webdriver" | "devtools";
desiredCapabilities: WebDriver.DesiredCapabilities | null;
desiredCapabilities: WebdriverIO.Capabilities | null;
gridUrl: string;
baseUrl: string;
sessionsPerBrowser: number;
Expand Down Expand Up @@ -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>;
Expand Down
2 changes: 2 additions & 0 deletions src/constants/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ module.exports = {
NONE: "none",
ONLY_FAILED: "onlyFailed",
},
NODEJS_TEST_RUN_ENV: "nodejs",
BROWSER_TEST_RUN_ENV: "browser",
};
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 {
private _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();
}
}
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 = "Testplane 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 BROWSER_EVENT_SUFFIX = "browser";
export const WORKER_EVENT_SUFFIX = "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;
}
}
}
61 changes: 61 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,61 @@
import { BrowserError } from "./browser.js";
import { LoadPageError } from "./load-page.js";
import { ViteRuntimeError } from "./vite-runtime.js";
import { getSelectorTextFromShadowRoot } from "../utils/index.js";
import { DOCUMENT_TITLE, VITE_SELECTORS } from "../constants.js";

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

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

return [LoadPageError.create()];
};

// TODO: use API from vite to get error in runtime (not existing right now)
const getViteRuntimeErrors = (): ViteRuntimeError[] => {
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 [ViteRuntimeError.create({ message, stack, file, frame, tip })];
};

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

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

const getErrors = (errors: ViteError | ViteError[] = []): ViteError[] => {
return [errors, getViteRuntimeErrors(), getBrowserErrors()].flat().filter(Boolean).map(prepareError);
};

export const getErrorsOnPageLoad = (initError?: Error): ErrorOnPageLoad[] => {
const errors = new Array<ViteError>().concat(initError || [], getLoadPageErrors());

return getErrors(errors);
};

export const getErrorsOnRunRunnable = (runnableError?: Error): ViteError[] => {
return getErrors(runnableError);
};

export { BrowserError, LoadPageError, ViteRuntimeError };
17 changes: 17 additions & 0 deletions src/runner/browser-env/vite/browser-modules/errors/load-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { BaseError } from "./base.js";

interface LoadPageErrorData {
message?: string;
}

type BrowserErrorCtor<T> = new (opts?: LoadPageErrorData) => T;

export class LoadPageError extends BaseError {
static create<T extends LoadPageError>(this: BrowserErrorCtor<T>, opts?: LoadPageErrorData): T {
return new this(opts);
}

constructor({ message = "failed to load Vite test page" }: LoadPageErrorData = {}) {
super({ message });
}
}
30 changes: 30 additions & 0 deletions src/runner/browser-env/vite/browser-modules/errors/vite-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { BaseError } from "./base.js";

interface ViteRuntimeErrorData {
message: string;
stack: string;
file: string;
frame: string;
tip: string;
}

type ViteRuntimeErrorCtor<T> = new (opts: ViteRuntimeErrorData) => T;

export class ViteRuntimeError extends BaseError {
file: string;
frame: string;
tip: string;

static create<T extends ViteRuntimeError>(this: ViteRuntimeErrorCtor<T>, opts: ViteRuntimeErrorData): T {
return new this(opts);
}

constructor({ message, stack, file, frame, tip }: ViteRuntimeErrorData) {
super({ message });

this.stack = `${this.constructor.name}: ${this.message}\n${stack}`;
this.file = file;
this.frame = frame;
this.tip = tip;
}
}
Loading

0 comments on commit 5a76453

Please sign in to comment.