-
Notifications
You must be signed in to change notification settings - Fork 64
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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": { | ||
|
@@ -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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ponyfill for There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
@@ -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" | ||
|
@@ -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", | ||
|
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 => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid values:
|
||
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(), | ||
|
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(); | ||
} | ||
} |
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"; |
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; | ||
} | ||
} | ||
} |
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; | ||
} | ||
} | ||
} |
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 }; |
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 }); | ||
} | ||
} |
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; | ||
} | ||
} |
There was a problem hiding this comment.
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)