Quellcodebibliothek Statistik Leitseite products/Sources/formale Sprachen/C/Firefox/toolkit/crashreporter/client/app/src/   (Browser von der Mozilla Stiftung Version 136.0.1©)  Datei vom 10.2.2025 mit Größe 43 kB image not shown  

Quelle  test.rs   Sprache: unbekannt

 
/* 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" },
                            "URL": "https://url.example.com"
                        }"#;

// 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 = Some(Default::default());
    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 = Some(Default::default());
    }
    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" },
                            "URL": "https://url.example.com",
                            "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" },
                            "URL": "https://url.example.com",
                            "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\
             URL: https://url.example.com\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
                ),
                "https://reports.example.com",
            ]
            .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),
                FluentArg("https://foo.bar.example")
            ),
        );
}

#[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();
}

[ Dauer der Verarbeitung: 0.41 Sekunden  (vorverarbeitet)  ]