Allow local log downloads when a rageshake URL is not configured. (#31716)

* Add support for storing debug logs locally and allowing local downloads.

* static

* Comprehensive testing for bug report flow.

* Driveby cleanup of typography

* fix i18n

* Improvements to UX

* More testing

* update snaps

* linting

* lint

* Fix feedback

* Fix boldnewss

* fix bold

* fix heading

* Increase test coverage

* remove focus

* Don't show the FAQ depending on whether you can submit feedback.

* move reset

* fix err

* Remove unused

* update snap

* Remove text

* Bumping up that coverage

* tidy

* lint

* update snap

* Use a const

* fix imports

* Remove import in e2e test

* whoops
This commit is contained in:
Will Hunt 2026-01-20 12:29:18 +00:00 committed by GitHub
parent b7a2e8c64e
commit a15efcc6d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 692 additions and 162 deletions

View File

@ -407,6 +407,7 @@ If you run your own rageshake server to collect bug reports, the following optio
1. `bug_report_endpoint_url`: URL for where to submit rageshake logs to. Rageshakes include feedback submissions and bug reports. When
not present in the config, the app will disable all rageshake functionality. Set to `https://rageshakes.element.io/api/submit` to submit
rageshakes to us, or use your own rageshake server.
You may also set the value to `"local"` if you wish to only store logs locally, in order to download them for debugging.
2. `uisi_autorageshake_app`: If a user has enabled the "automatically send debug logs on decryption errors" flag, this option will be sent
alongside the rageshake so the rageshake server can filter them by app name. By default, this will be `element-auto-uisi`
(in contrast to other rageshakes submitted by the app, which use `element-web`).

View File

@ -0,0 +1,139 @@
/*
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 { test, expect } from "../../element-web-test";
function formDataParser(data: string, contentType: string | null): Record<string, string> {
const [, boundary] =
contentType
?.split(";")
.map((v) => v.trim())
.find((v) => v.startsWith("boundary="))
?.split("=") ?? [];
if (!boundary) {
throw Error("No boundary found in form data request");
}
const dataMap: Record<string, string> = {};
for (const dataPart of data.split(boundary).map((p) => p.trim())) {
const lines = dataPart.split("\r\n");
const fieldName = lines[0].match(/name="([^"]+)"/)?.[1];
if (!fieldName) {
continue;
}
const data = lines.slice(1, -1).join("\n").trim();
dataMap[fieldName] = data;
}
return dataMap;
}
test.describe("Rageshakes", () => {
test.describe("visible when enabled", () => {
test.use({
config: {
// Enable this just so the options show up.
bug_report_endpoint_url: "https://example.org/bug-report-place",
},
});
test("should be able to open bug report dialog via slash command", async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test room" });
await app.viewRoomByName("Test room");
const composer = app.getComposer().locator("[contenteditable]");
await composer.fill("/rageshake");
await composer.press("Enter");
await expect(page.getByRole("dialog", { name: "Submit debug logs" })).toBeVisible();
});
test("should be able to open bug report dialog via feedback dialog", async ({ page, app, user }) => {
const menu = await app.openUserMenu();
await menu.getByRole("menuitem", { name: "Feedback" }).click();
const feedbackDialog = page.getByRole("dialog", { name: "Feedback" });
await feedbackDialog.getByRole("button", { name: "debug logs" }).click();
await expect(page.getByRole("dialog", { name: "Submit debug logs" })).toBeVisible();
});
test("should be able to open bug report dialog via Settings", async ({ page, app, user }) => {
const settings = await app.settings.openUserSettings("Help & About");
await settings.getByRole("button", { name: "Submit debug logs" }).click();
// Playwright can't see the dialog when both the settings and bug report dialogs are open, so key off heading.
await expect(page.locator(".mx_BugReportDialog")).toBeVisible();
});
});
test.describe("hidden when disabled", () => {
test("should NOT be able to open bug report dialog via slash command", async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test room" });
await app.viewRoomByName("Test room");
const composer = app.getComposer().locator("[contenteditable]");
await composer.fill("/rageshake");
await composer.press("Enter");
await expect(page.getByRole("dialog", { name: "Unknown command" })).toBeVisible();
});
test("should NOT be able to open bug report dialog via feedback dialog", async ({ page, app, user }) => {
const menu = await app.openUserMenu();
await expect(menu.getByRole("menuitem", { name: "Feedback" })).not.toBeVisible();
});
test("should NOT be able to open bug report dialog via Settings", async ({ page, app, user }) => {
const settings = await app.settings.openUserSettings("Help & About");
await expect(settings.getByRole("menuitem", { name: "Submit debug logs" })).not.toBeVisible();
});
});
test.describe("via bug report endpoint", () => {
test.use({
config: {
bug_report_endpoint_url: "http://example.org/bug-report-server",
},
});
test("should be able to rageshake to a URL", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.route("http://example.org/bug-report-server", async (route, request) => {
if (request.method() !== "POST") {
throw Error("Expected POST");
}
const fields = formDataParser(request.postData(), await request.headerValue("Content-Type"));
expect(fields.text).toEqual(
"These are some notes\n\nIssue: https://github.com/element-hq/element-web/12345",
);
expect(fields.app).toEqual("element-web");
expect(fields.user_id).toEqual(user.userId);
expect(fields.device_id).toEqual(user.deviceId);
// We don't check the logs contents, but we'd like for there to be a log.
expect(fields["compressed-log"]).toBeDefined();
return route.fulfill({ json: {}, status: 200 });
});
const settings = await app.settings.openUserSettings("Help & About");
await settings.getByRole("button", { name: "Submit debug logs" }).click();
const dialog = page.locator(".mx_BugReportDialog");
await dialog
.getByRole("textbox", { name: "GitHub issue" })
.fill("https://github.com/element-hq/element-web/12345");
await dialog.getByRole("textbox", { name: "Notes" }).fill("These are some notes");
await expect(dialog).toMatchScreenshot("rageshake_via_url.png");
await dialog.getByRole("button", { name: "Send logs" }).click();
await expect(page.getByRole("heading", { name: "Logs sent" })).toBeVisible();
});
});
test.describe("via local download", () => {
test.use({
config: {
bug_report_endpoint_url: "local",
},
});
test("should be able to rageshake to local download", { tag: "@screenshot" }, async ({ page, app, user }) => {
const settings = await app.settings.openUserSettings("Help & About");
await settings.getByRole("button", { name: "Download logs" }).click();
const dialog = page.locator(".mx_BugReportDialog");
await expect(dialog).toMatchScreenshot("rageshake_locally.png");
const downloadPromise = page.waitForEvent("download");
await dialog.getByRole("button", { name: "Download logs" }).click();
const download = await downloadPromise;
await download.cancel();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -80,7 +80,7 @@ declare global {
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
interface Window {
mxSendRageshake: (text: string, withLogs?: boolean) => void;
mxSendRageshake: (text: string, withLogs?: boolean) => Promise<void>;
matrixLogger: typeof logger;
matrixChat?: MatrixChat;
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;

View File

@ -17,6 +17,12 @@ import { type ValidatedServerConfig } from "./utils/ValidatedServerConfig";
/* eslint-disable camelcase */
/* eslint @typescript-eslint/naming-convention: ["error", { "selector": "property", "format": ["snake_case"] } ] */
/**
* Bug reports are enabled but must only be locally
* downloadable.
*/
export const BugReportEndpointURLLocal = "local";
// see element-web config.md for non-developer docs
export interface IConfigOptions {
// dev note: while true that this is arbitrary JSON, it's valuable to enforce that all
@ -98,7 +104,10 @@ export interface IConfigOptions {
show_labs_settings: boolean;
features?: Record<string, boolean>; // <FeatureName, EnabledBool>
bug_report_endpoint_url?: string; // omission disables bug reporting
/**
* Bug report endpoint URL. "local" means the logs should not be uploaded.
*/
bug_report_endpoint_url?: typeof BugReportEndpointURLLocal | string; // omission disables bug reporting
uisi_autorageshake_app?: string; // defaults to "element-auto-uisi"
sentry?: {
dsn: string;

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
@ -10,7 +11,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import { Link, Text } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
@ -26,6 +27,7 @@ import { sendSentryReport } from "../../../sentry";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { getBrowserSupport } from "../../../SupportedBrowser";
import { BugReportEndpointURLLocal } from "../../../IConfigOptions";
export interface BugReportDialogProps {
onFinished: (success: boolean) => void;
@ -48,6 +50,7 @@ interface IState {
export default class BugReportDialog extends React.Component<BugReportDialogProps, IState> {
private unmounted: boolean;
private issueRef: React.RefObject<Field | null>;
private readonly isLocalOnly: boolean;
public constructor(props: BugReportDialogProps) {
super(props);
@ -65,6 +68,8 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.unmounted = false;
this.issueRef = React.createRef();
// This config is static at runtime, but may change during tests.
this.isLocalOnly = SdkConfig.get().bug_report_endpoint_url === BugReportEndpointURLLocal;
}
public componentDidMount(): void {
@ -142,6 +147,14 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.setState({ busy: true, progress: null, err: null });
this.sendProgressCallback(_t("bug_reporting|preparing_logs"));
if (this.isLocalOnly) {
// Shouldn't reach here, but throw in case we do.
this.setState({
err: _t("bug_reporting|failed_send_logs_causes|unknown_error"),
});
return;
}
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
@ -241,77 +254,77 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
(window.Modernizr && Object.values(window.Modernizr).some((support) => support === false)) ||
!getBrowserSupport()
) {
warning = (
<p>
<strong>{_t("bug_reporting|unsupported_browser")}</strong>
</p>
);
warning = <Text weight="semibold">{_t("bug_reporting|unsupported_browser")}</Text>;
}
return (
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.onCancel}
title={_t("bug_reporting|submit_debug_logs")}
title={this.isLocalOnly ? _t("bug_reporting|download_logs") : _t("bug_reporting|submit_debug_logs")}
contentId="mx_Dialog_content"
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{warning}
<p>{_t("bug_reporting|description")}</p>
<p>
<strong>
{_t(
"bug_reporting|before_submitting",
{},
{
a: (sub) => (
<a
target="_blank"
href={SdkConfig.get().feedback.new_issue_url}
rel="noreferrer noopener"
>
{sub}
</a>
),
},
)}
</strong>
</p>
<Text>{_t("bug_reporting|description")}</Text>
{this.isLocalOnly ? (
<>{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}</>
) : (
<>
<Text weight="semibold">
{_t(
"bug_reporting|before_submitting",
{},
{
a: (sub) => (
<Link target="_blank" href={SdkConfig.get().feedback.new_issue_url}>
{sub}
</Link>
),
},
)}
</Text>
<div className="mx_BugReportDialog_download">
<AccessibleButton onClick={this.onDownload} kind="link" disabled={this.state.downloadBusy}>
{_t("bug_reporting|download_logs")}
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
</div>
<div className="mx_BugReportDialog_download">
<AccessibleButton
onClick={this.onDownload}
kind="link"
disabled={this.state.downloadBusy}
>
{_t("bug_reporting|download_logs")}
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
</div>
<Field
type="text"
className="mx_BugReportDialog_field_input"
label={_t("bug_reporting|github_issue")}
onChange={this.onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/element-web/issues/..."
ref={this.issueRef}
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("bug_reporting|textarea_label")}
rows={5}
onChange={this.onTextChange}
value={this.state.text}
placeholder={_t("bug_reporting|additional_context")}
/>
{progress}
{error}
<Field
type="text"
className="mx_BugReportDialog_field_input"
label={_t("bug_reporting|github_issue")}
onChange={this.onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/element-web/issues/..."
ref={this.issueRef}
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("bug_reporting|textarea_label")}
rows={5}
onChange={this.onTextChange}
value={this.state.text}
placeholder={_t("bug_reporting|additional_context")}
/>
{progress}
{error}
</>
)}
</div>
<DialogButtons
primaryButton={_t("bug_reporting|send_logs")}
onPrimaryButtonClick={this.onSubmit}
primaryButton={this.isLocalOnly ? _t("bug_reporting|download_logs") : _t("bug_reporting|send_logs")}
onPrimaryButtonClick={this.isLocalOnly ? this.onDownload : this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
disabled={this.isLocalOnly ? this.state.downloadBusy : this.state.busy}
/>
</BaseDialog>
);

View File

@ -45,6 +45,7 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const onFinished = (sendFeedback: boolean): void => {
if (hasFeedback && sendFeedback) {
const label = props.feature ? `${props.feature}-feedback` : "feedback";
// TODO: Handle rejection.
submitFeedback(label, comment, canContact);
Modal.createDialog(InfoDialog, {

View File

@ -38,7 +38,7 @@ const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
const sendFeedback = async (ok: boolean): Promise<void> => {
if (!ok) return onFinished(false);
// TODO: Handle rejection.
submitFeedback(rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);

View File

@ -0,0 +1,43 @@
/*
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 React, { useCallback } from "react";
import { Button } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import BugReportDialog, { type BugReportDialogProps } from "../dialogs/BugReportDialog";
import { BugReportEndpointURLLocal } from "../../../IConfigOptions";
/**
* Renders a button to open the BugReportDialog *if* the configuration
* supports it.
*/
export function BugReportDialogButton({
label,
error,
}: Pick<BugReportDialogProps, "label" | "error">): React.ReactElement | null {
const bugReportUrl = SdkConfig.get().bug_report_endpoint_url;
const onClick = useCallback(() => {
Modal.createDialog(BugReportDialog, {
label,
error,
});
}, [label, error]);
if (!bugReportUrl) {
return null;
}
return (
<Button kind="secondary" size="sm" onClick={onClick}>
{bugReportUrl === BugReportEndpointURLLocal
? _t("bug_reporting|download_logs")
: _t("bug_reporting|submit_debug_logs")}
</Button>
);
}

View File

@ -12,10 +12,9 @@ import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PlatformPeg from "../../../PlatformPeg";
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton from "./AccessibleButton";
import { BugReportDialogButton } from "./BugReportDialogButton";
interface Props {
children: ReactNode;
@ -60,13 +59,6 @@ export default class ErrorBoundary extends React.PureComponent<Props, IState> {
});
};
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {
label: "react-soft-crash",
error: this.state.error,
});
};
public render(): ReactNode {
if (this.state.error) {
const newIssueUrl = SdkConfig.get().feedback.new_issue_url;
@ -95,9 +87,7 @@ export default class ErrorBoundary extends React.PureComponent<Props, IState> {
&nbsp;
{_t("bug_reporting|description")}
</p>
<AccessibleButton onClick={this.onBugReport} kind="primary">
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
<BugReportDialogButton error={this.state.error} label="react-soft-crash" />
</React.Fragment>
);
}

View File

@ -12,12 +12,11 @@ import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import ViewSource from "../../structures/ViewSource";
import { type Layout } from "../../../settings/enums/Layout";
import { BugReportDialogButton } from "../elements/BugReportDialogButton";
interface IProps {
mxEvent: MatrixEvent;
@ -42,13 +41,6 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
return { error };
}
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {
label: "react-soft-crash-tile",
error: this.state.error,
});
};
private onViewSource = (): void => {
Modal.createDialog(
ViewSource,
@ -69,18 +61,6 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
mx_EventTile_tileError: true,
};
let submitLogsButton;
if (SdkConfig.get().bug_report_endpoint_url) {
submitLogsButton = (
<>
&nbsp;
<AccessibleButton kind="link" onClick={this.onBugReport}>
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
</>
);
}
let viewSourceButton;
if (mxEvent && SettingsStore.getValue("developerMode")) {
viewSourceButton = (
@ -99,7 +79,7 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
<span>
{_t("timeline|error_rendering_message")}
{mxEvent && ` (${mxEvent.getType()})`}
{submitLogsButton}
<BugReportDialogButton error={this.state.error} label="react-tile-soft-crash" />
{viewSourceButton}
</span>
</div>

View File

@ -13,16 +13,15 @@ import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import AccessibleButton from "../../../elements/AccessibleButton";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import Modal from "../../../../../Modal";
import PlatformPeg from "../../../../../PlatformPeg";
import UpdateCheckButton from "../../UpdateCheckButton";
import BugReportDialog from "../../../dialogs/BugReportDialog";
import CopyableText from "../../../elements/CopyableText";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
import ExternalLink from "../../../elements/ExternalLink";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { BugReportDialogButton } from "../../../elements/BugReportDialogButton";
interface IState {
appVersion: string | null;
@ -80,10 +79,6 @@ export default class HelpUserSettingsTab extends React.Component<EmptyObject, IS
});
};
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {});
};
private renderLegal(): ReactNode {
const tocLinks = SdkConfig.get().terms_and_conditions_links;
if (!tocLinks) return null;
@ -231,9 +226,7 @@ export default class HelpUserSettingsTab extends React.Component<EmptyObject, IS
</>
}
>
<AccessibleButton onClick={this.onBugReport} kind="primary_outline">
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
<BugReportDialogButton />
<SettingsSubsectionText>
{_t(
"bug_reporting|matrix_security_issue",

View File

@ -44,6 +44,7 @@ import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-t
import SdkConfig from "../SdkConfig.ts";
import DMRoomMap from "../utils/DMRoomMap.ts";
import { type WidgetMessaging, WidgetMessagingEvent } from "../stores/widgets/WidgetMessaging.ts";
import { BugReportEndpointURLLocal } from "../IConfigOptions.ts";
const TIMEOUT_MS = 16000;
const logger = rootLogger.getChild("models/Call");
@ -769,7 +770,7 @@ export class ElementCall extends Call {
}
const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url");
if (rageshakeSubmitUrl) {
if (rageshakeSubmitUrl && rageshakeSubmitUrl !== BugReportEndpointURLLocal) {
params.append("rageshakeSubmitUrl", rageshakeSubmitUrl);
}

View File

@ -20,6 +20,8 @@ import * as rageshake from "./rageshake";
import SettingsStore from "../settings/SettingsStore";
import SdkConfig from "../SdkConfig";
import { getServerVersionFromFederationApi } from "../components/views/dialogs/devtools/ServerInfo";
import type * as Tar from "tar-js";
import { BugReportEndpointURLLocal } from "../IConfigOptions";
interface IOpts {
labels?: string[];
@ -342,7 +344,7 @@ async function collectLogs(
* the server does not respond with an expected body format.
*/
export default async function sendBugReport(bugReportEndpoint?: string, opts: IOpts = {}): Promise<string> {
if (!bugReportEndpoint) {
if (!bugReportEndpoint || bugReportEndpoint === BugReportEndpointURLLocal) {
throw new Error("No bug report endpoint has been set.");
}
@ -354,20 +356,12 @@ export default async function sendBugReport(bugReportEndpoint?: string, opts: IO
}
/**
* Downloads the files from a bug report. This is the same as sendBugReport,
* but instead causes the browser to download the files locally.
* Loads a bug report into a tarball.
*
* @param {object} opts optional dictionary of options
*
* @param {string} opts.userText Any additional user input.
*
* @param {boolean} opts.sendLogs True to send logs
*
* @param {function(string)} opts.progressCallback Callback to call with progress updates
*
* @return {Promise} Resolved when the bug report is downloaded (or started).
* @param opts optional dictionary of options
* @return Resolves with a Tarball object.
*/
export async function downloadBugReport(opts: IOpts = {}): Promise<void> {
export async function loadBugReport(opts: IOpts = {}): Promise<Tar> {
const Tar = (await import("tar-js")).default;
const progressCallback = opts.progressCallback || ((): void => {});
const body = await collectBugReport(opts, false);
@ -391,7 +385,18 @@ export async function downloadBugReport(opts: IOpts = {}): Promise<void> {
}
}
tape.append("issue.txt", metadata);
return tape;
}
/**
* Downloads the files from a bug report. This is the same as sendBugReport,
* but instead causes the browser to download the files locally.
*
* @param opts optional dictionary of options
* @return Resolved when the bug report is downloaded (or started).
*/
export async function downloadBugReport(opts: IOpts = {}): Promise<void> {
const tape = await loadBugReport(opts);
// We have to create a new anchor to download if we want a filename. Otherwise we could
// just use window.open.
const dl = document.createElement("a");
@ -417,6 +422,10 @@ export async function submitFeedback(
canContact = false,
extraData: Record<string, any> = {},
): Promise<void> {
const bugReportEndpointUrl = SdkConfig.get().bug_report_endpoint_url;
if (!bugReportEndpointUrl || bugReportEndpointUrl === BugReportEndpointURLLocal) {
throw new Error("Bug report URL is not set or local");
}
let version: string | undefined;
try {
version = await PlatformPeg.get()?.getAppVersion();
@ -436,11 +445,7 @@ export async function submitFeedback(
body.append(k, JSON.stringify(extraData[k]));
}
const bugReportEndpointUrl = SdkConfig.get().bug_report_endpoint_url;
if (bugReportEndpointUrl) {
await submitReport(bugReportEndpointUrl, body, () => {});
}
await submitReport(bugReportEndpointUrl, body, () => {});
}
/**

View File

@ -405,15 +405,14 @@ export const SETTINGS: Settings = {
</p>
</>
),
faq: () =>
SdkConfig.get().bug_report_endpoint_url && (
<>
<h4>{_t("labs|video_rooms_faq1_question")}</h4>
<p>{_t("labs|video_rooms_faq1_answer")}</p>
<h4>{_t("labs|video_rooms_faq2_question")}</h4>
<p>{_t("labs|video_rooms_faq2_answer")}</p>
</>
),
faq: () => (
<>
<h4>{_t("labs|video_rooms_faq1_question")}</h4>
<p>{_t("labs|video_rooms_faq1_answer")}</p>
<h4>{_t("labs|video_rooms_faq2_question")}</h4>
<p>{_t("labs|video_rooms_faq2_answer")}</p>
</>
),
feedbackLabel: "video-room-feedback",
feedbackSubheading: _td("labs|video_rooms_feedbackSubheading"),
// eslint-disable-next-line @typescript-eslint/no-require-imports

View File

@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { BugReportEndpointURLLocal } from "../IConfigOptions";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { UIFeature } from "../settings/UIFeature";
export function shouldShowFeedback(): boolean {
return !!SdkConfig.get().bug_report_endpoint_url && SettingsStore.getValue(UIFeature.Feedback);
const url = SdkConfig.get().bug_report_endpoint_url;
return !!url && url !== BugReportEndpointURLLocal && SettingsStore.getValue(UIFeature.Feedback);
}

View File

@ -22,7 +22,8 @@ import { logger } from "matrix-js-sdk/src/logger";
import * as rageshake from "../rageshake/rageshake";
import SdkConfig from "../SdkConfig";
import sendBugReport from "../rageshake/submit-rageshake";
import sendBugReport, { loadBugReport } from "../rageshake/submit-rageshake";
import { BugReportEndpointURLLocal } from "../IConfigOptions";
export function initRageshake(): Promise<void> {
// we manually check persistence for rageshakes ourselves
@ -54,28 +55,40 @@ export function initRageshakeStore(): Promise<void> {
return rageshake.tryInitStorage();
}
window.mxSendRageshake = function (text: string, withLogs?: boolean): void {
window.mxSendRageshake = async function (text: string, withLogs = true): Promise<void> {
const url = SdkConfig.get().bug_report_endpoint_url;
if (!url) {
logger.error("Cannot send a rageshake - no bug_report_endpoint_url configured");
return;
}
if (withLogs === undefined) withLogs = true;
if (!text || !text.trim()) {
logger.error("Cannot send a rageshake without a message - please tell us what went wrong");
return;
}
sendBugReport(url, {
userText: text,
sendLogs: withLogs,
progressCallback: logger.log.bind(console),
}).then(
() => {
if (url === BugReportEndpointURLLocal) {
try {
const tape = await loadBugReport({
userText: text,
sendLogs: withLogs,
progressCallback: logger.log.bind(console),
});
const blob = new Blob([new Uint8Array(tape.out)], { type: "application/gzip" });
const url = URL.createObjectURL(blob);
logger.log(`Your logs are available at ${url}`);
} catch (err) {
logger.error("Failed to load bug report", err);
}
} else {
try {
await sendBugReport(url, {
userText: text,
sendLogs: withLogs,
progressCallback: logger.log.bind(console),
});
logger.log("Bug report sent!");
},
(err) => {
logger.error(err);
},
);
} catch (err) {
logger.error("Failed to send bug report", err);
}
}
};

View File

@ -17,6 +17,7 @@ import BugReportDialog, {
import SdkConfig from "../../../../../src/SdkConfig";
import { type ConsoleLogger } from "../../../../../src/rageshake/rageshake";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { BugReportEndpointURLLocal } from "../../../../../src/IConfigOptions";
const BUG_REPORT_URL = "https://example.org/submit";
@ -69,7 +70,7 @@ describe("BugReportDialog", () => {
it("can submit a bug report", async () => {
const { getByLabelText, getByText } = renderComponent();
fetchMock.postOnce(BUG_REPORT_URL, { report_url: "https://exmaple.org/report/url" });
fetchMock.postOnce(BUG_REPORT_URL, { report_url: "https://example.org/report/url" });
await userEvent.type(getByLabelText("GitHub issue"), "https://example.org/some/issue");
await userEvent.type(getByLabelText("Notes"), "Additional text");
await userEvent.click(getByText("Send logs"));
@ -78,6 +79,14 @@ describe("BugReportDialog", () => {
expect(fetchMock).toHaveFetched(BUG_REPORT_URL);
});
it("renders when the config only allows local downloads", async () => {
SdkConfig.put({
bug_report_endpoint_url: BugReportEndpointURLLocal,
});
const { container } = renderComponent();
expect(container).toMatchSnapshot("local-bug-reporter");
});
it.each([
{
errcode: undefined,

View File

@ -0,0 +1,77 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`BugReportDialog renders when the config only allows local downloads: local-bug-reporter 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_BugReportDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Download logs
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Reminder: Your browser is unsupported, so your experience may be unpredictable.
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.
</p>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Download logs
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;

View File

@ -0,0 +1,53 @@
/*
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 { render } from "jest-matrix-react";
import React, { type ComponentProps } from "react";
import { afterEach } from "node:test";
import userEvent from "@testing-library/user-event";
import { BugReportDialogButton } from "../../../../../src/components/views/elements/BugReportDialogButton";
import SdkConfig from "../../../../../src/SdkConfig";
import Modal from "../../../../../src/Modal";
import BugReportDialog from "../../../../../src/components/views/dialogs/BugReportDialog";
import { BugReportEndpointURLLocal } from "../../../../../src/IConfigOptions";
describe("<BugReportDialogButton />", () => {
const getComponent = (props: ComponentProps<typeof BugReportDialogButton> = {}) =>
render(<BugReportDialogButton {...props} />);
afterEach(() => {
SdkConfig.reset();
jest.restoreAllMocks();
});
it("renders nothing if the bug reporter is disabled", () => {
SdkConfig.put({ bug_report_endpoint_url: undefined });
const { container } = getComponent({});
expect(container).toBeEmptyDOMElement();
});
it("renders 'submit' label if a URL is configured", () => {
SdkConfig.put({ bug_report_endpoint_url: "https://example.org" });
const { container } = getComponent({});
expect(container).toMatchSnapshot();
});
it("renders 'download' label if 'loca' is configured", () => {
SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
const { container } = getComponent({});
expect(container).toMatchSnapshot();
});
it("passes through props to dialog", async () => {
SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
const spy = jest.spyOn(Modal, "createDialog");
const { getByRole } = getComponent({ label: "a label", error: "an error" });
await userEvent.click(getByRole("button"));
expect(spy).toHaveBeenCalledWith(BugReportDialog, { error: "an error", label: "a label" });
});
});

View File

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<BugReportDialogButton /> renders 'download' label if 'loca' is configured 1`] = `
<div>
<button
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
Download logs
</button>
</div>
`;
exports[`<BugReportDialogButton /> renders 'submit' label if a URL is configured 1`] = `
<div>
<button
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
Submit debug logs
</button>
</div>
`;

View File

@ -66,7 +66,20 @@ exports[`<LabsUserSettingsTab /> renders settings marked as beta as beta cards 1
</div>
<div
class="mx_BetaCard_faq"
/>
>
<h4>
How can I create a video room?
</h4>
<p>
Use the “+” button in the room section of the left panel.
</p>
<h4>
Can I use text chat alongside the video call?
</h4>
<p>
Yes, the chat timeline is displayed alongside the video.
</p>
</div>
</div>
<div
class="mx_BetaCard_columns_image_wrapper"

View File

@ -49,6 +49,7 @@ import { type SettingKey } from "../../../src/settings/Settings.tsx";
import SdkConfig from "../../../src/SdkConfig.ts";
import DMRoomMap from "../../../src/utils/DMRoomMap.ts";
import { WidgetMessagingEvent, type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging.ts";
import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions.ts";
const { enabledSettings } = enableCalls();
@ -494,12 +495,12 @@ describe("ElementCall", () => {
beforeEach(() => {
jest.useFakeTimers();
({ client, room, alice, roomSession } = setUpClientRoomAndStores());
SdkConfig.reset();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
SdkConfig.reset();
cleanUpClientRoomAndStores(client, room);
});
@ -699,6 +700,24 @@ describe("ElementCall", () => {
SettingsStore.getValue = originalGetValue;
});
it.each([
[undefined, null],
[BugReportEndpointURLLocal, null],
["other-value", "other-value"],
])("passes rageshake URL through widget URL", async (configSetting, expectedValue) => {
// Test with the preference set to false
SdkConfig.put({
bug_report_endpoint_url: configSetting,
});
ElementCall.create(room);
const call1 = Call.get(room);
if (!(call1 instanceof ElementCall)) throw new Error("Failed to create call");
const urlParams1 = new URLSearchParams(new URL(call1.widget.url).hash.slice(1));
expect(urlParams1.get("rageshakeSubmitUrl")).toBe(expectedValue);
call1.destroy();
});
it("passes analyticsID and posthog params through widget URL", async () => {
SdkConfig.put({
posthog: {

View File

@ -18,11 +18,13 @@ import {
import fetchMock from "@fetch-mock/jest";
import { getMockClientWithEventEmitter, mockClientMethodsCrypto, mockPlatformPeg } from "../test-utils";
import { collectBugReport } from "../../src/rageshake/submit-rageshake";
import { collectBugReport, downloadBugReport, submitFeedback } from "../../src/rageshake/submit-rageshake";
import SettingsStore from "../../src/settings/SettingsStore";
import { type ConsoleLogger } from "../../src/rageshake/rageshake";
import { type FeatureSettingKey, type SettingKey } from "../../src/settings/Settings.tsx";
import { SettingLevel } from "../../src/settings/SettingLevel.ts";
import SdkConfig from "../../src/SdkConfig.ts";
import { BugReportEndpointURLLocal } from "../../src/IConfigOptions.ts";
describe("Rageshakes", () => {
let mockClient: Mocked<MatrixClient>;
@ -53,6 +55,10 @@ describe("Rageshakes", () => {
jest.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("Basic Information", () => {
it("should include app version", async () => {
mockPlatformPeg({ getAppVersion: jest.fn().mockReturnValue("1.11.58") });
@ -493,7 +499,7 @@ describe("Rageshakes", () => {
expect(settingsData.showHiddenEventsInTimeline).toEqual(true);
});
it("should collect logs", async () => {
it("should collect logs for collectBugReport", async () => {
const mockConsoleLogger = {
flush: jest.fn(),
consume: jest.fn(),
@ -511,6 +517,37 @@ describe("Rageshakes", () => {
}
});
it("should collect logs for downloadBugReport", async () => {
const mockConsoleLogger = {
flush: jest.fn(),
consume: jest.fn(),
warn: jest.fn(),
} as unknown as Mocked<ConsoleLogger>;
mockConsoleLogger.flush.mockReturnValue("line 1\nline 2\n");
const prevLogger = global.mx_rage_logger;
global.mx_rage_logger = mockConsoleLogger;
const mockElement = {
href: "",
download: "",
click: jest.fn(),
};
jest.spyOn(document, "createElement").mockReturnValue(mockElement as any);
jest.spyOn(document, "body", "get").mockReturnValue({
appendChild: jest.fn(),
removeChild: jest.fn(),
} as any);
try {
await downloadBugReport({ sendLogs: true });
} finally {
global.mx_rage_logger = prevLogger;
}
expect(document.createElement).toHaveBeenCalledWith("a");
expect(mockElement.href).toMatch(/^data:application\/octet-stream;base64,.+/);
expect(mockElement.download).toEqual("rageshake.tar");
expect(mockElement.click).toHaveBeenCalledWith();
});
it("should notify progress", () => {
const progressCallback = jest.fn();
@ -518,4 +555,22 @@ describe("Rageshakes", () => {
expect(progressCallback).toHaveBeenCalled();
});
describe("submitFeedback", () => {
afterEach(() => {
SdkConfig.reset();
});
it("fails if the URL is not defined", async () => {
SdkConfig.put({ bug_report_endpoint_url: undefined });
await expect(() => submitFeedback("label", "comment")).rejects.toThrow(
"Bug report URL is not set or local",
);
});
it("fails if the URL is 'local'", async () => {
SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
await expect(() => submitFeedback("label", "comment")).rejects.toThrow(
"Bug report URL is not set or local",
);
});
});
});

View File

@ -6,33 +6,54 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import SdkConfig from "../../../src/SdkConfig";
import { shouldShowFeedback } from "../../../src/utils/Feedback";
import SettingsStore from "../../../src/settings/SettingsStore";
import { UIFeature } from "../../../src/settings/UIFeature";
import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions";
jest.mock("../../../src/SdkConfig");
jest.mock("../../../src/settings/SettingsStore");
const realGetValue = SettingsStore.getValue;
describe("shouldShowFeedback", () => {
afterEach(() => {
SdkConfig.reset();
jest.restoreAllMocks();
});
it("should return false if bug_report_endpoint_url is falsey", () => {
mocked(SdkConfig).get.mockReturnValue({
bug_report_endpoint_url: null,
SdkConfig.put({
bug_report_endpoint_url: undefined,
});
expect(shouldShowFeedback()).toBeFalsy();
expect(shouldShowFeedback()).toEqual(false);
});
it("should return false if bug_report_endpoint_url is 'test'", () => {
SdkConfig.put({
bug_report_endpoint_url: BugReportEndpointURLLocal,
});
expect(shouldShowFeedback()).toEqual(false);
});
it("should return false if UIFeature.Feedback is disabled", () => {
mocked(SettingsStore).getValue.mockReturnValue(false);
expect(shouldShowFeedback()).toBeFalsy();
jest.spyOn(SettingsStore, "getValue").mockImplementation((key, ...params) => {
if (key === UIFeature.Feedback) {
return false;
}
return realGetValue(key, ...params);
});
expect(shouldShowFeedback()).toEqual(false);
});
it("should return true if bug_report_endpoint_url is set and UIFeature.Feedback is true", () => {
mocked(SdkConfig).get.mockReturnValue({
SdkConfig.put({
bug_report_endpoint_url: "https://rageshake.server",
});
mocked(SettingsStore).getValue.mockReturnValue(true);
expect(shouldShowFeedback()).toBeTruthy();
jest.spyOn(SettingsStore, "getValue").mockImplementation((key, ...params) => {
if (key === UIFeature.Feedback) {
return true;
}
return realGetValue(key, ...params);
});
expect(shouldShowFeedback()).toEqual(true);
});
});

View File

@ -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);
});
});