Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
//! Tests here mostly interact with the [test UI](crate::ui::test). As such, most tests read a bit
//! more like integration tests than unit tests, testing the behavior of the application as a
//! whole.
use super::*;
use crate::config::{test::MINIDUMP_PRUNE_SAVE_COUNT, Config};
use crate::settings::Settings;
use crate::std::{
ffi::OsString,
fs::{MockFS, MockFiles},
io::ErrorKind,
mock,
process::Command,
sync::{
atomic::{AtomicUsize, Ordering::Relaxed},
Arc,
},
};
use crate::ui::{self, test::model, ui_impl::Interact};
/// A simple thread-safe counter which can be used in tests to mark that certain code paths were
/// hit.
#[derive(Clone, Default)]
pub struct Counter(Arc<AtomicUsize>);
impl Counter {
/// Create a new zero counter.
pub fn new() -> Self {
Self::default()
}
/// Increment the counter.
pub fn inc(&self) {
self.0.fetch_add(1, Relaxed);
}
/// Get the current count.
pub fn count(&self) -> usize {
self.0.load(Relaxed)
}
/// Assert that the current count is 1.
pub fn assert_one(&self) {
assert_eq!(self.count(), 1);
}
}
/// Fluent wraps arguments with the unicode BiDi characters.
struct FluentArg<T>(T);
impl<T: std::fmt::Display> std::fmt::Display for FluentArg<T> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
use crate::std::fmt::Write;
f.write_char('\u{2068}')?;
self.0.fmt(f)?;
f.write_char('\u{2069}')
}
}
/// Run a gui and interaction on separate threads.
fn gui_interact<G, I, R>(gui: G, interact: I) -> R
where
G: FnOnce() -> R,
I: FnOnce(Interact) + Send + 'static,
{
let i = Interact::hook();
let handle = {
let i = i.clone();
::std::thread::spawn(move || {
i.wait_for_ready();
interact(i);
})
};
let ret = gui();
// In case the gui failed before launching.
i.cancel();
// If the gui failed, it's possible the interact thread hit a panic. However we can't check
// whether `R` is in a failure state, so we ignore the result of joining the thread. If there
// is an error, a backtrace will show the thread's panic message.
let _ = handle.join();
ret
}
const MOCK_MINIDUMP_EXTRA: &str = r#"{
"Vendor": "FooCorp",
"ProductName": "Bar",
"ReleaseChannel": "release",
"BuildID": "1234",
"StackTraces": {
"status": "OK"
},
"Version": "100.0",
"ServerURL": "https://reports.example.com",
"TelemetryServerURL": "https://telemetry.example.com",
"TelemetryClientId": "telemetry_client",
"TelemetryProfileGroupId": "telemetry_profile_group",
"TelemetrySessionId": "telemetry_session",
"SomeNestedJson": { "foo": "bar" },
}"#;
// Actual content doesn't matter, aside from the hash that is generated.
const MOCK_MINIDUMP_FILE: &[u8] = &[1, 2, 3, 4];
const MOCK_MINIDUMP_SHA256: &str =
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a";
macro_rules! current_date {
() => {
"2004-11-09"
};
}
const MOCK_CURRENT_DATE: &str = current_date!();
const MOCK_CURRENT_TIME: &str = concat!(current_date!(), "T12:34:56.000Z");
const MOCK_PING_UUID: uuid::Uuid = uuid::Uuid::nil();
const MOCK_REMOTE_CRASH_ID: &str = "8cbb847c-def2-4f68-be9e-000000000000";
fn current_datetime() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
MOCK_CURRENT_TIME,
&time::format_description::well_known::Iso8601::DEFAULT,
)
.unwrap()
}
fn current_unix_time() -> i64 {
current_datetime().unix_timestamp()
}
fn current_system_time() -> ::std::time::SystemTime {
current_datetime().into()
}
/// A basic configuration which populates some necessary/useful fields.
fn test_config() -> Config {
let mut cfg = Config::default();
cfg.data_dir = Some("data_dir".into());
cfg.events_dir = Some("events_dir".into());
cfg.ping_dir = Some("ping_dir".into());
cfg.dump_file = Some("minidump.dmp".into());
cfg.strings = lang::LanguageInfo::default().load_strings().ok();
cfg
}
fn init_test_logger() {
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| {
env_logger::builder()
.target(env_logger::Target::Stderr)
.filter(Some("crashreporter"), log::LevelFilter::Debug)
.is_test(true)
.init();
})
}
/// A test fixture to make configuration, mocking, and assertions easier.
struct GuiTest {
/// The configuration used in the test. Initialized to [`test_config`].
pub config: Config,
/// The mock builder used in the test, initialized with a basic set of mocked values to ensure
/// most things will work out of the box.
pub mock: mock::Builder,
/// The mocked filesystem, which can be used for mock setup and assertions after completion.
pub files: MockFiles,
/// Whether glean should be initialized.
enable_glean: bool,
/// Callback to call before `try_run` but after test setup.
before_run: Option<Box<dyn FnOnce()>>,
}
impl GuiTest {
/// Create a new GuiTest with enough configured for the application to run
pub fn new() -> Self {
init_test_logger();
// Create a default set of files which allow successful operation.
let mock_files = MockFiles::new();
mock_files
.add_file_result(
"minidump.dmp",
Ok(MOCK_MINIDUMP_FILE.into()),
current_system_time(),
)
.add_file_result(
"minidump.extra",
Ok(MOCK_MINIDUMP_EXTRA.into()),
current_system_time(),
);
// Create a default mock environment which allows successful operation.
let mut mock = mock::builder();
mock.set(
Command::mock("work_dir/pingsender"),
Box::new(|_| Ok(crate::std::process::success_output())),
)
.set(
Command::mock("curl"),
Box::new(|_| {
let mut output = crate::std::process::success_output();
output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}").into();
Ok(output)
}),
)
.set(MockFS, mock_files.clone())
.set(
crate::std::env::MockCurrentExe,
"work_dir/crashreporter".into(),
)
.set(crate::std::time::MockCurrentTime, current_system_time())
.set(mock::MockHook::new("enable_glean_pings"), false)
.set(mock::MockHook::new("ping_uuid"), MOCK_PING_UUID);
GuiTest {
config: test_config(),
mock,
files: mock_files,
enable_glean: false,
before_run: None,
}
}
/// Enable glean pings (which will serialize the test run with other glean tests).
pub fn enable_glean_pings(&mut self) {
self.enable_glean = true;
self.mock
.set(mock::MockHook::new("enable_glean_pings"), true);
}
/// Run the given callback after test setup but before running the tests.
pub fn before_run(&mut self, f: impl FnOnce() + 'static) {
self.before_run = Some(Box::new(f));
}
/// Run the test as configured, using the given function to interact with the GUI.
///
/// Returns the final result of the application logic.
pub fn try_run<F: FnOnce(Interact) + Send + 'static>(
&mut self,
interact: F,
) -> anyhow::Result<bool> {
let GuiTest {
ref mut config,
ref mut mock,
ref enable_glean,
..
} = self;
let before_run = self.before_run.take();
let mut config = Arc::new(std::mem::take(config));
// Run the mock environment.
mock.run(move || {
let _glean = if *enable_glean {
Some(glean::test_init(&config))
} else {
None
};
gui_interact(
move || {
if let Some(f) = before_run {
f();
}
try_run(&mut config)
},
interact,
)
})
}
/// Run the test as configured, using the given function to interact with the GUI.
///
/// Panics if the application logic returns an error (which would normally be displayed to the
/// user).
pub fn run<F: FnOnce(Interact) + Send + 'static>(&mut self, interact: F) {
if let Err(e) = self.try_run(interact) {
panic!(
"gui failure:{}",
e.chain().map(|e| format!("\n {e}")).collect::<String>()
);
}
}
/// Get the file assertion helper.
pub fn assert_files(&self) -> AssertFiles {
AssertFiles {
data_dir: "data_dir".into(),
events_dir: "events_dir".into(),
inner: self.files.assert_files(),
}
}
}
/// A wrapper around the mock [`AssertFiles`](crate::std::fs::AssertFiles).
///
/// This implements higher-level assertions common across tests, but also supports the lower-level
/// assertions (though those return the [`AssertFiles`](crate::std::fs::AssertFiles) reference so
/// higher-level assertions must be chained first).
struct AssertFiles {
data_dir: String,
events_dir: String,
inner: std::fs::AssertFiles,
}
impl AssertFiles {
fn data(&self, rest: &str) -> String {
format!("{}/{rest}", &self.data_dir)
}
fn events(&self, rest: &str) -> String {
format!("{}/{rest}", &self.events_dir)
}
/// Set the data dir if not the default.
pub fn set_data_dir<S: ToString>(&mut self, data_dir: S) -> &mut Self {
let data_dir = data_dir.to_string();
// Data dir should be relative to root.
self.data_dir = data_dir.trim_start_matches('/').to_string();
self
}
/// Assert that the crash report was submitted according to the filesystem.
pub fn submitted(&mut self) -> &mut Self {
self.inner.check(
self.data(&format!("submitted/{MOCK_REMOTE_CRASH_ID}.txt")),
format!("Crash ID: {}\n", FluentArg(MOCK_REMOTE_CRASH_ID)),
);
self
}
/// Assert that the given settings where saved.
pub fn saved_settings(&mut self, settings: Settings) -> &mut Self {
self.inner.check(
self.data("crashreporter_settings.json"),
settings.to_string(),
);
self
}
/// Assert that a crash is pending according to the filesystem.
pub fn pending(&mut self) -> &mut Self {
let dmp = self.data("pending/minidump.dmp");
self.inner
.check(self.data("pending/minidump.extra"), MOCK_MINIDUMP_EXTRA)
.check_bytes(dmp, MOCK_MINIDUMP_FILE);
self
}
/// Assert that a crash ping was sent according to the filesystem.
pub fn ping(&mut self) -> &mut Self {
self.inner.check(
format!("ping_dir/{MOCK_PING_UUID}.json"),
serde_json::json! {{
"type": "crash",
"id": MOCK_PING_UUID,
"version": 4,
"creationDate": MOCK_CURRENT_TIME,
"clientId": "telemetry_client",
"profileGroupId": "telemetry_profile_group",
"payload": {
"sessionId": "telemetry_session",
"version": 1,
"crashDate": MOCK_CURRENT_DATE,
"crashTime": MOCK_CURRENT_TIME,
"hasCrashEnvironment": true,
"crashId": "minidump",
"minidumpSha256Hash": MOCK_MINIDUMP_SHA256,
"processType": "main",
"stackTraces": {
"status": "OK"
},
"metadata": {
"BuildID": "1234",
"ProductName": "Bar",
"ReleaseChannel": "release",
"Version": "100.0",
}
},
"application": {
"vendor": "FooCorp",
"name": "Bar",
"buildId": "1234",
"displayVersion": "",
"platformVersion": "",
"version": "100.0",
"channel": "release"
}
}}
.to_string(),
);
self
}
/// Assert that a crash submission event was written with the given submission status.
pub fn submission_event(&mut self, success: bool) -> &mut Self {
self.inner.check(
self.events("minidump-submission"),
format!(
"crash.submission.1\n\
{}\n\
minidump\n\
{success}\n\
{}",
current_unix_time(),
if success { MOCK_REMOTE_CRASH_ID } else { "" }
),
);
self
}
}
impl std::ops::Deref for AssertFiles {
type Target = std::fs::AssertFiles;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl std::ops::DerefMut for AssertFiles {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
#[test]
fn error_dialog() {
gui_interact(
|| {
let cfg = Config::default();
ui::error_dialog(&cfg, "an error occurred")
},
|interact| {
interact.element("close", |_style, b: &model::Button| b.click.fire(&()));
},
);
}
#[test]
fn no_dump_file() {
let mut cfg = Arc::new(Config::default());
{
let cfg = Arc::get_mut(&mut cfg).unwrap();
cfg.strings = lang::LanguageInfo::default().load_strings().ok();
}
assert!(try_run(&mut cfg).is_err());
Arc::get_mut(&mut cfg).unwrap().auto_submit = true;
assert!(try_run(&mut cfg).is_ok());
}
#[test]
fn no_extra_file() {
mock::builder()
.set(
crate::std::env::MockCurrentExe,
"work_dir/crashreporter".into(),
)
.set(MockFS, {
let files = MockFiles::new();
files.add_file_result(
"minidump.extra",
Err(ErrorKind::NotFound.into()),
::std::time::SystemTime::UNIX_EPOCH,
);
files
})
.run(|| {
let cfg = test_config();
assert!(try_run(&mut Arc::new(cfg)).is_err());
});
}
#[test]
fn auto_submit() {
let mut test = GuiTest::new();
test.config.auto_submit = true;
// auto_submit should not do any GUI things, including creating the crashreporter_settings.json
// file.
test.mock.run(|| {
assert!(try_run(&mut Arc::new(std::mem::take(&mut test.config))).is_ok());
});
test.assert_files().submitted().pending();
}
#[test]
fn restart() {
let mut test = GuiTest::new();
test.config.restart_command = Some("my_process".into());
test.config.restart_args = vec!["a".into(), "b".into()];
let ran_process = Counter::new();
let mock_ran_process = ran_process.clone();
test.mock.set(
Command::mock("my_process"),
Box::new(move |cmd| {
assert_eq!(cmd.args, &["a", "b"]);
mock_ran_process.inc();
Ok(crate::std::process::success_output())
}),
);
test.run(|interact| {
interact.element("restart", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted()
.pending();
ran_process.assert_one();
}
#[test]
fn no_restart_with_windows_error_reporting() {
let mut test = GuiTest::new();
test.config.restart_command = Some("my_process".into());
test.config.restart_args = vec!["a".into(), "b".into()];
// Add the "WindowsErrorReporting" key to the extra file
const MINIDUMP_EXTRA_CONTENTS: &str = r#"{
"Vendor": "FooCorp",
"ProductName": "Bar",
"ReleaseChannel": "release",
"BuildID": "1234",
"StackTraces": {
"status": "OK"
},
"Version": "100.0",
"ServerURL": "https://reports.example.com",
"TelemetryServerURL": "https://telemetry.example.com",
"TelemetryClientId": "telemetry_client",
"TelemetryProfileGroupId": "telemetry_profile_group",
"TelemetrySessionId": "telemetry_session",
"SomeNestedJson": { "foo": "bar" },
"WindowsErrorReporting": "1"
}"#;
test.files = {
let mock_files = MockFiles::new();
mock_files
.add_file_result(
"minidump.dmp",
Ok(MOCK_MINIDUMP_FILE.into()),
current_system_time(),
)
.add_file_result(
"minidump.extra",
Ok(MINIDUMP_EXTRA_CONTENTS.into()),
current_system_time(),
);
test.mock.set(MockFS, mock_files.clone());
mock_files
};
let ran_process = Counter::new();
let mock_ran_process = ran_process.clone();
test.mock.set(
Command::mock("my_process"),
Box::new(move |cmd| {
assert_eq!(cmd.args, &["a", "b"]);
mock_ran_process.inc();
Ok(crate::std::process::success_output())
}),
);
test.run(|interact| {
interact.element("restart", |style, b: &model::Button| {
// Check that the button is hidden, and invoke the click anyway to ensure the process
// isn't restarted (the window will still be closed).
assert_eq!(style.visible.get(), false);
b.click.fire(&())
});
});
let mut assert_files = test.assert_files();
assert_files.saved_settings(Settings::default()).submitted();
{
let dmp = assert_files.data("pending/minidump.dmp");
let extra = assert_files.data("pending/minidump.extra");
assert_files
.check(extra, MINIDUMP_EXTRA_CONTENTS)
.check_bytes(dmp, MOCK_MINIDUMP_FILE);
}
assert_eq!(ran_process.count(), 0);
}
#[test]
fn quit() {
let mut test = GuiTest::new();
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted()
.pending();
}
#[test]
fn delete_dump() {
let mut test = GuiTest::new();
test.config.delete_dump = true;
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted();
}
#[test]
fn no_submit() {
let mut test = GuiTest::new();
test.files.add_dir("data_dir").add_file(
"data_dir/crashreporter_settings.json",
Settings {
submit_report: true,
include_url: false,
}
.to_string(),
);
test.run(|interact| {
interact.element("send", |_style, c: &model::Checkbox| {
assert!(c.checked.get())
});
interact.element("include-url", |_style, c: &model::Checkbox| {
assert!(!c.checked.get())
});
interact.element("send", |_style, c: &model::Checkbox| c.checked.set(false));
interact.element("include-url", |_style, c: &model::Checkbox| {
c.checked.set(false)
});
// When submission is unchecked, the following elements should be disabled.
interact.element("details", |style, _: &model::Button| {
assert!(!style.enabled.get());
});
interact.element("comment", |style, _: &model::TextBox| {
assert!(!style.enabled.get());
});
interact.element("include-url", |style, _: &model::Checkbox| {
assert!(!style.enabled.get());
});
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings {
submit_report: false,
include_url: false,
})
.pending();
}
#[test]
fn ping_and_event_files() {
let mut test = GuiTest::new();
test.files
.add_dir("ping_dir")
.add_dir("events_dir")
.add_file(
"events_dir/minidump",
"1\n\
12:34:56\n\
e0423878-8d59-4452-b82e-cad9c846836e\n\
{\"foo\":\"bar\"}",
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted()
.pending()
.submission_event(true)
.ping()
.check(
"events_dir/minidump",
format!(
"1\n\
12:34:56\n\
e0423878-8d59-4452-b82e-cad9c846836e\n\
{}",
serde_json::json! {{
"foo": "bar",
"MinidumpSha256Hash": MOCK_MINIDUMP_SHA256,
"CrashPingUUID": MOCK_PING_UUID,
"StackTraces": { "status": "OK" }
}}
),
);
}
#[test]
fn pingsender_failure() {
let mut test = GuiTest::new();
test.mock.set(
Command::mock("work_dir/pingsender"),
Box::new(|_| Err(ErrorKind::NotFound.into())),
);
test.files
.add_dir("ping_dir")
.add_dir("events_dir")
.add_file(
"events_dir/minidump",
"1\n\
12:34:56\n\
e0423878-8d59-4452-b82e-cad9c846836e\n\
{\"foo\":\"bar\"}",
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted()
.pending()
.submission_event(true)
.ping()
.check(
"events_dir/minidump",
format!(
"1\n\
12:34:56\n\
e0423878-8d59-4452-b82e-cad9c846836e\n\
{}",
serde_json::json! {{
"foo": "bar",
"MinidumpSha256Hash": MOCK_MINIDUMP_SHA256,
// No crash ping UUID since pingsender fails
"StackTraces": { "status": "OK" }
}}
),
);
}
#[test]
fn glean_ping() {
let mut test = GuiTest::new();
test.enable_glean_pings();
let received_glean_ping = Counter::new();
test.mock.set(
net::http::MockHttp,
Box::new(cc! { (received_glean_ping)
move |_request, url| {
if url.starts_with("https://incoming.glean.example.com")
{
received_glean_ping.inc();
Ok(Ok(vec![]))
} else {
net::http::MockHttp::try_others()
}
}
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
received_glean_ping.assert_one();
}
#[test]
fn glean_ping_extra_stack_trace_fields() {
let mut test = GuiTest::new();
test.enable_glean_pings();
let received_glean_ping = Counter::new();
const MINIDUMP_EXTRA_CONTENTS: &str = r#"{
"Vendor": "FooCorp",
"ProductName": "Bar",
"ReleaseChannel": "release",
"BuildID": "1234",
"StackTraces": {
"status": "OK",
"foobar": "baz",
"crash_info": {
"address": "0xcafe"
}
},
"Version": "100.0",
"ServerURL": "https://reports.example.com",
"TelemetryServerURL": "https://telemetry.example.com",
"TelemetryClientId": "telemetry_client",
"TelemetryProfileGroupId": "telemetry_profile_group",
"TelemetrySessionId": "telemetry_session",
"SomeNestedJson": { "foo": "bar" },
"WindowsErrorReporting": "1"
}"#;
test.files = {
let mock_files = MockFiles::new();
mock_files
.add_file_result(
"minidump.dmp",
Ok(MOCK_MINIDUMP_FILE.into()),
current_system_time(),
)
.add_file_result(
"minidump.extra",
Ok(MINIDUMP_EXTRA_CONTENTS.into()),
current_system_time(),
);
test.mock.set(MockFS, mock_files.clone());
mock_files
};
test.mock.set(
net::http::MockHttp,
Box::new(cc! { (received_glean_ping)
move |_request, url| {
if url.starts_with("https://incoming.glean.example.com")
{
received_glean_ping.inc();
Ok(Ok(vec![]))
} else {
net::http::MockHttp::try_others()
}
}
}),
);
test.before_run(|| {
glean::crash.test_before_next_submit(|_| {
assert_eq!(
glean::crash::stack_traces.test_get_value(None),
Some(serde_json::json! {{"crash_address":"0xcafe"}})
);
});
});
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
received_glean_ping.assert_one();
}
#[test]
fn eol_version() {
let mut test = GuiTest::new();
test.files
.add_dir("data_dir")
.add_file("data_dir/EndOfLife100.0", "");
// Should fail before opening the gui
let result = test.try_run(|_| ());
assert_eq!(
result.expect_err("should fail on EOL version").to_string(),
"Version end of life: crash reports are no longer accepted."
);
test.assert_files()
.pending()
.ignore("data_dir/EndOfLife100.0");
}
#[test]
fn details_window() {
let mut test = GuiTest::new();
test.run(|interact| {
let details_visible = || {
interact.window("crash-details-window", |style, _w: &model::Window| {
style.visible.get()
})
};
assert_eq!(details_visible(), false);
interact.element("details", |_style, b: &model::Button| b.click.fire(&()));
assert_eq!(details_visible(), true);
let details_text = loop {
let v = interact.element("details-text", |_style, t: &model::TextBox| t.content.get());
if v == "Loading…" {
// Wait for the details to be populated.
std::thread::sleep(std::time::Duration::from_millis(50));
continue;
} else {
break v;
}
};
interact.element("close-details", |_style, b: &model::Button| b.click.fire(&()));
assert_eq!(details_visible(), false);
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
assert_eq!(details_text,
"BuildID: 1234\n\
ProductName: Bar\n\
ReleaseChannel: release\n\
SomeNestedJson: {\"foo\":\"bar\"}\n\
SubmittedFrom: Client\n\
TelemetryClientId: telemetry_client\n\
TelemetryProfileGroupId: telemetry_profile_group\n\
TelemetryServerURL: https://telemetry.example.com\n\
TelemetrySessionId: telemetry_session\n\
Throttleable: 1\n\
Vendor: FooCorp\n\
Version: 100.0\n\
This report also contains technical information about the state of the application when it crashed.\n"
);
});
}
#[test]
fn data_dir_default() {
let mut test = GuiTest::new();
test.config.data_dir = None;
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.set_data_dir("data_dir/FooCorp/Bar/Crash Reports")
.saved_settings(Settings::default())
.submitted()
.pending();
}
#[test]
fn include_url() {
for setting in [false, true] {
let mut test = GuiTest::new();
test.files.add_dir("data_dir").add_file(
"data_dir/crashreporter_settings.json",
Settings {
submit_report: true,
include_url: setting,
}
.to_string(),
);
test.mock.set(
net::report::MockReport,
Box::new(move |report| {
assert_eq!(
report.extra.get("URL").and_then(|v| v.as_str()),
setting.then_some("https://url.example.com")
);
Ok(Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}")))
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
}
}
#[test]
fn comment() {
const COMMENT: &str = "My program crashed";
for set_comment in [false, true] {
let invoked = Counter::new();
let mock_invoked = invoked.clone();
let mut test = GuiTest::new();
test.mock.set(
net::report::MockReport,
Box::new(move |report| {
mock_invoked.inc();
assert_eq!(
report.extra.get("Comments").and_then(|v| v.as_str()),
set_comment.then_some(COMMENT)
);
Ok(Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}")))
}),
);
test.run(move |interact| {
if set_comment {
interact.element("comment", |_style, c: &model::TextBox| {
c.content.set(COMMENT.into())
});
}
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
invoked.assert_one();
}
}
#[test]
fn curl_binary() {
let mut test = GuiTest::new();
test.files.add_file("minidump.memory.json.gz", "");
let ran_process = Counter::new();
let mock_ran_process = ran_process.clone();
test.mock.set(
Command::mock("curl"),
Box::new(move |cmd| {
if cmd.spawning {
return Ok(crate::std::process::success_output());
}
// Curl strings need backslashes escaped.
let curl_escaped_separator = if std::path::MAIN_SEPARATOR == '\\' {
"\\\\"
} else {
std::path::MAIN_SEPARATOR_STR
};
let expected_args: Vec<OsString> = [
"--user-agent",
net::http::USER_AGENT,
"--form",
"extra=@-;filename=extra.json;type=application/json",
"--form",
&format!(
"upload_file_minidump=@\"data_dir{0}pending{0}minidump.dmp\"",
curl_escaped_separator
),
"--form",
&format!(
"memory_report=@\"data_dir{0}pending{0}minidump.memory.json.gz\"",
curl_escaped_separator
),
]
.into_iter()
.map(Into::into)
.collect();
assert_eq!(cmd.args, expected_args);
let mut output = crate::std::process::success_output();
output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}").into();
mock_ran_process.inc();
Ok(output)
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
ran_process.assert_one();
}
#[test]
fn curl_library() {
let invoked = Counter::new();
let mock_invoked = invoked.clone();
let mut test = GuiTest::new();
test.mock.set(
net::report::MockReport,
Box::new(move |_| {
mock_invoked.inc();
Ok(Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}")))
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
invoked.assert_one();
}
#[test]
fn report_not_sent() {
let mut test = GuiTest::new();
test.files.add_dir("events_dir");
test.mock.set(
net::report::MockReport,
Box::new(move |_| Err(std::io::ErrorKind::NotFound.into())),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submission_event(false)
.pending();
}
#[test]
fn report_response_failed() {
let mut test = GuiTest::new();
test.files.add_dir("events_dir");
test.mock.set(
net::report::MockReport,
Box::new(move |_| Ok(Err(std::io::ErrorKind::NotFound.into()))),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submission_event(false)
.pending();
}
#[test]
fn response_indicates_discarded() {
let mut test = GuiTest::new();
// A response indicating discarded triggers a prune of the directory containing the minidump.
// Since there is one more minidump (the main one, minidump.dmp), pruning should keep all but
// the first 3, which will be the oldest.
const SHOULD_BE_PRUNED: usize = 3;
for i in 0..MINIDUMP_PRUNE_SAVE_COUNT + SHOULD_BE_PRUNED - 1 {
test.files.add_dir("data_dir/pending").add_file_result(
format!("data_dir/pending/minidump{i}.dmp"),
Ok("contents".into()),
::std::time::SystemTime::UNIX_EPOCH + ::std::time::Duration::from_secs(1234 + i as u64),
);
if i % 2 == 0 {
test.files
.add_file(format!("data_dir/pending/minidump{i}.extra"), "{}");
}
if i % 5 == 0 {
test.files
.add_file(format!("data_dir/pending/minidump{i}.memory.json.gz"), "{}");
}
}
test.mock.set(
Command::mock("curl"),
Box::new(|_| {
let mut output = crate::std::process::success_output();
output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}\nDiscarded=1").into();
Ok(output)
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
let mut assert_files = test.assert_files();
assert_files.saved_settings(Settings::default()).pending();
for i in SHOULD_BE_PRUNED..MINIDUMP_PRUNE_SAVE_COUNT + SHOULD_BE_PRUNED - 1 {
assert_files.check_exists(format!("data_dir/pending/minidump{i}.dmp"));
if i % 2 == 0 {
assert_files.check_exists(format!("data_dir/pending/minidump{i}.extra"));
}
if i % 5 == 0 {
assert_files.check_exists(format!("data_dir/pending/minidump{i}.memory.json.gz"));
}
}
}
#[test]
fn response_view_url() {
let mut test = GuiTest::new();
test.mock.set(
Command::mock("curl"),
Box::new(|_| {
let mut output = crate::std::process::success_output();
output.stdout =
format!("CrashID={MOCK_REMOTE_CRASH_ID}\nViewURL=https://foo.bar.example").into();
Ok(output)
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.pending()
.check(
format!("data_dir/submitted/{MOCK_REMOTE_CRASH_ID}.txt"),
format!(
"\
Crash ID: {}\n\
You can view details of this crash at {}.\n",
FluentArg(MOCK_REMOTE_CRASH_ID),
),
);
}
#[test]
fn response_stop_sending_reports() {
let mut test = GuiTest::new();
test.mock.set(
Command::mock("curl"),
Box::new(|_| {
let mut output = crate::std::process::success_output();
output.stdout =
format!("CrashID={MOCK_REMOTE_CRASH_ID}\nStopSendingReportsFor=100.0").into();
Ok(output)
}),
);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted()
.pending()
.check_exists("data_dir/EndOfLife100.0");
}
#[test]
fn rename_failure_uses_copy() {
let mut test = GuiTest::new();
test.mock.set(mock::MockHook::new("rename_fail"), true);
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.saved_settings(Settings::default())
.submitted()
.pending();
}
/// A real temporary directory in the host filesystem.
///
/// The directory is guaranteed to be unique to the test suite process (in case of crash, it can be
/// inspected).
///
/// When dropped, the directory is deleted.
struct TempDir {
path: ::std::path::PathBuf,
}
impl TempDir {
/// Create a new directory with the given identifying name.
///
/// The name should be unique to deconflict amongst concurrent tests.
pub fn new(name: &str) -> Self {
let path = ::std::env::temp_dir().join(format!(
"{}-test-{}-{name}",
env!("CARGO_PKG_NAME"),
std::process::id()
));
::std::fs::create_dir_all(&path).unwrap();
TempDir { path }
}
/// Get the temporary directory path.
pub fn path(&self) -> &::std::path::Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
// Best-effort removal, ignore errors.
let _ = ::std::fs::remove_dir_all(&self.path);
}
}
/// A mock crash report server.
///
/// When dropped, the server is shutdown.
struct TestCrashReportServer {
addr: ::std::net::SocketAddr,
shutdown_and_thread: Option<(
tokio::sync::oneshot::Sender<()>,
std::thread::JoinHandle<()>,
)>,
}
impl TestCrashReportServer {
/// Create and start a mock crash report server on an ephemeral port, returning a handle to the
/// server.
pub fn run() -> Self {
let (shutdown, rx) = tokio::sync::oneshot::channel();
use warp::Filter;
let submit = warp::path("submit")
.and(warp::filters::method::post())
.and(warp::filters::header::header("content-type"))
.and(warp::filters::body::bytes())
.and_then(|content_type: String, body: bytes::Bytes| async move {
let Some(boundary) = content_type.strip_prefix("multipart/form-data; boundary=")
else {
return Err(warp::reject());
};
let body = String::from_utf8_lossy(&*body).to_owned();
for part in body.split(&format!("--{boundary}")).skip(1) {
if part == "--\r\n" {
break;
}
let (_headers, _data) = part.split_once("\r\n\r\n").unwrap_or(("", part));
// TODO validate parts
}
Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}"))
});
let (addr_channel_tx, addr_channel_rx) = std::sync::mpsc::sync_channel(0);
let thread = ::std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create tokio runtime");
let _guard = rt.enter();
let (addr, server) =
warp::serve(submit).bind_with_graceful_shutdown(([127, 0, 0, 1], 0), async move {
rx.await.ok();
});
addr_channel_tx.send(addr).unwrap();
rt.block_on(server)
});
let addr = addr_channel_rx.recv().unwrap();
TestCrashReportServer {
addr,
shutdown_and_thread: Some((shutdown, thread)),
}
}
/// Get the url to which to submit crash reports for this mocked server.
pub fn submit_url(&self) -> String {
format!("http://{}/submit", self.addr)
}
}
impl Drop for TestCrashReportServer {
fn drop(&mut self) {
let (shutdown, thread) = self.shutdown_and_thread.take().unwrap();
let _ = shutdown.send(());
thread.join().unwrap();
}
}
#[test]
fn real_curl_binary() {
if ::std::process::Command::new("curl").output().is_err() {
eprintln!("no curl binary; skipping real_curl_binary test");
return;
}
let server = TestCrashReportServer::run();
let mut test = GuiTest::new();
test.mock.set(
Command::mock("curl"),
Box::new(|cmd| cmd.output_from_real_command()),
);
test.config.report_url = Some(server.submit_url().into());
test.config.delete_dump = true;
// We need the dump file to actually exist since the curl binary is passed the file path.
// The dump file needs to exist at the pending dir location.
let tempdir = TempDir::new("real_curl_binary");
let data_dir = tempdir.path().to_owned();
let pending_dir = data_dir.join("pending");
test.config.data_dir = Some(data_dir.clone().into());
::std::fs::create_dir_all(&pending_dir).unwrap();
let dump_file = pending_dir.join("minidump.dmp");
::std::fs::write(&dump_file, MOCK_MINIDUMP_FILE).unwrap();
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.set_data_dir(data_dir.display())
.saved_settings(Settings::default())
.submitted();
}
#[test]
fn real_curl_library() {
if !crate::net::can_load_libcurl() {
eprintln!("no libcurl; skipping real_libcurl test");
return;
}
let server = TestCrashReportServer::run();
let mut test = GuiTest::new();
test.mock
.set(
Command::mock("curl"),
Box::new(|_| Err(std::io::ErrorKind::NotFound.into())),
)
.set(mock::MockHook::new("use_system_libcurl"), true);
test.config.report_url = Some(server.submit_url().into());
test.config.delete_dump = true;
// We need the dump file to actually exist since libcurl is passed the file path.
// The dump file needs to exist at the pending dir location.
let tempdir = TempDir::new("real_libcurl");
let data_dir = tempdir.path().to_owned();
let pending_dir = data_dir.join("pending");
test.config.data_dir = Some(data_dir.clone().into());
::std::fs::create_dir_all(&pending_dir).unwrap();
let dump_file = pending_dir.join("minidump.dmp");
::std::fs::write(&dump_file, MOCK_MINIDUMP_FILE).unwrap();
test.run(|interact| {
interact.element("quit", |_style, b: &model::Button| b.click.fire(&()));
});
test.assert_files()
.set_data_dir(data_dir.display())
.saved_settings(Settings::default())
.submitted();
}