2021-04-02 23:11:27 +00:00
|
|
|
/**
|
|
|
|
* NOTE: this server is also exposed via test/nbrowser/testUtils; it's only moved into its own
|
|
|
|
* file to untangle dependencies between gristUtils and testUtils.
|
|
|
|
*
|
|
|
|
* Exports `server` to be used with mocha-webdriver's useServer(). This is normally set up using
|
|
|
|
* `setupTestSuite` from test/nbrowser/testUtils.
|
|
|
|
*
|
|
|
|
* Includes server.testingHooks and some useful methods that rely on them.
|
|
|
|
*
|
|
|
|
* Run with VERBOSE=1 in the environment to see the server log on the console. Normally it goes
|
|
|
|
* into a file whose path is printed when server starts.
|
|
|
|
*/
|
|
|
|
import {encodeUrl, IGristUrlState, parseSubdomain} from 'app/common/gristUrls';
|
2024-07-05 14:02:39 +00:00
|
|
|
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
|
2022-07-04 14:14:55 +00:00
|
|
|
import log from 'app/server/lib/log';
|
2021-04-02 23:11:27 +00:00
|
|
|
import {getAppRoot} from 'app/server/lib/places';
|
|
|
|
import {makeGristConfig} from 'app/server/lib/sendAppPage';
|
|
|
|
import {exitPromise} from 'app/server/lib/serverUtils';
|
|
|
|
import {connectTestingHooks, TestingHooksClient} from 'app/server/lib/TestingHooks';
|
|
|
|
import {ChildProcess, execFileSync, spawn} from 'child_process';
|
2023-06-27 06:11:08 +00:00
|
|
|
import EventEmitter from 'events';
|
2021-04-02 23:11:27 +00:00
|
|
|
import * as fse from 'fs-extra';
|
|
|
|
import {driver, IMochaServer, WebDriver} from 'mocha-webdriver';
|
|
|
|
import fetch from 'node-fetch';
|
|
|
|
import {tmpdir} from 'os';
|
|
|
|
import * as path from 'path';
|
2022-03-28 19:43:47 +00:00
|
|
|
import {removeConnection} from 'test/gen-server/seed';
|
2021-04-02 23:11:27 +00:00
|
|
|
import {HomeUtil} from 'test/nbrowser/homeUtil';
|
2022-04-28 11:51:55 +00:00
|
|
|
import {getDatabase} from 'test/testUtils';
|
2021-04-02 23:11:27 +00:00
|
|
|
|
2023-06-27 06:11:08 +00:00
|
|
|
export class TestServerMerged extends EventEmitter implements IMochaServer {
|
2021-04-02 23:11:27 +00:00
|
|
|
public testDir: string;
|
|
|
|
public testDocDir: string;
|
|
|
|
public testingHooks: TestingHooksClient;
|
|
|
|
|
|
|
|
// These have been moved to HomeUtil, and get set here when HomeUtil is created.
|
|
|
|
public simulateLogin: HomeUtil["simulateLogin"];
|
|
|
|
public removeLogin: HomeUtil["removeLogin"];
|
|
|
|
|
|
|
|
private _serverUrl: string;
|
2022-06-15 14:29:29 +00:00
|
|
|
private _proxyUrl: string|null = null;
|
2021-04-02 23:11:27 +00:00
|
|
|
private _server: ChildProcess;
|
|
|
|
private _exitPromise: Promise<number|string>;
|
|
|
|
private _starts: number = 0;
|
2022-03-28 19:43:47 +00:00
|
|
|
private _dbManager?: HomeDBManager;
|
2023-06-27 06:11:08 +00:00
|
|
|
private _driver?: WebDriver;
|
2021-04-02 23:11:27 +00:00
|
|
|
|
|
|
|
// The name is used to name the directory for server logs and data.
|
2023-06-27 06:11:08 +00:00
|
|
|
constructor(private _name: string) {
|
|
|
|
super();
|
|
|
|
}
|
2021-04-02 23:11:27 +00:00
|
|
|
|
|
|
|
public async start() {
|
|
|
|
await this.restart(true);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Restart the server. If reset is set, the database is cleared. If reset is not set,
|
|
|
|
* the database is preserved, and the temporary directory is unchanged.
|
|
|
|
*/
|
2022-10-31 14:46:02 +00:00
|
|
|
public async restart(reset: boolean = false, quiet = false) {
|
2021-04-02 23:11:27 +00:00
|
|
|
if (this.isExternalServer()) { return; }
|
|
|
|
if (this._starts > 0) {
|
2021-04-26 21:54:09 +00:00
|
|
|
this.resume();
|
2021-04-02 23:11:27 +00:00
|
|
|
await this.stop();
|
|
|
|
}
|
|
|
|
this._starts++;
|
2023-06-30 09:50:40 +00:00
|
|
|
const workerIdText = process.env.MOCHA_WORKER_ID || '0';
|
2021-04-02 23:11:27 +00:00
|
|
|
if (reset) {
|
2024-04-01 20:35:00 +00:00
|
|
|
// Make sure this test server doesn't keep using the DB that's about to disappear.
|
|
|
|
await this.closeDatabase();
|
|
|
|
|
2021-04-02 23:11:27 +00:00
|
|
|
if (process.env.TESTDIR) {
|
2023-06-30 09:50:40 +00:00
|
|
|
this.testDir = path.join(process.env.TESTDIR, workerIdText);
|
2021-04-02 23:11:27 +00:00
|
|
|
} else {
|
2023-06-27 06:11:08 +00:00
|
|
|
// Create a testDir of the form grist_test_{USER}_{SERVER_NAME}_{WORKER_ID}, removing any previous one.
|
2021-04-02 23:11:27 +00:00
|
|
|
const username = process.env.USER || "nobody";
|
2023-06-30 09:50:40 +00:00
|
|
|
this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}_${workerIdText}`);
|
2021-04-02 23:11:27 +00:00
|
|
|
await fse.remove(this.testDir);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.testDocDir = path.join(this.testDir, "data");
|
|
|
|
await fse.mkdirs(this.testDocDir);
|
|
|
|
log.warn(`Test logs and data are at: ${this.testDir}/`);
|
|
|
|
|
|
|
|
const nodeLogPath = path.join(this.testDir, 'node.log');
|
|
|
|
const nodeLogFd = await fse.open(nodeLogPath, 'a');
|
|
|
|
|
|
|
|
// The server isn't set up to close the testing socket cleanly and
|
|
|
|
// immediately. It is simplest to use a diffent socket each time
|
|
|
|
// we restart.
|
|
|
|
const testingSocket = path.join(this.testDir, `testing-${this._starts}.socket`);
|
2023-11-30 19:08:46 +00:00
|
|
|
if (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: ${testingSocket.length} (${testingSocket})`);
|
|
|
|
}
|
2021-04-02 23:11:27 +00:00
|
|
|
|
|
|
|
const stubCmd = '_build/stubs/app/server/server';
|
|
|
|
const isCore = await fse.pathExists(stubCmd + '.js');
|
|
|
|
const cmd = isCore ? stubCmd : '_build/core/app/server/devServerMain';
|
2022-06-15 14:29:29 +00:00
|
|
|
// If a proxy is set, use a single port - otherwise we'd need a lot of
|
|
|
|
// proxies.
|
|
|
|
const useSinglePort = this._proxyUrl !== null;
|
2021-04-02 23:11:27 +00:00
|
|
|
|
|
|
|
// The reason we fork a process rather than start a server within the same process is mainly
|
|
|
|
// logging. Server code uses a global logger, so it's hard to separate out (especially so if
|
|
|
|
// we ever run different servers for different tests).
|
|
|
|
const serverLog = process.env.VERBOSE ? 'inherit' : nodeLogFd;
|
2023-06-30 09:50:40 +00:00
|
|
|
const workerId = parseInt(workerIdText, 10);
|
2023-06-27 06:11:08 +00:00
|
|
|
const corePort = String(8295 + workerId * 2);
|
|
|
|
const untrustedPort = String(8295 + workerId * 2 + 1);
|
2022-03-23 13:41:34 +00:00
|
|
|
const env: Record<string, string> = {
|
2021-04-02 23:11:27 +00:00
|
|
|
TYPEORM_DATABASE: this._getDatabaseFile(),
|
|
|
|
GRIST_DATA_DIR: this.testDocDir,
|
|
|
|
GRIST_INST_DIR: this.testDir,
|
|
|
|
// uses the test installed plugins folder as the user installed plugins.
|
|
|
|
GRIST_USER_ROOT: path.resolve(getAppRoot(), 'test/fixtures/plugins/browserInstalledPlugins/'),
|
|
|
|
GRIST_TESTING_SOCKET: testingSocket,
|
|
|
|
// Set low limits for uploads, for testing.
|
|
|
|
GRIST_MAX_UPLOAD_IMPORT_MB: '1',
|
|
|
|
GRIST_MAX_UPLOAD_ATTACHMENT_MB: '2',
|
2021-10-01 14:24:23 +00:00
|
|
|
// The following line only matters for testing with non-localhost URLs, which some tests do.
|
|
|
|
GRIST_SERVE_SAME_ORIGIN: 'true',
|
2021-04-02 23:11:27 +00:00
|
|
|
// Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override.
|
2022-06-15 14:29:29 +00:00
|
|
|
...(useSinglePort ? {
|
|
|
|
APP_HOME_URL: this.getHost(),
|
|
|
|
GRIST_SINGLE_PORT: 'true',
|
|
|
|
} : (isCore ? {
|
2023-06-27 06:11:08 +00:00
|
|
|
HOME_PORT: corePort,
|
|
|
|
STATIC_PORT: corePort,
|
|
|
|
DOC_PORT: corePort,
|
2021-04-02 23:11:27 +00:00
|
|
|
DOC_WORKER_COUNT: '1',
|
2023-06-27 06:11:08 +00:00
|
|
|
PORT: corePort,
|
|
|
|
APP_UNTRUSTED_URL: `http://localhost:${untrustedPort}`,
|
|
|
|
GRIST_SERVE_PLUGINS_PORT: untrustedPort,
|
2021-04-02 23:11:27 +00:00
|
|
|
} : {
|
|
|
|
HOME_PORT: '8095',
|
|
|
|
STATIC_PORT: '8096',
|
|
|
|
DOC_PORT: '8100',
|
|
|
|
DOC_WORKER_COUNT: '5',
|
|
|
|
PORT: '0',
|
2023-06-27 06:11:08 +00:00
|
|
|
APP_UNTRUSTED_URL : "http://localhost:18096",
|
2022-06-15 14:29:29 +00:00
|
|
|
})),
|
2021-04-02 23:11:27 +00:00
|
|
|
// This skips type-checking when running server, but reduces startup time a lot.
|
|
|
|
TS_NODE_TRANSPILE_ONLY: 'true',
|
|
|
|
...process.env,
|
2022-11-02 00:01:15 +00:00
|
|
|
TEST_CLEAN_DATABASE: reset ? 'true' : '',
|
2021-04-02 23:11:27 +00:00
|
|
|
};
|
|
|
|
if (!process.env.REDIS_URL) {
|
|
|
|
// Multiple doc workers only possible when redis is available.
|
|
|
|
log.warn('Running without redis and without multiple doc workers');
|
|
|
|
delete env.DOC_WORKER_COUNT;
|
|
|
|
}
|
|
|
|
this._server = spawn('node', [cmd], {
|
2024-08-08 19:35:37 +00:00
|
|
|
env: {
|
|
|
|
...env,
|
|
|
|
...(process.env.SERVER_NODE_OPTIONS ? {NODE_OPTIONS: process.env.SERVER_NODE_OPTIONS} : {})
|
|
|
|
},
|
2022-10-31 14:46:02 +00:00
|
|
|
stdio: quiet ? 'ignore' : ['inherit', serverLog, serverLog],
|
2021-04-02 23:11:27 +00:00
|
|
|
});
|
|
|
|
this._exitPromise = exitPromise(this._server);
|
|
|
|
|
|
|
|
const port = parseInt(env.HOME_PORT, 10);
|
|
|
|
this._serverUrl = `http://localhost:${port}`;
|
|
|
|
log.info(`Waiting for node server to respond at ${this._serverUrl}`);
|
|
|
|
|
|
|
|
// Try to be more helpful when server exits by printing out the tail of its log.
|
|
|
|
this._exitPromise.then((code) => {
|
2022-10-31 14:46:02 +00:00
|
|
|
if (this._server.killed || quiet) { return; }
|
2021-04-02 23:11:27 +00:00
|
|
|
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(60000);
|
|
|
|
|
|
|
|
// Prepare testingHooks for certain behind-the-scenes interactions with the server.
|
|
|
|
this.testingHooks = await connectTestingHooks(testingSocket);
|
2023-06-27 06:11:08 +00:00
|
|
|
this.emit('start');
|
2021-04-02 23:11:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public async stop() {
|
|
|
|
if (this.isExternalServer()) { return; }
|
|
|
|
log.info("Stopping node server");
|
|
|
|
this._server.kill();
|
|
|
|
if (this.testingHooks) {
|
|
|
|
this.testingHooks.close();
|
|
|
|
}
|
|
|
|
await this._exitPromise;
|
2023-06-27 06:11:08 +00:00
|
|
|
this.emit('stop');
|
2021-04-02 23:11:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Set server on pause and call `callback()`. Callback must returned a promise and server will
|
|
|
|
* resume normal activity when that promise resolves. This is useful to test behavior when a
|
|
|
|
* request takes a long time.
|
|
|
|
*/
|
|
|
|
public async pauseUntil(callback: () => Promise<void>) {
|
2022-08-12 15:23:22 +00:00
|
|
|
if (this.isExternalServer()) {
|
|
|
|
throw new Error("Can't pause external server");
|
|
|
|
}
|
2021-04-02 23:11:27 +00:00
|
|
|
log.info("Pausing node server");
|
|
|
|
this._server.kill('SIGSTOP');
|
|
|
|
try {
|
|
|
|
await callback();
|
|
|
|
} finally {
|
|
|
|
log.info("Resuming node server");
|
|
|
|
this.resume();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public resume() {
|
|
|
|
if (this.isExternalServer()) { return; }
|
|
|
|
this._server.kill('SIGCONT');
|
|
|
|
}
|
|
|
|
|
|
|
|
public getHost(): string {
|
|
|
|
if (this.isExternalServer()) { return process.env.HOME_URL!; }
|
2022-06-15 14:29:29 +00:00
|
|
|
return this._proxyUrl || this._serverUrl;
|
2021-04-02 23:11:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public getUrl(team: string, relPath: string) {
|
|
|
|
if (!this.isExternalServer()) {
|
|
|
|
return `${this.getHost()}/o/${team}${relPath}`;
|
|
|
|
}
|
|
|
|
const state: IGristUrlState = { org: team };
|
|
|
|
const baseDomain = parseSubdomain(new URL(this.getHost()).hostname).base;
|
2023-06-06 17:08:50 +00:00
|
|
|
const gristConfig = makeGristConfig({
|
|
|
|
homeUrl: this.getHost(),
|
|
|
|
extra: {},
|
|
|
|
baseDomain,
|
|
|
|
});
|
2021-04-02 23:11:27 +00:00
|
|
|
const url = encodeUrl(gristConfig, state, new URL(this.getHost())).replace(/\/$/, "");
|
|
|
|
return `${url}${relPath}`;
|
|
|
|
}
|
|
|
|
|
2022-06-15 14:29:29 +00:00
|
|
|
// Configure the server to be accessed via a proxy. You'll need to
|
|
|
|
// restart the server after changing this setting.
|
|
|
|
public updateProxy(proxyUrl: string|null) {
|
|
|
|
this._proxyUrl = proxyUrl;
|
|
|
|
}
|
|
|
|
|
2021-04-02 23:11:27 +00:00
|
|
|
/**
|
|
|
|
* Returns whether the server is up and responsive.
|
|
|
|
*/
|
|
|
|
public async isServerReady(): Promise<boolean> {
|
|
|
|
try {
|
|
|
|
return (await fetch(`${this._serverUrl}/status/hooks`, {timeout: 1000})).ok;
|
|
|
|
} catch (err) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Wait for the server to be up and responsitve, for up to `ms` milliseconds.
|
|
|
|
*/
|
|
|
|
public async waitServerReady(ms: number): Promise<void> {
|
|
|
|
await this.driver.wait(() => Promise.race([
|
|
|
|
this.isServerReady(),
|
|
|
|
this._exitPromise.then(() => { throw new Error("Server exited while waiting for it"); }),
|
|
|
|
]), ms);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a connection to the database.
|
|
|
|
*/
|
|
|
|
public async getDatabase(): Promise<HomeDBManager> {
|
|
|
|
if (!this._dbManager) {
|
2022-04-28 11:51:55 +00:00
|
|
|
this._dbManager = await getDatabase(this._getDatabaseFile());
|
2021-04-02 23:11:27 +00:00
|
|
|
}
|
|
|
|
return this._dbManager;
|
|
|
|
}
|
|
|
|
|
2022-03-28 19:43:47 +00:00
|
|
|
public async closeDatabase() {
|
|
|
|
this._dbManager = undefined;
|
|
|
|
await removeConnection();
|
|
|
|
}
|
|
|
|
|
2021-04-02 23:11:27 +00:00
|
|
|
public get driver() {
|
|
|
|
return this._driver || driver;
|
|
|
|
}
|
|
|
|
|
|
|
|
// substitute a custom driver
|
2023-06-27 06:11:08 +00:00
|
|
|
public setDriver(customDriver?: WebDriver) {
|
2021-04-02 23:11:27 +00:00
|
|
|
this._driver = customDriver;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getTestingHooks() {
|
|
|
|
return this.testingHooks;
|
|
|
|
}
|
|
|
|
|
|
|
|
public isExternalServer() {
|
|
|
|
return Boolean(process.env.HOME_URL);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the path to the database.
|
|
|
|
*/
|
|
|
|
private _getDatabaseFile(): string {
|
2023-05-02 16:19:45 +00:00
|
|
|
if (process.env.TYPEORM_TYPE === 'postgres') {
|
|
|
|
const db = process.env.TYPEORM_DATABASE;
|
|
|
|
if (!db) { throw new Error("Missing TYPEORM_DATABASE"); }
|
|
|
|
return db;
|
|
|
|
}
|
2021-04-02 23:11:27 +00:00
|
|
|
return path.join(this.testDir, 'landing.db');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export const server = new TestServerMerged("merged");
|