/* eslint-disable require-atomic-updates */ import BaseApplication from '../BaseApplication'; import BaseModel from '../BaseModel'; import Logger, { TargetType, LoggerWrapper, LogLevel } from '../Logger'; import Setting from '../models/Setting'; import BaseService from '../services/BaseService'; import FsDriverNode from '../fs-driver-node'; import time from '../time'; import shim from '../shim'; import uuid from '../uuid'; import ResourceService from '../services/ResourceService'; import KeymapService from '../services/KeymapService'; import KvStore from '../services/KvStore'; import KeychainServiceDriver from '../services/keychain/KeychainServiceDriver.node'; import KeychainServiceDriverDummy from '../services/keychain/KeychainServiceDriver.dummy'; import FileApiDriverJoplinServer from '../file-api-driver-joplinServer'; import OneDriveApi from '../onedrive-api'; import SyncTargetOneDrive from '../SyncTargetOneDrive'; import JoplinDatabase from '../JoplinDatabase'; import * as fs from 'fs-extra'; const { DatabaseDriverNode } = require('../database-driver-node.js'); import Folder from '../models/Folder'; import Note from '../models/Note'; import ItemChange from '../models/ItemChange'; import Resource from '../models/Resource'; import Tag from '../models/Tag'; import NoteTag from '../models/NoteTag'; import Revision from '../models/Revision'; import MasterKey from '../models/MasterKey'; import BaseItem from '../models/BaseItem'; import { FileApi } from '../file-api'; const FileApiDriverMemory = require('../file-api-driver-memory').default; const { FileApiDriverLocal } = require('../file-api-driver-local.js'); const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js'); const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js'); const { FileApiDriverOneDrive } = require('../file-api-driver-onedrive.js'); const { FileApiDriverAmazonS3 } = require('../file-api-driver-amazon-s3.js'); import SyncTargetRegistry from '../SyncTargetRegistry'; const SyncTargetMemory = require('../SyncTargetMemory.js'); const SyncTargetFilesystem = require('../SyncTargetFilesystem.js'); const SyncTargetNextcloud = require('../SyncTargetNextcloud.js'); const SyncTargetDropbox = require('../SyncTargetDropbox.js'); const SyncTargetAmazonS3 = require('../SyncTargetAmazonS3.js'); import SyncTargetJoplinServer from '../SyncTargetJoplinServer'; import EncryptionService from '../services/e2ee/EncryptionService'; import DecryptionWorker from '../services/DecryptionWorker'; import RevisionService from '../services/RevisionService'; import ResourceFetcher from '../services/ResourceFetcher'; const WebDavApi = require('../WebDavApi'); const DropboxApi = require('../DropboxApi'); import JoplinServerApi from '../JoplinServerApi'; import { FolderEntity } from '../services/database/types'; import { credentialFile, readCredentialFile } from '../utils/credentialFiles'; import SyncTargetJoplinCloud from '../SyncTargetJoplinCloud'; import KeychainService from '../services/keychain/KeychainService'; import { loadKeychainServiceAndSettings } from '../services/SettingUtils'; import { setActiveMasterKeyId, setEncryptionEnabled } from '../services/synchronizer/syncInfoUtils'; import Synchronizer from '../Synchronizer'; import SyncTargetNone from '../SyncTargetNone'; import { setRSA } from '../services/e2ee/ppk'; const md5 = require('md5'); const S3 = require('aws-sdk/clients/s3'); const { Dirnames } = require('../services/synchronizer/utils/types'); import RSA from '../services/e2ee/RSA.node'; // Each suite has its own separate data and temp directory so that multiple // suites can be run at the same time. suiteName is what is used to // differentiate between suite and it is currently set to a random string // (Ideally it would be something like the filename currently being executed by // Jest, to make debugging easier, but it's not clear how to get this info). const suiteName_ = uuid.createNano(); const databases_: JoplinDatabase[] = []; let synchronizers_: Synchronizer[] = []; const fileApis_: Record = {}; const encryptionServices_: EncryptionService[] = []; const revisionServices_: RevisionService[] = []; const decryptionWorkers_: DecryptionWorker[] = []; const resourceServices_: ResourceService[] = []; const resourceFetchers_: ResourceFetcher[] = []; const kvStores_: KvStore[] = []; let currentClient_ = 1; // The line `process.on('unhandledRejection'...` in all the test files is going to // make it throw this error. It's not too big a problem so disable it for now. // https://stackoverflow.com/questions/9768444/possible-eventemitter-memory-leak-detected process.setMaxListeners(0); shim.setIsTestingEnv(true); const fsDriver = new FsDriverNode(); Logger.fsDriver_ = fsDriver; Resource.fsDriver_ = fsDriver; EncryptionService.fsDriver_ = fsDriver; FileApiDriverLocal.fsDriver_ = fsDriver; // Most test units were historically under /app-cli so most test-related // directories are there but that should be moved eventually under the right // packages, or even out of the monorepo for temp files, logs, etc. const oldTestDir = `${__dirname}/../../app-cli/tests`; const logDir = `${oldTestDir}/logs`; const baseTempDir = `${oldTestDir}/tmp/${suiteName_}`; const supportDir = `${oldTestDir}/support`; // We add a space in the data directory path as that will help uncover // various space-in-path issues. const dataDir = `${oldTestDir}/test data/${suiteName_}`; const profileDir = `${dataDir}/profile`; fs.mkdirpSync(logDir); fs.mkdirpSync(baseTempDir); fs.mkdirpSync(dataDir); fs.mkdirpSync(profileDir); SyncTargetRegistry.addClass(SyncTargetNone); SyncTargetRegistry.addClass(SyncTargetMemory); SyncTargetRegistry.addClass(SyncTargetFilesystem); SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetNextcloud); SyncTargetRegistry.addClass(SyncTargetDropbox); SyncTargetRegistry.addClass(SyncTargetAmazonS3); SyncTargetRegistry.addClass(SyncTargetJoplinServer); SyncTargetRegistry.addClass(SyncTargetJoplinCloud); let syncTargetName_ = ''; let syncTargetId_: number = null; let sleepTime = 0; let isNetworkSyncTarget_ = false; function syncTargetName() { return syncTargetName_; } function setSyncTargetName(name: string) { if (name === syncTargetName_) return syncTargetName_; const previousName = syncTargetName_; syncTargetName_ = name; syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_); sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400; isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer'].includes(syncTargetName_); synchronizers_ = []; return previousName; } setSyncTargetName('memory'); // setSyncTargetName('filesystem'); // setSyncTargetName('nextcloud'); // setSyncTargetName('dropbox'); // setSyncTargetName('onedrive'); // setSyncTargetName('amazon_s3'); // setSyncTargetName('joplinServer'); // console.info(`Testing with sync target: ${syncTargetName_}`); const syncDir = `${oldTestDir}/sync/${suiteName_}`; // 90 seconds now that the tests are running in parallel and have been // split into smaller suites might not be necessary but for now leave it // anyway. let defaultJestTimeout = 90 * 1000; if (isNetworkSyncTarget_) defaultJestTimeout = 60 * 1000 * 10; if (typeof jest !== 'undefined') jest.setTimeout(defaultJestTimeout); const dbLogger = new Logger(); dbLogger.addTarget(TargetType.Console); dbLogger.setLevel(Logger.LEVEL_WARN); const logger = new Logger(); logger.addTarget(TargetType.Console); logger.setLevel(LogLevel.Warn); // Set to DEBUG to display sync process in console Logger.initializeGlobalLogger(logger); BaseItem.loadClass('Note', Note); BaseItem.loadClass('Folder', Folder); BaseItem.loadClass('Resource', Resource); BaseItem.loadClass('Tag', Tag); BaseItem.loadClass('NoteTag', NoteTag); BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('Revision', Revision); Setting.setConstant('appId', 'net.cozic.joplintest-cli'); Setting.setConstant('appType', 'cli'); Setting.setConstant('tempDir', baseTempDir); Setting.setConstant('cacheDir', baseTempDir); Setting.setConstant('pluginDataDir', `${profileDir}/profile/plugin-data`); Setting.setConstant('profileDir', profileDir); Setting.setConstant('env', 'dev'); BaseService.logger_ = logger; Setting.autoSaveEnabled = false; function syncTargetId() { return syncTargetId_; } function isNetworkSyncTarget() { return isNetworkSyncTarget_; } function sleep(n: number) { return new Promise((resolve) => { shim.setTimeout(() => { resolve(null); }, Math.round(n * 1000)); }); } function msleep(ms: number) { // It seems setTimeout can sometimes last less time than the provided // interval: // // https://stackoverflow.com/a/50912029/561309 // // This can cause issues in tests where we expect the actual duration to be // the same as the provided interval or more, but not less. So the code // below check that the elapsed time is no less than the provided interval, // and if it is, it waits a bit longer. const startTime = Date.now(); return new Promise((resolve) => { shim.setTimeout(() => { if (Date.now() - startTime < ms) { const iid = setInterval(() => { if (Date.now() - startTime >= ms) { clearInterval(iid); resolve(null); } }, 2); } else { resolve(null); } }, ms); }); } function currentClientId() { return currentClient_; } async function afterEachCleanUp() { await ItemChange.waitForAllSaved(); KeymapService.destroyInstance(); } async function afterAllCleanUp() { if (fileApi()) { try { await fileApi().clearRoot(); } catch (error) { console.warn('Could not clear sync target root:', error); } } } async function switchClient(id: number, options: any = null) { options = Object.assign({}, { keychainEnabled: false }, options); if (!databases_[id]) throw new Error(`Call setupDatabaseAndSynchronizer(${id}) first!!`); await time.msleep(sleepTime); // Always leave a little time so that updated_time properties don't overlap await Setting.saveAll(); currentClient_ = id; BaseModel.setDb(databases_[id]); BaseItem.encryptionService_ = encryptionServices_[id]; Resource.encryptionService_ = encryptionServices_[id]; BaseItem.revisionService_ = revisionServices_[id]; await Setting.reset(); Setting.settingFilename = `settings-${id}.json`; Setting.setConstant('resourceDirName', resourceDirName(id)); Setting.setConstant('resourceDir', resourceDir(id)); Setting.setConstant('pluginDir', pluginDir(id)); await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy); Setting.setValue('sync.target', syncTargetId()); Setting.setValue('sync.wipeOutFailSafe', false); // To keep things simple, always disable fail-safe unless explicitely set in the test itself // More generally, this function should clear all data, and so that should // include settings.json await clearSettingFile(id); } async function clearDatabase(id: number = null) { if (id === null) id = currentClient_; if (!databases_[id]) return; await ItemChange.waitForAllSaved(); const tableNames = [ 'notes', 'folders', 'resources', 'tags', 'note_tags', 'master_keys', 'item_changes', 'note_resources', 'settings', 'deleted_items', 'sync_items', 'notes_normalized', 'revisions', 'key_values', ]; const queries = []; for (const n of tableNames) { queries.push(`DELETE FROM ${n}`); queries.push(`DELETE FROM sqlite_sequence WHERE name="${n}"`); // Reset autoincremented IDs } await databases_[id].transactionExecBatch(queries); } async function setupDatabase(id: number = null, options: any = null) { options = Object.assign({}, { keychainEnabled: false }, options); if (id === null) id = currentClient_; Setting.cancelScheduleSave(); // Note that this was changed from `Setting.cache_ = []` to `await // Setting.reset()` during the TypeScript conversion. Normally this is // more correct but something to keep in mind anyway in case there are // some strange async issue related to settings when the tests are // running. await Setting.reset(); if (databases_[id]) { BaseModel.setDb(databases_[id]); await clearDatabase(id); await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy); Setting.setValue('sync.target', syncTargetId()); return; } const filePath = `${dataDir}/test-${id}.sqlite`; try { await fs.unlink(filePath); } catch (error) { // Don't care if the file doesn't exist } databases_[id] = new JoplinDatabase(new DatabaseDriverNode()); databases_[id].setLogger(dbLogger); await databases_[id].open({ name: filePath }); BaseModel.setDb(databases_[id]); await clearSettingFile(id); await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy); Setting.setValue('sync.target', syncTargetId()); } async function clearSettingFile(id: number) { Setting.settingFilename = `settings-${id}.json`; await fs.remove(Setting.settingFilePath); } export async function createFolderTree(parentId: string, tree: any[], num: number = 0): Promise { let rootFolder: FolderEntity = null; for (const item of tree) { const isFolder = !!item.children; num++; const data = { ...item }; delete data.children; if (isFolder) { const folder = await Folder.save({ title: `Folder ${num}`, parent_id: parentId, ...data }); if (!rootFolder) rootFolder = folder; if (item.children.length) await createFolderTree(folder.id, item.children, num); } else { await Note.save({ title: `Note ${num}`, parent_id: parentId, ...data }); } } return rootFolder; } function exportDir(id: number = null) { if (id === null) id = currentClient_; return `${dataDir}/export`; } function resourceDirName(id: number = null) { if (id === null) id = currentClient_; return `resources-${id}`; } function resourceDir(id: number = null) { if (id === null) id = currentClient_; return `${dataDir}/${resourceDirName(id)}`; } function pluginDir(id: number = null) { if (id === null) id = currentClient_; return `${dataDir}/plugins-${id}`; } async function setupDatabaseAndSynchronizer(id: number, options: any = null) { if (id === null) id = currentClient_; BaseService.logger_ = logger; await setupDatabase(id, options); EncryptionService.instance_ = null; DecryptionWorker.instance_ = null; await fs.remove(resourceDir(id)); await fs.mkdirp(resourceDir(id)); await fs.remove(pluginDir(id)); await fs.mkdirp(pluginDir(id)); if (!synchronizers_[id]) { const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_); const syncTarget = new SyncTargetClass(db(id)); await initFileApi(); syncTarget.setFileApi(fileApi()); syncTarget.setLogger(logger); synchronizers_[id] = await syncTarget.synchronizer(); } encryptionServices_[id] = new EncryptionService(); revisionServices_[id] = new RevisionService(); decryptionWorkers_[id] = new DecryptionWorker(); decryptionWorkers_[id].setEncryptionService(encryptionServices_[id]); resourceServices_[id] = new ResourceService(); resourceFetchers_[id] = new ResourceFetcher(() => { return synchronizers_[id].api(); }); kvStores_[id] = new KvStore(); setRSA(RSA); await fileApi().initialize(); await fileApi().clearRoot(); } function db(id: number = null): JoplinDatabase { if (id === null) id = currentClient_; return databases_[id]; } function synchronizer(id: number = null) { if (id === null) id = currentClient_; return synchronizers_[id]; } // This is like calling synchronizer.start() but it handles the // complexity of passing around the sync context depending on // the client. async function synchronizerStart(id: number = null, extraOptions: any = null) { if (id === null) id = currentClient_; const contextKey = `sync.${syncTargetId()}.context`; const contextString = Setting.value(contextKey); const context = contextString ? JSON.parse(contextString) : {}; const options = Object.assign({}, extraOptions); if (context) options.context = context; const newContext = await synchronizer(id).start(options); Setting.setValue(contextKey, JSON.stringify(newContext)); return newContext; } function encryptionService(id: number = null) { if (id === null) id = currentClient_; return encryptionServices_[id]; } function kvStore(id: number = null) { if (id === null) id = currentClient_; const o = kvStores_[id]; o.setDb(db(id)); return o; } function revisionService(id: number = null) { if (id === null) id = currentClient_; return revisionServices_[id]; } function decryptionWorker(id: number = null) { if (id === null) id = currentClient_; const o = decryptionWorkers_[id]; o.setKvStore(kvStore(id)); return o; } function resourceService(id: number = null) { if (id === null) id = currentClient_; return resourceServices_[id]; } function resourceFetcher(id: number = null) { if (id === null) id = currentClient_; return resourceFetchers_[id]; } async function loadEncryptionMasterKey(id: number = null, useExisting = false) { const service = encryptionService(id); const password = '123456'; let masterKey = null; if (!useExisting) { // Create it masterKey = await service.generateMasterKey(password); masterKey = await MasterKey.save(masterKey); } else { // Use the one already available const masterKeys = await MasterKey.all(); if (!masterKeys.length) throw new Error('No master key available'); masterKey = masterKeys[0]; } const passwordCache = Setting.value('encryption.passwordCache'); passwordCache[masterKey.id] = password; Setting.setValue('encryption.passwordCache', passwordCache); await Setting.saveAll(); await service.loadMasterKey(masterKey, password, true); setActiveMasterKeyId(masterKey.id); return masterKey; } function mustRunInBand() { if (!process.argv.includes('--runInBand')) { throw new Error('Tests must be run sequentially for this sync target, with the --runInBand arg. eg `npm test -- --runInBand`'); } } async function initFileApi() { if (fileApis_[syncTargetId_]) return; let fileApi = null; if (syncTargetId_ == SyncTargetRegistry.nameToId('filesystem')) { fs.removeSync(syncDir); fs.mkdirpSync(syncDir); fileApi = new FileApi(syncDir, new FileApiDriverLocal()); } else if (syncTargetId_ == SyncTargetRegistry.nameToId('memory')) { fileApi = new FileApi('/root', new FileApiDriverMemory()); } else if (syncTargetId_ == SyncTargetRegistry.nameToId('nextcloud')) { const options = require(`${oldTestDir}/support/nextcloud-auth.json`); const api = new WebDavApi({ baseUrl: () => options.baseUrl, username: () => options.username, password: () => options.password, }); fileApi = new FileApi('', new FileApiDriverWebDav(api)); } else if (syncTargetId_ == SyncTargetRegistry.nameToId('dropbox')) { // To get a token, go to the App Console: // https://www.dropbox.com/developers/apps/ // Then select "JoplinTest" and click "Generated access token" const api = new DropboxApi(); const authTokenPath = `${oldTestDir}/support/dropbox-auth.txt`; const authToken = fs.readFileSync(authTokenPath, 'utf8'); if (!authToken) throw new Error(`Dropbox auth token missing in ${authTokenPath}`); api.setAuthToken(authToken); fileApi = new FileApi('', new FileApiDriverDropbox(api)); } else if (syncTargetId_ == SyncTargetRegistry.nameToId('onedrive')) { // To get a token, open the URL below corresponding to your account type, // then copy the *complete* redirection URL in onedrive-auth.txt. Keep in mind that auth // data only lasts 1h for OneDrive. // // Personal OneDrive Account: // https://login.live.com/oauth20_authorize.srf?client_id=f1e68e1e-a729-4514-b041-4fdd5c7ac03a&scope=files.readwrite,offline_access&response_type=token&redirect_uri=https://joplinapp.org // // Business OneDrive Account: // https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=f1e68e1e-a729-4514-b041-4fdd5c7ac03a&scope=files.readwrite offline_access&response_type=token&redirect_uri=https://joplinapp.org // // Also for now OneDrive tests cannot be run in parallel because // for that each suite would need its own sub-directory within the // OneDrive app directory, and it's not clear how to get that // working. mustRunInBand(); const { parameters, setEnvOverride } = require('../parameters.js'); Setting.setConstant('env', 'dev'); setEnvOverride('test'); const config = parameters().oneDriveTest; const api = new OneDriveApi(config.id, config.secret, false); const authData = fs.readFileSync(await credentialFile('onedrive-auth.txt'), 'utf8'); const urlInfo = require('url-parse')(authData, true); const auth = require('querystring').parse(urlInfo.hash.substr(1)); api.setAuth(auth); const accountProperties = await api.execAccountPropertiesRequest(); api.setAccountProperties(accountProperties); const appDir = await api.appDirectory(); fileApi = new FileApi(appDir, new FileApiDriverOneDrive(api)); } else if (syncTargetId_ == SyncTargetRegistry.nameToId('amazon_s3')) { const amazonS3CredsPath = `${oldTestDir}/support/amazon-s3-auth.json`; const amazonS3Creds = require(amazonS3CredsPath); if (!amazonS3Creds || !amazonS3Creds.accessKeyId) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "accessKeyId": "", "secretAccessKey": "", "bucket": "mybucket"}`); const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true }); fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket)); } else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) { mustRunInBand(); const joplinServerAuth = JSON.parse(await readCredentialFile('joplin-server-test-units-2.json')); // const joplinServerAuth = { // "email": "admin@localhost", // "password": "admin", // "baseUrl": "http://api.joplincloud.local:22300", // "userContentBaseUrl": "" // } // Note that to test the API in parallel mode, you need to use Postgres // as database, as the SQLite database is not reliable when being // read/write from multiple processes at the same time. const api = new JoplinServerApi({ baseUrl: () => joplinServerAuth.baseUrl, userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl, username: () => joplinServerAuth.email, password: () => joplinServerAuth.password, }); fileApi = new FileApi('', new FileApiDriverJoplinServer(api)); } fileApi.setLogger(logger); fileApi.setSyncTargetId(syncTargetId_); fileApi.setTempDirName(Dirnames.Temp); fileApi.requestRepeatCount_ = isNetworkSyncTarget_ ? 1 : 0; fileApis_[syncTargetId_] = fileApi; } function fileApi() { return fileApis_[syncTargetId_]; } function objectsEqual(o1: any, o2: any) { if (Object.getOwnPropertyNames(o1).length !== Object.getOwnPropertyNames(o2).length) return false; for (const n in o1) { if (!o1.hasOwnProperty(n)) continue; if (o1[n] !== o2[n]) return false; } return true; } async function checkThrowAsync(asyncFn: Function) { let hasThrown = false; try { await asyncFn(); } catch (error) { hasThrown = true; } return hasThrown; } async function expectThrow(asyncFn: Function, errorCode: any = undefined) { let hasThrown = false; let thrownError = null; try { await asyncFn(); } catch (error) { hasThrown = true; thrownError = error; } if (!hasThrown) { expect('not throw').toBe('throw'); } else if (thrownError.code !== errorCode) { console.error(thrownError); expect(`error code: ${thrownError.code}`).toBe(`error code: ${errorCode}`); } else { expect(true).toBe(true); } } async function expectNotThrow(asyncFn: Function) { let thrownError = null; try { await asyncFn(); } catch (error) { thrownError = error; } if (thrownError) { console.error(thrownError); expect(thrownError.message).toBe(''); } else { expect(true).toBe(true); } } function checkThrow(fn: Function) { let hasThrown = false; try { fn(); } catch (error) { hasThrown = true; } return hasThrown; } function fileContentEqual(path1: string, path2: string) { const fs = require('fs-extra'); const content1 = fs.readFileSync(path1, 'base64'); const content2 = fs.readFileSync(path2, 'base64'); return content1 === content2; } async function allSyncTargetItemsEncrypted() { const list = await fileApi().list('', { includeDirs: false }); const files = list.items; let totalCount = 0; let encryptedCount = 0; for (let i = 0; i < files.length; i++) { const file = files[i]; if (!BaseItem.isSystemPath(file.path)) continue; const remoteContentString = await fileApi().get(file.path); const remoteContent = await BaseItem.unserialize(remoteContentString); const ItemClass = BaseItem.itemClass(remoteContent); if (!ItemClass.encryptionSupported()) continue; totalCount++; if (remoteContent.type_ === BaseModel.TYPE_RESOURCE) { const content = await fileApi().get(`.resource/${remoteContent.id}`); totalCount++; if (content.substr(0, 5) === 'JED01') encryptedCount++; } if (remoteContent.encryption_applied) encryptedCount++; } if (!totalCount) throw new Error('No encryptable item on sync target'); return totalCount === encryptedCount; } function id(a: any) { return a.id; } function ids(a: any[]) { return a.map(n => n.id); } function sortedIds(a: any[]) { return ids(a).sort(); } function at(a: any[], indexes: any[]) { const out = []; for (let i = 0; i < indexes.length; i++) { out.push(a[indexes[i]]); } return out; } async function createNTestFolders(n: number) { const folders = []; for (let i = 0; i < n; i++) { const folder = await Folder.save({ title: 'folder' }); folders.push(folder); await time.msleep(10); } return folders; } async function createNTestNotes(n: number, folder: any, tagIds: string[] = null, title: string = 'note') { const notes = []; for (let i = 0; i < n; i++) { const title_ = n > 1 ? `${title}${i}` : title; const note = await Note.save({ title: title_, parent_id: folder.id, is_conflict: 0 }); notes.push(note); await time.msleep(10); } if (tagIds) { for (let i = 0; i < notes.length; i++) { await Tag.setNoteTagsByIds(notes[i].id, tagIds); await time.msleep(10); } } return notes; } async function createNTestTags(n: number) { const tags = []; for (let i = 0; i < n; i++) { const tag = await Tag.save({ title: 'tag' }); tags.push(tag); await time.msleep(10); } return tags; } function tempFilePath(ext: string) { return `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${ext}`; } async function createTempDir() { const tempDirPath = `${baseTempDir}/${uuid.createNano()}`; await fs.mkdirp(tempDirPath); return tempDirPath; } async function waitForFolderCount(count: number) { const timeout = 2000; const startTime = Date.now(); while (true) { const folders = await Folder.all(); if (folders.length >= count) return; if (Date.now() - startTime > timeout) throw new Error('Timeout waiting for folders to be created'); await msleep(10); } } let naughtyStrings_: string[] = null; export async function naughtyStrings() { if (naughtyStrings_) return naughtyStrings_; const t = await fs.readFile(`${supportDir}/big-list-of-naughty-strings.txt`, 'utf8'); const lines = t.split('\n'); naughtyStrings_ = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.indexOf('#') === 0) continue; naughtyStrings_.push(line); } return naughtyStrings_; } // TODO: Update for Jest // function mockDate(year, month, day, tick) { // const fixedDate = new Date(2020, 0, 1); // jasmine.clock().install(); // jasmine.clock().mockDate(fixedDate); // } // function restoreDate() { // jasmine.clock().uninstall(); // } // Application for feature integration testing class TestApp extends BaseApplication { private hasGui_: boolean; private middlewareCalls_: any[]; private logger_: LoggerWrapper; public constructor(hasGui = true) { KeychainService.instance().enabled = false; super(); this.hasGui_ = hasGui; this.middlewareCalls_ = []; this.logger_ = super.logger(); } public hasGui() { return this.hasGui_; } public async start(argv: any[]) { this.logger_.info('Test app starting...'); if (!argv.includes('--profile')) { argv = argv.concat(['--profile', `tests-build/profile/${uuid.create()}`]); } argv = await super.start(['',''].concat(argv)); // For now, disable sync and encryption to avoid spurious intermittent failures // caused by them interupting processing and causing delays. Setting.setValue('sync.interval', 0); setEncryptionEnabled(true); this.initRedux(); Setting.dispatchUpdateAll(); await ItemChange.waitForAllSaved(); await this.wait(); this.logger_.info('Test app started...'); } public async generalMiddleware(store: any, next: any, action: any) { this.middlewareCalls_.push(true); try { await super.generalMiddleware(store, next, action); } finally { this.middlewareCalls_.pop(); } } public async wait() { return new Promise((resolve) => { const iid = shim.setInterval(() => { if (!this.middlewareCalls_.length) { clearInterval(iid); resolve(null); } }, 100); }); } public async profileDir() { return Setting.value('profileDir'); } public async destroy() { this.logger_.info('Test app stopping...'); await this.wait(); await ItemChange.waitForAllSaved(); this.deinitRedux(); await super.destroy(); await time.msleep(100); } } export { supportDir, waitForFolderCount, afterAllCleanUp, exportDir, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };