rxjs#firstValueFrom TypeScript Examples

The following examples show how to use rxjs#firstValueFrom. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: my-trades.component.ts    From rubic-app with GNU General Public License v3.0 6 votes vote down vote up
private async receivePolygonBridgeTrade(
    trade: TableTrade,
    onTransactionHash: (hash: string) => void
  ): Promise<void> {
    await firstValueFrom(
      this.myTradesService.depositPolygonBridgeTradeAfterCheckpoint(
        trade.fromTransactionHash,
        onTransactionHash
      )
    );
  }
Example #2
Source File: modify-one-query.ts    From js-client with MIT License 6 votes vote down vote up
makeModifyOneQuery = (context: APIContext) => {
	const subscribeToOneQueryParsing = makeSubscribeToOneQueryParsing(context);
	let querySubP: ReturnType<typeof subscribeToOneQueryParsing> | null = null;

	return async (query: Query, filters: Array<ElementFilter>): Promise<Query> => {
		if (isNull(querySubP)) querySubP = subscribeToOneQueryParsing();
		const querySub = await querySubP;
		const id = SEARCH_SOCKET_ID_GENERATOR.generate();

		const parsingP = firstValueFrom(
			querySub.received$.pipe(
				filter(msg => msg.id === id),
				map(msg => pick(msg, ['isValid', 'error', 'query']) as ValidatedQuery),
			),
		);
		querySub.send({ id, query, filters });

		const parsed = await parsingP;

		if (parsed.isValid === false) {
			throw new Error(parsed.error.message);
		}
		return parsed.query;
	};
}
Example #3
Source File: AcalaGuardian.ts    From guardian with Apache License 2.0 6 votes vote down vote up
async setup(config: AcalaGuardianConfig) {
    const { network } = config;
    this._metadata = { ...this._metadata, network };

    const ws = new WsProvider(config.nodeEndpoint);
    const apiOptions = options({ provider: ws, types: customTypes });

    const apiRx = await firstValueFrom(ApiRx.create(apiOptions));

    // fetch token precision
    const properties = await firstValueFrom(apiRx.rpc.system.properties());
    const tokenSymbol = properties.tokenSymbol.unwrapOrDefault();
    const tokenDecimals = properties.tokenDecimals.unwrapOrDefault();
    if (tokenSymbol.length !== tokenDecimals.length) {
      throw Error(`Token symbols/decimals mismatch ${tokenSymbol} ${tokenDecimals}`);
    }
    tokenSymbol.forEach((symbol, index) => {
      this.tokenDecimals[symbol.toString()] = tokenDecimals[index].toNumber();
    });

    const getTokenPrecision = (token: string): number | undefined => {
      return this.tokenDecimals[token.toUpperCase()];
    };

    return { apiRx, getTokenPrecision };
  }
Example #4
Source File: global.credential-toolbox.service.ts    From Elastos.Essentials.App with MIT License 6 votes vote down vote up
private async sendStatsToService(): Promise<void> {
    let credentialsStats = await this.buildCredentialsStats();
    if (!credentialsStats) {
      Logger.log(CRED_TOOLBOX_LOG_TAG, "Not sending credential stats for now, stats not built (probably no master password)");
      return;
    }

    Logger.log(CRED_TOOLBOX_LOG_TAG, "Sending stats", credentialsStats);

    try {
      await firstValueFrom(this.http.post<void>(`${environment.CredentialsToolbox.serviceUrl}/statistics`, credentialsStats));
    }
    catch (e) {
      Logger.warn(CRED_TOOLBOX_LOG_TAG, "Failure from the credentials toolbox api", e);
      return;
    }

    // After successfully sending stats, save the date, and clear used credentials locally to not send them again
    await this.saveLastUploaded();
    await this.saveUsedCredentials([]);
  }
Example #5
Source File: confirm-dialog.component.ts    From alauda-ui with MIT License 6 votes vote down vote up
private toPromise<T>(beforeAction: BeforeAction<T>) {
    if (beforeAction.length) {
      return new Promise(beforeAction as PromiseExecutor<T>);
    }

    const result = (beforeAction as CustomBeforeAction<T>)();

    if (result instanceof Observable) {
      return firstValueFrom(result);
    }

    return result;
  }
Example #6
Source File: network.service.ts    From WowUp with GNU General Public License v3.0 6 votes vote down vote up
public deleteJson<T>(
    url: URL | string,
    headers: {
      [header: string]: string | string[];
    } = {},
    timeoutMs?: number
  ): Promise<T> {
    const cheaders = headers || {};
    return this.fire<T>(() =>
      firstValueFrom(
        this._httpClient
          .delete<T>(url.toString(), { headers: { ...cheaders } })
          .pipe(first(), timeout(timeoutMs ?? this._defaultTimeoutMs))
      )
    );
  }
Example #7
Source File: manifest-service.ts    From scion-microfrontend-platform with Eclipse Public License 2.0 6 votes vote down vote up
public async init(): Promise<void> {
    const messagingDisabled = Beans.get(BrokerGateway) instanceof NullBrokerGateway;
    if (messagingDisabled) {
      return;
    }

    // Wait until obtained registered applications so that they can be accessed synchronously by the application via `ManifestService#applications`.
    const applications$ = Beans.get(MessageClient).observe$<Application[]>(PlatformTopics.Applications);
    this._applications = await firstValueFrom(applications$.pipe(mapToBody()));
  }
Example #8
Source File: sl-dialog.service.ts    From s-libs with MIT License 6 votes vote down vote up
/**
   * Resolves when the dialog is closed. Resolves to the {@link DialogButton.value} of the clicked button, or to `undefined` if the dialog was closed another way (such as clicking off it).
   */
  async open<T>(data: DialogData<T>): Promise<T | undefined> {
    const ref = this.matDialog.open(DialogComponent, {
      data,
      autoFocus: false,
    });
    return firstValueFrom(ref.beforeClosed());
  }
Example #9
Source File: EventsTask.ts    From guardian with Apache License 2.0 6 votes vote down vote up
async start<T extends BaseSubstrateGuardian>(guardian: T) {
    const { apiRx } = await guardian.isReady();

    const { name } = this.arguments;

    return apiRx.derive.chain.subscribeNewHeads().pipe(
      mergeMap((header) => Promise.all([header, firstValueFrom(apiRx.query.system.events.at(header.hash))])),
      mergeMap(([header, records]) => {
        return records.map(({ phase, event }) => {
          const params = getEventParams(event);
          const { index, section, method, data } = event;
          const name = `${section}.${method}`;
          const args = {};
          data.forEach((value, index) => {
            const key = params[index] || index.toString();
            args[key] = value.toJSON();
          });
          return {
            blockNumber: header.number.toNumber(),
            blockHash: header.hash.toHex(),
            phase: phase.toJSON(),
            index: index.toHex(),
            name,
            args
          };
        });
      }),
      filter((event) => {
        if (Array.isArray(name)) {
          return name.includes(event.name);
        }
        return event.name === name;
      })
    );
  }
