diff --git a/src/config/Config.test.ts b/src/config/Config.test.ts index 34dd44cb7..5f8b09a27 100644 --- a/src/config/Config.test.ts +++ b/src/config/Config.test.ts @@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it, vi, afterEach } from "vitest"; import { logger } from "matrix-js-sdk/lib/logger"; -import { validateConfig } from "./Config"; -import { MatrixRTCMode } from "./ConfigOptions"; +import { Config, validateConfig } from "./Config"; +import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions"; describe("validateConfig", () => { afterEach(() => { @@ -52,3 +52,52 @@ describe("validateConfig", () => { expect(result.ssla).toBe("https://example.invalid/ssla"); }); }); + +describe("Config.initWith", () => { + // vitest.setup.ts has already called initDefault(), so every test here is + // free to re-initialize; the last call wins. + afterEach(() => { + vi.restoreAllMocks(); + Config.initDefault(); + }); + + it("makes the supplied config readable", () => { + Config.initWith({ ssla: "https://example.invalid/ssla" }); + expect(Config.get().ssla).toBe("https://example.invalid/ssla"); + }); + + it("fills in defaults for keys the embedder did not supply", () => { + Config.initWith({ ssla: "https://example.invalid/ssla" }); + expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality); + }); + + it("validates the supplied config just as a fetched one would be", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + Config.initWith({ + matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode, + }); + expect(Config.get().matrix_rtc_mode).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("does not share nested state with DEFAULT_CONFIG", () => { + Config.initWith({}); + expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality); + }); + + it("stops a later init() from fetching over the top of it", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + Config.initWith({ ssla: "https://example.invalid/ssla" }); + + await Config.init(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(Config.get().ssla).toBe("https://example.invalid/ssla"); + }); + + it("replaces a config initialized earlier", () => { + Config.initWith({ ssla: "https://first.invalid/ssla" }); + Config.initWith({ ssla: "https://second.invalid/ssla" }); + expect(Config.get().ssla).toBe("https://second.invalid/ssla"); + }); +}); diff --git a/src/config/Config.ts b/src/config/Config.ts index f52b28fde..37cdfc610 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -30,6 +30,14 @@ export class Config { return this.internalInstance.config; } + /** + * Initializes the config by fetching `config.json`, locating it relative to + * the current page. + * + * Does nothing if the config has already been initialized, including by + * {@link Config.initWith}, so that the regular startup path can run unchanged + * when an embedder has already supplied the config. + */ public static async init(): Promise { if (!Config.internalInstance?.initPromise) { const internalInstance = new Config(); @@ -50,17 +58,36 @@ export class Config { Config.internalInstance.initPromise = downloadConfig(fetchTarget).then( (config) => { - internalInstance.config = merge( - {}, - DEFAULT_CONFIG, - validateConfig(config), - ); + internalInstance.config = resolveConfig(config); }, ); } return Config.internalInstance.initPromise; } + /** + * Initializes the config from an object supplied by the embedder, instead of + * fetching `config.json`. + * + * {@link Config.init} derives the location of `config.json` from + * `window.location`, which only makes sense while Element Call owns the page. + * When it is embedded in a host application the host owns the configuration + * and passes it in here. + * + * The config goes through the same validation and defaulting as a fetched + * one, so that an injected config behaves identically to a hosted one. + * + * Replaces any config initialized earlier. + */ + public static initWith(config: ConfigOptions): void { + const internalInstance = new Config(); + internalInstance.config = resolveConfig(config); + // Mark initialization as already done, so that a later init() resolves + // immediately rather than fetching config.json over the top of this. + internalInstance.initPromise = Promise.resolve(); + Config.internalInstance = internalInstance; + } + /** * This is a alternative initializer that does not load anything * from a hosted config file but instead just initializes the config using the @@ -69,8 +96,7 @@ export class Config { * It is supposed to only be used in tests. (It is executed in `vite.setup.js`) */ public static initDefault(): void { - Config.internalInstance = new Config(); - Config.internalInstance.config = { ...DEFAULT_CONFIG }; + Config.initWith({}); } // Convenience accessors @@ -94,6 +120,15 @@ export class Config { private initPromise?: Promise; } +/** + * Applies validation and the built-in defaults to a config, however it was + * obtained. Deep-merges onto a fresh object so that the result never shares + * nested state with {@link DEFAULT_CONFIG}. + */ +function resolveConfig(config: ConfigOptions): ResolvedConfigOptions { + return merge({}, DEFAULT_CONFIG, validateConfig(config)); +} + export function validateConfig(config: ConfigOptions): ConfigOptions { const mode = config.matrix_rtc_mode; if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {