Add xtask command for running HIL tests (#912)

* feat: Initial test

* feat: Initial HIL tests in xtask

* refactor: End test if possible before timeout

* rebase

* Add checking for output from monitor

* CI: use xtask command instead of bash scripts

* clippy

* help find cargo

* Try increase duration for failing test

* remove bash tests

* reviews

* simplify

* Add a local_espflash flag to allow running espflash without re-building (CI) and re-building (locally)

* reviews

---------

Co-authored-by: Sergio Gasquez <sergio.gasquez@gmail.com>
This commit is contained in:
Juraj Sadel 2025-07-08 10:30:43 +02:00 committed by GitHub
parent f679e03a8e
commit b993a42fe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1272 additions and 637 deletions

View File

@ -46,18 +46,29 @@ jobs:
run: apt-get update && apt-get -y install curl musl-tools pkg-config
- name: Install toolchain
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
- name: Build espflash
run: $HOME/.cargo/bin/cargo build --release
working-directory: espflash
- name: Build xtask
run: $HOME/.cargo/bin/cargo build --release --locked
working-directory: xtask
- uses: actions/upload-artifact@v4
with:
name: espflash
path: target/release/espflash
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: xtask
path: target/release/xtask
if-no-files-found: error
run-target:
if: github.repository_owner == 'esp-rs'
name: ${{ matrix.board.mcu }}${{ matrix.board.freq }}
@ -95,44 +106,17 @@ jobs:
name: espflash
path: espflash_app
- uses: actions/download-artifact@v4
with:
name: xtask
path: xtask_app
- name: Set up espflash binary
run: |
chmod +x espflash_app/espflash
echo "$PWD/espflash_app" >> "$GITHUB_PATH"
chmod +x xtask_app/xtask
echo "$PWD/xtask_app" >> "$GITHUB_PATH"
- name: board-info test
run: timeout 10 bash espflash/tests/scripts/board-info.sh
- name: flash test
run: timeout 80 bash espflash/tests/scripts/flash.sh ${{ matrix.board.mcu }}
- name: monitor test
run: timeout 10 bash espflash/tests/scripts/monitor.sh
- name: erase-flash test
run: timeout 60 bash espflash/tests/scripts/erase-flash.sh
- name: save-image/write-bin test
run: |
timeout 180 bash espflash/tests/scripts/save-image_write-bin.sh ${{ matrix.board.mcu }}
- name: erase-region test
run: timeout 30 bash espflash/tests/scripts/erase-region.sh
- name: hold-in-reset test
run: timeout 10 bash espflash/tests/scripts/hold-in-reset.sh
- name: reset test
run: timeout 10 bash espflash/tests/scripts/reset.sh
- name: checksum-md5 test
run: timeout 40 bash espflash/tests/scripts/checksum-md5.sh
- name: list-ports test
run: timeout 10 bash espflash/tests/scripts/list-ports.sh
- name: write-bin test
run: timeout 20 bash espflash/tests/scripts/write-bin.sh
- name: read-flash test
run: timeout 60 bash espflash/tests/scripts/read-flash.sh
- name: Run all tests
run: xtask run-tests --chip ${{ matrix.board.mcu }} -t 60 --no-build

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
# Generated during tests
*.bin
# Generated by Cargo
# will have compiled files and executables
debug/

View File

@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Run the command and capture output and exit code
result=$(espflash board-info)
exit_code=$?
echo "$result"
# Extract chip type
chip_type=$(awk -F': *' '/Chip type:/ {print $2}' <<< "$result" | awk '{print $1}')
if [[ "$chip_type" == "esp32" ]]; then
# ESP32 doesn't support get_security_info
[[ $exit_code -eq 0 && "$result" =~ "Security features: None" ]] || {
echo "Expected Security features: None"
exit 1
}
else
# Non-ESP32 should contain required info
[[ $exit_code -eq 0 && "$result" =~ "Security Information:" && "$result" =~ "Flags" ]] || {
echo "Expected 'Security Information:' and 'Flags' in output but did not find them"
exit 1
}
fi

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
result=$(espflash erase-flash 2>&1)
echo "$result"
if [[ ! $result =~ "Flash has been erased!" ]]; then
exit 1
fi
result=$(espflash checksum-md5 0x1000 0x100 2>&1)
echo "$result"
if [[ ! $result =~ "0x827f263ef9fb63d05499d14fcef32f60" ]]; then
exit 1
fi

View File

@ -1,17 +0,0 @@
#!/usr/bin/env bash
result=$(espflash erase-flash 2>&1)
echo "$result"
if [[ ! $result =~ "Flash has been erased!" ]]; then
exit 1
fi
result=$(espflash read-flash 0 0x4000 flash_content.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Flash content successfully read and written to" ]]; then
exit 1
fi
echo "Checking if flash is empty"
if hexdump -v -e '/1 "%02x"' "flash_content.bin" | grep -qv '^ff*$'; then
exit 1
fi
echo "Flash is empty!"

View File

@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Function to check expected failure for unaligned erase arguments
check_unaligned_erase() {
local address=$1
local size=$2
result=$(espflash erase-region "$address" "$size" 2>&1)
echo "$result"
if [[ $result =~ "Invalid `address`" ]]; then
echo "Unaligned erase correctly rejected: address=$address, size=$size"
else
echo "Test failed: unaligned erase was not rejected!"
exit 1
fi
}
# Unaligned address (not a multiple of 4096)
check_unaligned_erase 0x1001 0x1000
# Unaligned size (not a multiple of 4096)
check_unaligned_erase 0x1000 0x1001
# Both address and size unaligned
check_unaligned_erase 0x1003 0x1005
# Valid erase - should succeed
result=$(espflash erase-region 0x1000 0x1000 2>&1)
echo "$result"
if [[ ! $result =~ "Erasing region at" ]]; then
exit 1
fi
result=$(espflash read-flash 0x1000 0x2000 flash_content.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Flash content successfully read and written to" ]]; then
echo "Failed to read flash contents"
exit 1
fi
# Check first 0x1000 bytes are FF
if head -c 4096 flash_content.bin | hexdump -v -e '/1 "%02x"' | grep -qv '^ff*$'; then
echo "First 0x1000 bytes should be empty (FF)"
exit 1
fi
# Check next 0x1000 bytes contain some non-FF bytes
if ! tail -c 4096 flash_content.bin | hexdump -v -e '/1 "%02x"' | grep -q '[0-e]'; then
echo "Next 0x1000 bytes should contain some non-FF bytes"
exit 1
fi
echo "Flash contents verified!"

View File

@ -1,90 +0,0 @@
#!/usr/bin/env bash
app="espflash/tests/data/$1"
part_table="espflash/tests/data/partitions.csv"
# espflash should not flash a partition table that is too big
result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app --flash-size 2mb --partition-table $part_table 2>&1)
echo "$result"
if [[ $result =~ "Flashing has completed!" ]]; then
echo "Flashing should have failed!"
exit 1
fi
if [[ $result =~ "espflash::partition_table::does_not_fit" ]]; then
echo "Flashing failed as expected!"
else
echo "Flashing has failed but not with the expected error!"
exit 1
fi
if [[ "$1" == "esp32c6" ]]; then
# With manual log-format
app_defmt="${app}_defmt"
result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app_defmt --log-format defmt 2>&1)
echo "$result"
if [[ ! $result =~ "Flashing has completed!" ]]; then
echo "Flashing failed!"
exit 1
fi
if ! echo "$result" | grep -q "Hello world!"; then
echo "Monitoring failed!"
exit 1
fi
# With auto-detected log-format
result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app_defmt 2>&1)
echo "$result"
if [[ ! $result =~ "Flashing has completed!" ]]; then
echo "Flashing failed!"
exit 1
fi
if ! echo "$result" | grep -q "Hello world!"; then
echo "Monitoring failed!"
exit 1
fi
# Backtrace test
app_backtrace="${app}_backtrace"
result=$(timeout 10s espflash flash --no-skip --monitor --non-interactive $app_backtrace 2>&1)
echo "$result"
if [[ ! $result =~ "Flashing has completed!" ]]; then
echo "Flashing failed!"
exit 1
fi
expected_strings=(
"0x420012c8"
"main"
"esp32c6_backtrace/src/bin/main.rs:"
"0x42001280"
"hal_main"
)
for expected in "${expected_strings[@]}"; do
if ! echo "$result" | grep -q "$expected"; then
echo "Monitoring failed! Expected '$expected' not found in output."
exit 1
fi
done
fi
result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app 2>&1)
echo "$result"
if [[ ! $result =~ "Flashing has completed!" ]]; then
echo "Flashing failed!"
exit 1
fi
if ! echo "$result" | grep -q "Hello world!"; then
echo "Monitoring failed!"
exit 1
fi
# Test with a higher baud rate
result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive --baud 921600 $app 2>&1 | tr -d '\0')
echo "$result"
if [[ ! $result =~ "Flashing has completed!" ]]; then
echo "Flashing failed!"
exit 1
fi
if ! echo "$result" | grep -q "Hello world!"; then
echo "Monitoring failed!"
exit 1
fi

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
result=$(espflash hold-in-reset 2>&1)
echo "$result"
if [[ ! $result =~ "Holding target device in reset" ]]; then
exit 1
fi

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
result=$(espflash list-ports 2>&1)
echo "$result"
if [[ ! $result =~ "Silicon Labs" ]]; then
exit 1
fi