Example #10
Source File: activator-installer.ts    From scion-microfrontend-platform with Eclipse Public License 2.0 6 votes vote down vote up
public async init(): Promise<void> {
    // Lookup activators.
    const activators: Activator[] = await firstValueFrom(Beans.get(ManifestService).lookupCapabilities$<Activator>({type: PlatformCapabilityTypes.Activator}));

    const monitor = Beans.get(ActivatorLoadProgressMonitor);
    if (!activators.length) {
      monitor.done();
      return;
    }

    // Group activators by their providing application.
    const activatorsGroupedByApp: Map<string, Activator[]> = activators
      .filter(this.skipInvalidActivators())
      .reduce((grouped, activator) => Maps.addListValue(grouped, activator.metadata!.appSymbolicName, activator), new Map<string, Activator[]>());

    // Create Promises that wait for activators to signal ready.
    const subMonitors = monitor.splitEven(activatorsGroupedByApp.size);
    const activatorReadyPromises: Promise<void>[] = Array
      .from(activatorsGroupedByApp.entries())
      .reduce((acc, [appSymbolicName, appActivators], index) => {
        return acc.concat(this.waitForActivatorsToSignalReady(appSymbolicName, appActivators, subMonitors[index]));
      }, [] as Promise<void>[]);

    // Mount activators in hidden iframes
    activatorsGroupedByApp.forEach((sameAppActivators: Activator[]) => {
      // Nominate one activator of each app as primary activator.
      const primaryActivator = sameAppActivators[0];
      sameAppActivators.forEach(activator => this.mountActivator(activator, activator === primaryActivator));
    });

    // Wait until activators signal ready.
    await Promise.all(activatorReadyPromises);
  }
Example #11
Source File: wago-addon-provider.ts    From WowUp with GNU General Public License v3.0 6 votes vote down vote up
public async searchByQuery(
    query: string,
    installation: WowInstallation,
    channelType?: AddonChannelType
  ): Promise<AddonSearchResult[]> {
    try {
      await firstValueFrom(this.ensureToken());
    } catch (e) {
      console.error("[wago]", e);
      return [];
    }

    const url = new URL(`${WAGO_BASE_URL}/addons/_search`);
    url.searchParams.set("query", query);
    url.searchParams.set("game_version", this.getGameVersion(installation.clientType));
    url.searchParams.set("stability", this.getStability(channelType));

    const response = await this.sendRequest(() =>
      this._cachingService.transaction(
        `${installation.id}|${query}|${url.toString()}`,
        () => this._circuitBreaker.getJson<WagoSearchResponse>(url, this.getRequestHeaders()),
        WAGO_SEARCH_CACHE_TIME_SEC
      )
    );

    const searchResults = response.data?.map((item) => this.toSearchResult(item)) ?? [];

    console.debug(`[wago] searchByQuery`, response, searchResults);

    return searchResults;
  }
Example #12
Source File: manifest-collector.ts    From scion-microfrontend-platform with Eclipse Public License 2.0 6 votes vote down vote up
private async fetchAndRegisterManifest(appConfig: ApplicationConfig, monitor: ProgressMonitor): Promise<void> {
    try {
      if (!appConfig.manifestUrl) {
        Beans.get(Logger).error(`[AppConfigError] Failed to fetch manifest for application '${appConfig.symbolicName}'. Manifest URL must not be empty.`);
        return;
      }

      const fetchManifest$ = from(Beans.get(HttpClient).fetch(appConfig.manifestUrl));
      const manifestFetchTimeout = appConfig.manifestLoadTimeout ?? Beans.get(MicrofrontendPlatformConfig).manifestLoadTimeout;
      const onManifestFetchTimeout = (): Observable<never> => throwError(() => `Timeout of ${manifestFetchTimeout}ms elapsed.`);
      const manifestFetchResponse = await firstValueFrom(fetchManifest$.pipe(manifestFetchTimeout ? timeout({first: manifestFetchTimeout, with: onManifestFetchTimeout}) : identity));

      if (!manifestFetchResponse.ok) {
        Beans.get(Logger).error(`[ManifestFetchError] Failed to fetch manifest for application '${appConfig.symbolicName}'. Maybe the application is currently unavailable. [httpStatusCode=${manifestFetchResponse.status}, httpStatusText=${manifestFetchResponse.statusText}]`, appConfig, manifestFetchResponse.status);
        return;
      }

      const manifest: Manifest = await manifestFetchResponse.json();

      // Let the host manifest be intercepted before registering it in the platform, for example by libraries integrating the SCION Microfrontend Platform, e.g., to allow the programmatic registration of capabilities or intentions.
      if (appConfig.symbolicName === Beans.get<string>(APP_IDENTITY)) {
        Beans.all(HostManifestInterceptor).forEach(interceptor => interceptor.intercept(manifest));
      }

      Beans.get(ApplicationRegistry).registerApplication(appConfig, manifest);
      Beans.get(Logger).info(`Registered application '${appConfig.symbolicName}' in the SCION Microfrontend Platform.`);
    }
    catch (error) {
      // The Promise returned from fetch() won’t reject on HTTP error status even if the response is an HTTP 404 or 500.
      // It will only reject on network failure or if anything prevented the request from completing.
      // See https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Checking_that_the_fetch_was_successful
      Beans.get(Logger).error(`[ManifestFetchError] Failed to fetch manifest for application '${appConfig.symbolicName}'. Maybe the application is currently unavailable.`, error);
    }
    finally {
      monitor.done();
    }
  }
Example #13
Source File: getOraclePrice.spec.ts    From guardian with Apache License 2.0 6 votes vote down vote up
describe('getOraclePrice', () => {
  it('should get the price', async (done) => {
    const ws = new WsProvider([
      'wss://testnet-node-1.laminar-chain.laminar.one/ws',
      'wss://node-6787234140909940736.jm.onfinality.io/ws'
    ]);
    const apiRx = await firstValueFrom(ApiRx.create(options({ provider: ws })));
    const oraclePrice = getOraclePrice(apiRx);
    const FEUR = apiRx.createType('CurrencyId', 'FEUR');
    oraclePrice(FEUR).subscribe((output) => {
      console.log(output);
      expect(output).toBeTruthy();
      done();
    });
  }, 60_000);
});
Example #14
Source File: platform-property-service.ts    From scion-microfrontend-platform with Eclipse Public License 2.0 6 votes vote down vote up
public async init(): Promise<void> {
    const messagingDisabled = Beans.get(BrokerGateway) instanceof NullBrokerGateway;
    if (messagingDisabled) {
      return;
    }

    // Wait until obtained platform properties so that they can be accessed synchronously by the application via `PlatformPropertyService#properties`.
    const properties$ = Beans.get(MessageClient).observe$<Dictionary>(PlatformTopics.PlatformProperties);
    this._properties = await firstValueFrom(properties$.pipe(mapToBody(), map(properties => Maps.coerce(properties))));
  }
