mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,313 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`showError should match snapshot 1`] = `
<div
id="matrixchat"
>
<div
class="mx_ErrorView cpd-theme-light"
>
<img
alt="Element"
class="mx_ErrorView_logo"
height="160"
src="themes/element/img/logos/element-app-logo.png"
/>
<div
class="mx_ErrorView_container"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Error title
</h1>
<p
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
>
msg1
</p>
<p
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
>
msg2
</p>
</div>
</div>
</div>
`;
exports[`showIncompatibleBrowser should match snapshot 1`] = `
<div
id="matrixchat"
>
<div
class="mx_ErrorView cpd-theme-light"
>
<img
alt="Element"
class="mx_ErrorView_logo"
height="160"
src="themes/element/img/logos/element-app-logo.png"
/>
<div
class="mx_ErrorView_container"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Element does not support this browser
</h1>
<p
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
>
Element uses some browser features which are not available in your current browser. If you continue, some features may stop working and there is a risk that you may lose data in the future.
</p>
<p
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
>
<span>
For the best experience, use
<a
href="https://google.com/chrome"
rel="noreferrer noopener"
target="_blank"
>
Chrome
</a>
,
<a
href="https://firefox.com"
rel="noreferrer noopener"
target="_blank"
>
Firefox
</a>
,
<a
href="https://microsoft.com/edge"
rel="noreferrer noopener"
target="_blank"
>
Edge
</a>
, or
<a
href="https://apple.com/safari"
rel="noreferrer noopener"
target="_blank"
>
Safari
</a>
.
</span>
</p>
<div
class="_flex_4dswl_9 mx_ErrorView_flexContainer mx_ErrorView_buttons"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"
/>
<path
d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2"
/>
</svg>
Learn more
</button>
<button
class="_button_13vu4_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Continue anyway
</button>
</div>
</div>
<div
class="_separator_cqpyv_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Use Element Desktop instead
</h2>
<div
class="_flex_4dswl_9 mx_ErrorView_flexContainer"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<a
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="lg"
href="https://packages.element.io/desktop/install/macos/Element.dmg"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.099 2.4a4.1 4.1 0 0 1-1.057 3.073c-.747.863-1.878 1.36-3.07 1.348-.075-1.081.315-2.146 1.085-2.96.78-.825 1.866-1.346 3.042-1.461m3.767 6.54c-1.37.783-2.214 2.163-2.234 3.657.002 1.69 1.092 3.215 2.768 3.873a9.4 9.4 0 0 1-1.44 2.723c-.848 1.178-1.737 2.329-3.149 2.35-.671.015-1.124-.165-1.596-.351-.493-.195-1.006-.398-1.809-.398-.852 0-1.388.21-1.905.412-.447.174-.88.343-1.49.367-1.343.046-2.37-1.258-3.25-2.425-1.756-2.383-3.124-6.716-1.29-9.664.86-1.437 2.471-2.349 4.241-2.402.763-.015 1.494.258 2.135.497.49.183.929.347 1.287.347.315 0 .74-.157 1.237-.34.78-.288 1.737-.64 2.71-.545 1.514.044 2.917.748 3.785 1.9"
fill-rule="evenodd"
/>
</svg>
Mac
</a>
<a
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="lg"
href="https://packages.element.io/desktop/install/win32/x64/Element%20Setup.exe"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.589 2.4H21.6v9.011h-9.011zM2.4 12.588h9.011v9.011H2.4zM2.4 2.4h9.011v9.011H2.4zm10.189 10.188H21.6v9.011h-9.011z"
/>
</svg>
Windows (64-bit)
</a>
<a
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="lg"
href="https://packages.element.io/desktop/install/win32/arm64/Element%20Setup.exe"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.589 2.4H21.6v9.011h-9.011zM2.4 12.588h9.011v9.011H2.4zM2.4 2.4h9.011v9.011H2.4zm10.189 10.188H21.6v9.011h-9.011z"
/>
</svg>
Windows (ARM 64-bit)
</a>
<a
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="lg"
href="https://element.io/download#linux"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M13.206 20.644a2.7 2.7 0 0 1-1.097.248 2.8 2.8 0 0 1-1.567-.495c-.153.38-.392.723-.7 1a2.8 2.8 0 0 1-1.078.604h6.141a2.8 2.8 0 0 1-.99-.51 2.8 2.8 0 0 1-.709-.847M6.71 16.673a.2.2 0 0 0 .067-.03q.056-.49.13-.94c-.01-.169.017-.338.08-.495.348-1.904.834-3.24 1.504-4.052a.13.13 0 0 1 .18-.018.123.123 0 0 1 .018.174 4 4 0 0 0-.203.272q-.764 1.113-1.199 3.389h.03a.6.6 0 0 1 .166 0c.198.042.508.203.985.67.061-2.72 1.661-4.913 3.642-4.913 1.748 0 3.208 1.715 3.556 3.997a1.7 1.7 0 0 1 .762-.742.8.8 0 0 1 .254-.047 7.5 7.5 0 0 0-.948-2.342 4 4 0 0 0-.2-.272.12.12 0 0 1-.03-.09.12.12 0 0 1 .048-.083.13.13 0 0 1 .092-.028.13.13 0 0 1 .085.045c.508.624.93 1.557 1.245 2.822.678.247.696 1.361.71 2.267 0 .43 0 .877.105.99.104.114.325.09.635-.04.036-.393.053-.742.063-1.036v-1.082c0-2.475-3.266-6.755-3.266-6.755l-.378-3.218c0-2.953-2.733-2.926-2.733-2.926s-2.743-.027-2.743 2.914l-.365 3.23s-3.267 4.277-3.267 6.755v.31s-.012.299 0 .767v.227c.414.154.839.315.976.28m5.388-10.41c.761.068 1.975.247 2.031.613.04.282-.429.822-.523.926-.195.215-.876.92-1.49.92-.615 0-1.296-.705-1.49-.92-.093-.104-.563-.644-.525-.926.046-.374 1.273-.545 1.996-.614"
/>
<path
d="M12.111 8.475c.333 0 .843-.33 1.298-.837.348-.384.475-.666.465-.73-.064-.127-.894-.317-1.778-.401-.856.084-1.687.274-1.75.406.091.275.252.523.467.722.457.51.965.84 1.298.84m.684 10.664c-.292-2.607-.023-3.146.206-3.324a.42.42 0 0 1 .4-.057c.23.11.431.271.587.47.287.304.488.495.674.391.131-.077.32-.495.507-.879.097-.205.196-.425.305-.636-.254-2.33-1.65-4.126-3.355-4.126-1.872 0-3.393 2.166-3.393 4.827v.096c.31.317.68.743 1.133 1.3q.266.328.487.685a2.7 2.7 0 0 1 .29 2.258c.426.312.943.485 1.476.495.341-.002.679-.073.99-.21a2.6 2.6 0 0 1-.16-.46 7 7 0 0 1-.147-.83"
/>
<path
d="M9.651 17.35c-.355-.44-.657-.789-.916-1.071l-.13-.139-.132-.138c-.587-.594-.897-.758-1.06-.785a.2.2 0 0 0-.068 0 .15.15 0 0 0-.114.082v.017a.9.9 0 0 0-.058.406v.327c.034.25-.019.505-.15.723a.4.4 0 0 1-.087.071l-.053.032a.6.6 0 0 1-.107.043H6.75c-.211.037-.554-.084-.988-.248l-.127-.05c-.544-.21-1.179-.452-1.644-.452a.63.63 0 0 0-.553.232c-.34.495.421 1.273 1.036 1.901.378.389.675.693.708.921.059.42-.393.582-.792.725a1.5 1.5 0 0 0-.564.278c-.06.069-.058.116-.045.15.055.191.507.61 2.897 1.285q.398.112.806.178c.49.073.992.01 1.448-.182a2.64 2.64 0 0 0 1.13-.902q.165-.245.271-.522l.05-.121c0-.042.024-.09.037-.131a2.48 2.48 0 0 0-.293-1.98 7 7 0 0 0-.475-.65m9.618-.17c-.212.02-.418.084-.604.186l-.277.117c-.305.113-.61.168-.807-.095a.6.6 0 0 1-.094-.227 3.6 3.6 0 0 1-.056-.592v-.322c0-.72-.026-1.586-.373-1.925a.5.5 0 0 0-.255-.14h-.038a.5.5 0 0 0-.254.035c-.3.12-.56.496-.79.936l-.109.215-.117.248-.104.225c-.223.495-.398.874-.601.99-.376.216-.704-.13-.991-.438a1.5 1.5 0 0 0-.48-.4.2.2 0 0 0-.076-.016q-.048 0-.084.03c-.158.124-.384.69-.112 3.104q.044.4.137.792.052.207.137.404.027.056.056.111c.02.037.033.074.054.111.208.377.51.696.879.928a2.66 2.66 0 0 0 2.504.171c.398-.18.743-.454 1.003-.8a7 7 0 0 0 .45-.675c1.218-2.116 1.241-2.722 1.147-2.896a.15.15 0 0 0-.144-.077"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M3.167 2.19H20.5V22H3.165z"
/>
</clippath>
</defs>
</svg>
Linux
</a>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Or use our mobile app
</h2>
<div
class="_flex_4dswl_9 mx_ErrorView_flexContainer"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x); --mx-flex-wrap: nowrap;"
>
<a
href="https://apps.apple.com/app/vector/id1083446067"
rel="noreferrer noopener"
target="_blank"
>
<img
alt="Apple App Store"
height="64"
src="themes/element/img/download/apple.svg"
/>
</a>
<a
href="https://play.google.com/store/apps/details?id=im.vector.app"
rel="noreferrer noopener"
target="_blank"
>
<img
alt="Google Play Store"
height="64"
src="themes/element/img/download/google.svg"
/>
</a>
<a
href="https://f-droid.org/repository/browse/?fdid=im.vector.app"
rel="noreferrer noopener"
target="_blank"
>
<img
alt="F-Droid"
height="64"
src="themes/element/img/download/fdroid.svg"
/>
</a>
</div>
</div>
</div>
`;
@@ -0,0 +1,92 @@
/**
* @jest-environment jest-fixed-jsdom
* @jest-environment-options {"url": "https://app.element.io/#/room/#room:server"}
*/
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Crypto } from "@peculiar/webcrypto";
import { loadApp } from "../../../src/vector/app.tsx";
import SdkConfig from "../../../src/SdkConfig.ts";
import PlatformPeg from "../../../src/PlatformPeg.ts";
import { mockPlatformPeg, unmockPlatformPeg } from "../../test-utils";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
const defaultConfig = {
default_hs_url: "https://synapse",
};
const issuer = "https://auth.org/";
const webCrypto = new Crypto();
describe("sso_redirect_options", () => {
beforeAll(() => {
Object.defineProperty(window, "crypto", {
value: {
// Stable stub
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) {
arr[i] = i;
}
return arr;
},
subtle: webCrypto.subtle,
},
});
});
beforeEach(() => {
SdkConfig.reset();
mockPlatformPeg({ getDefaultDeviceDisplayName: jest.fn(), startSingleSignOn: jest.fn() });
});
afterAll(() => {
unmockPlatformPeg();
});
describe("immediate", () => {
beforeEach(() => {
SdkConfig.put({
...defaultConfig,
sso_redirect_options: { immediate: true },
// Avoid testing dynamic client registration
oidc_static_clients: { [issuer]: { client_id: "12345" } },
});
// Signal we support v1.1 to pass the minimum js-sdk compatibility bar
// Signal we support v1.15 to use stable Native OIDC support
fetchMock.get("https://synapse/_matrix/client/versions", { versions: ["v1.1", "v1.15"] });
});
it("should redirect for legacy SSO", async () => {
fetchMock.getOnce("https://synapse/_matrix/client/v3/login", {
flows: [{ stages: ["m.login.sso"] }],
});
const startSingleSignOnSpy = jest.spyOn(PlatformPeg.get()!, "startSingleSignOn");
await loadApp({}, jest.fn());
expect(startSingleSignOnSpy).toHaveBeenCalledWith(expect.any(MatrixClient), "sso", "/room/#room:server");
});
it("should redirect for native OIDC", async () => {
const authConfig = makeDelegatedAuthConfig(issuer);
fetchMock.get("https://synapse/_matrix/client/v1/auth_metadata", authConfig);
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(authConfig.jwks_uri!, { keys: [] });
const startOidcLoginSpy = jest.spyOn(window.location, "href", "set");
await loadApp({}, jest.fn());
expect(startOidcLoginSpy).toHaveBeenCalledWith(
"https://auth.org/auth?client_id=12345&redirect_uri=https%3A%2F%2Fapp.element.io%2F%3Fno_universal_links%3Dtrue&response_type=code&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AwKpa6hpi3Y&nonce=38QgU2Pomx&state=10000000100040008000100000000000&code_challenge=awE81eIsGff70JahvrTqWRbGKLI10ooyo_Xm1sxuZvU&code_challenge_method=S256&response_mode=query",
);
});
});
});
@@ -0,0 +1,103 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { getVectorConfig } from "../../../src/vector/getconfig";
describe("getVectorConfig()", () => {
const elementDomain = "app.element.io";
const now = 1234567890;
const specificConfig = {
brand: "specific",
};
const generalConfig = {
brand: "general",
};
beforeEach(() => {
Object.defineProperty(window, "location", {
value: { href: `https://${elementDomain}`, hostname: elementDomain },
writable: true,
});
// stable value for cachebuster
jest.spyOn(Date, "now").mockReturnValue(now);
jest.clearAllMocks();
fetchMock.removeRoutes();
});
afterAll(() => {
jest.spyOn(Date, "now").mockRestore();
});
it("requests specific config for document domain", async () => {
fetchMock.getOnce("express:/config.app.element.io.json*", specificConfig);
fetchMock.getOnce("express:/config.json*", generalConfig);
await expect(getVectorConfig()).resolves.toEqual(specificConfig);
});
it("adds trailing slash to relativeLocation when not an empty string", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", specificConfig);
fetchMock.getOnce("express:/config.json", generalConfig);
await expect(getVectorConfig("..")).resolves.toEqual(specificConfig);
});
it("returns general config when specific config succeeds but is empty", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", {});
fetchMock.getOnce("express:/config.json", generalConfig);
await expect(getVectorConfig()).resolves.toEqual(generalConfig);
});
it("returns general config when specific config 404s", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", { status: 404 });
fetchMock.getOnce("express:/config.json", generalConfig);
await expect(getVectorConfig()).resolves.toEqual(generalConfig);
});
it("returns general config when specific config is fetched from a file and is empty", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", 0);
fetchMock.getOnce("express:/config.json", generalConfig);
await expect(getVectorConfig()).resolves.toEqual(generalConfig);
});
it("returns general config when specific config returns a non-200 status", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", { status: 401 });
fetchMock.getOnce("express:/config.json", generalConfig);
await expect(getVectorConfig()).resolves.toEqual(generalConfig);
});
it("returns general config when specific config returns an error", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", { throws: "err1" });
fetchMock.getOnce("express:/config.json", generalConfig);
await expect(getVectorConfig()).resolves.toEqual(generalConfig);
});
it("rejects with an error when general config rejects", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", { throws: "err-specific" });
fetchMock.getOnce("express:/config.json", { throws: "err-general" });
await expect(getVectorConfig()).rejects.toBe("err-general");
});
it("rejects with an error when config is invalid JSON", async () => {
fetchMock.getOnce("express:/config.app.element.io.json", { throws: "err-specific" });
fetchMock.getOnce("express:/config.json", '{"invalid": "json",}');
// We can't assert it'll be a SyntaxError as node-fetch behaves differently
// https://github.com/wheresrhys/fetch-mock/issues/270
await expect(getVectorConfig()).rejects.toThrow("in JSON at position 19");
});
});
@@ -0,0 +1,73 @@
/**
* @jest-environment jest-fixed-jsdom
* @jest-environment-options {"url": "https://app.element.io/?loginToken=123&state=abc&code=xyz&no_universal_links&something_else=value"}
*/
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { waitFor, screen } from "jest-matrix-react";
import { loadApp, showError, showIncompatibleBrowser } from "../../../src/vector/init.tsx";
import SdkConfig from "../../../src/SdkConfig.ts";
import MatrixChat from "../../../src/components/structures/MatrixChat.tsx";
function setUpMatrixChatDiv() {
document.getElementById("matrixchat")?.remove();
const div = document.createElement("div");
div.id = "matrixchat";
document.body.appendChild(div);
}
describe("showIncompatibleBrowser", () => {
beforeEach(setUpMatrixChatDiv);
it("should match snapshot", async () => {
await showIncompatibleBrowser(jest.fn());
await screen.findByText("Element does not support this browser");
expect(document.getElementById("matrixchat")).toMatchSnapshot();
});
});
describe("showError", () => {
beforeEach(setUpMatrixChatDiv);
it("should match snapshot", async () => {
await showError("Error title", ["msg1", "msg2"]);
await screen.findByText("Error title");
expect(document.getElementById("matrixchat")).toMatchSnapshot();
});
});
describe("loadApp", () => {
beforeEach(setUpMatrixChatDiv);
beforeEach(async () => {
fetchMock.get("https://matrix.org/_matrix/client/versions", { versions: ["v1.6"] });
SdkConfig.put({ default_server_config: { "m.homeserver": { base_url: "https://matrix.org" } } });
});
it("should set window.matrixChat to the MatrixChat instance", async () => {
await loadApp({});
await waitFor(() => expect(window.matrixChat).toBeInstanceOf(MatrixChat));
});
it("should pass onTokenLoginCompleted which strips searchParams to MatrixChat", async () => {
const spy = jest.spyOn(window.history, "replaceState");
await loadApp({});
await waitFor(() => expect(window.matrixChat).toBeInstanceOf(MatrixChat));
window.matrixChat!.props.onTokenLoginCompleted();
expect(spy).toHaveBeenCalledWith(
null,
"",
expect.stringContaining("https://app.element.io/?something_else=value"),
);
});
});
@@ -0,0 +1,531 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock";
import { waitFor } from "jest-matrix-react";
import { UpdateCheckStatus } from "../../../../src/BasePlatform";
import { Action } from "../../../../src/dispatcher/actions";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import * as rageshake from "../../../../src/rageshake/rageshake";
import { BreadcrumbsStore } from "../../../../src/stores/BreadcrumbsStore";
import Modal from "../../../../src/Modal";
import DesktopCapturerSourcePicker from "../../../../src/components/views/elements/DesktopCapturerSourcePicker";
import ElectronPlatform from "../../../../src/vector/platform/ElectronPlatform";
import { stubClient } from "../../../test-utils";
import ToastStore from "../../../../src/stores/ToastStore.ts";
jest.mock("../../../../src/rageshake/rageshake", () => ({
flush: jest.fn(),
}));
describe("ElectronPlatform", () => {
const initialiseValues = jest.fn().mockReturnValue({
protocol: "io.element.desktop",
sessionId: "session-id",
config: { _config: true },
supportedSettings: { setting1: false, setting2: true },
supportsBadgeOverlay: false,
});
const defaultUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36";
const mockElectron = {
on: jest.fn(),
send: jest.fn(),
initialise: initialiseValues,
setSettingValue: jest.fn().mockResolvedValue(undefined),
getSettingValue: jest.fn().mockResolvedValue(undefined),
} as unknown as MockedObject<Electron>;
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const dispatchFireSpy = jest.spyOn(dispatcher, "fire");
const logSpy = jest.spyOn(logger, "log").mockImplementation(() => {});
const userId = "@alice:server.org";
const deviceId = "device-id";
beforeEach(() => {
window.electron = mockElectron;
jest.clearAllMocks();
Object.defineProperty(window, "navigator", { value: { userAgent: defaultUserAgent }, writable: true });
});
const getElectronEventHandlerCall = (
eventType: string,
): [type: string, handler: (...args: any) => void] | undefined =>
mockElectron.on.mock.calls.find(([type]) => type === eventType);
it("flushes rageshake before quitting", () => {
new ElectronPlatform();
const [event, handler] = getElectronEventHandlerCall("before-quit")!;
// correct event bound
expect(event).toBeTruthy();
handler();
expect(logSpy).toHaveBeenCalled();
expect(rageshake.flush).toHaveBeenCalled();
});
it("should load config", async () => {
const platform = new ElectronPlatform();
await expect(platform.getConfig()).resolves.toEqual({ _config: true });
});
it("should return oidc client state as expected", async () => {
const platform = new ElectronPlatform();
await platform.getConfig();
expect(platform.getOidcClientState()).toMatchInlineSnapshot(`":element-desktop-ssoid:session-id"`);
});
it("dispatches view settings action on preferences event", () => {
new ElectronPlatform();
const [event, handler] = getElectronEventHandlerCall("preferences")!;
// correct event bound
expect(event).toBeTruthy();
handler();
expect(dispatchFireSpy).toHaveBeenCalledWith(Action.ViewUserSettings);
});
it("creates a modal on openDesktopCapturerSourcePicker", async () => {
const plat = new ElectronPlatform();
Modal.createDialog = jest.fn();
// @ts-ignore mock
mocked(Modal.createDialog).mockReturnValue({
finished: new Promise((r) => r(["source"])),
});
let res: () => void;
const waitForIPCSend = new Promise<void>((r) => {
res = r;
});
// @ts-ignore mock
jest.spyOn(plat.ipc, "call").mockImplementation(() => {
res();
});
const [event, handler] = getElectronEventHandlerCall("openDesktopCapturerSourcePicker")!;
handler();
await waitForIPCSend;
expect(event).toBeTruthy();
expect(Modal.createDialog).toHaveBeenCalledWith(DesktopCapturerSourcePicker);
// @ts-ignore mock
expect(plat.ipc.call).toHaveBeenCalledWith("callDisplayMediaCallback", "source");
});
it("should show a toast when showToast is fired", async () => {
new ElectronPlatform();
dispatcher.dispatch(
{
action: Action.ClientStarted,
},
true,
);
const spy = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
const [event, handler] = getElectronEventHandlerCall("showToast")!;
handler({} as any, { title: "title", description: "description" });
expect(event).toBeTruthy();
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
title: "title",
props: expect.objectContaining({ description: "description" }),
}),
),
);
});
describe("updates", () => {
it("dispatches on check updates action", () => {
new ElectronPlatform();
const [event, handler] = getElectronEventHandlerCall("check_updates")!;
// correct event bound
expect(event).toBeTruthy();
handler({}, true);
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CheckUpdates,
status: UpdateCheckStatus.Downloading,
});
});
it("dispatches on check updates action when update not available", () => {
new ElectronPlatform();
const [, handler] = getElectronEventHandlerCall("check_updates")!;
handler({}, false);
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CheckUpdates,
status: UpdateCheckStatus.NotAvailable,
});
});
it("starts update check", () => {
const platform = new ElectronPlatform();
platform.startUpdateCheck();
expect(mockElectron.send).toHaveBeenCalledWith("check_updates");
});
it("installs update", () => {
const platform = new ElectronPlatform();
platform.installUpdate();
expect(mockElectron.send).toHaveBeenCalledWith("install_update");
});
});
it("returns human readable name", () => {
const platform = new ElectronPlatform();
expect(platform.getHumanReadableName()).toEqual("Electron Platform");
});
describe("getDefaultDeviceDisplayName", () => {
it.each([
[
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
"Element Desktop: macOS",
],
[
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) " +
"electron/1.0.0 Chrome/53.0.2785.113 Electron/1.4.3 Safari/537.36",
"Element Desktop: Windows",
],
["Mozilla/5.0 (X11; Linux i686; rv:21.0) Gecko/20100101 Firefox/21.0", "Element Desktop: Linux"],
["Mozilla/5.0 (X11; FreeBSD i686; rv:21.0) Gecko/20100101 Firefox/21.0", "Element Desktop: FreeBSD"],
["Mozilla/5.0 (X11; OpenBSD i686; rv:21.0) Gecko/20100101 Firefox/21.0", "Element Desktop: OpenBSD"],
["Mozilla/5.0 (X11; SunOS i686; rv:21.0) Gecko/20100101 Firefox/21.0", "Element Desktop: SunOS"],
["custom user agent", "Element Desktop: Unknown"],
])("%s = %s", (userAgent, result) => {
Object.defineProperty(window, "navigator", { value: { userAgent }, writable: true });
const platform = new ElectronPlatform();
expect(platform.getDefaultDeviceDisplayName()).toEqual(result);
});
});
it("returns true for needsUrlTooltips", () => {
const platform = new ElectronPlatform();
expect(platform.needsUrlTooltips()).toBe(true);
});
it("should override browser shortcuts", () => {
const platform = new ElectronPlatform();
expect(platform.overrideBrowserShortcuts()).toBe(true);
});
it("allows overriding native context menus", () => {
const platform = new ElectronPlatform();
expect(platform.allowOverridingNativeContextMenus()).toBe(true);
});
it("indicates support for desktop capturer", () => {
const platform = new ElectronPlatform();
expect(platform.supportsDesktopCapturer()).toBe(true);
});
it("indicates no support for jitsi screensharing", () => {
const platform = new ElectronPlatform();
expect(platform.supportsJitsiScreensharing()).toBe(false);
});
describe("notifications", () => {
it("indicates support for notifications", () => {
const platform = new ElectronPlatform();
expect(platform.supportsNotifications()).toBe(true);
});
it("may send notifications", () => {
const platform = new ElectronPlatform();
expect(platform.maySendNotifications()).toBe(true);
});
it("pretends to request notification permission", async () => {
const platform = new ElectronPlatform();
const result = await platform.requestNotificationPermission();
expect(result).toEqual("granted");
});
it("creates a loud notification", async () => {
const platform = new ElectronPlatform();
platform.loudNotification(new MatrixEvent(), new Room("!room:server", {} as any, userId));
expect(mockElectron.send).toHaveBeenCalledWith("loudNotification");
});
it("sets notification count when count is changing", async () => {
const platform = new ElectronPlatform();
platform.setNotificationCount(0);
// not called because matches internal notificaiton count
expect(mockElectron.send).not.toHaveBeenCalledWith("setBadgeCount", 0);
platform.setNotificationCount(1);
expect(mockElectron.send).toHaveBeenCalledWith("setBadgeCount", 1);
});
});
describe("spellcheck", () => {
it("indicates support for spellcheck settings", () => {
const platform = new ElectronPlatform();
expect(platform.supportsSpellCheckSettings()).toBe(true);
});
it("gets available spellcheck languages", () => {
const platform = new ElectronPlatform();
mockElectron.send.mockClear();
platform.getAvailableSpellCheckLanguages();
const [channel, { name }] = mockElectron.send.mock.calls[0];
expect(channel).toEqual("ipcCall");
expect(name).toEqual("getAvailableSpellCheckLanguages");
});
});
describe("pickle key", () => {
it("makes correct ipc call to get pickle key", () => {
const platform = new ElectronPlatform();
mockElectron.send.mockClear();
platform.getPickleKey(userId, deviceId);
const [, { name, args }] = mockElectron.send.mock.calls[0];
expect(name).toEqual("getPickleKey");
expect(args).toEqual([userId, deviceId]);
});
it("makes correct ipc call to create pickle key", () => {
const platform = new ElectronPlatform();
mockElectron.send.mockClear();
platform.createPickleKey(userId, deviceId);
const [, { name, args }] = mockElectron.send.mock.calls[0];
expect(name).toEqual("createPickleKey");
expect(args).toEqual([userId, deviceId]);
});
it("makes correct ipc call to destroy pickle key", () => {
const platform = new ElectronPlatform();
mockElectron.send.mockClear();
platform.destroyPickleKey(userId, deviceId);
const [, { name, args }] = mockElectron.send.mock.calls[0];
expect(name).toEqual("destroyPickleKey");
expect(args).toEqual([userId, deviceId]);
});
});
describe("versions", () => {
it("calls install update", () => {
const platform = new ElectronPlatform();
platform.installUpdate();
expect(mockElectron.send).toHaveBeenCalledWith("install_update");
});
});
describe("breadcrumbs", () => {
it("should send breadcrumb updates over the IPC", () => {
const spy = jest.spyOn(BreadcrumbsStore.instance, "on");
new ElectronPlatform();
const cb = spy.mock.calls[0][1];
cb();
expect(mockElectron.send).toHaveBeenCalledWith(
"ipcCall",
expect.objectContaining({
name: "breadcrumbs",
}),
);
});
});
describe("authenticated media", () => {
it("should respond to relevant ipc requests", async () => {
const cli = stubClient();
mocked(cli.getAccessToken).mockReturnValue("access_token");
mocked(cli.getHomeserverUrl).mockReturnValue("homeserver_url");
mocked(cli.getVersions).mockResolvedValue({
versions: ["v1.1"],
unstable_features: {},
});
new ElectronPlatform();
const userAccessTokenCall = mockElectron.on.mock.calls.find((call) => call[0] === "userAccessToken");
userAccessTokenCall![1]({} as any);
const userAccessTokenResponse = mockElectron.send.mock.calls.find((call) => call[0] === "userAccessToken");
expect(userAccessTokenResponse![1]).toBe("access_token");
const homeserverUrlCall = mockElectron.on.mock.calls.find((call) => call[0] === "homeserverUrl");
homeserverUrlCall![1]({} as any);
const homeserverUrlResponse = mockElectron.send.mock.calls.find((call) => call[0] === "homeserverUrl");
expect(homeserverUrlResponse![1]).toBe("homeserver_url");
const serverSupportedVersionsCall = mockElectron.on.mock.calls.find(
(call) => call[0] === "serverSupportedVersions",
);
await (serverSupportedVersionsCall![1]({} as any) as unknown as Promise<unknown>);
const serverSupportedVersionsResponse = mockElectron.send.mock.calls.find(
(call) => call[0] === "serverSupportedVersions",
);
expect(serverSupportedVersionsResponse![1]).toEqual({ versions: ["v1.1"], unstable_features: {} });
});
});
describe("settings", () => {
let platform: ElectronPlatform;
beforeAll(async () => {
window.electron = mockElectron;
platform = new ElectronPlatform();
await platform.getConfig(); // await init
});
it("supportsSetting should return true for the platform", () => {
expect(platform.supportsSetting()).toBe(true);
});
it("supportsSetting should return true for available settings", () => {
expect(platform.supportsSetting("setting2")).toBe(true);
});
it("supportsSetting should return false for unavailable settings", () => {
expect(platform.supportsSetting("setting1")).toBe(false);
});
it("should read setting value over ipc", async () => {
mockElectron.getSettingValue.mockResolvedValue("value");
await expect(platform.getSettingValue("setting2")).resolves.toEqual("value");
expect(mockElectron.getSettingValue).toHaveBeenCalledWith("setting2");
});
it("should write setting value over ipc", async () => {
await platform.setSettingValue("setting2", "newValue");
expect(mockElectron.setSettingValue).toHaveBeenCalledWith("setting2", "newValue");
});
});
it("should forward call_state dispatcher events via ipc", async () => {
new ElectronPlatform();
dispatcher.dispatch(
{
action: "call_state",
state: "connected",
},
true,
);
const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "app_onAction");
expect(ipcMessage![1]).toEqual({
action: "call_state",
state: "connected",
});
});
describe("Notification overlay badges", () => {
beforeEach(() => {
initialiseValues.mockReturnValue({
protocol: "io.element.desktop",
sessionId: "session-id",
config: { _config: true },
supportsBadgeOverlay: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it("should send a badge with a notification count", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setNotificationCount(1);
// Badges are sent asynchronously
await waitFor(() => {
const ipcMessage = mockElectron.send.mock.lastCall;
expect(ipcMessage?.[1]).toEqual(1);
expect(ipcMessage?.[2].constructor.name).toEqual("ArrayBuffer");
});
});
it("should update badge and skip duplicates", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setNotificationCount(1);
platform.setNotificationCount(1); // Test that duplicates do not fire.
platform.setNotificationCount(2);
// Badges are sent asynchronously
await waitFor(() => {
const [ipcMessageA, ipcMessageB] = mockElectron.send.mock.calls.filter(
(call) => call[0] === "setBadgeCount",
);
expect(ipcMessageA?.[1]).toEqual(1);
expect(ipcMessageA?.[2].constructor.name).toEqual("ArrayBuffer");
expect(ipcMessageB?.[1]).toEqual(2);
expect(ipcMessageB?.[2].constructor.name).toEqual("ArrayBuffer");
});
});
it("should remove badge when notification count zeros", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setNotificationCount(1);
platform.setNotificationCount(0); // Test that duplicates do not fire.
// Badges are sent asynchronously
await waitFor(() => {
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
(call) => call[0] === "setBadgeCount",
);
expect(ipcMessageA?.[1]).toEqual(1);
expect(ipcMessageA?.[2].constructor.name).toEqual("ArrayBuffer");
expect(ipcMessageB?.[1]).toEqual(0);
expect(ipcMessageB?.[2]).toBeNull();
});
});
it("should show an error badge when the application errors", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setErrorStatus(true);
// Badges are sent asynchronously
await waitFor(() => {
const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "setBadgeCount");
expect(ipcMessage?.[1]).toEqual(0);
expect(ipcMessage?.[2].constructor.name).toEqual("ArrayBuffer");
expect(ipcMessage?.[3]).toEqual(true);
});
});
it("should restore after error is resolved", async () => {
const platform = new ElectronPlatform();
await platform.initialised;
platform.setErrorStatus(true);
platform.setErrorStatus(false);
// Badges are sent asynchronously
await waitFor(() => {
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
(call) => call[0] === "setBadgeCount",
);
expect(ipcMessageA?.[1]).toEqual(0);
expect(ipcMessageA?.[2].constructor.name).toEqual("ArrayBuffer");
expect(ipcMessageA?.[3]).toEqual(true);
expect(ipcMessageB?.[1]).toEqual(0);
expect(ipcMessageB?.[2]).toBeNull();
});
});
});
});
@@ -0,0 +1,55 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import PWAPlatform from "../../../../src/vector/platform/PWAPlatform";
import WebPlatform from "../../../../src/vector/platform/WebPlatform";
jest.mock("../../../../src/vector/platform/WebPlatform");
describe("PWAPlatform", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("setNotificationCount", () => {
it("should call Navigator::setAppBadge", () => {
navigator.setAppBadge = jest.fn().mockResolvedValue(undefined);
const platform = new PWAPlatform();
expect(navigator.setAppBadge).not.toHaveBeenCalled();
platform.setNotificationCount(123);
expect(navigator.setAppBadge).toHaveBeenCalledWith(123);
});
it("should no-op if the badge count isn't changing", () => {
navigator.setAppBadge = jest.fn().mockResolvedValue(undefined);
const platform = new PWAPlatform();
platform.setNotificationCount(123);
expect(navigator.setAppBadge).toHaveBeenCalledTimes(1);
platform.setNotificationCount(123);
expect(navigator.setAppBadge).toHaveBeenCalledTimes(1);
});
it("should fall back to WebPlatform::setNotificationCount if no Navigator::setAppBadge", () => {
// @ts-ignore
navigator.setAppBadge = undefined;
const platform = new PWAPlatform();
const superMethod = mocked(WebPlatform.prototype.setNotificationCount);
expect(superMethod).not.toHaveBeenCalled();
platform.setNotificationCount(123);
expect(superMethod).toHaveBeenCalledWith(123);
});
it("should handle Navigator::setAppBadge rejecting gracefully", () => {
navigator.setAppBadge = jest.fn().mockRejectedValue(new Error());
const platform = new PWAPlatform();
expect(() => platform.setNotificationCount(123)).not.toThrow();
});
});
});
@@ -0,0 +1,267 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { UpdateCheckStatus } from "../../../../src/BasePlatform";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import WebPlatform from "../../../../src/vector/platform/WebPlatform";
import ToastStore from "../../../../src/stores/ToastStore.ts";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher.ts";
import { emitPromise } from "../../../test-utils";
import { Action } from "../../../../src/dispatcher/actions.ts";
describe("WebPlatform", () => {
beforeEach(() => {
jest.spyOn(global, "navigator", "get").mockReturnValue({
...navigator,
// @ts-expect-error - mocking readonly object
serviceWorker: {
register: jest.fn().mockResolvedValue({
update: jest.fn(),
}),
addEventListener: jest.fn(),
},
});
});
it("returns human readable name", () => {
const platform = new WebPlatform();
expect(platform.getHumanReadableName()).toEqual("Web Platform");
});
describe("service worker", () => {
it("registers successfully", () => {
new WebPlatform();
expect(navigator.serviceWorker.register).toHaveBeenCalled();
});
it("handles errors", async () => {
jest.spyOn(global, "navigator", "get").mockReturnValue({
serviceWorker: {
// @ts-expect-error - mocking readonly object
register: undefined,
},
});
new WebPlatform();
defaultDispatcher.dispatch({ action: Action.ClientStarted });
await emitPromise(ToastStore.sharedInstance(), "update");
const toasts = ToastStore.sharedInstance().getToasts();
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toEqual("Failed to load service worker");
});
});
it("should call reload on window location object", () => {
Object.defineProperty(window, "location", { value: { reload: jest.fn() }, writable: true });
const platform = new WebPlatform();
expect(window.location.reload).not.toHaveBeenCalled();
platform.reload();
expect(window.location.reload).toHaveBeenCalled();
});
it("should call reload to install update", () => {
Object.defineProperty(window, "location", { value: { reload: jest.fn() }, writable: true });
const platform = new WebPlatform();
expect(window.location.reload).not.toHaveBeenCalled();
platform.installUpdate();
expect(window.location.reload).toHaveBeenCalled();
});
describe("getDefaultDeviceDisplayName", () => {
it.each([
[
"https://develop.element.io/#/room/!foo:bar",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/105.0.0.0 Safari/537.36",
"develop.element.io: Chrome on macOS",
],
])("%s & %s = %s", (url, userAgent, result) => {
jest.spyOn(global, "navigator", "get").mockReturnValue({ userAgent } as Navigator);
Object.defineProperty(window, "location", { value: { href: url }, writable: true });
const platform = new WebPlatform();
expect(platform.getDefaultDeviceDisplayName()).toEqual(result);
});
});
describe("notification support", () => {
const mockNotification = {
requestPermission: jest.fn(),
permission: "notGranted",
};
beforeEach(() => {
// @ts-ignore
window.Notification = mockNotification;
mockNotification.permission = "notGranted";
});
it("supportsNotifications returns false when platform does not support notifications", () => {
// @ts-ignore
window.Notification = undefined;
expect(new WebPlatform().supportsNotifications()).toBe(false);
});
it("supportsNotifications returns true when platform supports notifications", () => {
expect(new WebPlatform().supportsNotifications()).toBe(true);
});
it("maySendNotifications returns true when notification permissions are not granted", () => {
expect(new WebPlatform().maySendNotifications()).toBe(false);
});
it("maySendNotifications returns true when notification permissions are granted", () => {
mockNotification.permission = "granted";
expect(new WebPlatform().maySendNotifications()).toBe(true);
});
it("requests notification permissions and returns result", async () => {
mockNotification.requestPermission.mockImplementation((callback) => callback("test"));
const platform = new WebPlatform();
const result = await platform.requestNotificationPermission();
expect(result).toEqual("test");
});
});
describe("app version", () => {
const envVersion = process.env.VERSION;
const prodVersion = "1.10.13";
beforeEach(() => {
jest.spyOn(MatrixClientPeg, "userRegisteredWithinLastHours").mockReturnValue(false);
});
afterAll(() => {
// @ts-ignore
WebPlatform.VERSION = envVersion;
});
it("should return true from canSelfUpdate()", async () => {
const platform = new WebPlatform();
const result = await platform.canSelfUpdate();
expect(result).toBe(true);
});
it("getAppVersion returns normalized app version", async () => {
// @ts-ignore
WebPlatform.VERSION = prodVersion;
const platform = new WebPlatform();
const version = await platform.getAppVersion();
expect(version).toEqual(prodVersion);
// @ts-ignore
WebPlatform.VERSION = `v${prodVersion}`;
const version2 = await platform.getAppVersion();
// v prefix removed
expect(version2).toEqual(prodVersion);
// @ts-ignore
WebPlatform.VERSION = `version not like semver`;
const notSemverVersion = await platform.getAppVersion();
expect(notSemverVersion).toEqual(`version not like semver`);
});
describe("pollForUpdate()", () => {
it("should return not available and call showNoUpdate when current version matches most recent version", async () => {
// @ts-ignore
WebPlatform.VERSION = prodVersion;
fetchMock.getOnce("end:/version", prodVersion);
const platform = new WebPlatform();
const showUpdate = jest.fn();
const showNoUpdate = jest.fn();
const result = await platform.pollForUpdate(showUpdate, showNoUpdate);
expect(result).toEqual({ status: UpdateCheckStatus.NotAvailable });
expect(showUpdate).not.toHaveBeenCalled();
expect(showNoUpdate).toHaveBeenCalled();
});
it("should strip v prefix from versions before comparing", async () => {
// @ts-ignore
WebPlatform.VERSION = prodVersion;
fetchMock.getOnce("end:/version", `v${prodVersion}`);
const platform = new WebPlatform();
const showUpdate = jest.fn();
const showNoUpdate = jest.fn();
const result = await platform.pollForUpdate(showUpdate, showNoUpdate);
// versions only differ by v prefix, no update
expect(result).toEqual({ status: UpdateCheckStatus.NotAvailable });
expect(showUpdate).not.toHaveBeenCalled();
expect(showNoUpdate).toHaveBeenCalled();
});
it(
"should return ready and call showUpdate when current version " + "differs from most recent version",
async () => {
// @ts-ignore
WebPlatform.VERSION = "0.0.0"; // old version
fetchMock.getOnce("end:/version", prodVersion);
const platform = new WebPlatform();
const showUpdate = jest.fn();
const showNoUpdate = jest.fn();
const result = await platform.pollForUpdate(showUpdate, showNoUpdate);
expect(result).toEqual({ status: UpdateCheckStatus.Ready });
expect(showUpdate).toHaveBeenCalledWith("0.0.0", prodVersion);
expect(showNoUpdate).not.toHaveBeenCalled();
},
);
it("should return ready without showing update when user registered in last 24", async () => {
// @ts-ignore
WebPlatform.VERSION = "0.0.0"; // old version
jest.spyOn(MatrixClientPeg, "userRegisteredWithinLastHours").mockReturnValue(true);
fetchMock.getOnce("end:/version", prodVersion);
const platform = new WebPlatform();
const showUpdate = jest.fn();
const showNoUpdate = jest.fn();
const result = await platform.pollForUpdate(showUpdate, showNoUpdate);
expect(result).toEqual({ status: UpdateCheckStatus.Ready });
expect(showUpdate).not.toHaveBeenCalled();
expect(showNoUpdate).not.toHaveBeenCalled();
});
it("should return error when version check fails", async () => {
fetchMock.getOnce("end:/version", { throws: "oups" });
const platform = new WebPlatform();
const showUpdate = jest.fn();
const showNoUpdate = jest.fn();
const result = await platform.pollForUpdate(showUpdate, showNoUpdate);
expect(result).toEqual({ status: UpdateCheckStatus.Error, detail: "Unknown Error" });
expect(showUpdate).not.toHaveBeenCalled();
expect(showNoUpdate).not.toHaveBeenCalled();
});
});
});
it("should return config from config.json", async () => {
window.location.hostname = "domain.com";
fetchMock.get(/config\.json.*/, { brand: "test" });
const platform = new WebPlatform();
await expect(platform.getConfig()).resolves.toEqual(expect.objectContaining({ brand: "test" }));
});
it("should re-render favicon when setting error status", () => {
const platform = new WebPlatform();
const spy = jest.spyOn(platform.favicon, "badge");
platform.setErrorStatus(true);
expect(spy).toHaveBeenCalledWith(expect.anything(), { bgColor: "#f00" });
});
});
@@ -0,0 +1,65 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import type { Mocked } from "jest-mock";
import type { ConsoleLogger } from "../../../src/rageshake/rageshake";
import SdkConfig from "../../../src/SdkConfig";
import "../../../src/vector/rageshakesetup";
import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions";
const RAGESHAKE_URL = "https://logs.example.org/logtome";
describe("mxSendRageshake", () => {
let prevLogger: ConsoleLogger;
beforeEach(() => {
fetchMock.mockGlobal();
SdkConfig.put({ bug_report_endpoint_url: RAGESHAKE_URL });
fetchMock.postOnce(RAGESHAKE_URL, { status: 200, body: {} });
const mockConsoleLogger = {
flush: jest.fn(),
consume: jest.fn(),
warn: jest.fn(),
} as unknown as Mocked<ConsoleLogger>;
prevLogger = global.mx_rage_logger;
mockConsoleLogger.flush.mockReturnValue("line 1\nline 2\n");
global.mx_rage_logger = mockConsoleLogger;
});
afterEach(() => {
global.mx_rage_logger = prevLogger;
jest.restoreAllMocks();
fetchMock.unmockGlobal();
SdkConfig.reset();
});
it("Does not send a rageshake if the URL is not configured", async () => {
SdkConfig.put({ bug_report_endpoint_url: undefined });
await window.mxSendRageshake("test");
expect(fetchMock).not.toHaveFetched();
});
it.each(["", " ", undefined, null])("Does not send a rageshake if text is '%s'", async (text) => {
await window.mxSendRageshake(text as string);
expect(fetchMock).not.toHaveFetched();
});
it("Sends a rageshake via URL", async () => {
await window.mxSendRageshake("Hello world");
expect(fetchMock).toHaveFetched(RAGESHAKE_URL);
});
it("Provides a rageshake locally", async () => {
SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
const urlSpy = jest.spyOn(URL, "createObjectURL");
await window.mxSendRageshake("Hello world");
expect(fetchMock).not.toHaveFetched(RAGESHAKE_URL);
expect(urlSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,122 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { getInitialScreenAfterLogin, init, onNewScreen } from "../../../src/vector/routing";
import type MatrixChat from "../../../src/components/structures/MatrixChat.tsx";
describe("onNewScreen", () => {
it("should replace history if stripping via fields", () => {
Object.defineProperty(window, "location", {
value: {
hash: "#/room/!room:server?via=abc",
replace: jest.fn(),
assign: jest.fn(),
},
writable: true,
});
onNewScreen("room/!room:server");
expect(window.location.assign).not.toHaveBeenCalled();
expect(window.location.replace).toHaveBeenCalled();
});
it("should not replace history if changing rooms", () => {
Object.defineProperty(window, "location", {
value: {
hash: "#/room/!room1:server?via=abc",
replace: jest.fn(),
assign: jest.fn(),
},
writable: true,
});
onNewScreen("room/!room2:server");
expect(window.location.assign).toHaveBeenCalled();
expect(window.location.replace).not.toHaveBeenCalled();
});
});
describe("getInitialScreenAfterLogin", () => {
beforeEach(() => {
jest.spyOn(sessionStorage.__proto__, "getItem").mockClear().mockReturnValue(null);
jest.spyOn(sessionStorage.__proto__, "setItem").mockClear();
});
const makeMockLocation = (hash = "") => {
const url = new URL("https://test.org");
url.hash = hash;
return url as unknown as Location;
};
describe("when current url has no hash", () => {
it("does not set an initial screen in session storage", () => {
getInitialScreenAfterLogin(makeMockLocation());
expect(sessionStorage.setItem).not.toHaveBeenCalled();
});
it("returns undefined when there is no initial screen in session storage", () => {
expect(getInitialScreenAfterLogin(makeMockLocation())).toBeUndefined();
});
it("returns initial screen from session storage", () => {
const screen = {
screen: "/room/!test",
};
jest.spyOn(sessionStorage.__proto__, "getItem").mockReturnValue(JSON.stringify(screen));
expect(getInitialScreenAfterLogin(makeMockLocation())).toEqual(screen);
});
});
describe("when current url has a hash", () => {
it("sets an initial screen in session storage", () => {
const hash = "/room/!test";
getInitialScreenAfterLogin(makeMockLocation(hash));
expect(sessionStorage.setItem).toHaveBeenCalledWith(
"mx_screen_after_login",
JSON.stringify({
screen: "room/!test",
params: {},
}),
);
});
it("sets an initial screen in session storage with params", () => {
const hash = "/room/!test?param=test";
getInitialScreenAfterLogin(makeMockLocation(hash));
expect(sessionStorage.setItem).toHaveBeenCalledWith(
"mx_screen_after_login",
JSON.stringify({
screen: "room/!test",
params: { param: "test" },
}),
);
});
});
});
describe("init", () => {
afterAll(() => {
// @ts-ignore
delete window.matrixChat;
});
it("should call showScreen on MatrixChat on hashchange", () => {
Object.defineProperty(window, "location", {
value: {
hash: "#/room/!room:server?via=abc",
},
});
window.matrixChat = {
showScreen: jest.fn(),
} as unknown as MatrixChat;
init();
window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(window.matrixChat.showScreen).toHaveBeenCalledWith("room/!room:server", { via: "abc" });
});
});
@@ -0,0 +1,41 @@
/*
Copyright 2020-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { parseQsFromFragment, parseQs } from "../../../src/vector/url_utils";
describe("url_utils.ts", function () {
// @ts-ignore
const location: Location = {
hash: "",
search: "",
};
it("parseQsFromFragment", function () {
location.hash = "/home?foo=bar";
expect(parseQsFromFragment(location)).toEqual({
location: "home",
params: {
foo: "bar",
},
});
});
it("parseQs", function () {
location.search = "?foo=bar";
expect(parseQs(location)).toEqual({
foo: "bar",
});
});
it("parseQs with arrays", function () {
location.search = "?via=s1&via=s2&via=s2&foo=bar";
expect(parseQs(location)).toEqual({
via: ["s1", "s2", "s2"],
foo: "bar",
});
});
});