2023-05-08 09:49:53 +00:00
|
|
|
import {connectTestingHooks, TestingHooksClient} from "app/server/lib/TestingHooks";
|
|
|
|
import {ChildProcess, execFileSync, spawn} from "child_process";
|
2024-04-30 20:53:07 +00:00
|
|
|
import * as http from "http";
|
2023-08-07 14:28:17 +00:00
|
|
|
import FormData from 'form-data';
|
2023-05-08 09:49:53 +00:00
|
|
|
import path from "path";
|
|
|
|
import * as fse from "fs-extra";
|
|
|
|
import * as testUtils from "test/server/testUtils";
|
2023-08-07 14:28:17 +00:00
|
|
|
import {UserAPIImpl} from "app/common/UserAPI";
|
2023-05-08 09:49:53 +00:00
|
|
|
import {exitPromise} from "app/server/lib/serverUtils";
|
|
|
|
import log from "app/server/lib/log";
|
|
|
|
import {delay} from "bluebird";
|
|
|
|
import fetch from "node-fetch";
|
2023-11-30 19:08:46 +00:00
|
|
|
import {Writable} from "stream";
|
2024-04-30 20:53:07 +00:00
|
|
|
import express from "express";
|
|
|
|
import { AddressInfo } from "net";
|
2023-05-08 09:49:53 +00:00
|
|
|
|
2023-08-07 14:28:17 +00:00
|
|
|
/**
|
|
|
|
* This starts a server in a separate process.
|
|
|
|
*/
|
2023-05-08 09:49:53 +00:00
|
|
|
export class TestServer {
|
2023-08-07 14:28:17 +00:00
|
|
|
public static async startServer(
|
|
|
|
serverTypes: string,
|
|
|
|
tempDirectory: string,
|
|
|
|
suitename: string,
|
|
|
|
customEnv?: NodeJS.ProcessEnv,
|
|
|
|
_homeUrl?: string,
|
2023-11-30 19:08:46 +00:00
|
|
|
options: {output?: Writable} = {}, // Pipe server output to the given stream
|
2023-08-07 14:28:17 +00:00
|
|
|
): Promise<TestServer> {
|
|
|
|
|
2024-04-30 20:53:07 +00:00
|
|
|
const server = new this(serverTypes, tempDirectory, suitename);
|
2023-11-30 19:08:46 +00:00
|
|
|
await server.start(_homeUrl, customEnv, options);
|
2023-05-08 09:49:53 +00:00
|
|
|
return server;
|
|
|
|
}
|
|
|
|
|
|
|
|
public testingSocket: string;
|
|
|
|
public testingHooks: TestingHooksClient;
|
|
|
|
public stopped = false;
|
2024-04-30 20:53:07 +00:00
|
|
|
public get serverUrl() {
|
|
|
|
if (this._proxiedServer) {
|
|
|
|
throw new Error('Direct access to this test server is disallowed');
|
|
|
|
}
|
|
|
|
return this._serverUrl;
|
|
|
|
}
|
|
|
|
public get proxiedServer() { return this._proxiedServer; }
|
2023-05-08 09:49:53 +00:00
|
|
|
|
|
|
|
private _server: ChildProcess;
|
|
|
|
private _exitPromise: Promise<number | string>;
|
2024-04-30 20:53:07 +00:00
|
|
|
private _serverUrl: string;
|
|
|
|
private _proxiedServer: boolean = false;
|
2023-05-08 09:49:53 +00:00
|
|
|
|
|
|
|
private readonly _defaultEnv;
|
|
|
|
|
2023-08-07 14:28:17 +00:00
|
|
|
constructor(private _serverTypes: string, public readonly rootDir: string, private _suiteName: string) {
|
2023-05-08 09:49:53 +00:00
|
|
|
this._defaultEnv = {
|
2023-08-07 14:28:17 +00:00
|
|
|
GRIST_INST_DIR: this.rootDir,
|
|
|
|
GRIST_DATA_DIR: path.join(this.rootDir, "data"),
|
2023-05-08 09:49:53 +00:00
|
|
|
GRIST_SERVERS: this._serverTypes,
|
|
|
|
// with port '0' no need to hard code a port number (we can use testing hooks to find out what
|
|
|
|
// port server is listening on).
|
|
|
|
GRIST_PORT: '0',
|
|
|
|
GRIST_DISABLE_S3: 'true',
|
|
|
|
REDIS_URL: process.env.TEST_REDIS_URL,
|
|
|
|
GRIST_TRIGGER_WAIT_DELAY: '100',
|
|
|
|
// this is calculated value, some tests expect 4 attempts and some will try 3 times
|
|
|
|
GRIST_TRIGGER_MAX_ATTEMPTS: '4',
|
|
|
|
GRIST_MAX_QUEUE_SIZE: '10',
|
|
|
|
...process.env
|
|
|
|
};
|
|
|
|
}
|
2023-11-30 19:08:46 +00:00
|
|
|
public async start(_homeUrl?: string, customEnv?: NodeJS.ProcessEnv, options: {output?: Writable} = {}) {
|
2023-05-08 09:49:53 +00:00
|
|
|
// put node logs into files with meaningful name that relate to the suite name and server type
|
|
|
|
const fixedName = this._serverTypes.replace(/,/, '_');
|
2023-08-07 14:28:17 +00:00
|
|
|
const nodeLogPath = path.join(this.rootDir, `${this._suiteName}-${fixedName}-node.log`);
|
2023-05-08 09:49:53 +00:00
|
|
|
const nodeLogFd = await fse.open(nodeLogPath, 'a');
|
2023-11-30 19:08:46 +00:00
|
|
|
const serverLog = options.output ? 'pipe' : (process.env.VERBOSE ? 'inherit' : nodeLogFd);
|
2023-05-08 09:49:53 +00:00
|
|
|
// use a path for socket that relates to suite name and server types
|
2023-08-07 14:28:17 +00:00
|
|
|
this.testingSocket = path.join(this.rootDir, `${this._suiteName}-${fixedName}.socket`);
|
2023-11-30 19:08:46 +00:00
|
|
|
if (this.testingSocket.length >= 108) {
|
|
|
|
// Unix socket paths typically can't be longer than this. Who knew. Make the error obvious.
|
|
|
|
throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`);
|
|
|
|
}
|
2023-05-08 09:49:53 +00:00
|
|
|
const env = {
|
|
|
|
APP_HOME_URL: _homeUrl,
|
2024-04-30 20:53:07 +00:00
|
|
|
APP_HOME_INTERNAL_URL: _homeUrl,
|
2023-05-08 09:49:53 +00:00
|
|
|
GRIST_TESTING_SOCKET: this.testingSocket,
|
|
|
|
...this._defaultEnv,
|
|
|
|
...customEnv
|
|
|
|
};
|
|
|
|
const main = await testUtils.getBuildFile('app/server/mergedServerMain.js');
|
|
|
|
this._server = spawn('node', [main, '--testingHooks'], {
|
|
|
|
env,
|
|
|
|
stdio: ['inherit', serverLog, serverLog]
|
|
|
|
});
|
2023-11-30 19:08:46 +00:00
|
|
|
if (options.output) {
|
|
|
|
this._server.stdout!.pipe(options.output);
|
|
|
|
this._server.stderr!.pipe(options.output);
|
|
|
|
}
|
|
|
|
|
2023-05-08 09:49:53 +00:00
|
|
|
this._exitPromise = exitPromise(this._server);
|
|
|
|
|
|
|
|
// Try to be more helpful when server exits by printing out the tail of its log.
|
|
|
|
this._exitPromise.then((code) => {
|
|
|
|
if (this._server.killed) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
log.error("Server died unexpectedly, with code", code);
|
|
|
|
const output = execFileSync('tail', ['-30', nodeLogPath]);
|
|
|
|
log.info(`\n===== BEGIN SERVER OUTPUT ====\n${output}\n===== END SERVER OUTPUT =====`);
|
|
|
|
})
|
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
|
|
await this._waitServerReady();
|
2024-04-30 20:53:07 +00:00
|
|
|
log.info(`server ${this._serverTypes} up and listening on ${this._serverUrl}`);
|
2023-05-08 09:49:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public async stop() {
|
|
|
|
if (this.stopped) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
log.info("Stopping node server: " + this._serverTypes);
|
|
|
|
this.stopped = true;
|
|
|
|
this._server.kill();
|
|
|
|
this.testingHooks.close();
|
|
|
|
await this._exitPromise;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async isServerReady(): Promise<boolean> {
|
|
|
|
// Let's wait for the testingSocket to be created, then get the port the server is listening on,
|
|
|
|
// and then do an api check. This approach allow us to start server with GRIST_PORT set to '0',
|
|
|
|
// which will listen on first available port, removing the need to hard code a port number.
|
|
|
|
try {
|
|
|
|
|
|
|
|
// wait for testing socket
|
|
|
|
while (!(await fse.pathExists(this.testingSocket))) {
|
|
|
|
await delay(200);
|
|
|
|
}
|
|
|
|
|
|
|
|
// create testing hooks and get own port
|
|
|
|
this.testingHooks = await connectTestingHooks(this.testingSocket);
|
|
|
|
const port: number = await this.testingHooks.getOwnPort();
|
2024-04-30 20:53:07 +00:00
|
|
|
this._serverUrl = `http://localhost:${port}`;
|
2023-05-08 09:49:53 +00:00
|
|
|
|
|
|
|
// wait for check
|
2024-04-30 20:53:07 +00:00
|
|
|
return (await fetch(`${this._serverUrl}/status/hooks`, {timeout: 1000})).ok;
|
2023-05-08 09:49:53 +00:00
|
|
|
} catch (err) {
|
2023-08-07 14:28:17 +00:00
|
|
|
log.warn("Failed to initialize server", err);
|
2023-05-08 09:49:53 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-07 14:28:17 +00:00
|
|
|
// Get access to the ChildProcess object for this server, e.g. to get its PID.
|
|
|
|
public getChildProcess(): ChildProcess { return this._server; }
|
|
|
|
|
|
|
|
// Returns the promise for the ChildProcess's signal or exit code.
|
|
|
|
public getExitPromise(): Promise<string|number> { return this._exitPromise; }
|
|
|
|
|
2024-04-30 20:53:07 +00:00
|
|
|
public makeUserApi(
|
|
|
|
org: string,
|
|
|
|
user: string = 'chimpy',
|
|
|
|
{
|
|
|
|
headers = {Authorization: `Bearer api_key_for_${user}`},
|
|
|
|
serverUrl = this._serverUrl,
|
|
|
|
}: {
|
|
|
|
headers?: Record<string, string>
|
|
|
|
serverUrl?: string,
|
|
|
|
} = { headers: undefined, serverUrl: undefined },
|
|
|
|
): UserAPIImpl {
|
|
|
|
return new UserAPIImpl(`${serverUrl}/o/${org}`, {
|
|
|
|
headers,
|
2023-08-07 14:28:17 +00:00
|
|
|
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
|
|
newFormData: () => new FormData() as any,
|
|
|
|
});
|
|
|
|
}
|
2023-05-08 09:49:53 +00:00
|
|
|
|
2024-04-30 20:53:07 +00:00
|
|
|
public disallowDirectAccess() {
|
|
|
|
this._proxiedServer = true;
|
|
|
|
}
|
|
|
|
|
2023-05-08 09:49:53 +00:00
|
|
|
private async _waitServerReady() {
|
|
|
|
// It's important to clear the timeout, because it can prevent node from exiting otherwise,
|
|
|
|
// which is annoying when running only this test for debugging.
|
|
|
|
let timeout: any;
|
|
|
|
const maxDelay = new Promise((resolve) => {
|
|
|
|
timeout = setTimeout(resolve, 30000);
|
|
|
|
});
|
|
|
|
try {
|
|
|
|
await Promise.race([
|
|
|
|
this.isServerReady(),
|
|
|
|
this._exitPromise.then(() => {
|
|
|
|
throw new Error("Server exited while waiting for it");
|
|
|
|
}),
|
|
|
|
maxDelay,
|
|
|
|
]);
|
|
|
|
} finally {
|
|
|
|
clearTimeout(timeout);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-04-30 20:53:07 +00:00
|
|
|
|
|
|
|
// FIXME: found that TestProxyServer exist, what should I do? :'(
|
|
|
|
export class TestServerProxy {
|
|
|
|
public static readonly HOSTNAME: string = 'grist-test-proxy.localhost';
|
|
|
|
|
|
|
|
private _stopped: boolean = false;
|
|
|
|
private _app = express();
|
|
|
|
private _server: http.Server;
|
|
|
|
private _address: Promise<AddressInfo>;
|
|
|
|
|
|
|
|
public get stopped() { return this._stopped; }
|
|
|
|
|
|
|
|
public constructor() {
|
|
|
|
this._address = new Promise(resolve => {
|
|
|
|
this._server = this._app.listen(0, TestServerProxy.HOSTNAME, () => {
|
|
|
|
resolve(this._server.address() as AddressInfo);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public start(homeServer: TestServer, docServer: TestServer) {
|
|
|
|
this._app.all(['/dw/dw1', '/dw/dw1/*'], (oreq, ores) => this._getRequestHandlerFor(docServer));
|
|
|
|
this._app.all('/*', this._getRequestHandlerFor(homeServer));
|
|
|
|
// Forbid now the use of serverUrl property
|
|
|
|
homeServer.disallowDirectAccess();
|
|
|
|
docServer.disallowDirectAccess();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getAddress() {
|
|
|
|
return this._address;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getServerUrl() {
|
|
|
|
const address = await this.getAddress();
|
|
|
|
return `http://${TestServerProxy.HOSTNAME}:${address.port}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
public stop() {
|
|
|
|
if (this._stopped) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
log.info("Stopping node TestServerProxy");
|
|
|
|
this._stopped = true;
|
|
|
|
this._server.close();
|
|
|
|
}
|
|
|
|
|
|
|
|
private _getRequestHandlerFor(server: TestServer) {
|
|
|
|
const serverUrl = new URL(server.serverUrl);
|
|
|
|
|
|
|
|
return (oreq: express.Request, ores: express.Response) => {
|
|
|
|
const options = {
|
|
|
|
host: serverUrl.hostname,
|
|
|
|
port: serverUrl.port,
|
|
|
|
path: oreq.url,
|
|
|
|
method: oreq.method,
|
|
|
|
headers: oreq.headers,
|
|
|
|
};
|
|
|
|
|
|
|
|
log.debug('[proxy] Requesting: ' + oreq.url);
|
|
|
|
|
|
|
|
const creq = http
|
|
|
|
.request(options, pres => {
|
|
|
|
log.debug('[proxy] Received response for ' + pres.url);
|
|
|
|
|
|
|
|
// set encoding, required?
|
|
|
|
pres.setEncoding('utf8');
|
|
|
|
|
|
|
|
// set http status code based on proxied response
|
|
|
|
ores.writeHead(pres.statusCode ?? 200, pres.statusMessage, pres.headers);
|
|
|
|
|
|
|
|
// wait for data
|
|
|
|
pres.on('data', chunk => {
|
|
|
|
ores.write(chunk);
|
|
|
|
});
|
|
|
|
|
|
|
|
pres.on('close', () => {
|
|
|
|
// closed, let's end client request as well
|
|
|
|
ores.end();
|
|
|
|
});
|
|
|
|
|
|
|
|
pres.on('end', () => {
|
|
|
|
// finished, let's finish client request as well
|
|
|
|
ores.end();
|
|
|
|
});
|
|
|
|
})
|
|
|
|
.on('error', e => {
|
|
|
|
// we got an error
|
|
|
|
console.log(e.message);
|
|
|
|
try {
|
|
|
|
// attempt to set error message and http status
|
|
|
|
ores.writeHead(500);
|
|
|
|
ores.write(e.message);
|
|
|
|
} catch (e) {
|
|
|
|
// ignore
|
|
|
|
}
|
|
|
|
ores.end();
|
|
|
|
});
|
|
|
|
|
|
|
|
oreq.pipe(creq).on('end', () => creq.end());
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|