Example #15
Source File: getOraclePrice.spec.ts    From guardian with Apache License 2.0 6 votes vote down vote up
describe('getOraclePrice', () => {
  let apiRx: ApiRx;

  beforeAll(async () => {
    apiRx = await firstValueFrom(ApiRx.create({ types }));
  });

  it('fetch price', () => {
    testScheduler.run(({ expectObservable }) => {
      const DOT = apiRx.createType('CurrencyId', { token: 'DOT' });

      const stream$ = getOraclePrice(apiRx)(DOT);

      expectObservable(stream$).toBe('a 999ms (b|)', {
        a: 300e18,
        b: 280e18
      });
    });
  });
});
Example #16
Source File: wowup-folder-scanner.ts    From WowUp with GNU General Public License v3.0 6 votes vote down vote up
public async scanFolder(): Promise<WowUpScanResult> {
    const files = await readDirRecursive(this._folderPath);
    files.forEach((fp) => (this._fileMap[fp.toLowerCase()] = fp));

    let matchingFiles = await this.getMatchingFiles(this._folderPath, files);
    matchingFiles = _.orderBy(matchingFiles, [(f) => f.toLowerCase()], ["asc"]);

    async function toFileHash(file: string) {
      return { hash: await hashFile(file), file };
    }

    const fileFingerprints = await firstValueFrom(
      from(matchingFiles).pipe(
        mergeMap((file) => from(toFileHash(file)), 3),
        toArray()
      )
    );

    const fingerprintList = _.map(fileFingerprints, (ff) => ff.hash);
    const hashConcat = _.orderBy(fingerprintList).join("");
    const fingerprint = hashString(hashConcat);

    const result: WowUpScanResult = {
      fileFingerprints: fingerprintList,
      fingerprint,
      path: this._folderPath,
      folderName: path.basename(this._folderPath),
      fileCount: matchingFiles.length,
    };

    return result;
  }
Example #17
Source File: terminal.component.ts    From dev-manager-desktop with Apache License 2.0 5 votes vote down vote up
newTab() {
    firstValueFrom(this.deviceManager.selected$).then(device => this.addSession(device));
  }
Example #18
Source File: keep-data-range-test.spec.ts    From js-client with MIT License 5 votes vote down vote up
expectStatsFilter = async (stats$: Observable<SearchStats>, filter: SearchFilter): Promise<void> => {
	const matchesFilter = matches(filter);
	const statsP = firstValueFrom(stats$.pipe(skipWhile(x => matchesFilter(x.filter) === false)));

	await expectAsync(statsP)
		.withContext(`Expecting the filter to be ${JSON.stringify(filter)}`)
		.toBeResolved();
}
Example #19
Source File: dev-mode.service.ts    From dev-manager-desktop with Apache License 2.0 5 votes vote down vote up
async resetDevMode(sessionToken: string): Promise<any> {
    return firstValueFrom(this.http.get(`https://developer.lge.com/secure/ResetDevModeSession.dev`, {
      params: {sessionToken},
      observe: 'body',
      responseType: 'json'
    }));
  }
Example #20
Source File: Guardian.ts    From guardian with Apache License 2.0 5 votes vote down vote up
// Guardian is ready to run tasks
  public isReady(): Promise<Props> {
    return firstValueFrom(this.props);
  }
Example #21
Source File: network.service.ts    From WowUp with GNU General Public License v3.0 5 votes vote down vote up
public getText(url: URL | string, timeoutMs?: number): Promise<string> {
    return firstValueFrom(
      this._httpClient
        .get(url.toString(), { responseType: "text", headers: { ...CACHE_CONTROL_HEADERS } })
        .pipe(first(), timeout(timeoutMs ?? AppConfig.defaultHttpTimeoutMs))
    );
  }
Example #22
Source File: symbiosis.service.ts    From rubic-app with GNU General Public License v3.0 5 votes vote down vote up
public async revertTrade(hash: string, onTransactionHash: (hash: string) => void): Promise<void> {
    const request = this.pendingRequests.find(
      pendingRequest => pendingRequest.transactionHash === hash
    );

    const toBlockchain = BlockchainsInfo.getBlockchainById(request.chainIdTo).name;
    this.walletConnectorService.checkSettings(toBlockchain);

    try {
      const size = this.iframeService.isIframe ? 'fullscreen' : 's';

      const amount = request.fromTokenAmount.toFixed();
      const tokenSymbol = request.fromTokenAmount.token.symbol;
      const tokenAddress = request.fromTokenAmount.token.address;
      const blockchain = BlockchainsInfo.getBlockchainById(request.chainIdFrom).name;

      await firstValueFrom(
        this.dialogService.open(new PolymorpheusComponent(SymbiosisWarningTxModalComponent), {
          size,
          data: {
            amount,
            tokenSymbol,
            tokenAddress,
            blockchain
          }
        })
      );
    } catch (_err) {
      throw new UserRejectError();
    }

    const { transactionRequest } = await this.symbiosis.newRevertPending(request).revert();
    await this.web3PrivateService.trySendTransaction(
      transactionRequest.to,
      new BigNumber(transactionRequest.value?.toString() || 0),
      {
        data: transactionRequest.data.toString(),
        onTransactionHash
      }
    );
  }
Example #23
Source File: context-service.ts    From scion-microfrontend-platform with Eclipse Public License 2.0 5 votes vote down vote up
public lookup<T>(name: string, options?: ContextLookupOptions): Promise<T | T[] | null> {
    return firstValueFrom(this.observe$<T>(name, options));
  }
Example #24
Source File: network.service.ts    From WowUp with GNU General Public License v3.0 5 votes vote down vote up
public getJson<T>(url: URL | string, timeoutMs?: number): Promise<T> {
    return firstValueFrom(
      this._httpClient
        .get<T>(url.toString(), { headers: { ...CACHE_CONTROL_HEADERS } })
        .pipe(first(), timeout(timeoutMs ?? AppConfig.defaultHttpTimeoutMs))
    );
  }
Example #25
Source File: global.credential.types.service.ts    From Elastos.Essentials.App with MIT License 5 votes vote down vote up
// eslint-disable-next-line require-await
  private async fetchContext(contextUrl: string): Promise<ContextPayload> {
    let cacheEntry = this.contextsCache.get(contextUrl);
    if (cacheEntry) {
      return cacheEntry.data;
    }

    return this.fetchContextQueue.add(async () => {
      // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
      if (contextUrl.startsWith("http")) {
        let payload = await firstValueFrom(this.http.get(contextUrl, {
          headers: {
            'Accept': 'application/json'
          }
        }));

        this.contextsCache.set(contextUrl, payload as ContextPayload, moment().unix());
        // NOTE - don't ssve the cache = not persistent on disk - await this.contextsCache.save();

        // TODO: catch network errors

        return payload as ContextPayload;
      }
      else if (contextUrl.startsWith("did:")) { // EID url
        // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
        // Compute publisher's DID string based on context url
        let { publisher, shortType } = this.extractEIDContext(contextUrl);
        if (!publisher) {
          Logger.warn("credentialtypes", "Failed to extract publisher from context", contextUrl);
          return null;
        }

        let docStatus = await this.didDocumentsService.fetchOrAwaitDIDDocumentWithStatus(publisher);
        if (docStatus.document) {
          let serviceId = `${publisher}#${shortType}`;
          let contextPayload = this.getContextPayloadFromDIDDocument(docStatus.document, serviceId);

          this.contextsCache.set(contextUrl, contextPayload, moment().unix());
          // NOTE - don't save the cache = not persistent on disk - await this.contextsCache.save();

          return contextPayload;
        }
        else {
          return null;
        }
      }
      else {
        // Unsupported
        Logger.log("credentialtypes", "Unsupported credential context url", contextUrl);
        return null;
      }
    });
  }
