@ -6,31 +6,38 @@ import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI';
import { testDailyApiLimitFeatures } from 'app/gen-server/entity/Product' ;
import { AddOrUpdateRecord , Record as ApiRecord } from 'app/plugin/DocApiTypes' ;
import { CellValue , GristObjCode } from 'app/plugin/GristData' ;
import { applyQueryParameters , docApiUsagePeriods , docPeriodicApiUsageKey ,
getDocApiUsageKeysToIncr , WebhookSubscription } from 'app/server/lib/DocApi' ;
import {
applyQueryParameters ,
docApiUsagePeriods ,
docPeriodicApiUsageKey ,
getDocApiUsageKeysToIncr ,
WebhookSubscription
} from 'app/server/lib/DocApi' ;
import log from 'app/server/lib/log' ;
import { delayAbort } from 'app/server/lib/serverUtils' ;
import { WebhookSummary } from 'app/server/lib/Triggers' ;
import { waitForIt } from 'test/server/wait' ;
import { delayAbort , exitPromise } from 'app/server/lib/serverUtils' ;
import { connectTestingHooks , TestingHooksClient } from 'app/server/lib/TestingHooks' ;
import axios , { AxiosRequestConfig , AxiosResponse } from 'axios' ;
import { delay } from 'bluebird' ;
import * as bodyParser from 'body-parser' ;
import { assert } from 'chai' ;
import { ChildProcess , execFileSync , spawn } from 'child_process' ;
import FormData from 'form-data' ;
import * as fse from 'fs-extra' ;
import * as _ from 'lodash' ;
import LRUCache from 'lru-cache' ;
import * as moment from 'moment' ;
import { AbortController } from 'node-abort-controller' ;
import fetch from 'node-fetch' ;
import { tmpdir } from 'os' ;
import * as path from 'path' ;
import { createClient , RedisClient } from 'redis' ;
import { AbortController } from 'node-abort-controller' ;
import { configForUser } from 'test/gen-server/testUtils' ;
import { serveSomething , Serving } from 'test/server/customUtil' ;
import { prepareDatabase } from 'test/server/lib/helpers/PrepareDatabase' ;
import { prepareFilesystemDirectoryForTests } from 'test/server/lib/helpers/PrepareFilesystemDirectoryForTests' ;
import { signal } from 'test/server/lib/helpers/Signal' ;
import { TestServer } from 'test/server/lib/helpers/TestServer' ;
import * as testUtils from 'test/server/testUtils' ;
import { waitForIt } from 'test/server/wait' ;
import clone = require ( 'lodash/clone' ) ;
import defaultsDeep = require ( 'lodash/defaultsDeep' ) ;
import pick = require ( 'lodash/pick' ) ;
@ -76,21 +83,15 @@ describe('DocApi', function() {
}
// Create the tmp dir removing any previous one
await fse . remove ( tmpDir ) ;
await fse . mkdirs ( tmpDir ) ;
log . warn ( ` Test logs and data are at: ${ tmpDir } / ` ) ;
await prepareFilesystemDirectoryForTests ( tmpDir ) ;
// Let's create a sqlite db that we can share with servers that run in other processes, hence
// not an in-memory db. Running seed.ts directly might not take in account the most recent value
// for TYPEORM_DATABASE, because ormconfig.js may already have been loaded with a different
// configuration (in-memory for instance). Spawning a process is one way to make sure that the
// latest value prevail.
process . env . TYPEORM_DATABASE = path . join ( tmpDir , 'landing.db' ) ;
const seed = await testUtils . getBuildFile ( 'test/gen-server/seed.js' ) ;
execFileSync ( 'node' , [ seed , 'init' ] , {
env : process.env ,
stdio : 'inherit'
} ) ;
await prepareDatabase ( tmpDir ) ;
} ) ;
after ( ( ) = > {
@ -109,7 +110,11 @@ describe('DocApi', function() {
describe ( "should work with a merged server" , async ( ) = > {
setup ( 'merged' , async ( ) = > {
home = docs = await startServer ( 'home,docs' ) ;
const additionalEnvConfiguration = {
ALLOWED_WEBHOOK_DOMAINS : ` example.com,localhost: ${ webhooksTestPort } ` ,
GRIST_DATA_DIR : dataDir
} ;
home = docs = await TestServer . startServer ( 'home,docs' , tmpDir , suitename , additionalEnvConfiguration ) ;
homeUrl = serverUrl = home . serverUrl ;
hasHomeApi = true ;
} ) ;
@ -120,8 +125,13 @@ describe('DocApi', function() {
if ( process . env . TEST_REDIS_URL ) {
describe ( "should work with a home server and a docworker" , async ( ) = > {
setup ( 'separated' , async ( ) = > {
home = await startServer ( 'home' ) ;
docs = await startServer ( 'docs' , home . serverUrl ) ;
const additionalEnvConfiguration = {
ALLOWED_WEBHOOK_DOMAINS : ` example.com,localhost: ${ webhooksTestPort } ` ,
GRIST_DATA_DIR : dataDir
} ;
home = await TestServer . startServer ( 'home' , tmpDir , suitename , additionalEnvConfiguration ) ;
docs = await TestServer . startServer ( 'docs' , tmpDir , suitename , additionalEnvConfiguration , home . serverUrl ) ;
homeUrl = serverUrl = home . serverUrl ;
hasHomeApi = true ;
} ) ;
@ -130,8 +140,12 @@ describe('DocApi', function() {
describe ( "should work directly with a docworker" , async ( ) = > {
setup ( 'docs' , async ( ) = > {
home = await startServer ( 'home' ) ;
docs = await startServer ( 'docs' , home . serverUrl ) ;
const additionalEnvConfiguration = {
ALLOWED_WEBHOOK_DOMAINS : ` example.com,localhost: ${ webhooksTestPort } ` ,
GRIST_DATA_DIR : dataDir
} ;
home = await TestServer . startServer ( 'home' , tmpDir , suitename , additionalEnvConfiguration ) ;
docs = await TestServer . startServer ( 'docs' , tmpDir , suitename , additionalEnvConfiguration , home . serverUrl ) ;
homeUrl = home . serverUrl ;
serverUrl = docs . serverUrl ;
hasHomeApi = false ;
@ -857,7 +871,8 @@ function testDocApi() {
const query = "filter=" + encodeURIComponent ( JSON . stringify ( filters ) ) ;
return axios . get ( ` ${ serverUrl } /api/docs/ ${ docIds . Timesheets } /tables/Table1/data? ${ query } ` , chimpy ) ;
}
function checkResults ( resp : AxiosResponse < any > , expectedData : any ) {
function checkResults ( resp : AxiosResponse , expectedData : any ) {
assert . equal ( resp . status , 200 ) ;
assert . deepEqual ( resp . data , expectedData ) ;
}
@ -911,15 +926,24 @@ function testDocApi() {
const url = new URL ( ` ${ serverUrl } /api/docs/ ${ docIds . Timesheets } /tables/Table1/data ` ) ;
const config = configForUser ( 'chimpy' ) ;
if ( mode === 'url' ) {
if ( sort ) { url . searchParams . append ( 'sort' , sort . join ( ',' ) ) ; }
if ( limit ) { url . searchParams . append ( 'limit' , String ( limit ) ) ; }
if ( sort ) {
url . searchParams . append ( 'sort' , sort . join ( ',' ) ) ;
}
if ( limit ) {
url . searchParams . append ( 'limit' , String ( limit ) ) ;
}
} else {
if ( sort ) { config . headers [ 'x-sort' ] = sort . join ( ',' ) ; }
if ( limit ) { config . headers [ 'x-limit' ] = String ( limit ) ; }
if ( sort ) {
config . headers [ 'x-sort' ] = sort . join ( ',' ) ;
}
if ( limit ) {
config . headers [ 'x-limit' ] = String ( limit ) ;
}
}
return axios . get ( url . href , config ) ;
}
function checkResults ( resp : AxiosResponse < any > , expectedData : any ) {
function checkResults ( resp : AxiosResponse , expectedData : any ) {
assert . equal ( resp . status , 200 ) ;
assert . deepEqual ( resp . data , expectedData ) ;
}
@ -1109,7 +1133,7 @@ function testDocApi() {
} ) ;
} ) ;
function checkError ( status : number , test : RegExp | object , resp : AxiosResponse < any > , message? : string ) {
function checkError ( status : number , test : RegExp | object , resp : AxiosResponse , message? : string ) {
assert . equal ( resp . status , status ) ;
if ( test instanceof RegExp ) {
assert . match ( resp . data . error , test , message ) ;
@ -1446,13 +1470,15 @@ function testDocApi() {
await test ( { records : 1 } , { error : 'Invalid payload' , details : 'Error: body.records is not an array' } ) ;
// All column types are allowed, except Arrays (or objects) without correct code.
const testField = async ( A : any ) = > {
await test ( { records : [ { id : 1 , fields : { A } } ] } , { error : 'Invalid payload' , details :
await test ( { records : [ { id : 1 , fields : { A } } ] } , {
error : 'Invalid payload' , details :
'Error: body.records[0] is not a NewRecord; ' +
'body.records[0].fields.A is not a CellValue; ' +
'body.records[0].fields.A is none of number, ' +
'string, boolean, null, 1 more; body.records[0].' +
'fields.A[0] is not a GristObjCode; body.records[0]' +
'.fields.A[0] is not a valid enum value' } ) ;
'.fields.A[0] is not a valid enum value'
} ) ;
} ;
// test no code at all
await testField ( [ ] ) ;
@ -1601,6 +1627,7 @@ function testDocApi() {
it ( "validates request schema" , async function ( ) {
const url = ` ${ serverUrl } /api/docs/ ${ docIds . TestDoc } /tables/Foo/records ` ;
async function failsWithError ( payload : any , error : { error : string , details? : string } ) {
const resp = await axios . patch ( url , payload , chimpy ) ;
checkError ( 400 , error , resp ) ;
@ -1610,18 +1637,24 @@ function testDocApi() {
await failsWithError ( { records : 1 } , { error : 'Invalid payload' , details : 'Error: body.records is not an array' } ) ;
await failsWithError ( { records : [ ] } , { error : 'Invalid payload' , details :
'Error: body.records[0] is not a Record; body.records[0] is not an object' } ) ;
await failsWithError ( { records : [ ] } , {
error : 'Invalid payload' , details :
'Error: body.records[0] is not a Record; body.records[0] is not an object'
} ) ;
await failsWithError ( { records : [ { } ] } , { error : 'Invalid payload' , details :
await failsWithError ( { records : [ { } ] } , {
error : 'Invalid payload' , details :
'Error: body.records[0] is not a Record\n ' +
'body.records[0].id is missing\n ' +
'body.records[0].fields is missing' } ) ;
'body.records[0].fields is missing'
} ) ;
await failsWithError ( { records : [ { id : "1" } ] } , { error : 'Invalid payload' , details :
await failsWithError ( { records : [ { id : "1" } ] } , {
error : 'Invalid payload' , details :
'Error: body.records[0] is not a Record\n' +
' body.records[0].id is not a number\n' +
' body.records[0].fields is missing' } ) ;
' body.records[0].fields is missing'
} ) ;
await failsWithError (
{ records : [ { id : 1 , fields : { A : 1 } } , { id : 2 , fields : { B : 3 } } ] } ,
@ -1629,13 +1662,15 @@ function testDocApi() {
// Test invalid object codes
const fieldIsNotValid = async ( A : any ) = > {
await failsWithError ( { records : [ { id : 1 , fields : { A } } ] } , { error : 'Invalid payload' , details :
await failsWithError ( { records : [ { id : 1 , fields : { A } } ] } , {
error : 'Invalid payload' , details :
'Error: body.records[0] is not a Record; ' +
'body.records[0].fields.A is not a CellValue; ' +
'body.records[0].fields.A is none of number, ' +
'string, boolean, null, 1 more; body.records[0].' +
'fields.A[0] is not a GristObjCode; body.records[0]' +
'.fields.A[0] is not a valid enum value' } ) ;
'.fields.A[0] is not a valid enum value'
} ) ;
} ;
await fieldIsNotValid ( [ ] ) ;
await fieldIsNotValid ( [ 'ZZ' ] ) ;
@ -2232,7 +2267,9 @@ function testDocApi() {
} ) ;
it ( 'POST /workspaces/{wid}/import handles empty filenames' , async function ( ) {
if ( ! process . env . TEST_REDIS_URL ) { this . skip ( ) ; }
if ( ! process . env . TEST_REDIS_URL ) {
this . skip ( ) ;
}
const worker1 = await userApi . getWorkerAPI ( 'import' ) ;
const wid = ( await userApi . getOrgWorkspaces ( 'current' ) ) . find ( ( w ) = > w . name === 'Private' ) ! . id ;
const fakeData1 = await testUtils . readFixtureDoc ( 'Hello.grist' ) ;
@ -2246,7 +2283,9 @@ function testDocApi() {
} ) ;
it ( "document is protected during upload-and-import sequence" , async function ( ) {
if ( ! process . env . TEST_REDIS_URL ) { this . skip ( ) ; }
if ( ! process . env . TEST_REDIS_URL ) {
this . skip ( ) ;
}
// Prepare an API for a different user.
const kiwiApi = new UserAPIImpl ( ` ${ home . serverUrl } /o/Fish ` , {
headers : { Authorization : 'Bearer api_key_for_kiwi' } ,
@ -2332,15 +2371,15 @@ function testDocApi() {
const doc1 = await userApi . newDoc ( { name : 'testdoc1' , urlId : 'urlid1' } , ws1 ) ;
try {
// Make sure an edit made by docId is visible when accessed via docId or urlId
let resp = await axios . post ( ` ${ serverUrl } /api/docs/ ${ doc1 } /tables/Table1/data ` , {
await axios . post ( ` ${ serverUrl } /api/docs/ ${ doc1 } /tables/Table1/data ` , {
A : [ 'Apple' ] , B : [ 99 ]
} , chimpy ) ;
resp = await axios . get ( ` ${ serverUrl } /api/docs/ ${ doc1 } /tables/Table1/data ` , chimpy ) ;
let resp = await axios . get ( ` ${ serverUrl } /api/docs/ ${ doc1 } /tables/Table1/data ` , chimpy ) ;
assert . equal ( resp . data . A [ 0 ] , 'Apple' ) ;
resp = await axios . get ( ` ${ serverUrl } /api/docs/urlid1/tables/Table1/data ` , chimpy ) ;
assert . equal ( resp . data . A [ 0 ] , 'Apple' ) ;
// Make sure an edit made by urlId is visible when accessed via docId or urlId
resp = await axios . post ( ` ${ serverUrl } /api/docs/urlid1/tables/Table1/data ` , {
await axios . post ( ` ${ serverUrl } /api/docs/urlid1/tables/Table1/data ` , {
A : [ 'Orange' ] , B : [ 42 ]
} , chimpy ) ;
resp = await axios . get ( ` ${ serverUrl } /api/docs/ ${ doc1 } /tables/Table1/data ` , chimpy ) ;
@ -2564,7 +2603,8 @@ function testDocApi() {
{ tableRenames : [ ] , tableDeltas : { } } ) ;
const addA1 : ActionSummary = {
tableRenames : [ ] ,
tableDeltas : { Table1 : {
tableDeltas : {
Table1 : {
updateRows : [ ] ,
removeRows : [ ] ,
addRows : [ 2 ] ,
@ -2573,7 +2613,8 @@ function testDocApi() {
manualSort : { [ 2 ] : [ null , [ 2 ] ] } ,
} ,
columnRenames : [ ] ,
} }
}
}
} ;
assert . deepEqual ( comp . details ! . leftChanges , addA1 ) ;
@ -2617,7 +2658,8 @@ function testDocApi() {
{ tableRenames : [ ] , tableDeltas : { } } ) ;
const addA2 : ActionSummary = {
tableRenames : [ ] ,
tableDeltas : { Table1 : {
tableDeltas : {
Table1 : {
updateRows : [ ] ,
removeRows : [ ] ,
addRows : [ 3 ] ,
@ -2626,7 +2668,8 @@ function testDocApi() {
manualSort : { [ 3 ] : [ null , [ 3 ] ] } ,
} ,
columnRenames : [ ] ,
} }
}
}
} ;
assert . deepEqual ( comp . details ! . rightChanges , addA2 ) ;
} ) ;
@ -2701,8 +2744,10 @@ function testDocApi() {
removeRows : [ ] ,
addRows : [ 2 ] ,
columnDeltas : {
A : { [ 1 ] : [ [ 'a1' ] , [ 'A1' ] ] ,
[ 2 ] : [ null , [ 'a2' ] ] } ,
A : {
[ 1 ] : [ [ 'a1' ] , [ 'A1' ] ] ,
[ 2 ] : [ null , [ 'a2' ] ]
} ,
B : { [ 2 ] : [ null , [ 'b2' ] ] } ,
manualSort : { [ 2 ] : [ null , [ 2 ] ] } ,
} ,
@ -2748,7 +2793,7 @@ function testDocApi() {
await check ( { eventTypes : 0 } , 400 , /url is missing/ , /eventTypes is not an array/ ) ;
await check ( { eventTypes : [ ] } , 400 , /url is missing/ ) ;
await check ( { eventTypes : [ ] , url : "https://example.com" } , 400 , /eventTypes must be a non-empty array/ ) ;
await check ( { eventTypes : [ "foo" ] , url : "https://example.com" } , 400 , /eventTypes\[0 \ ] is none of "add", "update"/) ;
await check ( { eventTypes : [ "foo" ] , url : "https://example.com" } , 400 , /eventTypes\[0 ] is none of "add", "update"/) ;
await check ( { eventTypes : [ "add" ] } , 400 , /url is missing/ ) ;
await check ( { eventTypes : [ "add" ] , url : "https://evil.com" } , 403 , /Provided url is forbidden/ ) ;
await check ( { eventTypes : [ "add" ] , url : "http://example.com" } , 403 , /Provided url is forbidden/ ) ; // not https
@ -2825,7 +2870,9 @@ function testDocApi() {
let redisClient : RedisClient ;
before ( async function ( ) {
if ( ! process . env . TEST_REDIS_URL ) { this . skip ( ) ; }
if ( ! process . env . TEST_REDIS_URL ) {
this . skip ( ) ;
}
redisClient = createClient ( process . env . TEST_REDIS_URL ) ;
} ) ;
@ -2951,6 +2998,7 @@ function testDocApi() {
assert . equal ( nextHour , ` doc-myDocId-periodicApiUsage-2000-01-01T00 ` ) ;
const usage = new LRUCache < string , number > ( { max : 1024 } ) ;
function check ( expected : string [ ] | undefined ) {
assert . deepEqual ( getDocApiUsageKeysToIncr ( docId , usage , dailyMax , m ) , expected ) ;
}
@ -2979,7 +3027,9 @@ function testDocApi() {
} ) ;
after ( async function ( ) {
if ( ! process . env . TEST_REDIS_URL ) { this . skip ( ) ; }
if ( ! process . env . TEST_REDIS_URL ) {
this . skip ( ) ;
}
await redisClient . quitAsync ( ) ;
} ) ;
} ) ;
@ -3194,7 +3244,9 @@ function testDocApi() {
before ( async function ( ) {
this . timeout ( 30000 ) ;
// We rely on the REDIS server in this test.
if ( ! process . env . TEST_REDIS_URL ) { this . skip ( ) ; }
if ( ! process . env . TEST_REDIS_URL ) {
this . skip ( ) ;
}
requests = {
"add,update" : [ ] ,
"add" : [ ] ,
@ -3210,7 +3262,9 @@ function testDocApi() {
} ) ;
after ( async function ( ) {
if ( ! process . env . TEST_REDIS_URL ) { this . skip ( ) ; }
if ( ! process . env . TEST_REDIS_URL ) {
this . skip ( ) ;
}
await redisMonitor . quitAsync ( ) ;
} ) ;
@ -3847,7 +3901,8 @@ function testDocApi() {
const addRowProm = doc . addRows ( "Table1" , {
A : arrayRepeat ( 5 , 100 ) , // there are 2 webhooks, so 5 events per webhook.
B : arrayRepeat ( 5 , true )
} ) . catch ( ( ) = > { } ) ;
} ) . catch ( ( ) = > {
} ) ;
// WARNING: we can't wait for it, as the Webhooks will literally stop the document, and wait
// for the queue to drain. So we will carefully go further, and wait for the queue to drain.
@ -3941,7 +3996,9 @@ function testDocApi() {
stats = await readStats ( docId ) ;
assert . equal ( stats . length , 1 ) ;
assert . equal ( stats [ 0 ] . id , webhook . webhookId ) ;
if ( expectedFieldsCallback ) { expectedFieldsCallback ( expectedFields ) ; }
if ( expectedFieldsCallback ) {
expectedFieldsCallback ( expectedFields ) ;
}
assert . deepEqual ( stats [ 0 ] . fields , { . . . expectedFields , . . . fields } ) ;
if ( fields . tableId ) {
savedTableId = fields . tableId ;
@ -3974,7 +4031,7 @@ function testDocApi() {
await check ( { eventTypes : [ 'add' , 'update' ] } , 200 ) ;
await check ( { eventTypes : [ ] } , 400 , "eventTypes must be a non-empty array" ) ;
await check ( { eventTypes : [ "foo" ] } , 400 , /eventTypes\[0 \ ] is none of "add", "update"/) ;
await check ( { eventTypes : [ "foo" ] } , 400 , /eventTypes\[0 ] is none of "add", "update"/) ;
await check ( { isReadyColumn : null } , 200 ) ;
await check ( { isReadyColumn : "bar" } , 404 , ` Column not found "bar" ` ) ;
@ -4087,6 +4144,7 @@ interface WebhookRequests {
}
const ORG_NAME = 'docs-1' ;
function setup ( name : string , cb : ( ) = > Promise < void > ) {
let api : UserAPIImpl ;
@ -4128,129 +4186,9 @@ async function getWorkspaceId(api: UserAPIImpl, name: string) {
return workspaces . find ( ( w ) = > w . name === name ) ! . id ;
}
async function startServer ( serverTypes : string , _homeUrl? : string ) : Promise < TestServer > {
const server = new TestServer ( serverTypes ) ;
await server . start ( _homeUrl ) ;
return server ;
}
// TODO: deal with safe port allocation
const webhooksTestPort = 34365 ;
class TestServer {
public testingSocket : string ;
public testingHooks : TestingHooksClient ;
public serverUrl : string ;
public stopped = false ;
private _server : ChildProcess ;
private _exitPromise : Promise < number | string > ;
constructor ( private _serverTypes : string ) { }
public async start ( _homeUrl? : string ) {
// put node logs into files with meaningful name that relate to the suite name and server type
const fixedName = this . _serverTypes . replace ( /,/ , '_' ) ;
const nodeLogPath = path . join ( tmpDir , ` ${ suitename } - ${ fixedName } -node.log ` ) ;
const nodeLogFd = await fse . open ( nodeLogPath , 'a' ) ;
const serverLog = process . env . VERBOSE ? 'inherit' : nodeLogFd ;
// use a path for socket that relates to suite name and server types
this . testingSocket = path . join ( tmpDir , ` ${ suitename } - ${ fixedName } .socket ` ) ;
// env
const env = {
GRIST_DATA_DIR : dataDir ,
GRIST_INST_DIR : tmpDir ,
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_TESTING_SOCKET : this.testingSocket ,
GRIST_DISABLE_S3 : 'true' ,
REDIS_URL : process.env.TEST_REDIS_URL ,
APP_HOME_URL : _homeUrl ,
ALLOWED_WEBHOOK_DOMAINS : ` example.com,localhost: ${ webhooksTestPort } ` ,
GRIST_ALLOWED_HOSTS : ` example.com,localhost ` ,
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
} ;
const main = await testUtils . getBuildFile ( 'app/server/mergedServerMain.js' ) ;
this . _server = spawn ( 'node' , [ main , '--testingHooks' ] , {
env ,
stdio : [ 'inherit' , serverLog , serverLog ]
} ) ;
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 ( 30000 ) ;
log . info ( ` server ${ this . _serverTypes } up and listening on ${ this . serverUrl } ` ) ;
}
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 ( ) ;
this . serverUrl = ` http://localhost: ${ port } ` ;
// wait for check
return ( await fetch ( ` ${ this . serverUrl } /status/hooks ` , { timeout : 1000 } ) ) . ok ;
} catch ( err ) {
return false ;
}
}
private async _waitServerReady ( ms : number ) {
// 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 ) ;
}
}
}
async function setupDataDir ( dir : string ) {
// we'll be serving Hello.grist content for various document ids, so let's make copies of it in
@ -4263,42 +4201,3 @@ async function setupDataDir(dir: string) {
'ApiDataRecordsTest.grist' ,
path . resolve ( dir , docIds . ApiDataRecordsTest + '.grist' ) ) ;
}
/ * *
* Helper that creates a promise that can be resolved from outside .
* /
function signal() {
let resolve : null | ( ( data : any ) = > void ) = null ;
let promise : null | Promise < any > = null ;
let called = false ;
return {
emit ( data : any ) {
if ( ! resolve ) {
throw new Error ( "signal.emit() called before signal.reset()" ) ;
}
called = true ;
resolve ( data ) ;
} ,
async wait() {
if ( ! promise ) {
throw new Error ( "signal.wait() called before signal.reset()" ) ;
}
const proms = Promise . race ( [ promise , delay ( 2000 ) . then ( ( ) = > { throw new Error ( "signal.wait() timed out" ) ; } ) ] ) ;
return await proms ;
} ,
async waitAndReset() {
try {
return await this . wait ( ) ;
} finally {
this . reset ( ) ;
}
} ,
called() {
return called ;
} ,
reset() {
called = false ;
promise = new Promise ( ( res ) = > { resolve = res ; } ) ;
}
} ;
}