refactor: rename crate fai-module-sdk -> chain-module-sdk
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
The SDK is product-scoped (Ch∆In's module SDK), not a generic vendor crate — a future Brain SDK would collide. Crate + macros renamed to chain-module-sdk[-macros]; repo + dir follow. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
cd4457310b
commit
98e98e9371
14 changed files with 58 additions and 58 deletions
23
crates/chain-module-sdk/Cargo.toml
Normal file
23
crates/chain-module-sdk/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "chain-module-sdk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Stable, ergonomic surface for writing Ch∆In modules in Rust"
|
||||
|
||||
[dependencies]
|
||||
chain-module-sdk-macros = { path = "../chain-module-sdk-macros" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
wit-bindgen = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
13
crates/chain-module-sdk/src/context.rs
Normal file
13
crates/chain-module-sdk/src/context.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//! Invocation context passed to a module on every call.
|
||||
//!
|
||||
//! Mirrors the WIT `invocation-context` record. The
|
||||
//! `#[fai_module]` macro builds this from the WIT-generated value
|
||||
//! before handing it to user code.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub invocation_id: String,
|
||||
pub capability_namespace: String,
|
||||
pub capability_name: String,
|
||||
pub deadline_ms: u64,
|
||||
}
|
||||
49
crates/chain-module-sdk/src/error.rs
Normal file
49
crates/chain-module-sdk/src/error.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Module error type — mirrors the WIT `invocation-error` variant.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error a module returns when invocation fails.
|
||||
///
|
||||
/// The variants match the WIT `invocation-error` cases one-to-one,
|
||||
/// so a module's structured failure is preserved over the wire.
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum ModuleError {
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("resource exhausted: {0}")]
|
||||
ResourceExhausted(String),
|
||||
|
||||
#[error("deadline exceeded")]
|
||||
DeadlineExceeded,
|
||||
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl ModuleError {
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
ModuleError::InvalidInput(message.into())
|
||||
}
|
||||
|
||||
pub fn permission_denied(message: impl Into<String>) -> Self {
|
||||
ModuleError::PermissionDenied(message.into())
|
||||
}
|
||||
|
||||
pub fn resource_exhausted(message: impl Into<String>) -> Self {
|
||||
ModuleError::ResourceExhausted(message.into())
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
ModuleError::Internal(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ModuleError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
ModuleError::InvalidInput(format!("invalid JSON: {value}"))
|
||||
}
|
||||
}
|
||||
170
crates/chain-module-sdk/src/inputs.rs
Normal file
170
crates/chain-module-sdk/src/inputs.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//! Typed access to the inputs a module receives.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::ModuleError;
|
||||
use crate::payload::{BytesValue, FileRef, Payload};
|
||||
|
||||
/// The inputs a module receives, keyed by name as declared in the
|
||||
/// flow YAML.
|
||||
///
|
||||
/// The `require_*` accessors validate both presence and variant in
|
||||
/// one step, returning a clear `InvalidInput` error if either check
|
||||
/// fails. Module authors should prefer them over `get` + manual
|
||||
/// matching.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Inputs {
|
||||
items: Vec<(String, Payload)>,
|
||||
}
|
||||
|
||||
impl Inputs {
|
||||
/// Build directly from a list of `(name, payload)` pairs.
|
||||
/// Used by the `#[fai_module]` macro after WIT conversion.
|
||||
#[doc(hidden)]
|
||||
pub fn __from_pairs(items: Vec<(String, Payload)>) -> Self {
|
||||
Self { items }
|
||||
}
|
||||
|
||||
/// Build from a list of pairs — convenient for tests.
|
||||
pub fn from_pairs<I, K, V>(pairs: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<String>,
|
||||
V: Into<Payload>,
|
||||
{
|
||||
Self {
|
||||
items: pairs
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), v.into()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of inputs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// `true` if there are no inputs.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Iterate over `(name, payload)` pairs.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &Payload)> {
|
||||
self.items.iter().map(|(k, v)| (k.as_str(), v))
|
||||
}
|
||||
|
||||
/// Look up a payload by name, returning `None` if absent.
|
||||
pub fn get(&self, name: &str) -> Option<&Payload> {
|
||||
self.items
|
||||
.iter()
|
||||
.find_map(|(k, v)| if k == name { Some(v) } else { None })
|
||||
}
|
||||
|
||||
/// Require a `Text` input under `name`. Errors if missing or wrong variant.
|
||||
pub fn require_text(&self, name: &str) -> Result<&str, ModuleError> {
|
||||
match self.require(name)? {
|
||||
Payload::Text(s) => Ok(s.as_str()),
|
||||
other => Err(wrong_variant(name, "text", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Require a `Bytes` input under `name`. Returns `(mime, data)`.
|
||||
pub fn require_bytes(&self, name: &str) -> Result<&BytesValue, ModuleError> {
|
||||
match self.require(name)? {
|
||||
Payload::Bytes(b) => Ok(b),
|
||||
other => Err(wrong_variant(name, "bytes", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Require a `Json` input under `name`, returned as the raw JSON string.
|
||||
pub fn require_json_str(&self, name: &str) -> Result<&str, ModuleError> {
|
||||
match self.require(name)? {
|
||||
Payload::Json(s) => Ok(s.as_str()),
|
||||
other => Err(wrong_variant(name, "json", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Require a `Json` input under `name`, parsed as `T`.
|
||||
pub fn require_json<T: DeserializeOwned>(&self, name: &str) -> Result<T, ModuleError> {
|
||||
let raw = self.require_json_str(name)?;
|
||||
serde_json::from_str(raw).map_err(ModuleError::from)
|
||||
}
|
||||
|
||||
/// Require a `FileRef` input under `name`.
|
||||
pub fn require_file_ref(&self, name: &str) -> Result<&FileRef, ModuleError> {
|
||||
match self.require(name)? {
|
||||
Payload::FileRef(f) => Ok(f),
|
||||
other => Err(wrong_variant(name, "file-ref", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require(&self, name: &str) -> Result<&Payload, ModuleError> {
|
||||
self.get(name)
|
||||
.ok_or_else(|| ModuleError::InvalidInput(format!("missing input: {name}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn wrong_variant(name: &str, expected: &str, got: &Payload) -> ModuleError {
|
||||
ModuleError::InvalidInput(format!(
|
||||
"input '{name}' has wrong type: expected {expected}, got {got}",
|
||||
got = got.type_name(),
|
||||
))
|
||||
}
|
||||
|
||||
impl From<String> for Payload {
|
||||
fn from(value: String) -> Self {
|
||||
Payload::Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Payload {
|
||||
fn from(value: &str) -> Self {
|
||||
Payload::Text(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn require_text_ok() {
|
||||
let i = Inputs::from_pairs([("greeting", "hello")]);
|
||||
assert_eq!(i.require_text("greeting").unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_text_missing_is_invalid_input() {
|
||||
let i = Inputs::default();
|
||||
let err = i.require_text("greeting").unwrap_err();
|
||||
assert!(matches!(err, ModuleError::InvalidInput(_)));
|
||||
assert!(format!("{err}").contains("missing input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_text_wrong_variant_is_invalid_input() {
|
||||
let i = Inputs::from_pairs([("blob", Payload::bytes("text/plain", b"data".to_vec()))]);
|
||||
let err = i.require_text("blob").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("expected text"), "msg = {msg}");
|
||||
assert!(msg.contains("got bytes"), "msg = {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_json_parses_into_target() {
|
||||
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||
struct Goal {
|
||||
text: String,
|
||||
}
|
||||
let i = Inputs::from_pairs([("goal", Payload::Json(r#"{"text":"hi"}"#.to_string()))]);
|
||||
assert_eq!(
|
||||
i.require_json::<Goal>("goal").unwrap(),
|
||||
Goal {
|
||||
text: "hi".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
55
crates/chain-module-sdk/src/lib.rs
Normal file
55
crates/chain-module-sdk/src/lib.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! Ergonomic Rust surface for writing Ch∆In modules.
|
||||
//!
|
||||
//! A module written against this crate looks like:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use chain_module_sdk::prelude::*;
|
||||
//!
|
||||
//! #[fai_module]
|
||||
//! fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||
//! let text = inputs.require_text("input")?;
|
||||
//! Ok(Outputs::new().with_text("output", format!("got: {text}")))
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! The macro generates the WIT bindings, the `Guest` impl, and the
|
||||
//! `wit_bindgen::export!` invocation behind the scenes. Module authors
|
||||
//! never touch `wit_bindgen` directly.
|
||||
//!
|
||||
//! # Stability
|
||||
//!
|
||||
//! This crate insulates modules from the underlying WIT contract
|
||||
//! (`chain:platform`, frozen at v1.0). The WIT may evolve in additive
|
||||
//! minor versions or, eventually, a coordinated v2.0; the SDK absorbs
|
||||
//! that change so module code keeps compiling.
|
||||
|
||||
mod context;
|
||||
mod error;
|
||||
mod inputs;
|
||||
mod outputs;
|
||||
mod payload;
|
||||
|
||||
pub use context::Context;
|
||||
pub use error::ModuleError;
|
||||
pub use inputs::Inputs;
|
||||
pub use outputs::Outputs;
|
||||
pub use payload::{BytesValue, FileRef, Payload};
|
||||
|
||||
pub use chain_module_sdk_macros::fai_module;
|
||||
|
||||
/// Convenience prelude — `use chain_module_sdk::prelude::*;` brings
|
||||
/// every type a typical module needs into scope.
|
||||
pub mod prelude {
|
||||
pub use super::{
|
||||
BytesValue, Context, FileRef, Inputs, ModuleError, Outputs, Payload, fai_module,
|
||||
};
|
||||
}
|
||||
|
||||
/// Internal re-exports the `#[fai_module]` macro expansion depends on.
|
||||
///
|
||||
/// Not part of the public API. Anything in this module may change
|
||||
/// between SDK patch releases.
|
||||
#[doc(hidden)]
|
||||
pub mod __rt {
|
||||
pub use wit_bindgen;
|
||||
}
|
||||
119
crates/chain-module-sdk/src/outputs.rs
Normal file
119
crates/chain-module-sdk/src/outputs.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
//! Builder for the outputs a module returns.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::ModuleError;
|
||||
use crate::payload::Payload;
|
||||
|
||||
/// Output values a module produces, keyed by name as expected by
|
||||
/// the calling flow step.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Outputs {
|
||||
items: Vec<(String, Payload)>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.items.push((name.into(), Payload::Text(value.into())));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_json_str(mut self, name: impl Into<String>, raw: impl Into<String>) -> Self {
|
||||
self.items.push((name.into(), Payload::Json(raw.into())));
|
||||
self
|
||||
}
|
||||
|
||||
/// Serialize `value` as JSON and add it under `name`. Errors are
|
||||
/// returned as `ModuleError::Internal` since serialization of a
|
||||
/// well-typed value should not fail under normal conditions.
|
||||
pub fn with_json<T: Serialize>(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
value: &T,
|
||||
) -> Result<Self, ModuleError> {
|
||||
let raw = serde_json::to_string(value).map_err(|e| {
|
||||
ModuleError::Internal(format!("failed to serialize output as JSON: {e}"))
|
||||
})?;
|
||||
self.items.push((name.into(), Payload::Json(raw)));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_bytes(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
mime_type: impl Into<String>,
|
||||
data: impl Into<Vec<u8>>,
|
||||
) -> Self {
|
||||
self.items
|
||||
.push((name.into(), Payload::bytes(mime_type, data)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_payload(mut self, name: impl Into<String>, payload: Payload) -> Self {
|
||||
self.items.push((name.into(), payload));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &Payload)> {
|
||||
self.items.iter().map(|(k, v)| (k.as_str(), v))
|
||||
}
|
||||
|
||||
/// Consume the builder into the underlying `(name, payload)` list.
|
||||
/// Used by the `#[fai_module]` macro after WIT conversion.
|
||||
#[doc(hidden)]
|
||||
pub fn __into_pairs(self) -> Vec<(String, Payload)> {
|
||||
self.items
|
||||
}
|
||||
|
||||
/// Public consume for tests / advanced callers.
|
||||
pub fn into_pairs(self) -> Vec<(String, Payload)> {
|
||||
self.items
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fluent_builder_collects_items_in_order() {
|
||||
let out = Outputs::new().with_text("greeting", "hi").with_bytes(
|
||||
"blob",
|
||||
"application/octet-stream",
|
||||
vec![1u8, 2, 3],
|
||||
);
|
||||
let pairs = out.into_pairs();
|
||||
assert_eq!(pairs.len(), 2);
|
||||
assert_eq!(pairs[0].0, "greeting");
|
||||
assert!(matches!(pairs[1].1, Payload::Bytes(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_json_serializes_typed_value() {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Reply {
|
||||
ok: bool,
|
||||
}
|
||||
let out = Outputs::new()
|
||||
.with_json("reply", &Reply { ok: true })
|
||||
.unwrap();
|
||||
let pairs = out.into_pairs();
|
||||
match &pairs[0].1 {
|
||||
Payload::Json(s) => assert!(s.contains(r#""ok":true"#)),
|
||||
other => panic!("expected Json, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
76
crates/chain-module-sdk/src/payload.rs
Normal file
76
crates/chain-module-sdk/src/payload.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Module-side mirror of the WIT `payload` variant.
|
||||
//!
|
||||
//! The shape is fixed by the v1.0 WIT freeze. Field-by-field
|
||||
//! conversion to and from the WIT-generated type happens inside
|
||||
//! the `#[fai_module]` macro expansion in the user crate.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A typed value flowing between the host and a module.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Payload {
|
||||
/// Plain UTF-8 text.
|
||||
Text(String),
|
||||
/// JSON encoded as a string.
|
||||
Json(String),
|
||||
/// Raw bytes with a MIME type.
|
||||
Bytes(BytesValue),
|
||||
/// File reference by URI.
|
||||
FileRef(FileRef),
|
||||
}
|
||||
|
||||
/// Inline byte content with a MIME type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BytesValue {
|
||||
pub mime_type: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// File reference by URI plus integrity metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileRef {
|
||||
pub uri: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
pub fn text(value: impl Into<String>) -> Self {
|
||||
Payload::Text(value.into())
|
||||
}
|
||||
|
||||
pub fn json(value: impl Into<String>) -> Self {
|
||||
Payload::Json(value.into())
|
||||
}
|
||||
|
||||
pub fn bytes(mime_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
|
||||
Payload::Bytes(BytesValue {
|
||||
mime_type: mime_type.into(),
|
||||
data: data.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn file_ref(
|
||||
uri: impl Into<String>,
|
||||
mime_type: impl Into<String>,
|
||||
size_bytes: u64,
|
||||
sha256: impl Into<String>,
|
||||
) -> Self {
|
||||
Payload::FileRef(FileRef {
|
||||
uri: uri.into(),
|
||||
mime_type: mime_type.into(),
|
||||
size_bytes,
|
||||
sha256: sha256.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
Payload::Text(_) => "text",
|
||||
Payload::Json(_) => "json",
|
||||
Payload::Bytes(_) => "bytes",
|
||||
Payload::FileRef(_) => "file-ref",
|
||||
}
|
||||
}
|
||||
}
|
||||
53
crates/chain-module-sdk/tests/wit_freeze.rs
Normal file
53
crates/chain-module-sdk/tests/wit_freeze.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Snapshot test for the WIT mirror.
|
||||
//!
|
||||
//! The SDK ships its own copy of `wit/world.wit` so the proc-macro
|
||||
//! can embed it via `include_str!` at compile time. The hash MUST
|
||||
//! match the platform's frozen v1.0 hash. If this test fails, the
|
||||
//! mirror has drifted and the SDK cannot guarantee binary
|
||||
//! compatibility with the platform.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Identical to FROZEN_WIT_SHA256 in
|
||||
/// fai/platform :: crates/fai_runtime/tests/wit_freeze.rs.
|
||||
/// Update both repos in lockstep, never one alone.
|
||||
const PLATFORM_FROZEN_WIT_SHA256: &str =
|
||||
"6f4e2266a4264832a16fe5a3704622cd978b290b90f3300c3ed236d98b689b5f";
|
||||
|
||||
fn world_wit_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("crates parent")
|
||||
.parent()
|
||||
.expect("workspace root")
|
||||
.join("wit")
|
||||
.join("world.wit")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_mirror_matches_platform_frozen_hash() {
|
||||
let path = world_wit_path();
|
||||
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
|
||||
assert_eq!(
|
||||
actual, PLATFORM_FROZEN_WIT_SHA256,
|
||||
"\n\nwit/world.wit in this repo has drifted from the platform's\n\
|
||||
frozen v1.0 hash. The SDK MUST mirror the platform exactly\n\
|
||||
or modules built with this SDK will not be wire-compatible.\n\n\
|
||||
If the platform intentionally bumped to a new minor (e.g.\n\
|
||||
v1.1 with additive changes), update BOTH:\n\
|
||||
1. wit/world.wit here to match the platform copy\n\
|
||||
2. PLATFORM_FROZEN_WIT_SHA256 in this test\n\n\
|
||||
If the platform is still on the old hash, revert this WIT\n\
|
||||
change.\n\n\
|
||||
actual hash: {actual}\n"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue