ci: isolate heavy integration tests (#1266)

* fix staging CI coverage regressions

* ci: cover all e2e scenarios in staging

* ci: restrict staging PR checks and fix webhook assertions

* ci: keep code style checks on PRs

* ci: preserve e2e PR coverage

* test: stabilize staging e2e coverage

* fix: propagate postgres tls builder errors

* ci: isolate heavy integration tests

* fix: clean up heavy integration CI follow-up
This commit is contained in:
Henry Park
2026-03-16 16:10:20 -07:00
committed by GitHub
parent 1f209db0fa
commit ed0ed40dae
4 changed files with 174 additions and 96 deletions
+30 -3
View File
@@ -17,7 +17,10 @@ jobs:
matrix:
include:
- name: all-features
flags: "--all-features"
# Keep product feature coverage broad without pulling in the
# test-only `integration` feature, which is exercised separately
# in the heavy integration job below.
flags: "--no-default-features --features postgres,libsql,html-to-markdown,bedrock,import"
- name: default
flags: ""
- name: libsql-only
@@ -39,6 +42,26 @@ jobs:
- name: Run Tests
run: cargo test ${{ matrix.flags }} -- --nocapture
heavy-integration-tests:
name: Heavy Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip2
- uses: Swatinem/rust-cache@v2
with:
key: heavy-integration
- name: Build Telegram WASM channel
run: cargo build --manifest-path channels-src/telegram/Cargo.toml --target wasm32-wasip2 --release
- name: Run thread scheduling integration tests
run: cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
- name: Run Telegram thread-scope regression test
run: cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
telegram-tests:
name: Telegram Channel Tests
if: >
@@ -65,7 +88,7 @@ jobs:
matrix:
include:
- name: all-features
flags: "--all-features"
flags: "--no-default-features --features postgres,libsql,html-to-markdown,bedrock,import"
- name: default
flags: ""
- name: libsql-only
@@ -149,7 +172,7 @@ jobs:
name: Run Tests
runs-on: ubuntu-latest
if: always()
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check, bench-compile]
needs: [tests, heavy-integration-tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check, bench-compile]
steps:
- run: |
# Unit tests must always pass
@@ -157,6 +180,10 @@ jobs:
echo "Unit tests failed"
exit 1
fi
if [[ "${{ needs.heavy-integration-tests.result }}" != "success" ]]; then
echo "Heavy integration tests failed"
exit 1
fi
# Gated jobs: must pass on promotion PRs / push, skipped on developer PRs
for job in telegram-tests wasm-wit-compat docker-build windows-build version-check bench-compile; do
case "$job" in
+6
View File
@@ -222,11 +222,17 @@ postgres = [
"rust_decimal/db-tokio-postgres",
]
libsql = ["dep:libsql"]
# Opt-in feature for especially heavy integration-test targets that run in a
# dedicated CI job instead of the default Rust test matrix.
integration = []
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
import = ["dep:json5", "libsql"]
[[test]]
name = "e2e_thread_scheduling"
required-features = ["libsql", "integration"]
[[test]]
name = "html_to_markdown"
required-features = ["html-to-markdown"]
+130 -92
View File
@@ -860,6 +860,24 @@ impl WasmChannel {
self
}
/// Attach a message stream for integration tests.
///
/// This primes any startup-persisted workspace state, but tolerates
/// callback-level startup failures so tests can exercise webhook parsing
/// and message emission without depending on external network access.
#[cfg(feature = "integration")]
#[doc(hidden)]
pub async fn start_message_stream_for_test(&self) -> Result<MessageStream, WasmChannelError> {
self.prime_startup_state_for_test().await?;
let (tx, rx) = mpsc::channel(256);
*self.message_tx.write().await = Some(tx);
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
*self.shutdown_tx.write().await = Some(shutdown_tx);
Ok(Box::pin(ReceiverStream::new(rx)))
}
/// Update the channel config before starting.
///
/// Merges the provided values into the existing config JSON.
@@ -899,6 +917,29 @@ impl WasmChannel {
self.credentials.read().await.clone()
}
#[cfg(feature = "integration")]
async fn prime_startup_state_for_test(&self) -> Result<(), WasmChannelError> {
if self.prepared.component().is_none() {
return Ok(());
}
let (start_result, mut host_state) = self.execute_on_start_with_state().await?;
self.log_on_start_host_state(&mut host_state);
match start_result {
Ok(_) => Ok(()),
Err(WasmChannelError::CallbackFailed { reason, .. }) => {
tracing::warn!(
channel = %self.name,
reason = %reason,
"Ignoring startup callback failure in test-only message stream bootstrap"
);
Ok(())
}
Err(e) => Err(e),
}
}
/// Get the channel name.
pub fn channel_name(&self) -> &str {
&self.name
@@ -1132,6 +1173,85 @@ impl WasmChannel {
)
}
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
for entry in host_state.take_logs() {
match entry.level {
crate::tools::wasm::LogLevel::Error => {
tracing::error!(channel = %self.name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Warn => {
tracing::warn!(channel = %self.name, "{}", entry.message);
}
_ => {
tracing::debug!(channel = %self.name, "{}", entry.message);
}
}
}
}
async fn execute_on_start_with_state(
&self,
) -> Result<(Result<ChannelConfig, WasmChannelError>, ChannelHostState), WasmChannelError> {
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let config_json = self.config_json.read().await.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
host_credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel();
let config_result = channel_iface
.call_on_start(&mut store, &config_json)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))
.and_then(|wasm_result| match wasm_result {
Ok(wit_config) => Ok(convert_channel_config(wit_config)),
Err(err_msg) => Err(WasmChannelError::CallbackFailed {
name: prepared.name.clone(),
reason: err_msg,
}),
});
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok::<_, WasmChannelError>((config_result, host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
})?
})
.await
.map_err(|_| WasmChannelError::Timeout {
name: self.name.clone(),
callback: "on_start".to_string(),
})?
}
/// Execute the on_start callback.
///
/// Returns the channel configuration for HTTP endpoint registration.
@@ -1154,99 +1274,17 @@ impl WasmChannel {
});
}
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let config_json = self.config_json.read().await.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
let (config_result, mut host_state) = self.execute_on_start_with_state().await?;
self.log_on_start_host_state(&mut host_state);
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
host_credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_start using the generated typed interface
let channel_iface = instance.near_agent_channel();
let wasm_result = channel_iface
.call_on_start(&mut store, &config_json)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
// Convert the result
let config = match wasm_result {
Ok(wit_config) => convert_channel_config(wit_config),
Err(err_msg) => {
return Err(WasmChannelError::CallbackFailed {
name: prepared.name.clone(),
reason: err_msg,
});
}
};
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((config, host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
})?
})
.await;
match result {
Ok(Ok((config, mut host_state))) => {
// Surface WASM guest logs (errors/warnings from webhook setup, etc.)
for entry in host_state.take_logs() {
match entry.level {
crate::tools::wasm::LogLevel::Error => {
tracing::error!(channel = %self.name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Warn => {
tracing::warn!(channel = %self.name, "{}", entry.message);
}
_ => {
tracing::debug!(channel = %self.name, "{}", entry.message);
}
}
}
tracing::info!(
channel = %self.name,
display_name = %config.display_name,
endpoints = config.http_endpoints.len(),
"WASM channel on_start completed"
);
Ok(config)
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: self.name.clone(),
callback: "on_start".to_string(),
}),
}
let config = config_result?;
tracing::info!(
channel = %self.name,
display_name = %config.display_name,
endpoints = config.http_endpoints.len(),
"WASM channel on_start completed"
);
Ok(config)
}
/// Execute the on_http_request callback.
+8 -1
View File
@@ -13,13 +13,16 @@
use std::collections::HashMap;
use std::sync::Arc;
#[cfg(feature = "integration")]
use futures::StreamExt;
#[cfg(feature = "integration")]
use ironclaw::channels::Channel;
use ironclaw::channels::wasm::{
ChannelCapabilities, PreparedChannelModule, WasmChannel, WasmChannelRuntime,
WasmChannelRuntimeConfig,
};
use ironclaw::pairing::PairingStore;
#[cfg(feature = "integration")]
use tokio::time::{Duration, timeout};
/// Skip the test if the Telegram WASM module hasn't been built.
@@ -305,6 +308,7 @@ async fn test_private_message_with_owner_id_set_uses_guest_pairing_flow() {
}
#[tokio::test]
#[cfg(feature = "integration")]
async fn test_private_messages_use_chat_id_as_thread_scope() {
require_telegram_wasm!();
let runtime = create_test_runtime();
@@ -319,7 +323,10 @@ async fn test_private_messages_use_chat_id_as_thread_scope() {
.to_string();
let channel = create_telegram_channel(runtime, &config).await;
let mut stream = channel.start().await.expect("Failed to start channel");
let mut stream = channel
.start_message_stream_for_test()
.await
.expect("Failed to bootstrap test message stream");
for (update_id, message_id, text) in [(6, 105, "first"), (7, 106, "second")] {
let update = build_telegram_update(