diff --git a/README.md b/README.md index 11a3a5d..b920261 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,70 @@ Example: Compiling 1 file (.ex) Generated my_project app +### The `atomvm.esp32.build` task + +The `atomvm.esp32.build` task builds an AtomVM ESP32 firmware image from source, with Elixir support enabled, using either a local ESP-IDF installation or the ESP-IDF Docker image. The resulting flashable image is written under `_build/atomvm_images/` and can be flashed with `mix atomvm.esp32.install`. + +If no AtomVM source is supplied, the task clones the AtomVM `main` branch automatically. Use `--atomvm-path` to build from a local checkout or `--atomvm-url`/`--ref` to build from a specific git source. + +#### Requirements + +* Erlang/OTP 27 or later, Elixir 1.18 or later, and Git +* **Without Docker:** CMake (3.13+), Ninja (preferred) or Make, and ESP-IDF (v5.5.4 or later recommended) +* **With Docker (`--use-docker`):** Docker. Note that Docker build support requires AtomVM `main` from Jan 2, 2026 or later; earlier AtomVM versions must be built with a local ESP-IDF toolchain. + +#### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--atomvm-path` | - | Path to a local AtomVM repository (overrides `--atomvm-url` if both are given) | +| `--atomvm-url` | `https://github.com/atomvm/AtomVM` | Git URL to clone AtomVM from | +| `--ref` | `main` | Git reference to check out: branch, tag, commit SHA, or PR (e.g. `pr/1234` or `pull/1234/head`) | +| `--chip` | `esp32` | Target chip(s), comma-separated for multiple (`esp32`, `esp32s2`, `esp32s3`, `esp32c2`, `esp32c3`, `esp32c6`, `esp32h2`, `esp32p4`) | +| `--idf-path` | `idf.py` | Path to the `idf.py` executable | +| `--use-docker` | `false` | Use the ESP-IDF Docker image instead of a local installation | +| `--idf-version` | `v5.5.4` | ESP-IDF version for the Docker image | +| `--clean` | `false` | Clean the build directory before building | +| `--mbedtls-prefix` | - | Path to a custom MbedTLS installation (falls back to the `MBEDTLS_PREFIX` env var) | +| `--partition-table` | - | Path to custom partition table CSV file (falls back to `custom_partitions.csv` in project root) | + +#### Custom partition table + +You can explicitly specify a custom partition table file with the `--partition-table` option: + +```shell +mix atomvm.esp32.build --partition-table path/to/partitions.csv +``` + +If the `--partition-table` option is not provided but the root of your Mix project contains `custom_partitions.csv`, it is used as the default partition table for the build. + +ExAtomVM passes custom partition contents through unchanged, without imposing partition names, types, offsets, or sizes. It only checks that the selected file is readable, non-empty, and regular; AtomVM and ESP-IDF handle the contents during the build. + +The selected file is read once before cloning or building and reused for every chip, even if cleaning removes the source file. Its contents are copied into the AtomVM ESP32 platform tree only while the build runs — so Docker builds see it through the mounted AtomVM source tree — and the original partition table is restored afterwards, leaving the AtomVM checkout clean. + +> Note. When a custom partition table is used (either via `--partition-table` or default `custom_partitions.csv`), the task automatically forces a clean ESP32 platform build so CMake regenerates the partition layout — you do not need to pass `--clean` yourself. + +#### Examples + + # Build for the default esp32 chip (clones AtomVM main automatically) + shell$ mix atomvm.esp32.build + + # Build from a local AtomVM checkout for a specific chip, cleaning first + shell$ mix atomvm.esp32.build --atomvm-path /path/to/AtomVM --chip esp32s3 --clean + + # Build for multiple chips in one run + shell$ mix atomvm.esp32.build --chip esp32,esp32s3,esp32c6 + + # Build from a pull request + shell$ mix atomvm.esp32.build --ref pr/1234 + + # Build using Docker (clones AtomVM main automatically) + shell$ mix atomvm.esp32.build --use-docker --chip esp32s3 --clean + +When combining `--use-docker` with a local `--atomvm-path`, the task expands the checkout path to an absolute path and bind-mounts it into the container. For example, if AtomVM is checked out next to your project: + + shell$ mix atomvm.esp32.build --use-docker --atomvm-path ../AtomVM --chip esp32s3 + ### The `atomvm.esp32.flash` task The `atomvm.esp32.flash` task is used to flash your application to a micro-controller and executed by the AtomVM virtual machine. diff --git a/lib/esp32_build_staging.ex b/lib/esp32_build_staging.ex new file mode 100644 index 0000000..2cbdfc4 --- /dev/null +++ b/lib/esp32_build_staging.ex @@ -0,0 +1,45 @@ +defmodule ExAtomVM.Esp32BuildStaging do + @moduledoc false + + # Snapshots and restores files that a build stages into the AtomVM checkout, + # so the checkout is left as it was found. + # + # A snapshot is `{:content, binary}` for a regular file or `:missing` when the + # path does not exist. Directories, symlinks, and other non-regular files are + # refused with `{:error, :einval}`, since writing to them would replace them + # instead of restoring their contents. + + @doc false + def snapshot_file(path) do + case File.lstat(path) do + {:ok, %File.Stat{type: :regular}} -> + case File.read(path) do + {:ok, content} -> {:ok, {:content, content}} + {:error, reason} -> {:error, reason} + end + + {:ok, _stat} -> + {:error, :einval} + + {:error, :enoent} -> + {:ok, :missing} + + {:error, reason} -> + {:error, reason} + end + end + + @doc false + def restore_file(path, {:content, content}) do + File.write(path, content) + end + + @doc false + def restore_file(path, :missing) do + case File.rm(path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/esp32_custom_partitions.ex b/lib/esp32_custom_partitions.ex new file mode 100644 index 0000000..2d7c75c --- /dev/null +++ b/lib/esp32_custom_partitions.ex @@ -0,0 +1,94 @@ +defmodule ExAtomVM.Esp32CustomPartitions do + @moduledoc false + + alias ExAtomVM.Esp32BuildStaging + + @custom_partitions_csv "custom_partitions.csv" + @atomvm_elixir_partitions_csv "partitions-elixir.csv" + + # Capture the selection before cloning or cleaning can remove the source. + def load_custom_partitions(user_provided_path) do + path = Path.expand(user_provided_path || @custom_partitions_csv) + + case File.lstat(path) do + {:error, :enoent} when is_nil(user_provided_path) -> + {:ok, nil} + + {:error, :enoent} -> + {:error, "Partition table file does not exist: #{user_provided_path}"} + + _ -> + with :ok <- validate_partition_file(path), + {:ok, content} <- read_partition_file(path) do + {:ok, %{path: path, content: content}} + end + end + end + + def with_custom_partitions(_platform_dir, nil, fun), do: fun.() + + def with_custom_partitions(platform_dir, %{path: source_path, content: content}, fun) do + dest_path = Path.join(platform_dir, @atomvm_elixir_partitions_csv) + + case Esp32BuildStaging.snapshot_file(dest_path) do + {:ok, original} -> + source_filename = Path.basename(source_path) + IO.puts("Copying #{source_filename} to #{dest_path} for this build...") + + try do + # Writing bytes preserves the existing destination's permissions. + case File.write(dest_path, content) do + :ok -> + fun.() + + {:error, reason} -> + {:error, "Failed to copy #{source_filename}: #{:file.format_error(reason)}"} + end + after + restore!(dest_path, original) + end + + {:error, reason} -> + {:error, + "Failed to read existing #{@atomvm_elixir_partitions_csv}: #{:file.format_error(reason)}"} + end + end + + # A failed restoration leaves the AtomVM checkout modified, so it must raise + # rather than let the build report success. + defp restore!(path, snapshot) do + case Esp32BuildStaging.restore_file(path, snapshot) do + :ok -> + :ok + + {:error, reason} -> + raise File.Error, reason: reason, action: "restore", path: path + end + end + + defp read_partition_file(path) do + case File.read(path) do + {:ok, content} -> + {:ok, content} + + {:error, reason} -> + {:error, "cannot read #{Path.basename(path)}: #{:file.format_error(reason)}"} + end + end + + defp validate_partition_file(path) do + case File.stat(path) do + {:ok, %File.Stat{type: :regular, size: 0}} -> + {:error, "#{Path.basename(path)} is empty"} + + {:ok, %File.Stat{type: :regular}} -> + :ok + + {:ok, _stat} -> + {:error, "#{Path.basename(path)} exists but is not a regular file"} + + {:error, reason} -> + {:error, "cannot read #{Path.basename(path)}: #{:file.format_error(reason)}"} + end + end +end diff --git a/lib/mix/tasks/esp32.build.ex b/lib/mix/tasks/esp32.build.ex index 20ca29c..564ab6f 100644 --- a/lib/mix/tasks/esp32.build.ex +++ b/lib/mix/tasks/esp32.build.ex @@ -7,13 +7,13 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do ## Requirements **General requirements** - * Erlang/OTP (25 or later) - * Elixir (1.16 or later) + * Erlang/OTP (27 or later) + * Elixir (1.18 or later) * Git - - **Without Docker:** * CMake (3.13 or later) * Ninja (preferred) or Make + + **Without Docker:** * ESP-IDF (v5.5.4 or later recommended) **With Docker (--use-docker flag):** @@ -32,6 +32,12 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do * `--idf-version` - ESP-IDF version for Docker image (default: v5.5.4) * `--clean` - Clean build directory before building * `--mbedtls-prefix` - Path to custom MbedTLS installation (optional, falls back to MBEDTLS_PREFIX env var) + * `--partition-table` - Path to custom partition table CSV file (optional, defaults to custom_partitions.csv if present) + + If `--partition-table` is provided, or if your Mix project root contains `custom_partitions.csv`, + it will be used as the ESP32 partition table for the build. ExAtomVM passes the contents + through unchanged, without imposing partition names, types, offsets, or sizes. The selected + file must be readable, non-empty, and regular; AtomVM and ESP-IDF handle its contents. ## Examples @@ -70,6 +76,7 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do """ use Mix.Task + alias ExAtomVM.Esp32CustomPartitions @shortdoc "Build AtomVM for ESP32 from source" @@ -93,7 +100,8 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do use_docker: :boolean, idf_version: :string, clean: :boolean, - mbedtls_prefix: :string + mbedtls_prefix: :string, + partition_table: :string ] ) @@ -105,6 +113,16 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do idf_version = Keyword.get(opts, :idf_version, @default_idf_version) clean = Keyword.get(opts, :clean, false) + partition_table = + case Esp32CustomPartitions.load_custom_partitions(Keyword.get(opts, :partition_table)) do + {:ok, selected} -> + selected + + {:error, reason} -> + IO.puts("Error: #{reason}") + exit({:shutdown, 1}) + end + chips = opts |> Keyword.get(:chip, @default_chip) @@ -148,6 +166,16 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do with :ok <- check_esp_idf(idf_path, use_docker, idf_version), :ok <- check_escript(), :ok <- ExAtomVM.AtomVMBuilder.build_generic_unix(atomvm_path, mbedtls_prefix, clean) do + custom_partitions? = not is_nil(partition_table) + + if custom_partitions? and not clean do + filename = Path.basename(partition_table.path) + + IO.puts( + "#{filename} detected; forcing clean ESP32 platform build so partition metadata is regenerated..." + ) + end + results = chips |> Enum.with_index(1) @@ -156,9 +184,17 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do IO.puts("\n━━━ Building chip #{index}/#{length(chips)}: #{chip} ━━━\n") end - force_clean = index > 1 or clean - - case build_atomvm(atomvm_path, chip, idf_path, idf_version, use_docker, force_clean) do + force_clean = clean or index > 1 or custom_partitions? + + case build_atomvm( + atomvm_path, + chip, + idf_path, + idf_version, + use_docker, + force_clean, + partition_table + ) do {:ok, src_img} -> img = save_image(src_img) {chip, :ok, img} @@ -283,7 +319,7 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do end end - defp build_atomvm(atomvm_path, chip, idf_path, idf_version, use_docker, clean) do + defp build_atomvm(atomvm_path, chip, idf_path, idf_version, use_docker, clean, partition_table) do build_dir = Path.join([atomvm_path, "src", "platforms", "esp32", "build"]) platform_dir = Path.join([atomvm_path, "src", "platforms", "esp32"]) @@ -317,55 +353,57 @@ defmodule Mix.Tasks.Atomvm.Esp32.Build do File.cp!(dependencies_lock, dest_path) end - if clean and File.dir?(build_dir) do - IO.puts("Cleaning build directory...") - ExAtomVM.AtomVMBuilder.clean_dir(build_dir) - end - - IO.puts("Configuring build for #{chip}...") + Esp32CustomPartitions.with_custom_partitions(platform_dir, partition_table, fn -> + if clean and File.dir?(build_dir) do + IO.puts("Cleaning build directory...") + ExAtomVM.AtomVMBuilder.clean_dir(build_dir) + end - {_output, status} = - run_idf_command( - use_docker, - idf_version, - atomvm_path, - platform_dir, - idf_path, - idf_set_target_args(chip) - ) + IO.puts("Configuring build for #{chip}...") + + {_output, status} = + run_idf_command( + use_docker, + idf_version, + atomvm_path, + platform_dir, + idf_path, + idf_set_target_args(chip) + ) + + case status do + 0 -> + IO.puts("Building AtomVM... (this may take several minutes)") + + {_output, build_status} = + run_idf_command( + use_docker, + idf_version, + atomvm_path, + platform_dir, + idf_path, + idf_build_args() + ) - case status do - 0 -> - IO.puts("Building AtomVM... (this may take several minutes)") - - {_output, build_status} = - run_idf_command( - use_docker, - idf_version, - atomvm_path, - platform_dir, - idf_path, - idf_build_args() - ) + case build_status do + 0 -> + copy_dependencies_lock(platform_dir) - case build_status do - 0 -> - copy_dependencies_lock(platform_dir) + create_flashable_image( + Path.expand(atomvm_path), + Path.expand(build_dir), + chip, + use_docker + ) - create_flashable_image( - Path.expand(atomvm_path), - Path.expand(build_dir), - chip, - use_docker - ) - - _status -> - {:error, "Build failed"} - end + _status -> + {:error, "Build failed"} + end - _status -> - {:error, "Failed to set target chip"} - end + _status -> + {:error, "Failed to set target chip"} + end + end) end defp idf_set_target_args(chip) do diff --git a/test/esp32_build_staging_test.exs b/test/esp32_build_staging_test.exs new file mode 100644 index 0000000..78df41c --- /dev/null +++ b/test/esp32_build_staging_test.exs @@ -0,0 +1,45 @@ +defmodule ExAtomVM.Esp32BuildStagingTest do + use ExUnit.Case, async: false + + alias ExAtomVM.Esp32BuildStaging + + @moduletag :tmp_dir + + test "snapshots and restores the contents of a regular file", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "file") + File.write!(path, "original") + + assert {:ok, {:content, "original"}} = Esp32BuildStaging.snapshot_file(path) + File.write!(path, "staged") + assert :ok = Esp32BuildStaging.restore_file(path, {:content, "original"}) + assert File.read!(path) == "original" + end + + test "snapshots a missing file and removes it on restore", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "file") + + assert {:ok, :missing} = Esp32BuildStaging.snapshot_file(path) + File.write!(path, "staged") + assert :ok = Esp32BuildStaging.restore_file(path, :missing) + refute File.exists?(path) + end + + test "refuses directories and symlinks", %{tmp_dir: tmp_dir} do + dir = Path.join(tmp_dir, "dir") + File.mkdir!(dir) + assert {:error, :einval} = Esp32BuildStaging.snapshot_file(dir) + + link = Path.join(tmp_dir, "link") + File.ln_s!(Path.join(tmp_dir, "missing-target"), link) + assert {:error, :einval} = Esp32BuildStaging.snapshot_file(link) + end + + test "reports a failed restore instead of raising", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "dir") + File.mkdir!(path) + + assert {:error, reason} = Esp32BuildStaging.restore_file(path, :missing) + assert reason in [:eisdir, :eperm] + assert File.dir?(path) + end +end diff --git a/test/esp32_custom_partitions_test.exs b/test/esp32_custom_partitions_test.exs new file mode 100644 index 0000000..ce334b8 --- /dev/null +++ b/test/esp32_custom_partitions_test.exs @@ -0,0 +1,248 @@ +defmodule ExAtomVM.Esp32CustomPartitionsTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias ExAtomVM.Esp32CustomPartitions + + @moduletag :tmp_dir + @table File.read!(Path.join(__DIR__, "fixtures/esp32_partitions.csv")) + + setup %{tmp_dir: tmp_dir} do + platform_dir = Path.join(tmp_dir, "platform") + File.mkdir_p!(platform_dir) + source_path = Path.join(tmp_dir, "custom_partitions.csv") + File.write!(source_path, @table) + + {:ok, selected} = Esp32CustomPartitions.load_custom_partitions(source_path) + + {:ok, + platform_dir: platform_dir, + source_path: source_path, + dest_path: Path.join(platform_dir, "partitions-elixir.csv"), + selected: selected} + end + + test "loads the default CSV, or retains no selection if absent", %{tmp_dir: tmp_dir} do + File.cd!(tmp_dir, fn -> + assert {:ok, %{content: @table}} = Esp32CustomPartitions.load_custom_partitions(nil) + File.rm!("custom_partitions.csv") + assert {:ok, nil} = Esp32CustomPartitions.load_custom_partitions(nil) + File.write!("custom_partitions.csv", @table) + assert :stock = Esp32CustomPartitions.with_custom_partitions(tmp_dir, nil, fn -> :stock end) + end) + end + + test "rejects missing, empty, and directory paths", %{tmp_dir: tmp_dir} do + assert {:error, "Partition table file does not exist: " <> _} = + Esp32CustomPartitions.load_custom_partitions(Path.join(tmp_dir, "missing.csv")) + + path = Path.join(tmp_dir, "empty.csv") + File.write!(path, "") + assert {:error, "empty.csv is empty"} = Esp32CustomPartitions.load_custom_partitions(path) + + assert {:error, message} = Esp32CustomPartitions.load_custom_partitions(tmp_dir) + assert message =~ "not a regular file" + end + + test "loads custom CSV contents unchanged", %{source_path: path} do + assert {:ok, %{content: @table}} = Esp32CustomPartitions.load_custom_partitions(path) + + table = + @table + |> String.replace("app, factory, 0x10000", "0x00, 0, 64K") + |> String.replace("data, phy, 0x250000", "1, phy, 2424832") + + File.write!(path, table) + assert {:ok, %{content: ^table}} = Esp32CustomPartitions.load_custom_partitions(path) + end + + test "leaves main.avm offsets to AtomVM, including the JIT offset", %{source_path: path} do + for offset <- ["0x300000", "0x280000", ""] do + table = + @table + |> String.replace("0x250000", offset) + |> String.replace("0x1B0000", "0x100000") + + File.write!(path, table) + assert {:ok, %{content: ^table}} = Esp32CustomPartitions.load_custom_partitions(path) + end + end + + test "accepts A/B application partitions without main.avm", %{source_path: path} do + table = + String.replace( + @table, + "main.avm, data, phy, 0x250000, 0x1B0000,", + "main_a.avm, data, phy, 0x250000, 0xD0000,\nmain_b.avm, data, phy, 0x320000, 0xE0000," + ) + + refute table =~ "main.avm" + File.write!(path, table) + assert {:ok, %{content: ^table}} = Esp32CustomPartitions.load_custom_partitions(path) + end + + test "passes arbitrary partition contents through without layout validation", ctx do + for content <- [ + "slot_a, app, ota_0, 0x20000, 1M\nslot_b, app, ota_1, 0x120000, 1M\n", + "storage, data, spiffs, , 512K\n", + "# Name, Type, SubType, Offset, Size\n", + "not a CSV" + ] do + File.write!(ctx.source_path, content) + assert {:ok, selected} = Esp32CustomPartitions.load_custom_partitions(ctx.source_path) + assert selected.content == content + + capture_io(fn -> + assert :ok = + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, selected, fn -> + assert File.read!(ctx.dest_path) == content + :ok + end) + end) + end + end + + test "invalid selection fails before checking the AtomVM checkout", %{tmp_dir: tmp_dir} do + output = + capture_io(fn -> + assert catch_exit( + Mix.Tasks.Atomvm.Esp32.Build.run([ + "--partition-table", + Path.join(tmp_dir, "missing.csv"), + "--atomvm-path", + Path.join(tmp_dir, "missing-checkout") + ]) + ) == {:shutdown, 1} + end) + + assert output =~ "Partition table file does not exist" + refute output =~ "AtomVM path does not exist" + end + + test "restores bytes and mode with read-only and executable sources, even on callback failure", + ctx do + for mode <- [0o444, 0o755], outcome <- [:ok, :error, :raise] do + File.chmod!(ctx.source_path, mode) + File.write!(ctx.dest_path, "original table") + File.chmod!(ctx.dest_path, 0o644) + {:ok, selected} = Esp32CustomPartitions.load_custom_partitions(ctx.source_path) + + callback = fn -> + assert File.read!(ctx.dest_path) == @table + assert Bitwise.band(File.stat!(ctx.dest_path).mode, 0o777) == 0o644 + + case outcome do + :raise -> raise "build failed" + :error -> {:error, "build failed"} + :ok -> :ok + end + end + + capture_io(fn -> + if outcome == :raise do + assert_raise RuntimeError, "build failed", fn -> + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, selected, callback) + end + else + expected = if outcome == :ok, do: :ok, else: {:error, "build failed"} + + assert Esp32CustomPartitions.with_custom_partitions( + ctx.platform_dir, + selected, + callback + ) == expected + end + end) + + assert File.read!(ctx.dest_path) == "original table" + assert Bitwise.band(File.stat!(ctx.dest_path).mode, 0o777) == 0o644 + end + end + + test "removes the temporary destination when originally absent", ctx do + capture_io(fn -> + assert :ok = + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, ctx.selected, fn -> + assert File.read!(ctx.dest_path) == @table + :ok + end) + end) + + refute File.exists?(ctx.dest_path) + end + + test "rejects a directory or dangling symlink destination without changing it", ctx do + File.mkdir!(ctx.dest_path) + + assert {:error, _} = + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, ctx.selected, fn -> + flunk("must not build") + end) + + assert File.dir?(ctx.dest_path) + File.rmdir!(ctx.dest_path) + + target = Path.join(ctx.tmp_dir, "missing-target") + File.ln_s!(target, ctx.dest_path) + + assert {:error, _} = + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, ctx.selected, fn -> + flunk("must not build") + end) + + assert File.read_link!(ctx.dest_path) == target + refute File.exists?(target) + end + + test "reuses the selected bytes for later chips after cleaning deletes the source", ctx do + build_dir = Path.join(ctx.platform_dir, "build") + File.mkdir_p!(build_dir) + path = Path.join(build_dir, "custom.csv") + File.write!(path, @table) + File.write!(ctx.dest_path, "original table") + {:ok, selected} = Esp32CustomPartitions.load_custom_partitions(path) + + capture_io(fn -> + for _chip <- [:esp32, :esp32s3] do + assert :ok = + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, selected, fn -> + File.rm_rf!(build_dir) + assert File.read!(ctx.dest_path) == @table + :ok + end) + + assert File.read!(ctx.dest_path) == "original table" + end + end) + end + + test "supports selecting the destination itself", ctx do + File.write!(ctx.dest_path, @table) + {:ok, selected} = Esp32CustomPartitions.load_custom_partitions(ctx.dest_path) + + capture_io(fn -> + assert :ok = + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, selected, fn -> + assert File.read!(ctx.dest_path) == @table + :ok + end) + end) + + assert File.read!(ctx.dest_path) == @table + end + + test "restoration failure cannot report build success", ctx do + File.write!(ctx.dest_path, "original table") + + capture_io(fn -> + assert_raise File.Error, fn -> + Esp32CustomPartitions.with_custom_partitions(ctx.platform_dir, ctx.selected, fn -> + File.rm!(ctx.dest_path) + File.mkdir!(ctx.dest_path) + :ok + end) + end + end) + end +end diff --git a/test/fixtures/esp32_partitions.csv b/test/fixtures/esp32_partitions.csv new file mode 100644 index 0000000..6971be0 --- /dev/null +++ b/test/fixtures/esp32_partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x1C0000, +boot.avm, data, phy, 0x1D0000, 0x80000, +main.avm, data, phy, 0x250000, 0x1B0000,