Example #26
Source File: rxjs.resource.ts    From slickgrid-universal with MIT License 5 votes vote down vote up
/** Converts an observable to a promise by subscribing to the observable, and returning a promise that will resolve
   * as soon as the first value arrives from the observable. The subscription will then be closed.
   */
  firstValueFrom<T>(source: Observable<T>): Promise<T> {
    return firstValueFrom(source);
  }
Example #27
Source File: attach-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('attachToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag}`;
			const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json $${macroName} | count`;
			//const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };

			const searchCreated = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);
			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			// TODO: Waiting on gravwell/gravwell#3677
			// expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchCreated = await subscribeToOneSearch(query, { filter });
			const search = await attachToOneSearch(searchCreated.searchID, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchesCreated = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);
			const searches = await Promise.all(
				searchesCreated.map(searchCreated => attachToOneSearch(searchCreated.searchID, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on an inexistent search ID',
		integrationTest(async () => {
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);
			const searchID = `4723947892379482378`;
			await expectAsync(attachToOneSearch(searchID)).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject searches with invalid search IDs without affecting good ones',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const goodSearchID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;
			const badSearchID = `4723947892379482378`;

			// Attach to a bunch of search subscriptions with different search IDs to race them
			await Promise.all([
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(goodSearchID)).withContext('valid search ID should resolve').toBeResolved(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const searchCreatedID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;

			// Attach to a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(attachToOneSearch(searchCreatedID)).withContext('good query should resolve').toBeResolved(),
				),
			);
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };

				const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange } });
				const search = await attachToOneSearch(searchCreated.searchID);

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e?.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };

				const search1sCreated = await subscribeToOneSearch(query1s, { filter: filter1s });
				const search1s = await attachToOneSearch(search1sCreated.searchID, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };

				const search33sCreated = await subscribeToOneSearch(query33s, { filter: filter33s });
				const search33s = await attachToOneSearch(search33sCreated.searchID, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
						const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;
						const searchCreated = await subscribeToOneSearch(query, { filter: initialFilter });
						return await attachToOneSearch(searchCreated.searchID, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #28
Source File: ipc-events.ts    From WowUp with GNU General Public License v3.0 4 votes vote down vote up
export function initializeIpcHandlers(window: BrowserWindow): void {
  log.info("process.versions", process.versions);

  ipcMain.on("webview-error", (evt, err, msg) => {
    log.error("webview-error", err, msg);
  });

  // Just forward the token event out to the window
  // this is not a handler, just a passive listener
  ipcMain.on("wago-token-received", (evt, token) => {
    window?.webContents?.send("wago-token-received", token);
  });

  // Remove the pending URLs once read so they are only able to be gotten once
  handle(IPC_GET_PENDING_OPEN_URLS, (): string[] => {
    const urls = PENDING_OPEN_URLS;
    PENDING_OPEN_URLS = [];
    return urls;
  });

  handle(
    IPC_SYSTEM_PREFERENCES_GET_USER_DEFAULT,
    (
      _evt,
      key: string,
      type: "string" | "boolean" | "integer" | "float" | "double" | "url" | "array" | "dictionary"
    ) => {
      return systemPreferences.getUserDefault(key, type);
    }
  );

  handle("clipboard-read-text", () => {
    return clipboard.readText();
  });

  handle(IPC_SHOW_DIRECTORY, async (evt, filePath: string): Promise<string> => {
    return await shell.openPath(filePath);
  });

  handle(IPC_GET_ASSET_FILE_PATH, (evt, fileName: string) => {
    return path.join(__dirname, "..", "assets", fileName);
  });

  handle(IPC_CREATE_DIRECTORY_CHANNEL, async (evt, directoryPath: string): Promise<boolean> => {
    log.info(`[CreateDirectory] '${directoryPath}'`);
    await fsp.mkdir(directoryPath, { recursive: true });
    return true;
  });

  handle(IPC_GET_ZOOM_FACTOR, () => {
    return window?.webContents?.getZoomFactor();
  });

  handle(IPC_UPDATE_APP_BADGE, (evt, count: number) => {
    return app.setBadgeCount(count);
  });

  handle(IPC_SET_ZOOM_LIMITS, (evt, minimumLevel: number, maximumLevel: number) => {
    return window.webContents?.setVisualZoomLevelLimits(minimumLevel, maximumLevel);
  });

  handle("show-item-in-folder", (evt, path: string) => {
    shell.showItemInFolder(path);
  });

  handle(IPC_SET_ZOOM_FACTOR, (evt, zoomFactor: number) => {
    if (window?.webContents) {
      window.webContents.zoomFactor = zoomFactor;
    }
  });

  handle(IPC_ADDONS_SAVE_ALL, (evt, addons: Addon[]) => {
    if (!Array.isArray(addons)) {
      return;
    }

    for (const addon of addons) {
      addonStore.set(addon.id, addon);
    }
  });

  handle(IPC_GET_APP_VERSION, () => {
    return app.getVersion();
  });

  handle(IPC_GET_LOCALE, () => {
    return `${app.getLocale()}`;
  });

  handle(IPC_GET_LAUNCH_ARGS, () => {
    return process.argv;
  });

  handle(IPC_GET_LOGIN_ITEM_SETTINGS, () => {
    return app.getLoginItemSettings();
  });

  handle(IPC_SET_LOGIN_ITEM_SETTINGS, (evt, settings: Settings) => {
    return app.setLoginItemSettings(settings);
  });

  handle(IPC_READDIR, async (evt, dirPath: string): Promise<string[]> => {
    return await fsp.readdir(dirPath);
  });

  handle(IPC_IS_DEFAULT_PROTOCOL_CLIENT, (evt, protocol: string) => {
    return app.isDefaultProtocolClient(protocol);
  });

  handle(IPC_SET_AS_DEFAULT_PROTOCOL_CLIENT, (evt, protocol: string) => {
    return app.setAsDefaultProtocolClient(protocol);
  });

  handle(IPC_REMOVE_AS_DEFAULT_PROTOCOL_CLIENT, (evt, protocol: string) => {
    return app.removeAsDefaultProtocolClient(protocol);
  });

  handle(IPC_LIST_DIRECTORIES_CHANNEL, async (evt, filePath: string, scanSymlinks: boolean) => {
    const files = await fsp.readdir(filePath, { withFileTypes: true });
    let symlinkNames: string[] = [];
    if (scanSymlinks === true) {
      log.info("Scanning symlinks");
      const symlinkDirs = await getSymlinkDirs(filePath, files);
      symlinkNames = _.map(symlinkDirs, (symLink) => symLink.original.name);
    }

    const directories = files.filter((file) => file.isDirectory()).map((file) => file.name);
    return [...directories, ...symlinkNames];
  });

  handle(IPC_STAT_FILES_CHANNEL, async (evt, filePaths: string[]) => {
    const results: { [path: string]: FsStats } = {};

    const taskResults = await firstValueFrom(
      from(filePaths).pipe(
        mergeMap((filePath) => from(statFile(filePath)), 3),
        toArray()
      )
    );

    taskResults.forEach((r) => (results[r.path] = r.fsStats));

    return results;
  });

  handle(IPC_LIST_ENTRIES, async (evt, sourcePath: string, filter: string) => {
    const globFilter = globrex(filter);
    const results = await fsp.readdir(sourcePath, { withFileTypes: true });
    const matches = _.filter(results, (entry) => globFilter.regex.test(entry.name));
    return _.map(matches, (match) => {
      const dirEnt: FsDirent = {
        isBlockDevice: match.isBlockDevice(),
        isCharacterDevice: match.isCharacterDevice(),
        isDirectory: match.isDirectory(),
        isFIFO: match.isFIFO(),
        isFile: match.isFile(),
        isSocket: match.isSocket(),
        isSymbolicLink: match.isSymbolicLink(),
        name: match.name,
      };
      return dirEnt;
    });
  });

  handle(IPC_LIST_FILES_CHANNEL, async (evt, sourcePath: string, filter: string) => {
    const pathExists = await exists(sourcePath);
    if (!pathExists) {
      return [];
    }

    const globFilter = globrex(filter);
    const results = await fsp.readdir(sourcePath, { withFileTypes: true });
    const matches = _.filter(results, (entry) => globFilter.regex.test(entry.name));
    return _.map(matches, (match) => match.name);
  });

  handle(IPC_PATH_EXISTS_CHANNEL, async (evt, filePath: string) => {
    if (!filePath) {
      return false;
    }

    try {
      await fsp.access(filePath);
    } catch (e) {
      if (e.code !== "ENOENT") {
        log.error(e);
      }
      return false;
    }

    return true;
  });

  handle(IPC_WOWUP_GET_SCAN_RESULTS, async (evt, filePaths: string[]): Promise<WowUpScanResult[]> => {
    const taskResults = await firstValueFrom(
      from(filePaths).pipe(
        mergeMap((folder) => from(new WowUpFolderScanner(folder).scanFolder()), 3),
        toArray()
      )
    );

    return taskResults;
  });

  handle(IPC_UNZIP_FILE_CHANNEL, async (evt, arg: UnzipRequest) => {
    await new Promise((resolve, reject) => {
      yauzl.open(arg.zipFilePath, { lazyEntries: true }, (err, zipfile) => {
        handleZipFile(err, zipfile, arg.outputFolder).then(resolve).catch(reject);
      });
    });

    await chmodDir(arg.outputFolder, DEFAULT_FILE_MODE);

    return arg.outputFolder;
  });

  handle("zip-file", async (evt, srcPath: string, destPath: string) => {
    log.info(`[ZipFile]: '${srcPath} -> ${destPath}`);
    return await zipFile(srcPath, destPath);
  });

  handle("zip-read-file", async (evt, zipPath: string, filePath: string) => {
    log.info(`[ZipReadFile]: '${zipPath} : ${filePath}`);
    return await readFileInZip(zipPath, filePath);
  });

  handle("zip-list-files", (evt, zipPath: string, filter: string) => {
    log.info(`[ZipListEntries]: '${zipPath}`);
    return listZipFiles(zipPath, filter);
  });

  handle("rename-file", async (evt, srcPath: string, destPath: string) => {
    log.info(`[RenameFile]: '${srcPath} -> ${destPath}`);
    return await fsp.rename(srcPath, destPath);
  });

  handle("base64-encode", (evt, content: string) => {
    const buff = Buffer.from(content);
    return buff.toString("base64");
  });

  handle("base64-decode", (evt, content: string) => {
    const buff = Buffer.from(content, "base64");
    return buff.toString("utf-8");
  });

  handle(IPC_COPY_FILE_CHANNEL, async (evt, arg: CopyFileRequest): Promise<boolean> => {
    log.info(`[FileCopy] '${arg.sourceFilePath}' -> '${arg.destinationFilePath}'`);
    const stat = await fsp.lstat(arg.sourceFilePath);
    if (stat.isDirectory()) {
      await copyDir(arg.sourceFilePath, arg.destinationFilePath);
      await chmodDir(arg.destinationFilePath, DEFAULT_FILE_MODE);
    } else {
      await fsp.copyFile(arg.sourceFilePath, arg.destinationFilePath);
      await fsp.chmod(arg.destinationFilePath, DEFAULT_FILE_MODE);
    }
    return true;
  });

  handle(IPC_DELETE_DIRECTORY_CHANNEL, async (evt, filePath: string) => {
    log.info(`[FileRemove] ${filePath}`);
    return await remove(filePath);
  });

  handle(IPC_READ_FILE_CHANNEL, async (evt, filePath: string) => {
    return await fsp.readFile(filePath, { encoding: "utf-8" });
  });

  handle(IPC_READ_FILE_BUFFER_CHANNEL, async (evt, filePath: string) => {
    return await fsp.readFile(filePath);
  });

  handle("decode-product-db", async (evt, filePath: string) => {
    const productDbData = await fsp.readFile(filePath);
    const productDb = ProductDb.decode(productDbData);
    setImmediate(() => {
      console.log("productDb", JSON.stringify(productDb));
    });

    return productDb;
  });

  handle(IPC_WRITE_FILE_CHANNEL, async (evt, filePath: string, contents: string) => {
    return await fsp.writeFile(filePath, contents, { encoding: "utf-8", mode: DEFAULT_FILE_MODE });
  });

  handle(IPC_CREATE_TRAY_MENU_CHANNEL, (evt, config: SystemTrayConfig) => {
    return createTray(window, config);
  });

  handle(IPC_CREATE_APP_MENU_CHANNEL, (evt, config: MenuConfig) => {
    return createAppMenu(window, config);
  });

  handle(IPC_GET_LATEST_DIR_UPDATE_TIME, (evt, dirPath: string) => {
    return getLastModifiedFileDate(dirPath);
  });

  handle(IPC_LIST_DIR_RECURSIVE, (evt, dirPath: string): Promise<string[]> => {
    return readDirRecursive(dirPath);
  });

  handle(IPC_GET_DIRECTORY_TREE, (evt, args: GetDirectoryTreeRequest): Promise<TreeNode> => {
    log.debug(IPC_GET_DIRECTORY_TREE, args);
    return getDirTree(args.dirPath, args.opts);
  });

  handle(IPC_GET_HOME_DIR, (): string => {
    return os.homedir();
  });

  handle(IPC_MINIMIZE_WINDOW, () => {
    if (window?.minimizable) {
      window.minimize();
    }
  });

  handle(IPC_MAXIMIZE_WINDOW, () => {
    if (window?.maximizable) {
      if (window.isMaximized()) {
        window.unmaximize();
      } else {
        window.maximize();
      }
    }
  });

  handle(IPC_CLOSE_WINDOW, () => {
    window?.close();
  });

  handle(IPC_FOCUS_WINDOW, () => {
    restoreWindow(window);
    window?.focus();
  });

  handle(IPC_RESTART_APP, () => {
    log.info(`[RestartApp]`);
    app.relaunch();
    app.quit();
  });

  handle(IPC_QUIT_APP, () => {
    log.info(`[QuitApp]`);
    app.quit();
  });

  handle(IPC_LIST_DISKS_WIN32, async () => {
    const diskInfos = await nodeDiskInfo.getDiskInfo();
    // Cant pass complex objects over the wire, make them simple
    return diskInfos.map((di) => {
      return {
        mounted: di.mounted,
        filesystem: di.filesystem,
      };
    });
  });

  handle(IPC_WINDOW_LEAVE_FULLSCREEN, () => {
    window?.setFullScreen(false);
  });

  handle(IPC_SHOW_OPEN_DIALOG, async (evt, options: OpenDialogOptions) => {
    return await dialog.showOpenDialog(options);
  });

  handle(IPC_PUSH_INIT, () => {
    return push.startPushService();
  });

  handle(IPC_PUSH_REGISTER, async (evt, appId: string) => {
    return await push.registerForPush(appId);
  });

  handle(IPC_PUSH_UNREGISTER, async () => {
    return await push.unregisterPush();
  });

  handle(IPC_PUSH_SUBSCRIBE, async (evt, channel: string) => {
    return await push.subscribeToChannel(channel);
  });

  handle("get-focus", () => {
    return window.isFocused();
  });

  ipcMain.on(IPC_DOWNLOAD_FILE_CHANNEL, (evt, arg: DownloadRequest) => {
    handleDownloadFile(arg).catch((e) => log.error(e.toString()));
  });

  // In order to allow concurrent downloads, we have to get creative with this session handler
  window.webContents.session.on("will-download", (evt, item, wc) => {
    for (const key of _dlMap.keys()) {
      log.info(`will-download: ${key}`);
      if (!item.getURLChain().includes(key)) {
        continue;
      }

      try {
        const action = _dlMap.get(key);
        action.call(null, evt, item, wc);
      } catch (e) {
        log.error(e);
      } finally {
        _dlMap.delete(key);
      }
    }
  });

  async function statFile(filePath: string) {
    const stats = await fsp.stat(filePath);
    const fsStats: FsStats = {
      atime: stats.atime,
      atimeMs: stats.atimeMs,
      birthtime: stats.birthtime,
      birthtimeMs: stats.birthtimeMs,
      blksize: stats.blksize,
      blocks: stats.blocks,
      ctime: stats.ctime,
      ctimeMs: stats.ctimeMs,
      dev: stats.dev,
      gid: stats.gid,
      ino: stats.ino,
      isBlockDevice: stats.isBlockDevice(),
      isCharacterDevice: stats.isCharacterDevice(),
      isDirectory: stats.isDirectory(),
      isFIFO: stats.isFIFO(),
      isFile: stats.isFile(),
      isSocket: stats.isSocket(),
      isSymbolicLink: stats.isSymbolicLink(),
      mode: stats.mode,
      mtime: stats.mtime,
      mtimeMs: stats.mtimeMs,
      nlink: stats.nlink,
      rdev: stats.rdev,
      size: stats.size,
      uid: stats.uid,
    };
    return { path: filePath, fsStats };
  }

  async function handleDownloadFile(arg: DownloadRequest) {
    const status: DownloadStatus = {
      type: DownloadStatusType.Pending,
      savePath: "",
    };

    try {
      await fsp.mkdir(arg.outputFolder, { recursive: true });

      const downloadUrl = new URL(arg.url);
      if (typeof arg.auth?.queryParams === "object") {
        for (const [key, value] of Object.entries(arg.auth.queryParams)) {
          downloadUrl.searchParams.set(key, value);
        }
      }

      const savePath = path.join(arg.outputFolder, `${nanoid()}-${arg.fileName}`);
      log.info(`[DownloadFile] '${downloadUrl.toString()}' -> '${savePath}'`);

      const url = downloadUrl.toString();
      const writer = fs.createWriteStream(savePath);

      try {
        await new Promise((resolve, reject) => {
          let size = 0;
          let percentMod = -1;

          const req = net.request({
            url,
            redirect: "manual",
          });

          if (typeof arg.auth?.headers === "object") {
            for (const [key, value] of Object.entries(arg.auth.headers)) {
              log.info(`Setting header: ${key}=${value}`);
              req.setHeader(key, value);
            }
          }

          req.on("redirect", (status, method, redirectUrl) => {
            log.info(`[download] caught redirect`, status, redirectUrl);
            req.followRedirect();
          });

          req.on("response", (response) => {
            const fileLength = parseInt((response.headers["content-length"] as string) ?? "0", 10);

            response.on("data", (data) => {
              writer.write(data, () => {
                size += data.length;
                const percent = fileLength <= 0 ? 0 : Math.floor((size / fileLength) * 100);
                if (percent % 5 === 0 && percentMod !== percent) {
                  percentMod = percent;
                  log.debug(`Write: [${percent}] ${size}`);
                }
              });
            });

            response.on("end", () => {
              if (response.statusCode < 200 || response.statusCode >= 300) {
                return reject(new Error(`Invalid response (${response.statusCode}): ${url}`));
              }

              return resolve(undefined);
            });
            response.on("error", (err) => {
              return reject(err);
            });
          });
          req.end();
        });
      } finally {
        // always close stream
        writer.end();
      }

      status.type = DownloadStatusType.Complete;
      status.savePath = savePath;

      window.webContents.send(arg.responseKey, status);
    } catch (err) {
      log.error(err);
      status.type = DownloadStatusType.Error;
      status.error = err;
      window.webContents.send(arg.responseKey, status);
    }
  }
}
Example #29
Source File: topic-subscription.registry.spec.ts    From scion-microfrontend-platform with Eclipse Public License 2.0 4 votes vote down vote up
describe('TopicSubscriptionRegistry', () => {

  beforeEach(async () => await MicrofrontendPlatform.destroy());
  afterEach(async () => await MicrofrontendPlatform.destroy());

  it('should allow multiple subscriptions on the same topic from different clients', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    const client1 = newClient('client#1');
    const client2 = newClient('client#2');
    const client3 = newClient('client#3');
    const subscriptionCountCaptor = new ObserveCaptor();
    subscriptionRegistry.subscriptionCount$('myhome/livingroom/temperature').subscribe(subscriptionCountCaptor);

    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client1, 'subscriber#1');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client2, 'subscriber#2');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client3, 'subscriber#3');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(3);

    subscriptionRegistry.unsubscribe('subscriber#1');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);

    subscriptionRegistry.unsubscribe('subscriber#2');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);

    subscriptionRegistry.unsubscribe('subscriber#3');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);

    await expectEmissions(subscriptionCountCaptor).toEqual([0, 1, 2, 3, 2, 1, 0]);
  });

  it('should allow multiple subscriptions on the same topic from the same client', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    const client = newClient('client');
    const subscriptionCountCaptor = new ObserveCaptor();
    subscriptionRegistry.subscriptionCount$('myhome/livingroom/temperature').subscribe(subscriptionCountCaptor);

    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client, 'subscriber#1');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client, 'subscriber#2');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client, 'subscriber#3');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(3);

    subscriptionRegistry.unsubscribe('subscriber#1');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);

    subscriptionRegistry.unsubscribe('subscriber#2');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);

    subscriptionRegistry.unsubscribe('subscriber#3');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);

    await expectEmissions(subscriptionCountCaptor).toEqual([0, 1, 2, 3, 2, 1, 0]);
  });

  it('should ignore an unsubscribe attempt if there is no subscription for it', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    subscriptionRegistry.unsubscribe('does-not-exist');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);
  });

  it('should throw if trying to observe a non-exact topic', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    await expect(() => subscriptionRegistry.subscriptionCount$('myhome/livingroom/:measurement')).toThrowError(/TopicObserveError/);
  });

  it('should allow multiple subscriptions on different topics from the same client', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    const client = newClient('client');

    const temperatureSubscriptionCountCaptor = new ObserveCaptor();
    subscriptionRegistry.subscriptionCount$('myhome/livingroom/temperature').subscribe(temperatureSubscriptionCountCaptor);

    const humiditySubscriptionCountCaptor = new ObserveCaptor();
    subscriptionRegistry.subscriptionCount$('myhome/livingroom/humidity').subscribe(humiditySubscriptionCountCaptor);

    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(0);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client, 'subscriber#1');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(0);

    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client, 'subscriber#2');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(1);

    subscriptionRegistry.subscribe('myhome/livingroom/humidity', client, 'subscriber#3');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(2);

    subscriptionRegistry.unsubscribe('subscriber#2');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(1);

    subscriptionRegistry.unsubscribe('subscriber#1');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(1);

    subscriptionRegistry.unsubscribe('subscriber#3');
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/humidity').toBe(0);

    await expectEmissions(temperatureSubscriptionCountCaptor).toEqual([0, 1, 2, 1, 0]);
    await expectEmissions(humiditySubscriptionCountCaptor).toEqual([0, 1, 2, 1, 0]);
  });

  it('should count wildcard subscriptions when observing the subscriber count on a topic', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    const client1 = newClient('client#1');
    const client2 = newClient('client#2');

    subscriptionRegistry.subscribe('myhome/:room/temperature', client1, 'subscriber#1');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(1);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(0);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(1);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(0);

    subscriptionRegistry.subscribe('myhome/:room/temperature', client1, 'subscriber#2');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(2);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(0);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(2);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(0);

    subscriptionRegistry.subscribe('myhome/:room/temperature', client2, 'subscriber#3');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(3);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(0);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(3);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(0);

    subscriptionRegistry.subscribe('myhome/:room/temperature', client2, 'subscriber#4');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(4);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(0);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(4);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(0);

    subscriptionRegistry.subscribe('myhome/:room/:measurement', client1, 'subscriber#5');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(5);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(0);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(5);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(0);

    subscriptionRegistry.subscribe('myhome/:room/:measurement', client2, 'subscriber#6');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(6);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(0);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(6);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(0);

    subscriptionRegistry.subscribe('myhome/:room/:measurement/:unit', client1, 'subscriber#7');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(6);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(1);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(6);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(1);

    subscriptionRegistry.subscribe('myhome/:room/:measurement/:unit', client2, 'subscriber#8');
    await expectSubscriptionCount('myhome/livingroom').toBe(0);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(6);
    await expectSubscriptionCount('myhome/livingroom/temperature/celcius').toBe(2);
    await expectSubscriptionCount('myhome/kitchen').toBe(0);
    await expectSubscriptionCount('myhome/kitchen/temperature').toBe(6);
    await expectSubscriptionCount('myhome/kitchen/temperature/celcius').toBe(2);
  });

  it('should remove all subscriptions of a client', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);

    const clientRegistry = Beans.get(ClientRegistry);
    const client1 = newClient('client#1');
    const client2 = newClient('client#2');
    clientRegistry.registerClient(client1);
    clientRegistry.registerClient(client2);

    const subscriptionCountCaptor = new ObserveCaptor();
    subscriptionRegistry.subscriptionCount$('myhome/livingroom/temperature').subscribe(subscriptionCountCaptor);

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client1, 'subscriber#1');
    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client1, 'subscriber#2');
    subscriptionRegistry.subscribe('myhome/:livingroom/:measurement', client1, 'subscriber#3');
    subscriptionRegistry.subscribe(':building/:livingroom/:measurement', client1, 'subscriber#4');

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client2, 'subscriber#5');
    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client2, 'subscriber#6');
    subscriptionRegistry.subscribe('myhome/:livingroom/:measurement', client2, 'subscriber#7');
    subscriptionRegistry.subscribe(':building/:livingroom/:measurement', client2, 'subscriber#8');

    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(8);

    clientRegistry.unregisterClient(client1);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(4);

    clientRegistry.unregisterClient(client2);
    await expectSubscriptionCount('myhome/livingroom/temperature').toBe(0);

    await expectEmissions(subscriptionCountCaptor).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 4, 0]);
  });

  it('should resolve subscribers which observe the topic \'myhome/livingroom/temperature\'', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    const client1 = newClient('client#1');
    const client2 = newClient('client#2');

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client1, 'client#1;sub#1');
    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client1, 'client#1;sub#2');
    subscriptionRegistry.subscribe('myhome/kitchen/:measurement', client1, 'client#1;sub#3');
    subscriptionRegistry.subscribe('myhome/:room/temperature', client1, 'client#1;sub#4');
    subscriptionRegistry.subscribe('myhome/:room/:measurement', client1, 'client#1;sub#5');
    subscriptionRegistry.subscribe(':building/kitchen/:measurement', client1, 'client#1;sub#6');

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client2, 'client#2;sub#1');
    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client2, 'client#2;sub#2');
    subscriptionRegistry.subscribe('myhome/kitchen/:measurement', client2, 'client#2;sub#3');
    subscriptionRegistry.subscribe('myhome/:room/temperature', client2, 'client#2;sub#4');
    subscriptionRegistry.subscribe('myhome/:room/:measurement', client2, 'client#2;sub#5');
    subscriptionRegistry.subscribe(':building/kitchen/:measurement', client2, 'client#2;sub#6');

    // Resolve the subscribers which observe the topic 'myhome/livingroom/temperature'.
    const destinations = subscriptionRegistry.resolveTopicDestinations('myhome/livingroom/temperature');

    expect(destinations.map(destination => destination.subscription.subscriberId)).toEqual([
      'client#1;sub#1',
      'client#1;sub#2',
      'client#1;sub#4',
      'client#1;sub#5',
      'client#2;sub#1',
      'client#2;sub#2',
      'client#2;sub#4',
      'client#2;sub#5',
    ]);

    expect(destinations[0]).withContext('(a)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map(),
      subscription: {
        subscriberId: 'client#1;sub#1',
        topic: 'myhome/livingroom/temperature',
        client: client1,
      },
    });
    expect(destinations[1]).withContext('(b)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map().set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#1;sub#2',
        topic: 'myhome/livingroom/:measurement',
        client: client1,
      },
    });
    expect(destinations[2]).withContext('(c)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map().set('room', 'livingroom'),
      subscription: {
        subscriberId: 'client#1;sub#4',
        topic: 'myhome/:room/temperature',
        client: client1,
      },
    });
    expect(destinations[3]).withContext('(d)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map().set('room', 'livingroom').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#1;sub#5',
        topic: 'myhome/:room/:measurement',
        client: client1,
      },
    });
    expect(destinations[4]).withContext('(e)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map(),
      subscription: {
        subscriberId: 'client#2;sub#1',
        topic: 'myhome/livingroom/temperature',
        client: client2,
      },
    });
    expect(destinations[5]).withContext('(f)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map().set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#2;sub#2',
        topic: 'myhome/livingroom/:measurement',
        client: client2,
      },
    });
    expect(destinations[6]).withContext('(g)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map().set('room', 'livingroom'),
      subscription: {
        subscriberId: 'client#2;sub#4',
        topic: 'myhome/:room/temperature',
        client: client2,
      },
    });
    expect(destinations[7]).withContext('(h)').toEqual({
      topic: 'myhome/livingroom/temperature',
      params: new Map().set('room', 'livingroom').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#2;sub#5',
        topic: 'myhome/:room/:measurement',
        client: client2,
      },
    });
  });

  it('should resolve subscribers which observe the topic \'myhome/kitchen/temperature\'', async () => {
    await MicrofrontendPlatform.startHost({applications: []});

    const subscriptionRegistry = Beans.get(TopicSubscriptionRegistry);
    const client1 = newClient('client#1');
    const client2 = newClient('client#2');

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client1, 'client#1;sub#1');
    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client1, 'client#1;sub#2');
    subscriptionRegistry.subscribe('myhome/kitchen/:measurement', client1, 'client#1;sub#3');
    subscriptionRegistry.subscribe('myhome/:room/temperature', client1, 'client#1;sub#4');
    subscriptionRegistry.subscribe('myhome/:room/:measurement', client1, 'client#1;sub#5');
    subscriptionRegistry.subscribe(':building/kitchen/:measurement', client1, 'client#1;sub#6');
    subscriptionRegistry.subscribe(':building/:room/:measurement', client1, 'client#1;sub#7');

    subscriptionRegistry.subscribe('myhome/livingroom/temperature', client2, 'client#2;sub#1');
    subscriptionRegistry.subscribe('myhome/livingroom/:measurement', client2, 'client#2;sub#2');
    subscriptionRegistry.subscribe('myhome/kitchen/:measurement', client2, 'client#2;sub#3');
    subscriptionRegistry.subscribe('myhome/:room/temperature', client2, 'client#2;sub#4');
    subscriptionRegistry.subscribe('myhome/:room/:measurement', client2, 'client#2;sub#5');
    subscriptionRegistry.subscribe(':building/kitchen/:measurement', client2, 'client#2;sub#6');
    subscriptionRegistry.subscribe(':building/:room/:measurement', client2, 'client#2;sub#7');

    // Resolve the subscribers which observe the topic 'myhome/kitchen/temperature'.
    const destinations = subscriptionRegistry.resolveTopicDestinations('myhome/kitchen/temperature');

    expect(destinations.map(destination => destination.subscription.subscriberId)).toEqual([
      'client#1;sub#3',
      'client#1;sub#4',
      'client#1;sub#5',
      'client#1;sub#6',
      'client#1;sub#7',
      'client#2;sub#3',
      'client#2;sub#4',
      'client#2;sub#5',
      'client#2;sub#6',
      'client#2;sub#7',
    ]);

    expect(destinations[0]).withContext('(client 1)(a)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#1;sub#3',
        topic: 'myhome/kitchen/:measurement',
        client: client1,
      },
    });
    expect(destinations[1]).withContext('(client 1)(b)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('room', 'kitchen'),
      subscription: {
        subscriberId: 'client#1;sub#4',
        topic: 'myhome/:room/temperature',
        client: client1,
      },
    });
    expect(destinations[2]).withContext('(client 1)(c)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('room', 'kitchen').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#1;sub#5',
        topic: 'myhome/:room/:measurement',
        client: client1,
      },
    });
    expect(destinations[3]).withContext('(client 1)(d)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('building', 'myhome').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#1;sub#6',
        topic: ':building/kitchen/:measurement',
        client: client1,
      },
    });
    expect(destinations[4]).withContext('(client 1)(e)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('building', 'myhome').set('room', 'kitchen').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#1;sub#7',
        topic: ':building/:room/:measurement',
        client: client1,
      },
    });
    expect(destinations[5]).withContext('(client 2)(a)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#2;sub#3',
        topic: 'myhome/kitchen/:measurement',
        client: client2,
      },
    });
    expect(destinations[6]).withContext('(client 2)(b)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('room', 'kitchen'),
      subscription: {
        subscriberId: 'client#2;sub#4',
        topic: 'myhome/:room/temperature',
        client: client2,
      },
    });
    expect(destinations[7]).withContext('(client 2)(c)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('room', 'kitchen').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#2;sub#5',
        topic: 'myhome/:room/:measurement',
        client: client2,
      },
    });
    expect(destinations[8]).withContext('(client 2)(d)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('building', 'myhome').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#2;sub#6',
        topic: ':building/kitchen/:measurement',
        client: client2,
      },
    });
    expect(destinations[9]).withContext('(client 2)(e)').toEqual({
      topic: 'myhome/kitchen/temperature',
      params: new Map().set('building', 'myhome').set('room', 'kitchen').set('measurement', 'temperature'),
      subscription: {
        subscriberId: 'client#2;sub#7',
        topic: ':building/:room/:measurement',
        client: client2,
      },
    });
  });

  function expectSubscriptionCount(topic: string): {toBe: (expected: number) => Promise<void>} {
    return {
      toBe: async (expected: any): Promise<void> => {
        await expect(await firstValueFrom(Beans.get(TopicSubscriptionRegistry).subscriptionCount$(topic))).withContext(`topic: ${topic}`).toBe(expected);
      },
    };
  }

  function newClient(id: string): Client {
    return new ɵClient(id, {} as Window, {symbolicName: 'app'} as Application, Beans.get(VERSION));
  }
});