diff --git a/.vscode/launch.json b/.vscode/launch.json index c9e9ddfb..131ea71d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -46,7 +46,6 @@ "--experimental-vm-modules", "node_modules/.bin/jest", "--config", "jest.config.json", - //"test/unit/.*.test.ts" ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", @@ -66,7 +65,7 @@ "--runInBand", "--config", "jest.config.json", //"--detectOpenHandles", - "test/unit/resources.resource-manager.test.ts" + "test/unit/icloud.test.ts" ], "env": { "NODE_NO_WARNINGS": "1" diff --git a/app/jest.config.json b/app/jest.config.json index 0b6120e8..f1a79364 100644 --- a/app/jest.config.json +++ b/app/jest.config.json @@ -1,5 +1,5 @@ { - "preset": "ts-jest/presets/default-esm-legacy", + "preset": "ts-jest/presets/default-esm", "testEnvironment": "node", "slowTestThreshold": 30, "reporters": [ @@ -20,15 +20,6 @@ "html", "json-summary" ], - "extensionsToTreatAsEsm": [".ts"], - "transform": { - "\\.ts$": [ - "ts-jest", - { - "useESM": true - } - ] - }, "moduleNameMapper": { "^(\\.{1,2}/.*)\\.js$": "$1" } diff --git a/app/src/app/error/codes/archive.ts b/app/src/app/error/codes/archive.ts index 02b1dbdb..ff275544 100644 --- a/app/src/app/error/codes/archive.ts +++ b/app/src/app/error/codes/archive.ts @@ -3,6 +3,10 @@ import {buildErrorStruct, ErrorStruct} from "../error-codes.js"; const name = `ArchiveError`; const prefix = `ARCHIVE`; +export const NO_ASSETS: ErrorStruct = buildErrorStruct( + name, prefix, `NO_ASSETS`, `No remote assets available`, +); + export const UUID_PATH: ErrorStruct = buildErrorStruct( name, prefix, `UUID_PATH`, `UUID path selected, use named path only`, ); diff --git a/app/src/app/error/error.ts b/app/src/app/error/error.ts index 16e614a9..85da8e5a 100644 --- a/app/src/app/error/error.ts +++ b/app/src/app/error/error.ts @@ -32,7 +32,7 @@ export class iCPSError extends Error { /** * If this error was reported, it will receive a UUID for future reference */ - btUUID: string = undefined; + btUUID?: string = undefined; /** * Creates an application specific error using the provided @@ -121,7 +121,8 @@ export class iCPSError extends Error { * @returns the error code for the first thrown error */ getRootErrorCode(onlyICPSError: boolean = false): string { - return this.getErrorCodeStack(onlyICPSError).pop(); + // getErrorCodeStack returns at least one item + return this.getErrorCodeStack(onlyICPSError).pop()!; } /** diff --git a/app/src/app/event/cli.ts b/app/src/app/event/cli.ts index de7793db..8ac256ee 100644 --- a/app/src/app/event/cli.ts +++ b/app/src/app/event/cli.ts @@ -80,7 +80,7 @@ export class CLIInterface { this.print(chalk.white(`Resending MFA code via ${method.toString()}...`)); }) .on(iCPSEventMFA.MFA_RECEIVED, (method: MFAMethod, code: string) => { - this.print(chalk.white(`MFA code received via ${method.toString()} (${code})`)); + this.print(chalk.white(`MFA code received from ${method.toString()} (${code})`)); }) .on(iCPSEventMFA.MFA_NOT_PROVIDED, () => { this.print(chalk.yellowBright(`MFA code not provided in time, aborting...`)); diff --git a/app/src/app/event/error-handler.ts b/app/src/app/event/error-handler.ts index 97f6eb20..4bdade83 100644 --- a/app/src/app/event/error-handler.ts +++ b/app/src/app/event/error-handler.ts @@ -123,6 +123,9 @@ export class ErrorHandler { } async handleFiletype(_ext: string, _descriptor?: string) { + if(this.btClient === undefined){ + return + } const report = new bt.BacktraceReport(new iCPSError(FILETYPE_REPORT), { 'icps.filetype.extension': _ext, @@ -140,6 +143,9 @@ export class ErrorHandler { * Registers event listeners to provide breadcrumbs */ registerBreadcrumbs() { + if (this.btClient === undefined || this.btClient.breadcrumbs === undefined ){ + return; + } Resources.events(this) .on(iCPSEventRuntimeWarning.MFA_ERROR, (err: Error) => { this.btClient.breadcrumbs.warn(`MFA_ERROR`, {error: iCPSError.toiCPSError(err).getDescription()}); diff --git a/app/src/app/icloud-app.ts b/app/src/app/icloud-app.ts index adeea07c..fef87992 100644 --- a/app/src/app/icloud-app.ts +++ b/app/src/app/icloud-app.ts @@ -52,8 +52,11 @@ export class DaemonApp extends iCPSApp { async performScheduledSync(syncApp: SyncApp = new SyncApp()) { try { Resources.emit(iCPSEventApp.SCHEDULED_START); - await syncApp.run(); - Resources.emit(iCPSEventApp.SCHEDULED_DONE, this.job?.nextRun()); + const [remoteAssets] = await syncApp.run() as [Asset[], Album[]]; + + if (remoteAssets.length > 0) { + Resources.emit(iCPSEventApp.SCHEDULED_DONE, this.job?.nextRun()); + } } catch (err) { Resources.emit(iCPSEventRuntimeError.SCHEDULED_ERROR, new iCPSError(APP_ERR.DAEMON).addCause(err)); Resources.emit(iCPSEventApp.SCHEDULED_RETRY, this.job?.nextRun()); @@ -89,7 +92,7 @@ abstract class iCloudApp extends iCPSApp { /** * This function acquires the library lock and establishes the iCloud connection. * @param eventHandlers - A list of EventHandlers that will be registering relevant objects - * @returns A promise that resolves once the iCloud service is fully available + * @returns A promise that resolves to true once the iCloud service is fully available. If it resolves to false, the MFA code was not provided in time and the object is not ready. * @throws A iCPSError in case an error occurs */ async run(): Promise { @@ -177,7 +180,7 @@ export class TokenApp extends iCloudApp { // Emitting event for CLI Resources.emit(iCPSEventApp.TOKEN, token); }); - await super.run(); + return await super.run(); } catch (err) { throw new iCPSError(APP_ERR.TOKEN) .addCause(err); @@ -187,9 +190,6 @@ export class TokenApp extends iCloudApp { await this.clean(); } } - - // Has to return something (TS2355) - return true; } /** @@ -227,12 +227,16 @@ export class SyncApp extends iCloudApp { /** * Runs the synchronization of the local Photo Library * @param eventHandlers - A list of EventHandlers that will be registering relevant objects - * @returns A Promise that resolves to a tuple containing a list of assets as fetched from the remote state. It can be assumed that this reflects the local state (given a warning free execution of the sync). + * @returns A Promise that resolves to a tuple containing a list of assets and albums as fetched from the remote state. The returned arrays might be empty, if the iCloud connection was not established successfully. * @throws A SyncError in case an error occurs */ async run(): Promise { try { - await super.run(); + const ready = await super.run() as boolean; + if (!ready) { + return [[], []]; + } + return await this.syncEngine.sync(); } catch (err) { throw new iCPSError(APP_ERR.SYNC) diff --git a/app/src/lib/archive-engine/archive-engine.ts b/app/src/lib/archive-engine/archive-engine.ts index 437bd989..7daedf17 100644 --- a/app/src/lib/archive-engine/archive-engine.ts +++ b/app/src/lib/archive-engine/archive-engine.ts @@ -41,6 +41,10 @@ export class ArchiveEngine { Resources.logger(this).debug(`Archiving path ${archivePath}`); Resources.emit(iCPSEventArchiveEngine.ARCHIVE_START, archivePath); + if (assetList.length === 0) { + throw new iCPSError(ARCHIVE_ERR.NO_ASSETS); + } + const albumName = path.basename(archivePath); if (albumName.startsWith(`.`)) { throw new iCPSError(ARCHIVE_ERR.UUID_PATH); diff --git a/app/src/lib/icloud/icloud.ts b/app/src/lib/icloud/icloud.ts index 22b4a9e5..a7d33d8b 100644 --- a/app/src/lib/icloud/icloud.ts +++ b/app/src/lib/icloud/icloud.ts @@ -1,4 +1,4 @@ -import {AxiosRequestConfig} from 'axios'; +import {AxiosError, AxiosRequestConfig} from 'axios'; import {MFAServer} from './mfa/mfa-server.js'; import {iCloudPhotos} from './icloud-photos/icloud-photos.js'; import {MFAMethod} from './mfa/mfa-method.js'; @@ -24,9 +24,9 @@ export class iCloud { photos: iCloudPhotos; /** - * A promise that will resolve, once the object is ready or reject, in case there is an error + * A promise that will resolve to true, if the connection was established successfully, false in case the MFA code was not provided in time or reject, in case there is an error */ - ready: Promise; + ready: Promise; /** * Creates a new iCloud Object @@ -66,14 +66,14 @@ export class iCloud { /** * - * @returns - A promise, that will resolve once this objects emits 'READY' or reject if it emits 'ERROR' or the MFA server times out + * @returns A promise that will resolve to true, if the connection was established successfully, false in case the MFA code was not provided in time or reject, in case there is an error */ - getReady(): Promise { - return new Promise((resolve, reject) => { + getReady(): Promise { + return new Promise((resolve, reject) => { Resources.events(this) - .once(iCPSEventPhotos.READY, () => resolve()) + .once(iCPSEventPhotos.READY, () => resolve(true)) + .once(iCPSEventMFA.MFA_NOT_PROVIDED, err => resolve(false)) .once(iCPSEventCloud.ERROR, err => reject(err)) - .once(iCPSEventMFA.MFA_NOT_PROVIDED, err => reject(err)) .once(iCPSEventMFA.ERROR, err => reject(err)); }); } @@ -82,7 +82,7 @@ export class iCloud { * Initiates authentication flow * Tries to directly login using trustToken, otherwise starts MFA flow */ - async authenticate(): Promise { + async authenticate(): Promise { Resources.logger(this).info(`Authenticating user`); Resources.emit(iCPSEventCloud.AUTHENTICATION_STARTED); @@ -131,7 +131,9 @@ export class iCloud { return; } - if (err?.response?.status) { + // Does not seem to work + //if (err instanceof AxiosError) { + if ((err as AxiosError).isAxiosError) { switch (err.response.status) { case 401: Resources.emit(iCPSEventCloud.ERROR, new iCPSError(AUTH_ERR.UNAUTHORIZED).addCause(err)); diff --git a/app/src/lib/sync-engine/sync-engine.ts b/app/src/lib/sync-engine/sync-engine.ts index d6836dc7..3fc6a7e3 100644 --- a/app/src/lib/sync-engine/sync-engine.ts +++ b/app/src/lib/sync-engine/sync-engine.ts @@ -37,7 +37,7 @@ export class SyncEngine { /** * Performs the sync and handles all connections - * @returns A list of assets as fetched from the remote state. It can be assumed that this reflects the local state (given a warning free execution of the sync) + * @returns A list of assets and albums as fetched from the remote state. It can be assumed that this reflects the local state (given a warning free execution of the sync) */ async sync(): Promise<[Asset[], Album[]]> { Resources.logger(this).info(`Starting sync`); @@ -70,7 +70,9 @@ export class SyncEngine { Resources.logger(this).debug(`Refreshing iCloud cookies...`); const iCloudReady = this.icloud.getReady(); await this.icloud.setupAccount(); - await iCloudReady; + if (!await iCloudReady) { + return [[], []]; + } } } diff --git a/app/test/unit/app.test.ts b/app/test/unit/app.test.ts index 0a8c01dc..356b6f66 100644 --- a/app/test/unit/app.test.ts +++ b/app/test/unit/app.test.ts @@ -139,12 +139,34 @@ describe(`App control flow`, () => { expect(tokenApp.releaseLibraryLock).toHaveBeenCalledTimes(1); }); + test(`Handle MFA not provided`, async () => { + const tokenApp = appFactory(validOptions.token) as TokenApp; + tokenApp.acquireLibraryLock = jest.fn() + .mockResolvedValue(); + tokenApp.icloud.authenticate = jest.fn() + .mockResolvedValue(false); + tokenApp.releaseLibraryLock = jest.fn() + .mockResolvedValue(); + Resources._instances.network.resetSession = jest.fn() + .mockResolvedValue(); + Resources._instances.event.removeListenersFromRegistry = jest.fn() + .mockReturnValue(Resources._instances.event); + + await expect(tokenApp.run()).resolves.toBeFalsy(); + + expect(Resources._instances.network.resetSession).toHaveBeenCalledTimes(1); + expect(Resources._instances.event.removeListenersFromRegistry).toHaveBeenCalledTimes(4); + + expect(tokenApp.acquireLibraryLock).toHaveBeenCalledTimes(1); + expect(tokenApp.releaseLibraryLock).toHaveBeenCalledTimes(1); + }); + test(`Handle lock acquisition error`, async () => { const tokenApp = appFactory(validOptions.token) as TokenApp; tokenApp.acquireLibraryLock = jest.fn() .mockRejectedValue(new Error()); tokenApp.icloud.authenticate = jest.fn() - .mockResolvedValue(); + .mockResolvedValue(true); tokenApp.releaseLibraryLock = jest.fn() .mockResolvedValue(); Resources._instances.network.resetSession = jest.fn() @@ -185,7 +207,7 @@ describe(`App control flow`, () => { .mockImplementationOnce(originalRemoveListenersFromRegistry) // Original implementation need to run once, so it can divert the execution flow .mockReturnValue(Resources._instances.event); - await tokenApp.run(); + await expect(tokenApp.run()).resolves.toBeTruthy(); expect(tokenApp.acquireLibraryLock).toHaveBeenCalledTimes(1); expect(tokenApp.icloud.authenticate).toHaveBeenCalledTimes(1); @@ -205,7 +227,7 @@ describe(`App control flow`, () => { syncApp.acquireLibraryLock = jest.fn() .mockResolvedValue(); syncApp.icloud.authenticate = jest.fn() - .mockResolvedValue(); + .mockResolvedValue(true); syncApp.syncEngine.sync = jest.fn() .mockResolvedValue([[], []]); syncApp.releaseLibraryLock = jest.fn() @@ -225,13 +247,39 @@ describe(`App control flow`, () => { expect(Resources._instances.event.removeListenersFromRegistry).toHaveBeenCalledTimes(2); }); - test(`Handle sync error`, async () => { + test(`Handle MFA not provided`, async () => { const syncApp = appFactory(validOptions.sync) as SyncApp; syncApp.acquireLibraryLock = jest.fn() .mockResolvedValue(); syncApp.icloud.authenticate = jest.fn() + .mockResolvedValue(false); + syncApp.syncEngine.sync = jest.fn() + .mockRejectedValue(new Error(`MFA required`)); + syncApp.releaseLibraryLock = jest.fn() + .mockResolvedValue(); + Resources._instances.network.resetSession = jest.fn() .mockResolvedValue(); + Resources._instances.event.removeListenersFromRegistry = jest.fn() + .mockReturnValue(Resources._instances.event); + + await expect(syncApp.run()).resolves.toEqual([[], []]); + + expect(syncApp.acquireLibraryLock).toHaveBeenCalledTimes(1); + expect(syncApp.icloud.authenticate).toHaveBeenCalledTimes(1); + expect(syncApp.syncEngine.sync).not.toHaveBeenCalled(); + expect(syncApp.releaseLibraryLock).toHaveBeenCalledTimes(1); + expect(Resources._instances.network.resetSession).toHaveBeenCalledTimes(1); + expect(Resources._instances.event.removeListenersFromRegistry).toHaveBeenCalledTimes(2); + }); + + test(`Handle sync error`, async () => { + const syncApp = appFactory(validOptions.sync) as SyncApp; + + syncApp.acquireLibraryLock = jest.fn() + .mockResolvedValue(); + syncApp.icloud.authenticate = jest.fn() + .mockResolvedValue(true); syncApp.syncEngine.sync = jest.fn() .mockRejectedValue(new Error()); syncApp.releaseLibraryLock = jest.fn() @@ -258,7 +306,7 @@ describe(`App control flow`, () => { archiveApp.acquireLibraryLock = jest.fn() .mockResolvedValue(); archiveApp.icloud.authenticate = jest.fn() - .mockResolvedValue(); + .mockResolvedValue(true); const remoteState = [{fileChecksum: `someChecksum`}] as Asset[]; archiveApp.syncEngine.sync = jest.fn() @@ -284,12 +332,43 @@ describe(`App control flow`, () => { expect(Resources._instances.event.removeListenersFromRegistry).toHaveBeenCalledTimes(2); }); - test(`Handle archive error`, async () => { + test(`Handle MFA not provided`, async () => { const archiveApp = appFactory(validOptions.archive) as ArchiveApp; archiveApp.acquireLibraryLock = jest.fn() .mockResolvedValue(); archiveApp.icloud.authenticate = jest.fn() + .mockResolvedValue(false); + + const remoteState = [{fileChecksum: `someChecksum`}] as Asset[]; + archiveApp.syncEngine.sync = jest.fn() + .mockResolvedValue([remoteState, []]); + + archiveApp.archiveEngine.archivePath = jest.fn() + .mockResolvedValue(); + archiveApp.releaseLibraryLock = jest.fn() + .mockResolvedValue(); + Resources._instances.network.resetSession = jest.fn() .mockResolvedValue(); + Resources._instances.event.removeListenersFromRegistry = jest.fn() + .mockReturnValue(Resources._instances.event); + + await archiveApp.run(); + + expect(archiveApp.acquireLibraryLock).toHaveBeenCalledTimes(1); + expect(archiveApp.icloud.authenticate).toHaveBeenCalledTimes(1); + expect(archiveApp.syncEngine.sync).not.toHaveBeenCalled(); + expect(archiveApp.archiveEngine.archivePath).toHaveBeenCalledWith(validOptions.archive[validOptions.archive.length - 1], []); + expect(archiveApp.releaseLibraryLock).toHaveBeenCalledTimes(1); + expect(Resources._instances.network.resetSession).toHaveBeenCalledTimes(1); + expect(Resources._instances.event.removeListenersFromRegistry).toHaveBeenCalledTimes(2); + }); + + test(`Handle archive error`, async () => { + const archiveApp = appFactory(validOptions.archive) as ArchiveApp; + archiveApp.acquireLibraryLock = jest.fn() + .mockResolvedValue(); + archiveApp.icloud.authenticate = jest.fn() + .mockResolvedValue(true); archiveApp.syncEngine.sync = jest.fn() .mockResolvedValue([[], []]); @@ -337,9 +416,10 @@ describe(`App control flow`, () => { const daemonApp = appFactory(validOptions.daemon) as DaemonApp; const successEvent = spyOnEvent(Resources._instances.event._eventBus, iCPSEventApp.SCHEDULED_DONE); + const remoteState = [{fileChecksum: `someChecksum`}] as Asset[]; const syncApp = new SyncApp(); syncApp.run = jest.fn() - .mockResolvedValue(undefined); + .mockResolvedValue([remoteState, []]); await daemonApp.performScheduledSync(syncApp); @@ -347,6 +427,20 @@ describe(`App control flow`, () => { expect(successEvent).toHaveBeenCalled(); }); + test(`Scheduled sync requires MFA`, async () => { + const daemonApp = appFactory(validOptions.daemon) as DaemonApp; + const successEvent = spyOnEvent(Resources._instances.event._eventBus, iCPSEventApp.SCHEDULED_DONE); + + const syncApp = new SyncApp(); + syncApp.run = jest.fn() + .mockResolvedValue([[], []]); + + await daemonApp.performScheduledSync(syncApp); + + expect(syncApp.run).toHaveBeenCalled(); + expect(successEvent).not.toHaveBeenCalled(); + }); + test(`Scheduled sync fails`, async () => { const daemonApp = appFactory(validOptions.daemon) as DaemonApp; const retryEvent = spyOnEvent(Resources._instances.event._eventBus, iCPSEventApp.SCHEDULED_RETRY); diff --git a/app/test/unit/icloud.test.ts b/app/test/unit/icloud.test.ts index c97b1532..f840d8ae 100644 --- a/app/test/unit/icloud.test.ts +++ b/app/test/unit/icloud.test.ts @@ -83,7 +83,7 @@ describe(`Control structure`, () => { test(`MFA_NOT_PROVIDED event triggered`, async () => { mockedEventManager.emit(iCPSEventMFA.MFA_NOT_PROVIDED, new iCPSError(MFA_ERR.SERVER_TIMEOUT)); - await expect(icloud.ready).rejects.toThrow(/^MFA server timeout \(code needs to be provided within 10 minutes\)$/); + await expect(icloud.ready).resolves.toBeFalsy() }); test.each([ @@ -93,9 +93,6 @@ describe(`Control structure`, () => { }, { desc: `MFA`, event: iCPSEventMFA.ERROR, - }, { - desc: `MFA_Timeout`, - event: iCPSEventMFA.MFA_NOT_PROVIDED, }, ])(`$desc error event triggered`, async ({event}) => { mockedEventManager._eventBus.removeAllListeners(event); // Not sure why this is necessary @@ -127,7 +124,7 @@ describe.each([ describe(`Authenticate`, () => { test(`Valid Trust Token`, async () => { // ICloud.authenticate returns ready promise. Need to modify in order to resolve at the end of the test - icloud.ready = new Promise((resolve, _reject) => resolve()); + icloud.ready = new Promise((resolve, _reject) => resolve(true)); const authenticationEvent = mockedEventManager.spyOnEvent(iCPSEventCloud.AUTHENTICATION_STARTED); const trustedEvent = mockedEventManager.spyOnEvent(iCPSEventCloud.TRUSTED); @@ -164,7 +161,7 @@ describe.each([ }); // ICloud.authenticate returns ready promise. Need to modify in order to resolve at the end of the test - icloud.ready = new Promise((resolve, _reject) => resolve()); + icloud.ready = new Promise((resolve, _reject) => resolve(true)); const authenticationEvent = mockedEventManager.spyOnEvent(iCPSEventCloud.AUTHENTICATION_STARTED); const mfaEvent = mockedEventManager.spyOnEvent(iCPSEventCloud.MFA_REQUIRED); @@ -216,7 +213,7 @@ describe.each([ }); describe(`Authentication backend error`, () => { - test.each([ + test.each([ { desc: `Unknown username`, status: 403, diff --git a/app/test/unit/sync-engine.test.ts b/app/test/unit/sync-engine.test.ts index c40e1af9..e31e7262 100644 --- a/app/test/unit/sync-engine.test.ts +++ b/app/test/unit/sync-engine.test.ts @@ -40,7 +40,7 @@ describe(`Coordination`, () => { mockedNetworkManager.settleCCYLimiter = jest.fn(); syncEngine.icloud.setupAccount = jest.fn(); syncEngine.icloud.getReady = jest.fn() - .mockResolvedValue(); + .mockResolvedValue(true); }); describe(`Sync`, () => { @@ -146,6 +146,36 @@ describe(`Coordination`, () => { expect(syncEngine.icloud.setupAccount).toHaveBeenCalledTimes(1); expect(doneEvent).toHaveBeenCalledTimes(1); }); + + test(`MFA timeout after retry`, async () => { + syncEngine.icloud.getReady = jest.fn() + .mockResolvedValue(false); + + const error = new Error(); + + const startEvent = mockedEventManager.spyOnEvent(iCPSEventSyncEngine.START); + const retryEvent = mockedEventManager.spyOnEvent(iCPSEventSyncEngine.RETRY); + syncEngine.fetchAndLoadState = jest.fn() + .mockResolvedValue(fetchAndLoadStateReturnValue); + syncEngine.diffState = jest.fn() + .mockResolvedValue(diffStateReturnValue); + syncEngine.writeState = jest.fn() + .mockRejectedValue(error); + const doneEvent = mockedEventManager.spyOnEvent(iCPSEventSyncEngine.DONE); + + await expect(syncEngine.sync()).resolves.toEqual([[], []]); + + expect(startEvent).toHaveBeenCalled(); + expect(retryEvent).toHaveBeenCalledWith(2, expect.objectContaining({message: "Unknown error during sync"})); + expect(syncEngine.fetchAndLoadState).toHaveBeenCalledTimes(1); + expect(syncEngine.diffState).toHaveBeenCalledTimes(1); + expect(syncEngine.diffState).toHaveBeenNthCalledWith(1, ...fetchAndLoadStateReturnValue); + expect(syncEngine.writeState).toHaveBeenCalledTimes(1); + expect(syncEngine.writeState).toHaveBeenNthCalledWith(1, ...diffStateReturnValue); + expect(mockedNetworkManager.settleCCYLimiter).toHaveBeenCalledTimes(1); + expect(syncEngine.icloud.setupAccount).toHaveBeenCalledTimes(1); + expect(doneEvent).not.toHaveBeenCalled(); + }); }); test(`Fetch & Load State`, async () => { diff --git a/app/tsconfig.json b/app/tsconfig.json index 7a9b213e..7086a98e 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -8,7 +8,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "strictNullChecks": false, - "outDir": "build/out", + "outDir": "bin", "skipLibCheck": true }, "include": ["src/**/*"],