View File

@ -1,8 +0,0 @@
#!/usr/bin/env bash
echo "Monitoring..."
result=$(timeout 5s espflash monitor --non-interactive || true)
echo "$result"
if ! echo "$result" | grep -q "Hello world!"; then
exit 1
fi

View File

@ -1,53 +0,0 @@
#!/usr/bin/env bash
KNOWN_PATTERN=$'\x01\xa0\x02\xB3\x04\xC4\x08\xD5\x10\xE6\x20\xF7\x40\x88\x50\x99'
KNOWN_PATTERN+=$'\x60\xAA\x70\xBB\x80\xCC\x90\xDD\xA0\xEE\xB0\xFF\xC0\x11\xD0\x22'
KNOWN_PATTERN+=$'\xE0\x33\xF0\x44\x05\x55\x15\x66\x25\x77\x35\x88\x45\x99\x55\xAA'
KNOWN_PATTERN+=$'\x65\xBB\x75\xCC\x85\xDD\x95\xEE\xA5\xFF\xB5\x00\xC5\x11\xD5\x22'
KNOWN_PATTERN+=$'\xE5\x33\xF5\x44\x06\x55\x16\x66\x26\x77\x36\x88\x46\x99\x56\xAA'
KNOWN_PATTERN+=$'\x66\xBB\x76\xCC\x86\xDD\x96\xEE\xA6\xFF\xB6\x00\xC6\x11\xD6\x22'
echo -ne "$KNOWN_PATTERN" > pattern.bin
result=$(espflash write-bin 0x0 pattern.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Binary successfully written to flash!" ]]; then
echo "Failed to write binary to flash"
exit 1
fi
lengths=(2 5 10 26 44 86)
for len in "${lengths[@]}"; do
echo "Testing read-flash with length: $len"
result=$(espflash read-flash 0 "$len" flash_content.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Flash content successfully read and written to" ]]; then
echo "Failed to read $len bytes from flash"
exit 1
fi
EXPECTED=$(echo -ne "$KNOWN_PATTERN" | head -c "$len")
if ! cmp -s <(echo -ne "$EXPECTED") flash_content.bin; then
echo "Verification failed: content does not match expected for length"
exit 1
fi
echo "Testing ROM read-flash with length: $len"
result=$(espflash read-flash --no-stub 0 "$len" flash_content.bin 2>&1)
echo "$result"
if ! cmp -s <(echo -ne "$EXPECTED") flash_content.bin; then
echo "Verification failed: content does not match expected for length"
exit 1
fi
if [[ ! $result =~ "Flash content successfully read and written to" ]]; then
echo "Failed to read $len bytes from flash"
exit 1
fi
done
echo "All read-flash tests passed!"

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
result=$(espflash reset 2>&1)
echo "$result"
if [[ ! $result =~ "Resetting target device" ]]; then
exit 1
fi

View File

@ -1,43 +0,0 @@
#!/usr/bin/env bash
app="espflash/tests/data/$1"
# if $1 is esp32c2, create an variable that contains `-x 26mhz`
if [[ $1 == "esp32c2" ]]; then
freq="-x 26mhz"
fi
result=$(espflash save-image --merge --chip $1 $freq $app app.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Image successfully saved!" ]]; then
exit 1
fi
echo "Writing binary"
result=$(timeout 90 espflash write-bin --monitor 0x0 app.bin --non-interactive 2>&1)
echo "$result"
if [[ ! $result =~ "Binary successfully written to flash!" ]]; then
exit 1
fi
if ! echo "$result" | grep -q "Hello world!"; then
exit 1
fi
if [[ $1 == "esp32c6" ]]; then
# Regression test for https://github.com/esp-rs/espflash/issues/741
app="espflash/tests/data/esp_idf_firmware_c6.elf"
result=$(espflash save-image --merge --chip esp32c6 $app app.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Image successfully saved!" ]]; then
exit 1
fi
echo "Checking that app descriptor is first"
# Read 4 bytes from 0x10020, it needs to be 0xABCD5432
app_descriptor=$(xxd -p -s 0x10020 -l 4 app.bin)
if [[ $app_descriptor != "3254cdab" ]]; then
echo "App descriptor magic word is not correct: $app_descriptor"
exit 1
fi
fi

View File

@ -1,23 +0,0 @@
#!/usr/bin/env bash
part_table="espflash/tests/data/partitions.csv"
# https://github.com/esp-rs/espflash/issues/622 reproducer
echo -ne "\x01\xa0" >binary_file.bin
result=$(espflash write-bin 0x0 binary_file.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Binary successfully written to flash!" ]]; then
echo "Failed to write binary"
exit 1
fi
result=$(espflash read-flash 0 64 flash_content.bin 2>&1)
echo "$result"
if [[ ! $result =~ "Flash content successfully read and written to" ]]; then
echo "Failed to read flash content"
exit 1
fi
# Check that the flash_content.bin contains the '01 a0' bytes
if ! grep -q -a -F $'\x01\xa0' flash_content.bin; then
echo "Failed verifying content"
exit 1
fi

View File

@ -0,0 +1,264 @@
use std::{
cmp::Ordering,
collections::{BTreeMap, HashMap},
ffi::OsStr,
fs::{self, OpenOptions},
io::{BufWriter, Write},
path::{Path, PathBuf},
process::Command,
};
use clap::{Args, Parser};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
// ----------------------------------------------------------------------------
// Command-line Interface
#[derive(Debug, Parser)]
enum Cli {
/// Generate eFuse field definitions
GenerateEfuseFields(GenerateEfuseFieldsArgs),
}
#[derive(Debug, Args)]
pub(crate) struct GenerateEfuseFieldsArgs {
/// Local path to the `esptool` repository
esptool_path: PathBuf,
}
const HEADER: &str = r#"
//! eFuse field definitions for the $CHIP
//!
//! This file was automatically generated, please do not edit it manually!
//!
//! Generated: $DATE
//! Version: $VERSION
#![allow(unused)]
use super::EfuseField;
"#;
type EfuseFields = HashMap<String, EfuseYaml>;
#[derive(Debug, serde::Deserialize)]
struct EfuseYaml {
#[serde(rename = "VER_NO")]
version: String,
#[serde(rename = "EFUSES")]
fields: HashMap<String, EfuseAttrs>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
struct EfuseAttrs {
#[serde(rename = "blk")]
block: u32,
word: u32,
len: u32,
start: u32,
#[serde(rename = "desc")]
description: String,
}
impl PartialOrd for EfuseAttrs {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for EfuseAttrs {
fn cmp(&self, other: &Self) -> Ordering {
match self.block.cmp(&other.block) {
Ordering::Equal => {}
ord => return ord,
}
match self.word.cmp(&other.word) {
Ordering::Equal => {}
ord => return ord,
}
self.start.cmp(&other.start)
}
}
pub(crate) fn generate_efuse_fields(workspace: &Path, args: GenerateEfuseFieldsArgs) -> Result<()> {
let efuse_yaml_path = args
.esptool_path
.join("espefuse")
.join("efuse_defs")
.canonicalize()?;
let espflash_path = workspace.join("espflash").canonicalize()?;
let mut efuse_fields = parse_efuse_fields(&efuse_yaml_path)?;
process_efuse_definitions(&mut efuse_fields)?;
generate_efuse_definitions(&espflash_path, efuse_fields)?;
Command::new("cargo")
.args(["+nightly", "fmt"])
.current_dir(workspace)
.output()?;
Ok(())
}
fn parse_efuse_fields(efuse_yaml_path: &Path) -> Result<EfuseFields> {
// TODO: We can probably handle this better, e.g. by defining a `Chip` enum
// which can be iterated over, but for now this is good enough.
const CHIPS: &[&str] = &[
"esp32", "esp32c2", "esp32c3", "esp32c5", "esp32c6", "esp32h2", "esp32p4", "esp32s2",
"esp32s3",
];
let mut efuse_fields = EfuseFields::new();
for result in fs::read_dir(efuse_yaml_path)? {
let path = result?.path();
if path.extension().is_none_or(|ext| ext != OsStr::new("yaml")) {
continue;
}
let chip = path.file_stem().unwrap().to_string_lossy().to_string();
if !CHIPS.contains(&chip.as_str()) {
continue;
}
let efuse_yaml = fs::read_to_string(&path)?;
let efuse_yaml: EfuseYaml = serde_yaml::from_str(&efuse_yaml)?;
efuse_fields.insert(chip.to_string(), efuse_yaml);
}
Ok(efuse_fields)
}
fn process_efuse_definitions(efuse_fields: &mut EfuseFields) -> Result<()> {
// This is all a special case for the MAC field, which is larger than a single
// word (i.e. 32-bits) in size. To handle this, we just split it up into two
// separate fields, and update the fields' attributes accordingly.
for yaml in (*efuse_fields).values_mut() {
let mac_attrs = yaml.fields.get("MAC").unwrap();
let mut mac0_attrs = mac_attrs.clone();
mac0_attrs.len = 32;
let mut mac1_attrs = mac_attrs.clone();
mac1_attrs.start = mac0_attrs.start + 32;
mac1_attrs.word = mac1_attrs.start / 32;
mac1_attrs.len = 16;
yaml.fields.remove("MAC").unwrap();
yaml.fields.insert("MAC0".into(), mac0_attrs);
yaml.fields.insert("MAC1".into(), mac1_attrs);
}
// The ESP32-S2 seems to be missing a reserved byte at the end of BLOCK0
// (Or, something else weird is going on).
efuse_fields.entry("esp32s2".into()).and_modify(|yaml| {
yaml.fields
.entry("RESERVED_0_162".into())
.and_modify(|field| field.len = 30);
});
Ok(())
}
fn generate_efuse_definitions(espflash_path: &Path, efuse_fields: EfuseFields) -> Result<()> {
let targets_efuse_path = espflash_path
.join("src")
.join("target")
.join("efuse")
.canonicalize()?;
for (chip, yaml) in efuse_fields {
let f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(targets_efuse_path.join(format!("{chip}.rs")))?;
let mut writer = BufWriter::new(f);
write!(
writer,
"{}",
HEADER
.replace("$CHIP", &chip)
.replace(
"$DATE",
&chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string()
)
.replace("$VERSION", &yaml.version)
.trim_start()
)?;
generate_efuse_block_sizes(&mut writer, &yaml.fields)?;
generate_efuse_constants(&mut writer, &yaml.fields)?;
}
Ok(())
}
fn generate_efuse_block_sizes(
writer: &mut dyn Write,
fields: &HashMap<String, EfuseAttrs>,
) -> Result<()> {
let mut field_attrs = fields.values().collect::<Vec<_>>();
field_attrs.sort();
let block_sizes = field_attrs
.chunk_by(|a, b| a.block == b.block)
.enumerate()
.map(|(block, attrs)| {
let last = attrs.last().unwrap();
let size_bits = last.start + last.len;
assert!(size_bits % 8 == 0);
(block, size_bits / 8)
})
.collect::<BTreeMap<_, _>>();
writeln!(writer, "/// Total size in bytes of each block")?;
writeln!(
writer,
"pub(crate) const BLOCK_SIZES: &[u32] = &[{}];\n",
block_sizes
.values()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ")
)?;
Ok(())
}
fn generate_efuse_constants(
writer: &mut dyn Write,
fields: &HashMap<String, EfuseAttrs>,
) -> Result<()> {
let mut sorted = fields.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| (a.1).cmp(b.1));
for (name, attrs) in sorted {
let EfuseAttrs {
block,
word,
len,
start,
description,
} = attrs;
let description = description.replace('[', "\\[").replace(']', "\\]");
writeln!(writer, "/// {description}")?;
writeln!(
writer,
"pub const {name}: EfuseField = EfuseField::new({block}, {word}, {start}, {len});"
)?;
}
Ok(())
}

View File

@ -1,16 +1,13 @@
use std::{
cmp::Ordering,
collections::{BTreeMap, HashMap},
ffi::OsStr,
fs::{self, OpenOptions},
io::{BufWriter, Write},
path::{Path, PathBuf},
process::Command,
};
use std::{env, path::PathBuf};
use clap::{Args, Parser};
use clap::Parser;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
// Import modules
mod efuse_generator;
mod test_runner;
// Type definition for results
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
// ----------------------------------------------------------------------------
// Command-line Interface
@ -18,13 +15,10 @@ type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[derive(Debug, Parser)]
enum Cli {
/// Generate eFuse field definitions
GenerateEfuseFields(GenerateEfuseFieldsArgs),
}
GenerateEfuseFields(efuse_generator::GenerateEfuseFieldsArgs),
#[derive(Debug, Args)]
struct GenerateEfuseFieldsArgs {
/// Local path to the `esptool` repository
esptool_path: PathBuf,
/// Run espflash tests
RunTests(test_runner::RunTestsArgs),
}
// ----------------------------------------------------------------------------
@ -35,251 +29,27 @@ fn main() -> Result<()> {
.filter_module("xtask", log::LevelFilter::Info)
.init();
// The directory containing the cargo manifest for the 'xtask' package is a
// subdirectory within the cargo workspace:
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace = workspace.parent().unwrap().canonicalize()?;
// Determine the path to the workspace (i.e. the root of the repository).
// At compile-time we know where the `xtask` crate lives, but that absolute
// path may not exist at runtime once the binary is distributed as an
// artefact and executed on a different machine (e.g. a self-hosted CI
// runner). Therefore we
// 1. Try the compile-time location first.
// 2. Fallback to the current working directory if that fails.
let workspace_from_build = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("`CARGO_MANIFEST_DIR` should always have a parent")
.to_path_buf();
let workspace = if workspace_from_build.exists() {
workspace_from_build.canonicalize()?
} else {
env::current_dir()?.canonicalize()?
};
match Cli::parse() {
Cli::GenerateEfuseFields(args) => generate_efuse_fields(&workspace, args),
Cli::GenerateEfuseFields(args) => efuse_generator::generate_efuse_fields(&workspace, args),
Cli::RunTests(args) => test_runner::run_tests(&workspace, args),
}
}
// ----------------------------------------------------------------------------
// Generate eFuse Fields
const HEADER: &str = r#"
//! eFuse field definitions for the $CHIP
//!
//! This file was automatically generated, please do not edit it manually!
//!
//! Generated: $DATE
//! Version: $VERSION
#![allow(unused)]
use super::EfuseField;
"#;
type EfuseFields = HashMap<String, EfuseYaml>;
#[derive(Debug, serde::Deserialize)]
struct EfuseYaml {
#[serde(rename = "VER_NO")]
version: String,
#[serde(rename = "EFUSES")]
fields: HashMap<String, EfuseAttrs>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
struct EfuseAttrs {
#[serde(rename = "blk")]
block: u32,
word: u32,
len: u32,
start: u32,
#[serde(rename = "desc")]
description: String,
}
impl PartialOrd for EfuseAttrs {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for EfuseAttrs {
fn cmp(&self, other: &Self) -> Ordering {
match self.block.cmp(&other.block) {
Ordering::Equal => {}
ord => return ord,
}
match self.word.cmp(&other.word) {
Ordering::Equal => {}
ord => return ord,
}
self.start.cmp(&other.start)
}
}
fn generate_efuse_fields(workspace: &Path, args: GenerateEfuseFieldsArgs) -> Result<()> {
let efuse_yaml_path = args
.esptool_path
.join("espefuse")
.join("efuse_defs")
.canonicalize()?;
let espflash_path = workspace.join("espflash").canonicalize()?;
let mut efuse_fields = parse_efuse_fields(&efuse_yaml_path)?;
process_efuse_definitions(&mut efuse_fields)?;
generate_efuse_definitions(&espflash_path, efuse_fields)?;
Command::new("cargo")
.args(["+nightly", "fmt"])
.current_dir(workspace)
.output()?;
Ok(())
}
fn parse_efuse_fields(efuse_yaml_path: &Path) -> Result<EfuseFields> {
// TODO: We can probably handle this better, e.g. by defining a `Chip` enum
// which can be iterated over, but for now this is good enough.
const CHIPS: &[&str] = &[
"esp32", "esp32c2", "esp32c3", "esp32c5", "esp32c6", "esp32h2", "esp32p4", "esp32s2",
"esp32s3",
];
let mut efuse_fields = EfuseFields::new();
for result in fs::read_dir(efuse_yaml_path)? {
let path = result?.path();
if path.extension().is_none_or(|ext| ext != OsStr::new("yaml")) {
continue;
}
let chip = path.file_stem().unwrap().to_string_lossy().to_string();
if !CHIPS.contains(&chip.as_str()) {
continue;
}
let efuse_yaml = fs::read_to_string(&path)?;
let efuse_yaml: EfuseYaml = serde_yaml::from_str(&efuse_yaml)?;
efuse_fields.insert(chip.to_string(), efuse_yaml);
}
Ok(efuse_fields)
}
fn process_efuse_definitions(efuse_fields: &mut EfuseFields) -> Result<()> {
// This is all a special case for the MAC field, which is larger than a single
// word (i.e. 32-bits) in size. To handle this, we just split it up into two
// separate fields, and update the fields' attributes accordingly.
for yaml in (*efuse_fields).values_mut() {
let mac_attrs = yaml.fields.get("MAC").unwrap();
let mut mac0_attrs = mac_attrs.clone();
mac0_attrs.len = 32;
let mut mac1_attrs = mac_attrs.clone();
mac1_attrs.start = mac0_attrs.start + 32;
mac1_attrs.word = mac1_attrs.start / 32;
mac1_attrs.len = 16;
yaml.fields.remove("MAC").unwrap();
yaml.fields.insert("MAC0".into(), mac0_attrs);
yaml.fields.insert("MAC1".into(), mac1_attrs);
}
// The ESP32-S2 seems to be missing a reserved byte at the end of BLOCK0
// (Or, something else weird is going on).
efuse_fields.entry("esp32s2".into()).and_modify(|yaml| {
yaml.fields
.entry("RESERVED_0_162".into())
.and_modify(|field| field.len = 30);
});
Ok(())
}
fn generate_efuse_definitions(espflash_path: &Path, efuse_fields: EfuseFields) -> Result<()> {
let targets_efuse_path = espflash_path
.join("src")
.join("target")
.join("efuse")
.canonicalize()?;
for (chip, yaml) in efuse_fields {
let f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(targets_efuse_path.join(format!("{chip}.rs")))?;
let mut writer = BufWriter::new(f);
write!(
writer,
"{}",
HEADER
.replace("$CHIP", &chip)
.replace(
"$DATE",
&chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string()
)
.replace("$VERSION", &yaml.version)
.trim_start()
)?;
generate_efuse_block_sizes(&mut writer, &yaml.fields)?;
generate_efuse_constants(&mut writer, &yaml.fields)?;
}
Ok(())
}
fn generate_efuse_block_sizes(
writer: &mut dyn Write,
fields: &HashMap<String, EfuseAttrs>,
) -> Result<()> {
let mut field_attrs = fields.values().collect::<Vec<_>>();
field_attrs.sort();
let block_sizes = field_attrs
.chunk_by(|a, b| a.block == b.block)
.enumerate()
.map(|(block, attrs)| {
let last = attrs.last().unwrap();
let size_bits = last.start + last.len;
assert!(size_bits % 8 == 0);
(block, size_bits / 8)
})
.collect::<BTreeMap<_, _>>();
writeln!(writer, "/// Total size in bytes of each block")?;
writeln!(
writer,
"pub(crate) const BLOCK_SIZES: &[u32] = &[{}];\n",
block_sizes
.values()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ")
)?;
Ok(())
}
fn generate_efuse_constants(
writer: &mut dyn Write,
fields: &HashMap<String, EfuseAttrs>,
) -> Result<()> {
let mut sorted = fields.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| (a.1).cmp(b.1));
for (name, attrs) in sorted {
let EfuseAttrs {
block,
word,
len,
start,
description,
} = attrs;
let description = description.replace('[', "\\[").replace(']', "\\]");
writeln!(writer, "/// {description}")?;
writeln!(
writer,
"pub const {name}: EfuseField = EfuseField::new({block}, {word}, {start}, {len});"
)?;
}
Ok(())
}

953
xtask/src/test_runner.rs Normal file
View File

@ -0,0 +1,953 @@
use std::{
fs,
io::{BufRead, BufReader},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use clap::{ArgAction, Args};
use log::info;
use crate::Result;
type SpawnedCommandOutput = (
Child,
Arc<Mutex<String>>,
thread::JoinHandle<()>,
thread::JoinHandle<()>,
);
/// Arguments for running tests
#[derive(Debug, Args)]
pub struct RunTestsArgs {
/// Which test to run (or "all" to run all tests)
#[clap(default_value = "all")]
pub test: String,
/// Chip target
#[clap(short, long)]
pub chip: Option<String>,
/// Timeout for test commands in seconds
#[clap(short, long, default_value = "15")]
pub timeout: u64,
/// Whether to build espflash before running tests, true by default
#[arg(long = "no-build", action = ArgAction::SetFalse, default_value_t = true)]
pub build_espflash: bool,
}
/// A struct to manage and run tests for the espflash
pub struct TestRunner {
/// The workspace directory where the tests are located
pub workspace: PathBuf,
/// The directory containing the test files
pub tests_dir: PathBuf,
/// Timeout for test commands
pub timeout: Duration,
/// Optional chip target for tests
pub chip: Option<String>,
/// Build espflash before running tests
pub build_espflash: bool,
}
impl TestRunner {
/// Creates a new [TestRunner] instance
pub fn new(
workspace: &Path,
tests_dir: PathBuf,
timeout_secs: u64,
build_espflash: bool,
) -> Self {
Self {
workspace: workspace.to_path_buf(),
tests_dir,
timeout: Duration::from_secs(timeout_secs),
chip: None,
build_espflash,
}
}
fn setup_command(&self, cmd: &mut Command) {
cmd.current_dir(&self.workspace)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
}
fn terminate_process(child: &mut Option<&mut Child>) {
if let Some(child_proc) = child {
let _ = child_proc.kill();
// Wait for the process to terminate
if let Some(child_proc) = child {
let _ = child_proc.wait();
}
}
}
fn restore_terminal() {
#[cfg(unix)]
{
let _ = Command::new("stty").arg("sane").status();
}
}
fn spawn_and_capture_output(cmd: &mut Command) -> Result<SpawnedCommandOutput> {
info!("Spawning command: {cmd:?}");
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().expect("Failed to capture stdout");
let stderr = child.stderr.take().expect("Failed to capture stderr");
let output = Arc::new(Mutex::new(String::new()));
let out_clone1 = Arc::clone(&output);
let out_clone2 = Arc::clone(&output);
let stdout_handle = thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(|line| line.ok()) {
println!("{line}");
out_clone1.lock().unwrap().push_str(&line);
out_clone1.lock().unwrap().push('\n');
}
});
let stderr_handle = thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(|line| line.ok()) {
println!("{line}");
out_clone2.lock().unwrap().push_str(&line);
out_clone2.lock().unwrap().push('\n');
}
});
Ok((child, output, stdout_handle, stderr_handle))
}
/// Runs a command with a timeout, returning the exit code
pub fn run_command_with_timeout(&self, cmd: &mut Command, timeout: Duration) -> Result<i32> {
log::debug!("Running command: {cmd:?}");
self.setup_command(cmd);
let mut child = cmd.spawn()?;
let completed = Arc::new(AtomicBool::new(false));
let child_id = child.id();
let completed_clone = Arc::clone(&completed);
let timer = thread::spawn(move || {
let interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < timeout {
thread::sleep(interval);
elapsed += interval;
if completed_clone.load(Ordering::SeqCst) {
return;
}
}
log::warn!("Command timed out after {timeout:?}, killing process {child_id}");
Self::terminate_process(&mut None);
});
let status = match child.wait() {
Ok(s) => {
completed.store(true, Ordering::SeqCst);
s
}
Err(e) => {
completed.store(true, Ordering::SeqCst);
thread::sleep(Duration::from_millis(10));
return Err(format!("Command execution failed: {e}").into());
}
};
let _ = timer.join();
let exit_code = status.code().unwrap_or(1);
log::debug!("Command exit code: {exit_code}");
Ok(exit_code)
}
/// Runs a command for a specified duration, returning whether it terminated
/// naturally
pub fn run_command_for(&self, cmd: &mut Command, duration: Duration) -> Result<bool> {
log::debug!("Running command: {cmd:?}");
let mut child = cmd.spawn()?;
let start_time = Instant::now();
let mut naturally_terminated = false;
if let Ok(Some(_)) = child.try_wait() {
naturally_terminated = true;
} else {
thread::sleep(duration);
if let Ok(Some(_)) = child.try_wait() {
naturally_terminated = true;
}
}
if !naturally_terminated {
log::info!(
"Command ran for {:?}, terminating process {}",
start_time.elapsed(),
child.id()
);
Self::terminate_process(&mut Some(&mut child));
}
Self::restore_terminal();
log::debug!("Command completed after {:?}", start_time.elapsed());
Ok(naturally_terminated)
}
fn build_espflash(&self) {
let mut cmd = Command::new("cargo");
log::info!("Building espflash...");
cmd.args(["build", "-p", "espflash", "--release", "--"]);
let status = cmd.status().expect("Failed to build espflash");
if !status.success() {
panic!("espflash build failed with status: {status}");
}
}
fn create_espflash_command(&self, args: &[&str]) -> Command {
let mut cmd = Command::new("cargo");
// we need to distinguish between local and CI runs, on CI we are building
// espflash and then copying the binary, so we can use just `espflash`
match self.build_espflash {
true => {
log::info!("Running cargo run...");
cmd.args(["run", "-p", "espflash", "--release", "--quiet", "--"]);
}
false => {
log::info!("Using system espflash");
let mut cmd = Command::new("espflash");
cmd.args(args);
return cmd;
}
}
cmd.args(args);
cmd
}
/// Runs a simple command test, capturing output and checking for expected
/// outputs
pub fn run_simple_command_test(
&self,
args: &[&str],
expected_contains: Option<&[&str]>,
timeout: Duration,
test_name: &str,
) -> Result<()> {
log::info!("Running {test_name} test");
let mut cmd = self.create_espflash_command(args);
if let Some(expected) = expected_contains {
let (mut child, output, h1, h2) = Self::spawn_and_capture_output(&mut cmd)?;
let start_time = Instant::now();
let mut terminated_naturally = false;
while start_time.elapsed() < timeout {
if let Ok(Some(_)) = child.try_wait() {
terminated_naturally = true;
break;
}
thread::sleep(Duration::from_millis(100));
}
// If still running, kill it
if !terminated_naturally {
let _ = child.kill();
let _ = child.wait();
}
let _ = h1.join();
let _ = h2.join();
let output = output.lock().unwrap();
for &expected in expected {
if !output.contains(expected) {
Self::restore_terminal();
return Err(format!("Missing expected output: {expected}").into());
}
}
log::info!("{test_name} test passed and output verified");
} else {
let exit_code = self.run_command_with_timeout(&mut cmd, timeout)?;
if exit_code != 0 {
return Err(
format!("{test_name} test failed: non-zero exit code {exit_code}").into(),
);
}
log::info!("{test_name} test passed with exit code 0");
}
Ok(())
}
/// Runs a timed command test, capturing output and checking for expected
/// outputs after a specified duration
pub fn run_timed_command_test(
&self,
args: &[&str],
expected_contains: Option<&[&str]>,
duration: Duration,
test_name: &str,
) -> Result<()> {
log::info!("Running {test_name} test");
let mut cmd = self.create_espflash_command(args);
if let Some(expected) = expected_contains {
let (mut child, output, h1, h2) = Self::spawn_and_capture_output(&mut cmd)?;
thread::sleep(duration);
let _ = child.kill();
let _ = child.wait();
let _ = h1.join();
let _ = h2.join();
let output = output.lock().unwrap();
for &expected in expected {
if !output.contains(expected) {
Self::restore_terminal();
return Err(format!("Missing expected output: {expected}").into());
}
}
log::info!("{test_name} test passed and output verified");
} else {
let terminated_naturally = self.run_command_for(&mut cmd, duration)?;
log::info!("{test_name} test completed (terminated naturally: {terminated_naturally})");
}
Self::restore_terminal();
Ok(())
}
fn is_flash_empty(&self, file_path: &Path) -> Result<bool> {
let flash_data = fs::read(file_path)?;
Ok(flash_data.iter().all(|&b| b == 0xFF))
}
fn flash_output_file(&self) -> PathBuf {
self.tests_dir.join("flash_content.bin")
}
fn contains_sequence(data: &[u8], sequence: &[u8]) -> bool {
if sequence.len() > data.len() {
return false;
}
for i in 0..=(data.len() - sequence.len()) {
if &data[i..(i + sequence.len())] == sequence {
return true;
}
}
false
}
/// Runs all tests in the test suite, optionally overriding the chip target
pub fn run_all_tests(&self, chip_override: Option<&str>) -> Result<()> {
log::info!("Running all tests");
let chip = chip_override.or(self.chip.as_deref()).unwrap_or("esp32");
self.test_board_info()?;
self.test_flash(Some(chip))?;
self.test_monitor()?;
self.test_erase_flash()?;
self.test_save_image(Some(chip))?;
self.test_erase_region()?;
self.test_hold_in_reset()?;
self.test_reset()?;
self.test_checksum_md5()?;
self.test_list_ports()?;
self.test_write_bin()?;
self.test_read_flash()?;
log::info!("All tests completed successfully");
Ok(())
}
/// Runs a specific test by name, optionally overriding the chip target
pub fn run_specific_test(&self, test_name: &str, chip_override: Option<&str>) -> Result<()> {
let chip = chip_override.or(self.chip.as_deref()).unwrap_or("esp32");
match test_name {
"board-info" => self.test_board_info(),
"flash" => self.test_flash(Some(chip)),
"monitor" => self.test_monitor(),
"erase-flash" => self.test_erase_flash(),
"save-image" => self.test_save_image(Some(chip)),
"erase-region" => self.test_erase_region(),
"hold-in-reset" => self.test_hold_in_reset(),
"reset" => self.test_reset(),
"checksum-md5" => self.test_checksum_md5(),
"list-ports" => self.test_list_ports(),
"write-bin" => self.test_write_bin(),
"read-flash" => self.test_read_flash(),
_ => Err(format!("Unknown test: {test_name}").into()),
}
}
// Board info test
pub fn test_board_info(&self) -> Result<()> {
self.run_simple_command_test(
&["board-info"],
Some(&["Chip type:"]),
Duration::from_secs(5),
"board-info",
)
}
// Flash test
pub fn test_flash(&self, chip: Option<&str>) -> Result<()> {
let chip = chip.unwrap_or_else(|| self.chip.as_deref().unwrap_or("esp32"));
log::info!("Running flash test for chip: {chip}");
let app = format!("espflash/tests/data/{chip}");
let app_backtrace = format!("espflash/tests/data/{chip}_backtrace");
let part_table = "espflash/tests/data/partitions.csv";
// Partition table is too big
self.run_timed_command_test(
&[
"flash",
"--no-skip",
"--monitor",
"--non-interactive",
&app,
"--flash-size",
"2mb",
"--partition-table",
part_table,
],
Some(&["espflash::partition_table::does_not_fit"]),
Duration::from_secs(5),
"partition too big",
)?;
// Additional tests for ESP32-C6 with manual log-format
if chip == "esp32c6" {
// Test with manual log-format and with auto-detected log-format
self.test_flash_with_defmt(&app)?;
// Backtrace test
self.test_backtrace(&app_backtrace)?;
}
// Test standard flashing
self.run_timed_command_test(
&["flash", "--no-skip", "--monitor", "--non-interactive", &app],
Some(&["Flashing has completed!", "Hello world!"]),
Duration::from_secs(15),
"standard flashing",
)?;
// Test standard flashing
self.run_timed_command_test(
&[
"flash",
"--no-skip",
"--monitor",
"--non-interactive",
"--baud",
"921600",
&app,
],
Some(&["Flashing has completed!", "Hello world!"]),
Duration::from_secs(15),
"standard flashing with high baud rate",
)?;
Ok(())
}
fn test_flash_with_defmt(&self, app: &str) -> Result<()> {
let app_defmt = format!("{app}_defmt");
// Test with manual log-format
self.run_timed_command_test(
&[
"flash",
"--no-skip",
"--monitor",
"--non-interactive",
&app_defmt,
"--log-format",
"defmt",
],
Some(&["Flashing has completed!", "Hello world!"]),
Duration::from_secs(15),
"defmt manual log-format",
)?;
// Test with auto-detected log-format
self.run_timed_command_test(
&[
"flash",
"--no-skip",
"--monitor",
"--non-interactive",
&app_defmt,
],
Some(&["Flashing has completed!", "Hello world!"]),
Duration::from_secs(15),
"defmt auto-detected log-format",
)?;
Ok(())
}
fn test_backtrace(&self, app_backtrace: &str) -> Result<()> {
// Test flashing with backtrace
self.run_timed_command_test(
&[
"flash",
"--no-skip",
"--monitor",
"--non-interactive",
app_backtrace,
],
Some(&[
"0x420012c8",
"main",
"esp32c6_backtrace/src/bin/main.rs:",
"0x42001280",
"hal_main",
]),
Duration::from_secs(15),
"backtrace test",
)?;
Ok(())
}
/// Tests listing available ports
pub fn test_list_ports(&self) -> Result<()> {
self.run_simple_command_test(
&["list-ports"],
Some(&["Silicon Labs"]),
Duration::from_secs(5),
"list-ports",
)?;
Ok(())
}
/// Tests erasing the flash memory
pub fn test_erase_flash(&self) -> Result<()> {
log::info!("Running erase-flash test");
let flash_output = self.flash_output_file();
self.run_simple_command_test(
&["erase-flash"],
Some(&["Flash has been erased!"]),
Duration::from_secs(40),
"erase-flash",
)?;
// Read a portion of the flash to verify it's erased
self.run_simple_command_test(
&["read-flash", "0", "0x4000", flash_output.to_str().unwrap()],
Some(&["Flash content successfully read"]),
Duration::from_secs(5),
"read after erase",
)?;
// Verify the flash is empty (all 0xFF)
if let Ok(is_empty) = self.is_flash_empty(&flash_output) {
if !is_empty {
return Err("Flash is not empty after erase-flash command".into());
}
} else {
return Err("Failed to check if flash is empty".into());
}
log::info!("erase-flash test passed");
Ok(())
}
/// Tests erasing a specific region of the flash memory
pub fn test_erase_region(&self) -> Result<()> {
log::info!("Running erase-region test");
let flash_output = self.flash_output_file();
// Test unaligned address (not multiple of 4096)
let mut cmd = self.create_espflash_command(&["erase-region", "0x1001", "0x1000"]);
let exit_code = self.run_command_with_timeout(&mut cmd, Duration::from_secs(5))?;
if exit_code == 0 {
return Err("Unaligned address erase should have failed but succeeded".into());
}
// Test unaligned size (not multiple of 4096)
let mut cmd = self.create_espflash_command(&["erase-region", "0x1000", "0x1001"]);
let exit_code = self.run_command_with_timeout(&mut cmd, Duration::from_secs(5))?;
if exit_code == 0 {
return Err("Unaligned size erase should have failed but succeeded".into());
}
// Valid erase - should succeed
self.run_simple_command_test(
&["erase-region", "0x1000", "0x1000"],
Some(&["Erasing region at"]),
Duration::from_secs(5),
"erase-region valid",
)?;
// Read the region to verify it was erased
self.run_simple_command_test(
&[
"read-flash",
"0x1000",
"0x2000",
flash_output.to_str().unwrap(),
],
Some(&["Flash content successfully read"]),
Duration::from_secs(5),
"read after erase-region",
)?;
// Check flash contents - first part should be erased
if let Ok(flash_data) = fs::read(&flash_output) {
// First 0x1000 bytes should be 0xFF (erased)
let first_part = &flash_data[0..4096];
if !first_part.iter().all(|&b| b == 0xFF) {
return Err("First 0x1000 bytes should be empty (0xFF)".into());
}
// Next 0x1000 bytes should contain some non-FF bytes
let second_part = &flash_data[4096..8192];
if second_part.iter().all(|&b| b == 0xFF) {
return Err("Next 0x1000 bytes should contain some non-FF bytes".into());
}
} else {
return Err("Failed to read flash_content.bin file".into());
}
log::info!("erase-region test passed");
Ok(())
}
/// Tests reading the flash memory
pub fn test_read_flash(&self) -> Result<()> {
log::info!("Running read-flash test");
let flash_output = self.flash_output_file();
let pattern_file = self.tests_dir.join("pattern.bin");
// Create a pattern to write to flash
let known_pattern: Vec<u8> = vec![
0x01, 0xA0, 0x02, 0xB3, 0x04, 0xC4, 0x08, 0xD5, 0x10, 0xE6, 0x20, 0xF7, 0x40, 0x88,
0x50, 0x99, 0x60, 0xAA, 0x70, 0xBB, 0x80, 0xCC, 0x90, 0xDD, 0xA0, 0xEE, 0xB0, 0xFF,
0xC0, 0x11, 0xD0, 0x22,
];
// Write the pattern to a file
fs::write(&pattern_file, &known_pattern)?;
// Write the pattern to the flash
self.run_simple_command_test(
&["write-bin", "0x0", pattern_file.to_str().unwrap()],
Some(&["Binary successfully written to flash!"]),
Duration::from_secs(5),
"write pattern",
)?;
// Test reading various lengths
for &len in &[2, 5, 10, 26] {
log::info!("Testing read-flash with length: {len}");
// Test normal read
self.run_simple_command_test(
&[
"read-flash",
"0",
&len.to_string(),
flash_output.to_str().unwrap(),
],
Some(&["Flash content successfully read and written to"]),
Duration::from_secs(5),
&format!("read {len} bytes"),
)?;
// Verify the read data matches the expected pattern
if let Ok(read_data) = fs::read(&flash_output) {
let expected = &known_pattern[0..len as usize];
if &read_data[0..len as usize] != expected {
return Err(format!(
"Verification failed for length {len}: content does not match"
)
.into());
}
} else {
return Err(format!("Failed to read flash_content.bin for length {len}").into());
}
// Test ROM read (--no-stub option)
self.run_simple_command_test(
&[
"read-flash",
"--no-stub",
"0",
&len.to_string(),
flash_output.to_str().unwrap(),
],
Some(&["Flash content successfully read and written to"]),
Duration::from_secs(5),
&format!("read {len} bytes with ROM bootloader"),
)?;
// Verify the ROM read data matches the expected pattern
if let Ok(read_data) = fs::read(&flash_output) {
let expected = &known_pattern[0..len as usize];
if &read_data[0..len as usize] != expected {
return Err(format!(
"ROM read verification failed for length {len}: content does not match"
)
.into());
}
} else {
return Err(
format!("Failed to read flash_content.bin for ROM read length {len}").into(),
);
}
}
log::info!("read-flash test passed");
Ok(())
}
/// Tests writing a binary file to the flash memory
pub fn test_write_bin(&self) -> Result<()> {
log::info!("Running write-bin test");
let flash_content = self.flash_output_file();
let binary_file = self.tests_dir.join("binary_file.bin");
// Create a simple binary with a known pattern (regression test for issue #622)
let test_pattern = [0x01, 0xA0];
fs::write(&binary_file, test_pattern)?;
// Write the binary to a specific address
self.run_simple_command_test(
&["write-bin", "0x0", binary_file.to_str().unwrap()],
Some(&["Binary successfully written to flash!"]),
Duration::from_secs(15),
"write-bin to address",
)?;
// Read the flash to verify
self.run_simple_command_test(
&["read-flash", "0", "64", flash_content.to_str().unwrap()],
Some(&["Flash content successfully read"]),
Duration::from_secs(50),
"read after write-bin",
)?;
// Verify the flash content contains the test pattern
if let Ok(flash_data) = fs::read(&flash_content) {
if !Self::contains_sequence(&flash_data, &test_pattern) {
return Err("Failed verifying content: test pattern not found in flash".into());
}
} else {
return Err("Failed to read flash_content.bin file".into());
}
log::info!("write-bin test passed");
Ok(())
}
/// Tests saving an image to the flash memory
pub fn test_save_image(&self, chip: Option<&str>) -> Result<()> {
let chip = chip.unwrap_or_else(|| self.chip.as_deref().unwrap_or("esp32"));
log::info!("Running save-image test for chip: {chip}");
let app = format!("espflash/tests/data/{chip}");
let app_bin = self.tests_dir.join("app.bin");
// Determine if frequency option is needed
let mut args = vec![
"save-image",
"--merge",
"--chip",
chip,
&app,
app_bin.to_str().unwrap(),
];
// Add frequency option for esp32c2
if chip == "esp32c2" {
args.splice(2..2, ["-x", "26mhz"].iter().copied());
}
// Save image
self.run_simple_command_test(
&args,
Some(&["Image successfully saved!"]),
self.timeout,
"save-image",
)?;
// Write the image and monitor
self.run_timed_command_test(
&[
"write-bin",
"--monitor",
"0x0",
app_bin.to_str().unwrap(),
"--non-interactive",
],
Some(&["Hello world!"]),
Duration::from_secs(80),
"write-bin and monitor",
)?;
// Additional regression test for ESP32-C6
if chip == "esp32c6" {
self.test_esp32c6_regression(&app_bin)?;
}
log::info!("save-image test passed");
Ok(())
}
/// Tests the ESP32-C6 regression case
fn test_esp32c6_regression(&self, app_bin: &Path) -> Result<()> {
log::info!("Running ESP32-C6 regression test");
let app = "espflash/tests/data/esp_idf_firmware_c6.elf";
// Save image with ESP32-C6 regression test case
self.run_simple_command_test(
&[
"save-image",
"--merge",
"--chip",
"esp32c6",
app,
app_bin.to_str().unwrap(),
],
Some(&["Image successfully saved!"]),
Duration::from_secs(5),
"save-image C6 regression",
)?;
// Check that app descriptor is in the correct position
if let Ok(bin_data) = fs::read(app_bin) {
if bin_data.len() >= 0x10024 {
let app_descriptor_offset = 0x10020;
// Check for magic word 0xABCD5432 (in little-endian format)
let expected_magic = [0x32, 0x54, 0xCD, 0xAB];
if bin_data[app_descriptor_offset..app_descriptor_offset + 4] != expected_magic {
return Err("App descriptor magic word is not correct".into());
}
} else {
return Err("Binary file is too small to contain app descriptor".into());
}
} else {
return Err("Failed to read app.bin file".into());
}
log::info!("ESP32-C6 regression test passed");
Ok(())
}
/// Tests the MD5 checksum command
pub fn test_checksum_md5(&self) -> Result<()> {
log::info!("Running checksum-md5 test");
// First erase the flash
self.run_simple_command_test(
&["erase-flash"],
Some(&["Flash has been erased!"]),
Duration::from_secs(40),
"erase-flash for checksum",
)?;
// Then check the MD5 checksum of a region
self.run_simple_command_test(
&["checksum-md5", "0x1000", "0x100"],
Some(&["0x827f263ef9fb63d05499d14fcef32f60"]),
Duration::from_secs(5),
"checksum-md5",
)?;
log::info!("checksum-md5 test passed");
Ok(())
}
/// Tests the monitor command
pub fn test_monitor(&self) -> Result<()> {
self.run_timed_command_test(
&["monitor", "--non-interactive"],
Some(&["Hello world!"]),
Duration::from_secs(5),
"monitor",
)?;
Ok(())
}
/// Tests resetting the target device
pub fn test_reset(&self) -> Result<()> {
self.run_simple_command_test(
&["reset"],
Some(&["Resetting target device"]),
Duration::from_secs(5),
"reset",
)?;
Ok(())
}
/// Tests holding the target device in reset
pub fn test_hold_in_reset(&self) -> Result<()> {
self.run_simple_command_test(
&["hold-in-reset"],
Some(&["Holding target device in reset"]),
Duration::from_secs(5),
"hold-in-reset",
)?;
Ok(())
}
}
/// Runs the tests based on the provided arguments
pub fn run_tests(workspace: &Path, args: RunTestsArgs) -> Result<()> {
log::info!("Running espflash tests");
let tests_dir = workspace.join("espflash").join("tests");
let test_runner = TestRunner::new(workspace, tests_dir, args.timeout, args.build_espflash);
// Build espflash before running test(s) so we are not "waisting" test's
// duration or timeout
if args.build_espflash {
test_runner.build_espflash();
}
match args.test.as_str() {
"all" => {
if let Err(e) = test_runner.run_all_tests(args.chip.as_deref()) {
log::error!("Test suite failed: {e}");
return Err(e);
}
}
specific_test => {
if let Err(e) = test_runner.run_specific_test(specific_test, args.chip.as_deref()) {
log::error!("Test '{specific_test}' failed: {e}");
return Err(e);
}
}
}
Ok(())
}