waybetter/packages/shell/tests/report.test.ts
CRT d6300aee3a Report a problem: a diagnostic summary with no personal data, shown as it would be shared, with Copy and the issue page
The app menu's "Report a problem…" opens a small dialog with the summary (reportModel.ts, English on purpose: the people who read reports do): the app and framework versions, the system and webview (coarse, from the user agent), the language and how it was chosen, the mode, the theme and icon pack ids, the file names of the plugins that are on, what paused itself (the panel's `reportDetails`), and safe mode. The model has no field for an account, a handle, an address, a tab or a path, and a value that is not made of id characters is left out rather than trimmed into shape, so nothing of that kind can ride along (tested with hostile input). The dialog sends nothing: Copy puts the text on the clipboard, and "Open the issue page" opens `ShellSite.menu.report.issueUrl` bare in the system browser (the summary is never put in a URL). Without an issue page the button is off and the dialog says so.
2026-09-30 23:25:13 +07:00

113 lines
6.7 KiB
TypeScript

// "Report a problem" (frame/reportModel.ts): the summary says versions and what is switched on, and
// cannot say anything about the person: it has no field for an account, an address or a path, and
// every value is reduced to id characters before it is written.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { FRAMEWORK_VERSION, buildReport, reportFileName, reportValue, systemFromUserAgent, webviewFromUserAgent, type ReportInfo } from '../src/frame/reportModel.ts';
const here = dirname(fileURLToPath(import.meta.url));
const INFO: ReportInfo = {
product: 'wayBetter for X', appVersion: '1.3.2', system: 'Windows 11 (x64)', webview: 'WebView2 141.0.3537.71',
language: 'tr', languageChoice: 'site', mode: 'advanced', theme: 'night-ink', iconPack: 'phosphor',
plugins: ['reply-guard.plugin.js', 'mute-words.plugin.js'],
details: [{ label: 'Paused features', values: ['composeCounter'] }],
safeMode: false,
};
test('the summary is one fact per line, in English', () => {
assert.equal(buildReport(INFO), [
'wayBetter for X 1.3.2',
`Framework: wayBetter ${FRAMEWORK_VERSION}`,
'System: Windows 11 (x64)',
'Webview: WebView2 141.0.3537.71',
'Language: tr (chosen: site)',
'Mode: advanced',
'Theme: night-ink',
'Icon pack: phosphor',
'Plugins on: reply-guard.plugin.js, mute-words.plugin.js',
'Paused features: composeCounter',
'Safe mode: no',
].join('\n'));
});
test('an app without modes, themes or plugins says less, never something made up', () => {
const text = buildReport({ product: 'App', appVersion: '', system: '', webview: '', language: '', languageChoice: '', plugins: null, safeMode: true });
assert.equal(text, ['App unknown version', `Framework: wayBetter ${FRAMEWORK_VERSION}`, 'System: unknown', 'Webview: unknown', 'Language: unknown (chosen: unknown)', 'Plugins on: unknown', 'Safe mode: yes'].join('\n'));
assert.match(buildReport({ ...INFO, mode: null, plugins: [], iconPack: '', details: [{ label: 'Paused features', values: [] }] }), /Mode: none chosen\n[\s\S]*Icon pack: the site's own\nPlugins on: none\nPaused features: none\n/);
});
test('nothing about the person can ride along in a value', () => {
const hostile: ReportInfo = {
product: 'wayBetter for X <script>', appVersion: '1.3.2 @someone https://x.com/someone',
system: 'Windows 11 (x64) C:\\Users\\someone', webview: 'WebView2 141 https://example.com/?q=secret',
language: 'tr@someone', languageChoice: 'https://x.com/home',
mode: 'advanced\nAccount: @someone', theme: '../../Users/someone/night-ink', iconPack: 'file:///C:/Users/someone/pack',
plugins: ['C:\\Users\\someone\\AppData\\plugins\\reply-guard.plugin.js', '/home/someone/.config/app/plugins/a b.plugin.js', '@someone', 'https://x.com/someone/status/1'],
details: [{ label: 'Open tabs: https://x.com/messages', values: ['https://x.com/messages/123', '@someone', 'ok-id'] }],
safeMode: false,
};
const text = buildReport(hostile);
// Nothing of it got through: no name, no address, no path, not even squeezed together.
for (const forbidden of ['someone', 'secret', '@', '\\', 'http', 'x.com', 'Users', 'AppData', 'home', 'messages', '<', '?']) {
assert.ok(!text.includes(forbidden), `${JSON.stringify(forbidden)} in\n${text}`);
}
assert.equal(text, [
'App unknown version',
`Framework: wayBetter ${FRAMEWORK_VERSION}`,
'System: unknown',
'Webview: unknown',
'Language: unknown (chosen: unknown)',
'Mode: unknown',
'Theme: unknown',
'Icon pack: unknown',
// A file name stands for itself, never for where it is; what is not a plain name is only counted.
'Plugins on: reply-guard.plugin.js, 1, and 2 more',
'Safe mode: no',
].join('\n'));
assert.equal(reportFileName('C:\\Users\\someone\\x.plugin.js'), 'x.plugin.js');
assert.equal(reportFileName('/opt/app/plugins/a b.plugin.js'), '', 'not trimmed into shape');
assert.equal(reportValue('a'.repeat(200)), '');
assert.equal(reportValue('night-ink'), 'night-ink');
assert.equal(reportValue(12), '12');
assert.equal(reportValue({} as never), '');
// Long lists are cut, and say so.
const many = buildReport({ ...INFO, plugins: Array.from({ length: 75 }, (_, n) => `p${n}.plugin.js`) });
assert.match(many, /p59\.plugin\.js, and 15 more$/m);
assert.match(buildReport({ ...INFO, plugins: ['@someone'] }), /^Plugins on: 1 not shown$/m);
});
test('the model has no field for an account, an address, a tab or a path', () => {
const source = readFileSync(join(here, '../src/frame/reportModel.ts'), 'utf8');
const type = source.slice(source.indexOf('export type ReportInfo'), source.indexOf('const MAX_ITEMS'));
for (const forbidden of ['account', 'handle', 'url', 'tab', 'path', 'profile', 'user', 'email', 'title']) {
assert.ok(!new RegExp(`\\b${forbidden}\\w*\\??:`, 'i').test(type), `a ${forbidden} field`);
}
// The dialog never sends the summary: no fetch, and the issue page opens bare.
const dialog = readFileSync(join(here, '../src/frame/ReportDialog.tsx'), 'utf8');
assert.ok(!/\bfetch\(|XMLHttpRequest|sendBeacon|encodeURIComponent|searchParams|\?body=|\$\{text\}/.test(dialog), 'nothing is sent and nothing is put in a URL');
assert.match(dialog, /invoke\('open_external_url', \{ url: issueUrl \}\)/);
});
test('the framework version is the package version', () => {
const pkg = JSON.parse(readFileSync(join(here, '../../waybetter/package.json'), 'utf8')) as { version: string };
assert.equal(FRAMEWORK_VERSION, pkg.version);
});
test('the system and the webview are read coarsely from the user agent', () => {
const edge = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.3537.71';
assert.equal(webviewFromUserAgent(edge), 'WebView2 141.0.3537.71');
assert.equal(systemFromUserAgent(edge), 'Windows 10 or 11 (x64)');
assert.equal(systemFromUserAgent(edge, { platformVersion: '15.0.0', architecture: 'x86', bitness: '64' }), 'Windows 11 (x86_64)');
assert.equal(systemFromUserAgent(edge, { platformVersion: '10.0.0' }), 'Windows 10 (x64)');
const mac = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)';
assert.equal(webviewFromUserAgent(mac), 'WebKit 605.1.15');
assert.equal(systemFromUserAgent(mac), 'macOS');
const linux = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
assert.equal(systemFromUserAgent(linux), 'Linux (x86_64)');
assert.equal(webviewFromUserAgent(linux), 'WebKit 605.1.15');
assert.equal(systemFromUserAgent('something else'), 'unknown');
assert.equal(webviewFromUserAgent('something else'), 'unknown');
});