Skip to main content

ic_testkit/pic/
standalone_recipe.rs

1use std::io;
2
3use candid::Principal;
4
5use super::{
6    CachedPocketIcBaseline, CanisterRestoreReceipt, CycleResetPolicy, FailureDisposition,
7    FixtureRecipeId, PocketIcBaselineRecipe, PreparedBaseline, ReadinessReceipt, RebuildReason,
8    ResetReceipt, ResetRequirements, SnapshotRestoreFunding, StandaloneCanisterFixture,
9    ValidationReceipt, is_dead_pocket_ic_transport_error,
10};
11
12/// Snapshot-only recipe for one caller-built, installed canister.
13///
14/// Use with [`super::CachedPocketIcBaselinePool`]. The baseline metadata is the
15/// installed canister ID; calls go through the baseline's borrowed PocketIC.
16/// The builder owns initialization and readiness before capture and must produce
17/// the same topology and seeded state for every slot of this recipe.
18///
19/// Only the captured canister is restored. Time, other canisters, pending work
20/// and external resources are not reset. Use a custom recipe or a fresh instance
21/// when those mutations matter. Funding is applied immediately before restore.
22/// Non-transport restore failures are fatal: the pool discards the affected
23/// baseline before returning the error. Dead transport permits one recovery build.
24pub struct StandaloneCanisterRecipe<B> {
25    id: FixtureRecipeId,
26    requirements: ResetRequirements,
27    funding: SnapshotRestoreFunding,
28    build: B,
29}
30
31impl<B> StandaloneCanisterRecipe<B> {
32    /// Describe a single-canister recipe without invoking its builder.
33    ///
34    /// Static suites can initialize the recipe and pool with [`std::sync::LazyLock`].
35    #[must_use]
36    pub fn new(id: FixtureRecipeId, funding: SnapshotRestoreFunding, build: B) -> Self {
37        let cycle_policy = match funding {
38            SnapshotRestoreFunding::Preserve => CycleResetPolicy::PreserveCurrent,
39            SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
40                CycleResetPolicy::TopUpTo(minimum_cycles)
41            }
42        };
43        Self {
44            id,
45            requirements: ResetRequirements::try_new(cycle_policy, [])
46                .expect("empty non-snapshot requirements are valid"),
47            funding,
48            build,
49        }
50    }
51}
52
53impl<B> PocketIcBaselineRecipe for StandaloneCanisterRecipe<B>
54where
55    B: Fn() -> StandaloneCanisterFixture + Send + Sync + 'static,
56{
57    type Metadata = Principal;
58    // Retain the typed snapshot/contract cause without another error vocabulary.
59    type Error = io::Error;
60
61    fn id(&self) -> &FixtureRecipeId {
62        &self.id
63    }
64
65    fn reset_requirements(&self) -> &ResetRequirements {
66        &self.requirements
67    }
68
69    fn build(&self) -> Result<CachedPocketIcBaseline<Principal>, io::Error> {
70        let (pocket_ic, canister_id) = (self.build)().into_parts();
71        CachedPocketIcBaseline::capture(pocket_ic, canister_id, [canister_id], canister_id)
72            .map_err(io::Error::other)
73    }
74
75    fn restore_canisters(
76        &self,
77        baseline: &CachedPocketIcBaseline<Principal>,
78    ) -> Result<CanisterRestoreReceipt, io::Error> {
79        baseline
80            .restore_with_captured_senders_and_funding(self.funding)
81            .map_err(io::Error::other)?;
82        CanisterRestoreReceipt::try_from_baseline(baseline, self.requirements.cycle_policy())
83            .map_err(io::Error::other)
84    }
85
86    fn reset_non_snapshot_state(
87        &self,
88        _baseline: &CachedPocketIcBaseline<Principal>,
89    ) -> Result<ResetReceipt, io::Error> {
90        Ok(ResetReceipt::empty())
91    }
92
93    fn drive_to_readiness(
94        &self,
95        _baseline: &CachedPocketIcBaseline<Principal>,
96    ) -> Result<ReadinessReceipt, io::Error> {
97        ReadinessReceipt::try_new("snapshot-operation-completed").map_err(io::Error::other)
98    }
99
100    fn validate(
101        &self,
102        _baseline: &CachedPocketIcBaseline<Principal>,
103        _preparation: &PreparedBaseline,
104    ) -> Result<ValidationReceipt, io::Error> {
105        ValidationReceipt::try_new(self.id.clone(), "single-canister-snapshot-completed")
106            .map_err(io::Error::other)
107    }
108
109    fn classify_failure(
110        &self,
111        _stage: super::BaselinePreparationStage,
112        error: &io::Error,
113    ) -> FailureDisposition {
114        if is_dead_pocket_ic_transport_error(error) {
115            FailureDisposition::Rebuild(RebuildReason::DeadPocketIcTransport)
116        } else {
117            FailureDisposition::Fatal
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use std::num::NonZeroUsize;
125
126    use super::*;
127    use crate::pic::CachedPocketIcBaselinePool;
128
129    fn build_fixture() -> StandaloneCanisterFixture {
130        panic!("constructing an empty pool must not invoke its builder")
131    }
132
133    #[test]
134    fn runtime_capacity_preserves_lazy_fixture_construction() {
135        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
136            let recipe = StandaloneCanisterRecipe::new(
137                FixtureRecipeId::try_new("lazy-single-canister").unwrap(),
138                SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 },
139                build_fixture,
140            );
141            let pool = CachedPocketIcBaselinePool::new(capacity, recipe);
142            assert_eq!(pool.capacity(), capacity);
143        }
144    }
145}