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

feat: ability to run unit tests in browser #880

Merged
merged 1 commit into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to build browser modules separately (used esm)

"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",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ponyfill for import.meta.resolve from esm

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐴fill =)

"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,
DudaGod marked this conversation as resolved.
Show resolved Hide resolved
};

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 => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid values:

  • nodejs
  • browser
  • ["browser", {viteConfig: string | object | function}]

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 {
DudaGod marked this conversation as resolved.
Show resolved Hide resolved
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
Loading