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
27 changed files with 692 additions and 162 deletions

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