Skip to content

Commit

Permalink
feat: add ability to use dev server
Browse files Browse the repository at this point in the history
  • Loading branch information
KuznetsovRoman committed Apr 18, 2024
1 parent ac77d36 commit b2160c5
Show file tree
Hide file tree
Showing 10 changed files with 865 additions and 0 deletions.
38 changes: 38 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,3 +651,41 @@ The `browser` argument is a `WebdriverIO` session.

### prepareEnvironment
Configuration data can be changed depending on extra conditions in the `prepareEnvironment` function.

### devServer
Launch dev server on testplane initialize (INIT event).

For example, this setup:
```js
// .testplane.conf.js
const SERVER_PORT = 3000;
...
export default {
...
devServer: {
command: "npm run server:dev",
env: {PORT: SERVER_PORT},
readinessProbe: {
url: `http://localhost:${SERVER_PORT}/health`
timeouts: { // optional
waitServerTimeout: 60_000 // default value
}
}
}
}
```
Will spawn child process "npm run server:dev", pass extra environment variable "PORT" with value "3000" and wait, until "http://localhost:3000/health" is ready to receive network requests and responds with 200-299 status code. If server is still not ready after 60 seconds, Testplane will fail.

Full list of parameters:
- command (optional) `String` – command to launch dev server. If null or not defined, dev server is disabled
- env (optional) `Record<string, string>` – extra environment variables to pass to child process, in addition to your `process.env`
- args (optional) `String[]` – arguments to pass to child process
- cwd (optional) `String` – current working directory of the child process. If not defined, testplane will try to find nearest "package.json", starting from the directory with testplane config
- logs (optional) `Boolean` – if enabled, shows dev server logs in the console with prefix "\[dev server\]". Enabled by default
- readinessProbe (optional) `(devServer: ChildProcess) => Promise<void> | Object` - if function, ready check is completed when function is resolved. Receives child process object. Object by default
- url (optional) `String` – url to request ready check status. If not defined, ready check is disabled
- isReady (optional) `(fetchResponse => bool | Promise<bool>)` – predicate to check if server is ready based on `readinessProbe.url` fetch response. Returns `true` if statusCode is 2xx by default
- timeouts (optional) `Object` – server waiting timeouts
- waitServerTimeout (optional) `Number` - timeout to wait for server to be ready (ms). 60_000 by default
- probeRequestTimeout (optional) `Number` - one request timeout (ms), after which request will be aborted. 10_000 by default
- probeRequestInterval (optional) `Number` - interval between ready probe requests (ms). 1_000 by default
16 changes: 16 additions & 0 deletions src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ module.exports = {
headless: null,
isolation: null,
testRunEnv: NODEJS_TEST_RUN_ENV,
devServer: {
command: null,
cwd: null,
env: {},
args: [],
logs: true,
readinessProbe: {
url: null,
isReady: null,
timeouts: {
waitServerTimeout: 60000, // 60s
probeRequestTimeout: 10000, // 10s
probeRequestInterval: 1000, // 1s
},
},
},
};

module.exports.configPaths = [".testplane.conf.ts", ".testplane.conf.js", ".hermione.conf.ts", ".hermione.conf.js"];
55 changes: 55 additions & 0 deletions src/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,61 @@ const rootSection = section(
"": { files: [] }, // Use `all` set with default values if sets were not specified in a config
},
),

devServer: section({
command: options.optionalString("devServer.command"),
env: options.optionalObject("devServer.env"),
args: options.optionalArray("devServer.args"),
cwd: options.optionalString("devServer.cwd"),
logs: options.optionalBoolean("devServer.logs"),
readinessProbe: option({
defaultValue: defaults.devServer.readinessProbe,
validate: value => {
if (typeof value === "function" || value === null) {
return;
}

if (!_.isPlainObject(value)) {
throw new Error('"devServer.readinessProbe" must be a function, object or null');
}

if (!_.isUndefined(value.url) && typeof value.url !== "string" && value.url !== null) {
throw new Error('"devServer.readinessProbe.url" must be a string or null');
}

if (
!_.isUndefined(value.isReady) &&
typeof value.isReady !== "function" &&
value.isReady !== null
) {
throw new Error('"devServer.readinessProbe.isReady" must be a function or null');
}

if (!_.isUndefined(value.timeouts) && !_.isPlainObject(value.timeouts)) {
throw new Error('"devServer.readinessProbe.timeouts" must be an object');
}

if (value.timeouts) {
["waitServerTimeout", "probeRequestTimeout", "probeRequestInterval"].forEach(name => {
if (!_.isUndefined(value.timeouts[name]) && typeof value.timeouts[name] !== "number") {
throw new Error(`"devServer.readinessProbe.timeouts.${name}" must be a number`);
}
});
}
},
map: value =>
typeof value === "function"
? value
: {
...defaults.devServer.readinessProbe,
...(value || {}),
timeouts: {
...defaults.devServer.readinessProbe.timeouts,
...((value && value.timeouts) || {}),
},
},
}),
}),
}),
);

Expand Down
26 changes: 26 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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";
import type { ChildProcessWithoutNullStreams } from "child_process";

export interface CompareOptsConfig {
shouldCluster: boolean;
Expand Down Expand Up @@ -75,6 +76,22 @@ export interface SystemConfig {
testRunEnv: "nodejs" | "browser" | ["browser", BrowserTestRunEnvOptions];
}

type ReadinessProbeIsReadyFn = (response: Awaited<ReturnType<typeof globalThis.fetch>>) => boolean | Promise<boolean>;

type ReadinessProbeFn = (childProcess: ChildProcessWithoutNullStreams) => Promise<void>;

type ReadinessProbeObj = {
url: string | null;
isReady: ReadinessProbeIsReadyFn | null;
timeouts: {
waitServerTimeout: number;
probeRequestTimeout: number;
probeRequestInterval: number;
};
};

type ReadinessProbe = ReadinessProbeFn | ReadinessProbeObj;

export interface CommonConfig {
configPath?: string;
automationProtocol: "webdriver" | "devtools";
Expand Down Expand Up @@ -130,6 +147,15 @@ export interface CommonConfig {
failOnNetworkError: boolean;
ignoreNetworkErrorsPatterns: Array<RegExp | string>;
};

devServer: {
command: string | null;
cwd: string | null;
env: Record<string, string>;
args: Array<string>;
logs: boolean;
readinessProbe: ReadinessProbe;
};
}

export interface SetsConfig {
Expand Down
56 changes: 56 additions & 0 deletions src/dev-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import _ from "lodash";
import { spawn } from "child_process";
import debug from "debug";
import { Config } from "../config";
import { findCwd, pipeLogsWithPrefix, waitDevServerReady } from "./utils";
import logger = require("../utils/logger");
import type { Testplane } from "../testplane";

export type DevServerOpts = { testplane: Testplane; devServerConfig: Config["devServer"]; configPath: string };

export type InitDevServer = (opts: DevServerOpts) => Promise<void>;

export const initDevServer: InitDevServer = async ({ testplane, devServerConfig, configPath }) => {
if (!devServerConfig || !devServerConfig.command) {
return;
}

logger.log("Starting dev server with command", `"${devServerConfig.command}"`);

const debugLog = debug("testplane:dev-server");

if (!_.isEmpty(devServerConfig.args)) {
debugLog("Dev server args:", JSON.stringify(devServerConfig.args));
}

if (!_.isEmpty(devServerConfig.env)) {
debugLog("Dev server env:", JSON.stringify(devServerConfig.env, null, 4));
}

const devServer = spawn(devServerConfig.command, devServerConfig.args, {
env: { ...process.env, ...devServerConfig.env },
cwd: devServerConfig.cwd || findCwd(configPath),
shell: true,
windowsHide: true,
});

if (devServerConfig.logs) {
pipeLogsWithPrefix(devServer, "[dev server] ");
}

devServer.once("exit", (code, signal) => {
if (signal !== "SIGINT") {
const errorMessage = [
"An error occured while launching dev server",
`Dev server failed with code '${code}' (signal: ${signal})`,
].join("\n");
testplane.halt(new Error(errorMessage), 5000);
}
});

process.once("exit", () => {
devServer.kill("SIGINT");
});

await waitDevServerReady(devServer, devServerConfig.readinessProbe);
};
145 changes: 145 additions & 0 deletions src/dev-server/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { pipeline, Transform, TransformCallback } from "stream";
import path from "path";
import fs from "fs";
import chalk from "chalk";
import type { ChildProcessWithoutNullStreams } from "child_process";
import logger from "../utils/logger";
import type { Config } from "../config";

export const findCwd = (configPath: string): string => {
let prev = configPath;

// eslint-disable-next-line no-constant-condition
while (true) {
const dir = path.dirname(prev);

if (dir === prev) {
return path.dirname(configPath);
}

const foundPackageJson = fs.existsSync(path.join(dir, "package.json"));

if (foundPackageJson) {
return dir;
}

prev = dir;
}
};

class WithPrefixTransformer extends Transform {
prefix: string;
includePrefix: boolean;

constructor(prefix: string) {
super();

this.prefix = chalk.green(prefix);
this.includePrefix = true;
}

_transform(chunk: string, _: string, callback: TransformCallback): void {
const chunkString = chunk.toString();
const chunkRows = chunkString.split("\n");

const includeSuffix = chunkString.endsWith("\n") && chunkRows.pop() === "";

const resultPrefix = this.includePrefix ? this.prefix : "";
const resultSuffix = includeSuffix ? "\n" : "";
const resultData = resultPrefix + chunkRows.join("\n" + this.prefix) + resultSuffix;

this.push(resultData);
this.includePrefix = includeSuffix;

callback();
}
}

export const pipeLogsWithPrefix = (childProcess: ChildProcessWithoutNullStreams, prefix: string): void => {
const logOnErrorCb = (error: Error | null): void => {
if (error) {
logger.error("Got an error trying to pipeline dev server logs:", error.message);
}
};

pipeline(childProcess.stdout, new WithPrefixTransformer(prefix), process.stdout, logOnErrorCb);
pipeline(childProcess.stderr, new WithPrefixTransformer(prefix), process.stderr, logOnErrorCb);
};

const defaultIsReadyFn = (response: Awaited<ReturnType<typeof globalThis.fetch>>): boolean => {
return response.status >= 200 && response.status < 300;
};

export const waitDevServerReady = async (
devServer: ChildProcessWithoutNullStreams,
readinessProbe: Config["devServer"]["readinessProbe"],
): Promise<void> => {
if (typeof readinessProbe !== "function" && !readinessProbe.url) {
return;
}

logger.log("Waiting for dev server to be ready");

if (typeof readinessProbe === "function") {
return Promise.resolve()
.then(() => readinessProbe(devServer))
.then(res => {
logger.log("Dev server is ready");

return res;
});
}

const isReadyFn = readinessProbe.isReady || defaultIsReadyFn;

let isSuccess = false;
let isError = false;

const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (!isError && !isSuccess) {
isError = true;
reject(new Error(`Dev server is still not ready after ${readinessProbe.timeouts.waitServerTimeout}ms`));
}
}, readinessProbe.timeouts.waitServerTimeout).unref();
});

const readyPromise = new Promise<void>(resolve => {
const tryToFetch = async (): Promise<void> => {
const signal = AbortSignal.timeout(readinessProbe.timeouts.probeRequestTimeout);

try {
const response = await fetch(readinessProbe.url!, { signal });
const isReady = await isReadyFn(response);

if (!isReady) {
throw new Error("Dev server is not ready yet");
}

if (!isError && !isSuccess) {
isSuccess = true;
logger.log("Dev server is ready");
resolve();
}
} catch (error) {
const err = error as { cause?: { code?: string } };

if (!isError && !isSuccess) {
setTimeout(tryToFetch, readinessProbe.timeouts.probeRequestInterval).unref();

const errorMessage = err && err.cause && (err.cause.code || err.cause);

if (errorMessage && errorMessage !== "ECONNREFUSED") {
logger.warn("Dev server ready probe failed:", errorMessage);
}
}
}
};

tryToFetch();
});

return Promise.race([timeoutPromise, readyPromise]);
};

export default { findCwd, pipeLogsWithPrefix, waitDevServerReady };
Loading

0 comments on commit b2160c5

Please sign in to comment.