Skip to main content

ic_testkit/pic/
standalone_recipe.rs

1use std::io;
2
3use candid::Principal;
4
5use super::{
6    CachedPocketIcBaseline, CanisterRestoreReceipt, CanisterSnapshotTarget, CycleResetPolicy,
7    FailureDisposition, FixtureRecipeId, PocketIcBaselineRecipe, PreparedBaseline,
8    ReadinessReceipt, RebuildReason, ResetReceipt, ResetRequirements, SnapshotRestoreFunding,
9    StandaloneCanisterFixture, 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(
72            pocket_ic,
73            [CanisterSnapshotTarget::new(canister_id, None)],
74            canister_id,
75        )
76        .map_err(io::Error::other)
77    }
78
79    fn restore_canisters(
80        &self,
81        baseline: &CachedPocketIcBaseline<Principal>,
82    ) -> Result<CanisterRestoreReceipt, io::Error> {
83        baseline.restore(self.funding).map_err(io::Error::other)?;
84        CanisterRestoreReceipt::try_from_baseline(baseline, self.requirements.cycle_policy())
85            .map_err(io::Error::other)
86    }
87
88    fn reset_non_snapshot_state(
89        &self,
90        _baseline: &CachedPocketIcBaseline<Principal>,
91    ) -> Result<ResetReceipt, io::Error> {
92        Ok(ResetReceipt::empty())
93    }
94
95    fn drive_to_readiness(
96        &self,
97        _baseline: &CachedPocketIcBaseline<Principal>,
98    ) -> Result<ReadinessReceipt, io::Error> {
99        ReadinessReceipt::try_new("snapshot-operation-completed").map_err(io::Error::other)
100    }
101
102    fn validate(
103        &self,
104        _baseline: &CachedPocketIcBaseline<Principal>,
105        _preparation: &PreparedBaseline,
106    ) -> Result<ValidationReceipt, io::Error> {
107        ValidationReceipt::try_new(self.id.clone(), "single-canister-snapshot-completed")
108            .map_err(io::Error::other)
109    }
110
111    fn classify_failure(
112        &self,
113        _stage: super::BaselinePreparationStage,
114        error: &io::Error,
115    ) -> FailureDisposition {
116        if is_dead_pocket_ic_transport_error(error) {
117            FailureDisposition::Rebuild(RebuildReason::DeadPocketIcTransport)
118        } else {
119            FailureDisposition::Fatal
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::num::NonZeroUsize;
127
128    use super::*;
129    use crate::pic::CachedPocketIcBaselinePool;
130
131    fn build_fixture() -> StandaloneCanisterFixture {
132        panic!("constructing an empty pool must not invoke its builder")
133    }
134
135    #[test]
136    fn runtime_capacity_preserves_lazy_fixture_construction() {
137        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
138            let recipe = StandaloneCanisterRecipe::new(
139                FixtureRecipeId::try_new("lazy-single-canister").unwrap(),
140                SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 },
141                build_fixture,
142            );
143            let pool = CachedPocketIcBaselinePool::new(capacity, recipe);
144            assert_eq!(pool.capacity(), capacity);
145        }
146    }
147}