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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
65
test/unit-tests/vector/rageshakesetup-test.ts
Normal file
65
test/unit-tests/vector/rageshakesetup-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user