diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0163641 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @rust3ds/active diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..4a1db0b --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,40 @@ +name: Setup Rust3DS +description: Set up CI environment for Rust + 3DS development + +inputs: + toolchain: + description: The Rust toolchain to use for the steps + required: true + default: nightly + +runs: + using: composite + steps: + # https://github.com/nektos/act/issues/917#issuecomment-1074421318 + - if: ${{ env.ACT }} + shell: bash + name: Hack container for local development + run: | + curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - + sudo apt-get install -y nodejs + + - name: Setup default Rust toolchain + # Use this helper action so we get matcher support + # https://github.com/actions-rust-lang/setup-rust-toolchain/pull/15 + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy, rustfmt, rust-src + toolchain: ${{ inputs.toolchain }} + + - name: Install build tools for host + shell: bash + run: sudo apt-get update && sudo apt-get install -y build-essential + + - name: Install cargo-3ds + shell: bash + run: cargo install cargo-3ds + + - name: Set PATH to include devkitARM + shell: bash + # For some reason devkitARM/bin is not part of the default PATH in the container + run: echo "${DEVKITARM}/bin" >> $GITHUB_PATH diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..657c5f9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +env: + # actions-rust-lang/setup-rust-toolchain sets some default RUSTFLAGS, which we don't want to use + RUSTFLAGS: "" + +jobs: + lint: + strategy: + matrix: + toolchain: + - nightly-2023-06-01 + + runs-on: ubuntu-latest + container: devkitpro/devkitarm + steps: + - name: Checkout branch + uses: actions/checkout@v2 + + - uses: ./.github/actions/setup + with: + toolchain: ${{ matrix.toolchain }} + + - name: Check formatting + run: cargo fmt --all --verbose -- --check + + - name: Run clippy + # We have to build the test crate here since it's not included in build-std by default + run: cargo 3ds clippy -Zbuild-std=std,test --color=always --verbose --all-targets + + test: + strategy: + matrix: + toolchain: + # Oldest supported nightly + - nightly-2023-06-01 + - nightly + + continue-on-error: ${{ matrix.toolchain == 'nightly' }} + runs-on: ubuntu-latest + container: devkitpro/devkitarm + steps: + - name: Checkout branch + uses: actions/checkout@v3 + + - uses: ./.github/actions/setup + with: + toolchain: ${{ matrix.toolchain }} + + - name: Build lib tests + run: cargo 3ds test --no-run --lib + + - name: Build integration tests + run: cargo 3ds test --no-run --test integration + + - name: Build doc tests + # need build-std=test until https://github.com/rust3ds/cargo-3ds/pull/42 + run: cargo 3ds test --doc -Zbuild-std=std,test diff --git a/.gitignore b/.gitignore index e69de29..6985cf1 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..60a6399 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "test-runner" +version = "0.1.0" +edition = "2021" + +[features] +console = [] +gdb = [] +socket = [] + +[dependencies] +ctru-rs = { git = "https://github.com/rust3ds/ctru-rs" } +ctru-sys = { git = "https://github.com/rust3ds/ctru-rs" } +libc = "0.2.147" +shim-3ds = { git = "https://github.com/rust3ds/shim-3ds", version = "0.1.0" } diff --git a/docker/citra.dockerfile b/docker/citra.dockerfile deleted file mode 100644 index 18cb7a4..0000000 --- a/docker/citra.dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM buildpack-deps:latest as builder - -# ARG CITRA_RELEASE=nightly-1783 -# ARG CITRA_RELEASE_FILE=citra-linux-20220902-746609f.tar.xz - -ARG CITRA_CHANNEL=nightly -ARG CITRA_RELEASE=1816 - -WORKDIR /tmp -COPY ./citra/download_citra.sh /usr/local/bin/download_citra -RUN apt-get update -y && apt-get install -y jq -RUN download_citra ${CITRA_CHANNEL} ${CITRA_RELEASE} - -FROM ubuntu:latest - -RUN apt-get update -y && \ - apt-get install -y \ - libswscale5 \ - libsdl2-2.0-0 \ - libavformat58 \ - libavfilter7 \ - xvfb - -COPY --from=builder /tmp/citra /usr/local/bin -COPY ./citra/sdl2-config.ini /root/.config/citra-emu/ - -WORKDIR /app - -CMD [ "citra", "--version" ] diff --git a/docker/citra/download_citra.sh b/docker/download_citra.sh similarity index 100% rename from docker/citra/download_citra.sh rename to docker/download_citra.sh diff --git a/docker/citra/sdl2-config.ini b/docker/sdl2-config.ini similarity index 100% rename from docker/citra/sdl2-config.ini rename to docker/sdl2-config.ini diff --git a/docker/test-runner.gdb b/docker/test-runner.gdb new file mode 100644 index 0000000..c3258a7 --- /dev/null +++ b/docker/test-runner.gdb @@ -0,0 +1,16 @@ +# https://github.com/devkitPro/libctru/blob/master/libctru/source/system/stack_adjust.s#LL28C23-L28C23 +# or should this be `_exit` ? +break __ctru_exit +commands + # TODO: needed? + continue + # ARM calling convention will put the exit code in r0 when __ctru_exit is called. + # Just tell GDB to exit with the same code, since it doesn't get passed back when + # the program exits + quit $r0 +end + +# TODO: parametrize or pass as command line arg instead +target extended-remote 192.168.0.167:4003 +# target extended-remote :4000 +continue diff --git a/src/console.rs b/src/console.rs new file mode 100644 index 0000000..5e61951 --- /dev/null +++ b/src/console.rs @@ -0,0 +1,51 @@ +use ctru::prelude::*; +use ctru::services::gfx::{Flush, Swap}; + +use super::TestRunner; + +pub struct ConsoleRunner { + gfx: Gfx, + hid: Hid, + apt: Apt, +} + +impl Default for ConsoleRunner { + fn default() -> Self { + let gfx = Gfx::new().unwrap(); + let hid = Hid::new().unwrap(); + let apt = Apt::new().unwrap(); + + gfx.top_screen.borrow_mut().set_wide_mode(true); + + Self { gfx, hid, apt } + } +} + +impl TestRunner for ConsoleRunner { + type Context<'this> = Console<'this>; + + fn setup(&mut self) -> Self::Context<'_> { + Console::new(self.gfx.top_screen.borrow_mut()) + } + + fn cleanup(mut self, _test_result: std::io::Result) { + // We don't actually care about the test result, either way we'll stop + // and show the results to the user + + // Wait to make sure the user can actually see the results before we exit + println!("Press START to exit."); + + while self.apt.main_loop() { + let mut screen = self.gfx.top_screen.borrow_mut(); + screen.flush_buffers(); + screen.swap_buffers(); + + self.gfx.wait_for_vblank(); + + self.hid.scan_input(); + if self.hid.keys_down().contains(KeyPad::START) { + break; + } + } + } +} diff --git a/src/gdb.rs b/src/gdb.rs new file mode 100644 index 0000000..d8a7c46 --- /dev/null +++ b/src/gdb.rs @@ -0,0 +1,48 @@ +use ctru::error::ResultCode; + +use super::TestRunner; + +#[derive(Default)] +pub struct GdbRunner; + +impl Drop for GdbRunner { + fn drop(&mut self) { + unsafe { ctru_sys::gdbHioDevExit() } + } +} + +impl TestRunner for GdbRunner { + type Context<'this> = (); + + fn setup(&mut self) -> Self::Context<'_> { + // TODO: `ctru` expose safe API to do this and call that instead + || -> ctru::Result<()> { + unsafe { + ResultCode(ctru_sys::gdbHioDevInit())?; + // TODO: should we actually redirect stdin or nah? + ResultCode(ctru_sys::gdbHioDevRedirectStdStreams(true, true, true))?; + } + Ok(()) + }() + .expect("failed to redirect I/O streams to GDB"); + } + + fn cleanup(self, test_result: std::io::Result) { + // GDB actually has the opportunity to inspect the exit code, + // unlike other runners, so let's follow the default behavior of the + // stdlib test runner. + match test_result { + Ok(success) => { + if success { + std::process::exit(0); + } else { + std::process::exit(101); + } + } + Err(err) => { + eprintln!("Error: {err}"); + std::process::exit(101); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3f9597c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,158 @@ +//! Custom test runner for building/running tests on the 3DS. +//! +//! This library can be used with +//! [`custom_test_frameworks`](https://doc.rust-lang.org/unstable-book/language-features/custom-test-frameworks.html) +//! to enable normal Rust testing workflows for 3DS homebrew. + +#![feature(test)] +#![feature(custom_test_frameworks)] +#![test_runner(run_gdb)] + +extern crate test; + +mod console; +mod gdb; +mod socket; + +use console::ConsoleRunner; +use gdb::GdbRunner; +use socket::SocketRunner; + +use test::{ColorConfig, OutputFormat, TestDescAndFn, TestFn, TestOpts}; + +/// Show test output in GDB, using the [File I/O Protocol] (called HIO in some 3DS +/// homebrew resources). Both stdout and stderr will be printed to the GDB console. +/// +/// [File I/O Protocol]: https://sourceware.org/gdb/onlinedocs/gdb/File_002dI_002fO-Overview.html#File_002dI_002fO-Overview +pub fn run_gdb(tests: &[&TestDescAndFn]) { + run::(tests) +} + +/// Run tests using the `ctru` [`Console`] (print results to the 3DS screen). +/// This is mostly useful for running tests manually, especially on real hardware. +/// +/// [`Console`]: ctru::console::Console +pub fn run_console(tests: &[&TestDescAndFn]) { + run::(tests) +} + +/// Show test output via a network socket to `3dslink`. This runner is only useful +/// on real hardware, since `3dslink` doesn't work with emulators. +/// +/// See [`Soc::redirect_to_3dslink`] for more details. +/// +/// [`Soc::redirect_to_3dslink`]: ctru::services::soc::Soc::redirect_to_3dslink +pub fn run_socket(tests: &[&TestDescAndFn]) { + run::(tests) +} + +fn run(tests: &[&TestDescAndFn]) { + std::env::set_var("RUST_BACKTRACE", "1"); + + let mut runner = Runner::default(); + let ctx = runner.setup(); + + let opts = TestOpts { + force_run_in_process: true, + run_tests: true, + // TODO: color doesn't work because of TERM/TERMINFO. + // With RomFS we might be able to fake this out nicely... + color: ColorConfig::AlwaysColor, + format: OutputFormat::Pretty, + test_threads: Some(1), + // Hopefully this interface is more stable vs specifying individual options, + // and parsing the empty list of args should always work, I think. + // TODO Ideally we could pass actual std::env::args() here too + ..test::test::parse_opts(&[]).unwrap().unwrap() + }; + + let tests = tests.iter().map(|t| make_owned_test(t)).collect(); + let result = test::run_tests_console(&opts, tests); + + drop(ctx); + + runner.cleanup(result); +} + +/// Adapted from [`test::make_owned_test`]. +/// Clones static values for putting into a dynamic vector, which `test_main()` +/// needs to hand out ownership of tests to parallel test runners. +/// +/// This will panic when fed any dynamic tests, because they cannot be cloned. +fn make_owned_test(test: &TestDescAndFn) -> TestDescAndFn { + let testfn = match test.testfn { + TestFn::StaticTestFn(f) => TestFn::StaticTestFn(f), + TestFn::StaticBenchFn(f) => TestFn::StaticBenchFn(f), + _ => panic!("non-static tests passed to test::test_main_static"), + }; + + TestDescAndFn { + testfn, + desc: test.desc.clone(), + } +} + +/// A helper trait to make the behavior of test runners consistent. +trait TestRunner: Sized + Default { + /// Any context the test runner needs to remain alive for the duration of + /// the test. This can be used for things that need to borrow the test runner + /// itself. + // TODO: with associated type defaults this could be `= ();` + type Context<'this> + where + Self: 'this; + + /// Create the [`Context`](Self::Context), if any. + fn setup(&mut self) -> Self::Context<'_>; + + /// Handle the results of the test and perform any necessary cleanup. + /// The [`Context`](Self::Context) will be dropped just before this is called. + fn cleanup(self, test_result: std::io::Result); +} + +/// This module has stubs needed to link the test library, but they do nothing +/// because we don't actually need them for the runner to work. +mod link_fix { + #[no_mangle] + extern "C" fn execvp( + _argc: *const libc::c_char, + _argv: *mut *const libc::c_char, + ) -> libc::c_int { + -1 + } + + #[no_mangle] + extern "C" fn pipe(_fildes: *mut libc::c_int) -> libc::c_int { + -1 + } + + #[no_mangle] + extern "C" fn sigemptyset(_arg1: *mut libc::sigset_t) -> ::libc::c_int { + -1 + } +} + +/// Verify that doctests work as expected +/// ``` +/// assert_eq!(2 + 2, 4); +/// ``` +/// +/// ```should_panic +/// assert_eq!(2 + 2, 5); +/// ``` +#[cfg(doctest)] +struct Dummy; + +#[cfg(test)] +mod tests { + #[test] + fn it_works() { + assert_eq!(2 + 2, 4); + } + + #[test] + #[should_panic] + fn it_fails() { + assert_eq!(2 + 2, 5); + } +} diff --git a/src/socket.rs b/src/socket.rs new file mode 100644 index 0000000..dab54d1 --- /dev/null +++ b/src/socket.rs @@ -0,0 +1,27 @@ +use ctru::prelude::*; + +use super::TestRunner; + +pub struct SocketRunner { + soc: Soc, +} + +impl Default for SocketRunner { + fn default() -> Self { + Self { + soc: Soc::new().expect("failed to initialize network service"), + } + } +} + +impl TestRunner for SocketRunner { + type Context<'this> = (); + + fn setup(&mut self) -> Self::Context<'_> { + self.soc + .redirect_to_3dslink(true, true) + .expect("failed to redirect to socket"); + } + + fn cleanup(self, _test_result: std::io::Result) {} +} diff --git a/test-crate/.gitignore b/test-crate/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/test-crate/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/test-crate/Cargo.lock b/test-crate/Cargo.lock deleted file mode 100644 index edd7621..0000000 --- a/test-crate/Cargo.lock +++ /dev/null @@ -1,119 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "const-zero" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d3d68618a1f2c2d86e0bd2adec82e31960ca11aaeb5f353ff01746a4e08f36" - -[[package]] -name = "ctru-rs" -version = "0.7.1" -source = "git+https://github.com/Meziu/ctru-rs.git#ac6c81e7819185be46576af3441f5260d39a2320" -dependencies = [ - "bitflags", - "cfg-if", - "const-zero", - "ctru-sys", - "libc", - "linker-fix-3ds", - "once_cell", - "pthread-3ds", - "toml", - "widestring", -] - -[[package]] -name = "ctru-sys" -version = "0.4.1" -source = "git+https://github.com/Meziu/ctru-rs.git#ac6c81e7819185be46576af3441f5260d39a2320" -dependencies = [ - "libc", -] - -[[package]] -name = "libc" -version = "0.2.132" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" - -[[package]] -name = "linker-fix-3ds" -version = "0.1.0" -source = "git+https://github.com/Meziu/rust-linker-fix-3ds.git#d5d3be4a0da876df6d6ac55cc8b48488713e149a" -dependencies = [ - "ctru-sys", - "libc", -] - -[[package]] -name = "once_cell" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" - -[[package]] -name = "pthread-3ds" -version = "0.1.0" -source = "git+https://github.com/Meziu/pthread-3ds.git#42a80c0e816251138df535648258671d93e047a6" -dependencies = [ - "ctru-sys", - "libc", - "spin", - "static_assertions", -] - -[[package]] -name = "serde" -version = "1.0.137" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" - -[[package]] -name = "spin" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "test-crate" -version = "0.1.0" -dependencies = [ - "ctru-rs", - "ctru-sys", -] - -[[package]] -name = "toml" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" -dependencies = [ - "serde", -] - -[[package]] -name = "widestring" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7157704c2e12e3d2189c507b7482c52820a16dfa4465ba91add92f266667cadb" diff --git a/test-crate/Cargo.toml b/test-crate/Cargo.toml deleted file mode 100644 index b5dd4a7..0000000 --- a/test-crate/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "test-crate" -version = "0.1.0" -edition = "2021" -authors = [""] - -[dependencies] -ctru-rs = { git = "https://github.com/Meziu/ctru-rs.git" } -ctru-sys = { git = "https://github.com/Meziu/ctru-rs.git" } diff --git a/test-crate/src/main.rs b/test-crate/src/main.rs deleted file mode 100644 index c8957bf..0000000 --- a/test-crate/src/main.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::time::Duration; - -use ctru::console::Console; -use ctru::gfx::Gfx; -use ctru::services::apt::Apt; -use ctru::services::hid::{Hid, KeyPad}; - -fn main() { - ctru::init(); - let gfx = Gfx::init().expect("Couldn't obtain GFX controller"); - let hid = Hid::init().expect("Couldn't obtain HID controller"); - let apt = Apt::init().expect("Couldn't obtain APT controller"); - // let _console = Console::init(gfx.top_screen.borrow_mut()); - - let _ = unsafe { ctru_sys::consoleDebugInit(ctru_sys::debugDevice_SVC) }; - - std::env::set_var("RUST_BACKTRACE", "full"); - - let res = unsafe { ctru_sys::gdbHioDevInit() }; - if res != 0 { - eprintln!("failed to init gdbHIO: {res}"); - } else { - eprintln!("init gdb hio"); - } - - // let res = unsafe { ctru_sys::gdbHioDevRedirectStdStreams(false, true, true) }; - // if res != 0 { - // eprintln!("failed to redirect gdbHIO: {res}"); - // } else { - // eprintln!("redirected gdb hio"); - // } - - println!("hey stdout"); - eprintln!("hey stderr"); - - // Main loop - while apt.main_loop() { - //Scan all the inputs. This should be done once for each frame - hid.scan_input(); - - if hid.keys_down().contains(KeyPad::KEY_START) { - break; - } - // Flush and swap framebuffers - gfx.flush_buffers(); - gfx.swap_buffers(); - - //Wait for VBlank - gfx.wait_for_vblank(); - } - - unsafe { ctru_sys::gdbHioDevExit() }; -} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..b00b99f --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,13 @@ +// Workaround for https://github.com/rust-lang/rust/issues/94348 +extern crate shim_3ds; + +#[test] +fn it_works() { + assert_eq!(2 + 2, 4); +} + +#[test] +#[should_panic] +fn it_fails() { + assert_eq!(2 + 2, 5); +}