Merge remote-tracking branch 'origin/staging' into feat/gemini-cli-oauth

This commit is contained in:
Artem
2026-03-19 21:57:29 +03:00
84 changed files with 8673 additions and 2131 deletions
+360
View File
@@ -0,0 +1,360 @@
//! Integration tests for dispatched routine run tracking (#1317).
//!
//! Verifies:
//! 1. list_dispatched_routine_runs returns only running runs with linked jobs
//! 2. Completed jobs cause linked routine runs to be finalized as Ok
//! 3. Failed jobs cause linked routine runs to be finalized as Failed
//! 4. Active (InProgress) jobs are not finalized
//! 5. Orphaned runs (job_id set but no job record) are handled
#[cfg(feature = "libsql")]
mod tests {
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use ironclaw::agent::routine::{
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::context::{JobContext, JobState};
use ironclaw::db::Database;
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
use ironclaw::db::libsql::LibSqlBackend;
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
backend.run_migrations().await.expect("migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
}
fn make_routine(id: Uuid) -> Routine {
Routine {
id,
name: format!("test-routine-{}", id),
description: "Test routine".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "Test job".to_string(),
description: "Test description".to_string(),
max_iterations: 5,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: Default::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn make_run(routine_id: Uuid, job_id: Option<Uuid>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id,
created_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// Test 1: list_dispatched_routine_runs returns only running runs with jobs
// -----------------------------------------------------------------------
#[tokio::test]
async fn list_dispatched_returns_only_running_with_job_id() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create jobs first (FK constraint requires job records to exist)
let job1 = JobContext::new("Job 1", "Dispatched job");
db.save_job(&job1).await.expect("save job1");
let job2 = JobContext::new("Job 2", "Completed job");
db.save_job(&job2).await.expect("save job2");
// Create a running run WITH job_id (dispatched full_job)
let dispatched_run = make_run(routine_id, Some(job1.job_id));
db.create_routine_run(&dispatched_run)
.await
.expect("create dispatched run");
// Create a running run WITHOUT job_id (lightweight in-progress)
let lightweight_run = make_run(routine_id, None);
db.create_routine_run(&lightweight_run)
.await
.expect("create lightweight run");
// Create a completed run WITH job_id (already finalized)
let mut completed_run = make_run(routine_id, Some(job2.job_id));
completed_run.status = RunStatus::Ok;
completed_run.completed_at = Some(Utc::now());
db.create_routine_run(&completed_run)
.await
.expect("create completed run");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1, "Should return only the dispatched run");
assert_eq!(dispatched[0].id, dispatched_run.id);
assert_eq!(dispatched[0].job_id, Some(job1.job_id));
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 2: Completed job linked to run can be detected
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_completed_job_can_be_finalized() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create and save a job in Completed state
let mut job = JobContext::new("Test job", "Test description");
job.state = JobState::Completed;
db.save_job(&job).await.expect("save job");
// Create a dispatched run linked to that job
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify the run is listed as dispatched
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify we can fetch the linked job and see it's completed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Completed);
// Simulate sync: complete the run
db.complete_routine_run(run.id, RunStatus::Ok, Some("Job completed"), None)
.await
.expect("complete run");
// Run should no longer appear in dispatched list
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 3: Failed job causes run to be finalized as Failed
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_failed_job() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Failing job", "Will fail");
job.state = JobState::Failed;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is failed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Failed);
// Simulate sync: complete the run as failed
db.complete_routine_run(run.id, RunStatus::Failed, Some("Job failed"), None)
.await
.expect("complete run as failed");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(dispatched.is_empty(), "Failed run should be finalized");
}
// -----------------------------------------------------------------------
// Test 4: Active (InProgress) job leaves run as running
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_active_job_stays_running() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Active job", "Still running");
job.state = JobState::InProgress;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is still active
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert!(!fetched_job.state.is_terminal());
// Run should still be in dispatched list (not finalized)
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(
dispatched.len(),
1,
"Run with active job should remain dispatched"
);
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 5: Orphaned run (job_id set but job record missing)
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_orphan_detection() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create a real job so the FK constraint is satisfied
let job = JobContext::new("Will be orphaned", "Test orphan detection");
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// The run appears in dispatched list
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify orphan detection: a random UUID returns None from get_job
let nonexistent_id = Uuid::new_v4();
let missing = db
.get_job(nonexistent_id)
.await
.expect("get_job should not error");
assert!(
missing.is_none(),
"get_job for nonexistent ID should return None"
);
// Simulate sync handling of an orphaned run: mark as failed
db.complete_routine_run(
run.id,
RunStatus::Failed,
Some(&format!("Linked job {} not found (orphaned)", job.job_id)),
None,
)
.await
.expect("complete orphaned run");
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 6: link_routine_run_to_job then list shows linked run
// -----------------------------------------------------------------------
#[tokio::test]
async fn link_and_list_dispatched_run() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create job record (FK constraint)
let job = JobContext::new("Linked job", "Test linking");
db.save_job(&job).await.expect("save job");
// Create a running run without job_id initially
let run = make_run(routine_id, None);
db.create_routine_run(&run).await.expect("create run");
// Should not appear in dispatched list yet
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(
dispatched.is_empty(),
"Run without job_id should not be dispatched"
);
// Link the run to the job
db.link_routine_run_to_job(run.id, job.job_id)
.await
.expect("link run to job");
// Now it should appear
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after link");
assert_eq!(
dispatched_after.len(),
1,
"Linked run should appear in dispatched list"
);
assert_eq!(dispatched_after[0].job_id, Some(job.job_id));
}
}
+11 -4
View File
@@ -45,12 +45,13 @@ SEL = {
"approval_always_btn": ".approval-actions button.always",
"approval_deny_btn": ".approval-actions button.deny",
"approval_resolved": ".approval-resolved",
# Extensions tab sections
# Settings subtabs
"settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]',
"settings_subpanel": "#settings-{subtab}",
# Extensions section
"extensions_list": "#extensions-list",
"available_wasm_list": "#available-wasm-list",
"mcp_servers_list": "#mcp-servers-list",
"tools_tbody": "#tools-tbody",
"tools_empty": "#tools-empty",
# Extensions tab cards
"ext_card_installed": "#extensions-list .ext-card",
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
@@ -92,6 +93,12 @@ SEL = {
"ext_stepper": ".ext-stepper",
"stepper_step": ".stepper-step",
"stepper_circle": ".stepper-circle",
# Confirm modal (custom, replaces window.confirm)
"confirm_modal": "#confirm-modal",
"confirm_modal_btn": "#confirm-modal-btn",
"confirm_modal_cancel": "#confirm-modal-cancel-btn",
# Channels subtab cards
"channels_ext_card": "#settings-channels-content .ext-card",
# Toast notifications
"toast": ".toast",
"toast_success": ".toast.toast-success",
@@ -106,7 +113,7 @@ SEL = {
"routines_empty": "#routines-empty",
}
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
TABS = ["chat", "memory", "jobs", "routines", "settings"]
# Auth token used across all tests
AUTH_TOKEN = "e2e-test-token"
+71 -133
View File
@@ -87,23 +87,21 @@ _REGISTRY_MCP = {
"installed": False,
}
_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"}
_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"}
# ─── Navigation helpers ────────────────────────────────────────────────────────
async def go_to_extensions(page):
"""Click the Extensions tab and wait for the panel to appear.
"""Navigate to Settings > Extensions subtab and wait for content.
Waits for loadExtensions() to finish rendering by polling for the first
content signal (empty-state div or an installed card) rather than sleeping.
"""
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for(
state="visible", timeout=5000
)
# loadExtensions() fires three parallel fetches then renders. Wait for the
# loadExtensions() fires parallel fetches then renders. Wait for the
# first concrete DOM signal instead of a hard sleep so the test is
# deterministic even under CI load.
await page.locator(
@@ -111,19 +109,39 @@ async def go_to_extensions(page):
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
"""Intercept the three extension list APIs with fixture data.
async def go_to_channels(page):
"""Navigate to Settings > Channels subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
Must be called BEFORE navigating to the extensions tab.
async def go_to_mcp(page):
"""Navigate to Settings > MCP subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, registry=None):
"""Intercept the extension list APIs with fixture data.
Must be called BEFORE navigating to the extensions subtab.
"""
ext_body = json.dumps({"extensions": installed or []})
tools_body = json.dumps({"tools": tools or []})
registry_body = json.dumps({"entries": registry or []})
# Playwright evaluates route handlers in LIFO order (last-registered fires
# first). Register the broad handler first so it is checked last; the
# specific /tools and /registry handlers are registered after and therefore
# checked first — no continue_() fallthrough needed.
# specific /registry handler is registered after and therefore checked
# first — no continue_() fallthrough needed.
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
@@ -133,13 +151,9 @@ async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
await page.route("**/api/extensions*", handle_ext_list)
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body=tools_body)
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body=registry_body)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
@@ -151,46 +165,17 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000):
# ─── Group A: Structural / empty state ────────────────────────────────────────
async def test_extensions_empty_tab_layout(page):
"""Extensions tab with no data shows all three sections with correct empty-state messages."""
await mock_ext_apis(page, tools=[])
"""Extensions subtab with no data shows sections with correct empty-state messages."""
await mock_ext_apis(page)
await go_to_extensions(page)
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
assert await panel.is_visible()
ext_list = page.locator(SEL["extensions_list"])
assert await ext_list.is_visible()
assert "No extensions installed" in await ext_list.text_content()
wasm_list = page.locator(SEL["available_wasm_list"])
assert await wasm_list.is_visible()
assert "No additional WASM extensions available" in await wasm_list.text_content()
mcp_list = page.locator(SEL["mcp_servers_list"])
assert await mcp_list.is_visible()
assert "No MCP servers available" in await mcp_list.text_content()
# Tools table should be empty
tbody = page.locator(SEL["tools_tbody"])
rows = await tbody.locator("tr").count()
empty_visible = await page.locator(SEL["tools_empty"]).is_visible()
assert empty_visible or rows == 0, "Expected tools table to be empty"
async def test_extensions_tools_table_populated(page):
"""Two mock tools produce two rows in the tools table."""
await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2])
await go_to_extensions(page)
tbody = page.locator(SEL["tools_tbody"])
rows = tbody.locator("tr")
await rows.first.wait_for(state="visible", timeout=5000)
assert await rows.count() == 2
text = await tbody.text_content()
assert "echo" in text
assert "time" in text
# ─── Group B: Installed WASM tool cards ───────────────────────────────────────
@@ -248,9 +233,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page):
async def test_installed_mcp_server_active(page):
"""Active MCP server shows 'Active' label and no Activate button."""
await mock_ext_apis(page, installed=[_MCP_ACTIVE])
await go_to_extensions(page)
await go_to_mcp(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["ext_card_mcp"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_active_label"]).count() == 1
assert await card.locator(SEL["ext_activate_btn"]).count() == 0
@@ -260,9 +245,9 @@ async def test_installed_mcp_server_active(page):
async def test_installed_mcp_server_inactive_shows_activate(page):
"""Inactive MCP server shows Activate button."""
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await go_to_extensions(page)
await go_to_mcp(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["ext_card_mcp"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_activate_btn"]).count() == 1
@@ -270,7 +255,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page):
async def test_mcp_server_in_registry_not_installed(page):
"""Registry MCP entry (not installed) appears in the MCP section with Install button."""
await mock_ext_apis(page, registry=[_REGISTRY_MCP])
await go_to_extensions(page)
await go_to_mcp(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -285,7 +270,7 @@ async def test_mcp_server_installed_auth_dot(page):
installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp])
await go_to_extensions(page)
await go_to_mcp(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -299,8 +284,9 @@ async def test_mcp_server_installed_auth_dot(page):
async def _load_wasm_channel(page, activation_status, activation_error=None):
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
await mock_ext_apis(page, installed=[ext])
await go_to_extensions(page)
card = page.locator(SEL["ext_card_installed"]).first
await go_to_channels(page)
# Find the WASM channel card specifically (not built-in channel cards)
card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").first
await card.wait_for(state="visible", timeout=5000)
return card
@@ -446,9 +432,9 @@ async def test_install_wasm_channel_triggers_configure(page):
await page.route("**/api/extensions/test-channel/setup", handle_channel_setup)
await page.route("**/api/extensions/install", handle_channel_install)
await go_to_extensions(page)
await go_to_channels(page)
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first
await install_btn.wait_for(state="visible", timeout=5000)
await install_btn.click()
@@ -523,13 +509,14 @@ async def test_remove_installed_extension_confirmed(page):
# Override for subsequent calls
await page.route("**/api/extensions*", handle_ext_empty)
# Auto-accept confirm dialog
await page.evaluate("window.confirm = () => true")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Confirm via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_btn"]).click()
# Card should disappear
await page.wait_for_function(
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0",
@@ -543,13 +530,14 @@ async def test_remove_cancelled_keeps_card(page):
await mock_ext_apis(page, installed=[_WASM_TOOL])
await go_to_extensions(page)
# Reject the confirm dialog
await page.evaluate("window.confirm = () => false")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Cancel via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_cancel"]).click()
assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel"
@@ -973,14 +961,10 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
@@ -989,6 +973,9 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
# Inject a counter to confirm refreshCurrentSettingsTab is called
await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };")
await page.evaluate("""
handleAuthCompleted({
extension_name: 'gmail',
@@ -999,14 +986,11 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await wait_for_toast(page, "OAuth flow expired. Please try again.")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
assert (
await page.locator(
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
).count()
>= 1
)
await page.wait_for_timeout(600)
# Wait for the refresh to complete
await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000)
# Give the async fetch time to complete
await page.wait_for_timeout(1000)
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
@@ -1026,9 +1010,9 @@ async def test_activate_mcp_server_success(page):
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000):
@@ -1051,9 +1035,9 @@ async def test_activate_awaiting_token_opens_configure(page):
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1070,9 +1054,9 @@ async def test_activate_failure_shows_error_toast(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1088,9 +1072,9 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1106,7 +1090,7 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
async def test_extensions_tab_reloads_on_revisit(page):
"""loadExtensions() is called again when re-navigating to the extensions tab."""
"""loadExtensions() is called again when re-navigating to the extensions subtab."""
call_count = []
async def counting_handler(route):
@@ -1121,14 +1105,10 @@ async def test_extensions_tab_reloads_on_revisit(page):
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
# First visit
@@ -1148,48 +1128,6 @@ async def test_extensions_tab_reloads_on_revisit(page):
assert count_after_second > count_after_first, "loadExtensions not called on return visit"
async def test_auth_completed_sse_triggers_extensions_reload(page):
"""auth_completed SSE event while on the extensions tab triggers a reload."""
reload_count = []
async def counting_handler(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
reload_count.append(1)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": []}),
)
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
count_before = len(reload_count)
# Simulate auth_completed via the shared handler.
await page.evaluate("""
handleAuthCompleted({
extension_name: 'reload-ext',
success: true,
message: 'Reloaded.',
});
""")
await page.wait_for_timeout(600)
assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed"
# ─── Regression tests ─────────────────────────────────────────────────────────
# Each test below is a regression for a specific bug found after the initial
# test suite was written. The bug description is in the docstring.
@@ -1267,9 +1205,9 @@ async def test_oauth_url_injection_blocked(page):
)
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
+18 -7
View File
@@ -4,11 +4,18 @@ import pytest
from helpers import SEL
async def go_to_skills(page):
"""Navigate to Settings > Skills subtab."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="skills")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="skills")).wait_for(
state="visible", timeout=5000
)
async def test_skills_tab_visible(page):
"""Skills tab shows the search interface."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
await panel.wait_for(state="visible", timeout=5000)
"""Skills subtab shows the search interface."""
await go_to_skills(page)
search_input = page.locator(SEL["skill_search_input"])
assert await search_input.is_visible(), "Skills search input not visible"
@@ -16,7 +23,7 @@ async def test_skills_tab_visible(page):
async def test_skills_search(page):
"""Search ClawHub for skills and verify results appear."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
await go_to_skills(page)
search_input = page.locator(SEL["skill_search_input"])
await search_input.fill("markdown")
@@ -35,7 +42,7 @@ async def test_skills_search(page):
async def test_skills_install_and_remove(page):
"""Install a skill from search results, then remove it."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
await go_to_skills(page)
# Search
search_input = page.locator(SEL["skill_search_input"])
@@ -68,10 +75,14 @@ async def test_skills_install_and_remove(page):
installed_count = await installed.count()
assert installed_count >= 1, "Skill should appear in installed list after install"
# Remove the skill (confirm is already overridden)
# Remove the skill via confirm modal
remove_btn = installed.first.locator("button", has_text="Remove")
if await remove_btn.count() > 0:
await remove_btn.click()
# Confirm in the modal
confirm_btn = page.locator(SEL["confirm_modal_btn"])
await confirm_btn.wait_for(state="visible", timeout=5000)
await confirm_btn.click()
# Wait for the card to disappear or list to shrink
await page.wait_for_timeout(3000)
new_count = await page.locator(SEL["skill_installed"]).count()
@@ -33,17 +33,28 @@ _TELEGRAM_ACTIVE = {
}
async def go_to_extensions(page):
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
async def go_to_channels(page):
"""Navigate to Settings → Channels subtab (where wasm_channel extensions live)."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}"
).first.wait_for(state="visible", timeout=8000)
# Wait for the Telegram card specifically (built-in cards render first)
await page.locator(SEL["channels_ext_card"], has_text="Telegram").wait_for(
state="visible", timeout=8000
)
async def mock_extension_lists(page, ext_handler):
async def _default_gateway_status_handler(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"enabled_channels": [], "sse_connections": 0, "ws_connections": 0}),
)
async def mock_extension_lists(page, ext_handler, *, gateway_status_handler=None):
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
@@ -69,6 +80,10 @@ async def mock_extension_lists(page, ext_handler):
await page.route("**/api/extensions*", handle_ext_list)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await page.route(
"**/api/gateway/status",
gateway_status_handler or _default_gateway_status_handler,
)
async def wait_for_toast(page, text: str, *, timeout: int = 5000):
@@ -106,9 +121,9 @@ async def test_telegram_setup_modal_shows_bot_token_field(page):
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_extensions(page)
await go_to_channels(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["channels_ext_card"], has_text="Telegram")
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
@@ -198,9 +213,9 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_extensions(page)
await go_to_channels(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["channels_ext_card"], has_text="Telegram")
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
+4 -4
View File
@@ -507,10 +507,10 @@ async def test_configure_noninstalled(ironclaw_server):
async def test_extensions_tab_shows_registry(page):
"""Extensions tab loads and shows available extensions from registry."""
tab_btn = page.locator(SEL["tab_button"].format(tab="extensions"))
await tab_btn.click()
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
"""Extensions subtab loads and shows available extensions from registry."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
await panel.wait_for(state="visible", timeout=5000)
available_section = page.locator(SEL["available_wasm_list"])
+176 -21
View File
@@ -142,16 +142,18 @@ mod tests {
match &routine.action {
RoutineAction::Lightweight {
prompt,
context_paths,
use_tools,
max_tool_rounds,
..
} => {
assert!(prompt.contains("Check system status"));
assert_eq!(context_paths, &vec!["context/priorities.md".to_string()]);
assert!(*use_tools, "lightweight routine should keep use_tools=true");
assert_eq!(*max_tool_rounds, 2);
}
other => panic!("expected lightweight action, got {other:?}"),
other => panic!("expected lightweight routine action, got {other:?}"),
}
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
@@ -369,7 +371,132 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: skill_install_routine_webhook_sim
// Test 8: routine_create_grouped
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_create_grouped() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_create_grouped.json"
))
.expect("failed to load routine_create_grouped.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a grouped cron routine with delivery settings")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "weekday-digest")
.await
.expect("get_routine_by_name")
.expect("weekday-digest should exist");
match &routine.trigger {
Trigger::Cron { schedule, timezone } => {
assert_eq!(schedule, "0 0 9 * * MON-FRI");
assert_eq!(timezone.as_deref(), Some("UTC"));
}
other => panic!("expected cron trigger, got {other:?}"),
}
match &routine.action {
RoutineAction::FullJob {
description,
tool_permissions,
..
} => {
assert!(description.contains("Prepare the morning digest"));
assert_eq!(
tool_permissions,
&vec!["message".to_string(), "http".to_string()]
);
}
other => panic!("expected full_job action, got {other:?}"),
}
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
assert_eq!(routine.notify.user.as_deref(), Some("ops-team"));
assert_eq!(routine.guardrails.cooldown.as_secs(), 30);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 9: routine_system_event_emit_grouped
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_system_event_emit_grouped() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json"
))
.expect("failed to load routine_system_event_emit_grouped.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a grouped system-event routine and emit a matching event")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "grouped-gh-issue-watch")
.await
.expect("get_routine_by_name")
.expect("grouped-gh-issue-watch should exist");
match &routine.trigger {
Trigger::SystemEvent {
source,
event_type,
filters,
} => {
assert_eq!(source, "github");
assert_eq!(event_type, "issue.opened");
assert_eq!(
filters.get("repository").map(String::as_str),
Some("nearai/ironclaw")
);
assert_eq!(filters.get("priority").map(String::as_str), Some("p1"));
}
other => panic!("expected system_event trigger, got {other:?}"),
}
let results = rig.tool_results();
let emit_result = results
.iter()
.find(|(n, _)| n == "event_emit")
.expect("event_emit result missing");
let emit_json: serde_json::Value =
serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON");
assert!(
emit_json["fired_routines"].as_u64().unwrap_or(0) > 0,
"event_emit should have fired at least one grouped routine: {:?}",
emit_result.1
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 10: skill_install_routine_webhook_sim
// -----------------------------------------------------------------------
#[tokio::test]
@@ -571,10 +698,11 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test: tool_info_discovery (two-level detail)
// Test: tool_info_discovery (three-level detail)
// -----------------------------------------------------------------------
// Verifies the tool_info built-in returns:
// - Default (no include_schema): name, description, parameter names array
// - `detail: "summary"`: curated summary guidance
// - With include_schema: true: adds full typed JSON Schema
#[tokio::test]
@@ -597,13 +725,13 @@ mod tests {
rig.verify_trace_expects(&trace, &responses);
// tool_info should have been called twice (echo + time), both succeeding.
// tool_info should have been called three times (echo + routine_create + time), all succeeding.
let completed = rig.tool_calls_completed();
let tool_info_calls: Vec<_> = completed.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(
tool_info_calls.len(),
2,
"Expected 2 tool_info calls, got {tool_info_calls:?}"
3,
"Expected 3 tool_info calls, got {tool_info_calls:?}"
);
assert!(
tool_info_calls.iter().all(|(_, ok)| *ok),
@@ -613,44 +741,71 @@ mod tests {
// Verify the results contain expected fields.
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
let info_json: Vec<serde_json::Value> = info_results
.iter()
.map(|(_, preview)| {
serde_json::from_str(preview)
.expect("tool_info result preview should be valid JSON")
})
.collect();
// First call was for "echo" (default, no include_schema) — result should
// contain "echo" and "parameters" as an array of names (not full schema).
let echo_result = info_results
let echo_json = info_json
.iter()
.find(|(_, preview)| preview.contains("echo"))
.find(|info| info["name"] == "echo")
.expect("tool_info result should contain 'echo'");
assert!(
echo_result.1.contains("message"),
echo_json["parameters"]
.as_array()
.is_some_and(|params| params.iter().any(|param| param == "message")),
"echo default result should list 'message' parameter name: {:?}",
echo_result.1
echo_json
);
// Default mode should NOT include the full "schema" key
let echo_json: serde_json::Value = serde_json::from_str(&echo_result.1)
.expect("echo tool_info result should be valid JSON");
assert!(
echo_json.get("schema").is_none(),
"Default tool_info should not include schema field: {:?}",
echo_result.1
echo_json
);
// Second call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_result = info_results
// Second call was for "routine_create" with detail: "summary" — result
// should contain a summary object with rules/examples.
let routine_json = info_json
.iter()
.find(|(_, preview)| preview.contains("time"))
.find(|info| info["name"] == "routine_create")
.expect("tool_info result should contain 'routine_create'");
assert!(
routine_json.get("summary").is_some(),
"detail: summary should include summary field: {:?}",
routine_json
);
assert!(
routine_json["summary"]["conditional_requirements"]
.as_array()
.is_some_and(|rules| rules.iter().any(|rule| {
rule.as_str()
.is_some_and(|rule| rule.contains("request.kind='cron'"))
})),
"routine_create summary should mention cron requirement: {:?}",
routine_json
);
// Third call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_json = info_json
.iter()
.find(|info| info["name"] == "time")
.expect("tool_info result should contain 'time'");
let time_json: serde_json::Value = serde_json::from_str(&time_result.1)
.expect("time tool_info result should be valid JSON");
assert!(
time_json.get("schema").is_some(),
"include_schema: true should include schema field: {:?}",
time_result.1
time_json
);
assert!(
time_json["schema"]["properties"].is_object(),
"schema should have properties: {:?}",
time_result.1
time_json
);
rig.shutdown();
+245 -10
View File
@@ -238,7 +238,13 @@ mod tests {
"default",
"deploy to production now",
);
let fired = engine.check_event_triggers(&matching_msg).await;
let fired = engine
.check_event_triggers(
&matching_msg.user_id,
&matching_msg.channel,
&matching_msg.content,
)
.await;
assert!(
fired >= 1,
"Expected >= 1 routine fired on match, got {fired}"
@@ -255,7 +261,13 @@ mod tests {
"default",
"check the staging environment",
);
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
let fired_neg = engine
.check_event_triggers(
&non_matching_msg.user_id,
&non_matching_msg.channel,
&non_matching_msg.content,
)
.await;
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
}
@@ -315,7 +327,9 @@ mod tests {
"guest-sender",
"deploy to production now",
);
let guest_fired = engine.check_event_triggers(&guest_msg).await;
let guest_fired = engine
.check_event_triggers(&guest_msg.user_id, &guest_msg.channel, &guest_msg.content)
.await;
assert_eq!(
guest_fired, 0,
"Guest scope must not fire owner event routines"
@@ -338,7 +352,9 @@ mod tests {
"owner-sender",
"deploy to production now",
);
let owner_fired = engine.check_event_triggers(&owner_msg).await;
let owner_fired = engine
.check_event_triggers(&owner_msg.user_id, &owner_msg.channel, &owner_msg.content)
.await;
assert!(
owner_fired >= 1,
"Owner scope should fire matching owner event routine"
@@ -562,7 +578,9 @@ mod tests {
"default",
"test-cooldown trigger",
);
let fired1 = engine.check_event_triggers(&msg).await;
let fired1 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert!(fired1 >= 1, "First fire should work");
// Give spawn time, then update last_run_at to simulate recent execution.
@@ -577,7 +595,9 @@ mod tests {
engine.refresh_event_cache().await;
// Second fire should be blocked by cooldown.
let fired2 = engine.check_event_triggers(&msg).await;
let fired2 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert_eq!(fired2, 0, "Second fire should be blocked by cooldown");
}
@@ -745,7 +765,9 @@ mod tests {
engine.refresh_event_cache().await;
let msg = IncomingMessage::new("test", "default", "DISABLE_ME");
let fired_before = engine.check_event_triggers(&msg).await;
let fired_before = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert!(fired_before >= 1, "Expected routine to fire before disable");
// Simulate what routines_toggle_handler now does: update DB, then refresh.
@@ -754,7 +776,9 @@ mod tests {
db.update_routine(&routine).await.expect("update_routine");
engine.refresh_event_cache().await;
let fired_after = engine.check_event_triggers(&msg).await;
let fired_after = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert_eq!(
fired_after, 0,
"Disabled routine must not fire after cache refresh"
@@ -780,7 +804,10 @@ mod tests {
let msg = IncomingMessage::new("test", "default", "DELETE_ME");
assert!(
engine.check_event_triggers(&msg).await >= 1,
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await
>= 1,
"Expected routine to fire before delete"
);
@@ -789,9 +816,217 @@ mod tests {
engine.refresh_event_cache().await;
assert_eq!(
engine.check_event_triggers(&msg).await,
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await,
0,
"Deleted routine must not fire after cache refresh"
);
}
// -----------------------------------------------------------------------
// Test: full_job per-routine concurrency blocks second fire (issue #1318)
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_max_concurrent_blocks_second_fire_while_first_active() {
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::error::RoutineError;
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Stub LLM — fire_manual will be rejected before any LLM call
let trace = LlmTrace::single_turn(
"stub",
"stub",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 10,
output_tokens: 5,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(4);
let tools = Arc::new(ToolRegistry::new());
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
llm,
ws,
notify_tx,
None, // no scheduler — rejected before dispatch
tools,
safety,
));
// Create a full_job routine with max_concurrent = 1
let routine = Routine {
id: Uuid::new_v4(),
name: "concurrent-guard".to_string(),
description: "test max_concurrent for full_job".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "t".to_string(),
description: "d".to_string(),
max_iterations: 3,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
db.create_routine(&routine).await.expect("create_routine");
// Simulate first full_job run still active: the fix keeps the
// routine_run in Running state while the linked job executes.
let active_run = RoutineRun {
id: Uuid::new_v4(),
routine_id: routine.id,
trigger_type: "cron".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: Utc::now(),
};
db.create_routine_run(&active_run)
.await
.expect("create_routine_run");
// Attempt to fire the same routine again — must be rejected
let result = engine.fire_manual(routine.id, None).await;
assert!(
matches!(result, Err(RoutineError::MaxConcurrent { .. })),
"second fire while first full_job active must be rejected by max_concurrent=1, got: {:?}",
result
);
}
// -----------------------------------------------------------------------
// Test: global running_count tracks live full_job runs (issue #1318)
// -----------------------------------------------------------------------
#[tokio::test]
async fn global_concurrency_counts_live_full_job_runs() {
use std::sync::atomic::Ordering;
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
let trace = LlmTrace::single_turn(
"test-global-limit",
"check",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 50,
output_tokens: 5,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
let tools = Arc::new(ToolRegistry::new());
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
}));
// Configure global limit of 1
let config = RoutineConfig {
max_concurrent_routines: 1,
..RoutineConfig::default()
};
let engine = Arc::new(RoutineEngine::new(
config,
db.clone(),
llm,
ws,
notify_tx,
None,
tools,
safety,
));
// Insert a due cron routine
let mut routine = make_routine(
"global-limit-test",
Trigger::Cron {
schedule: "* * * * *".to_string(),
timezone: None,
},
"Check status.",
);
routine.next_fire_at = Some(Utc::now() - chrono::Duration::minutes(1));
db.create_routine(&routine).await.expect("create_routine");
// Simulate one full_job from another routine holding the global slot.
// With the fix, running_count stays elevated for the full job duration.
engine
.running_count_for_test()
.fetch_add(1, Ordering::Relaxed);
// check_cron_triggers should see global limit hit and skip
engine.check_cron_triggers().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let runs = db
.list_routine_runs(routine.id, 10)
.await
.expect("list_routine_runs");
assert!(
runs.is_empty(),
"cron routine must not fire when global limit is reached by live full_job"
);
// Release the global slot
engine
.running_count_for_test()
.fetch_sub(1, Ordering::Relaxed);
// Now the routine should fire
engine.check_cron_triggers().await;
tokio::time::sleep(Duration::from_millis(200)).await;
// Because the first check skipped it, next_fire_at is unchanged —
// the second check should see it as still due and fire it.
let runs_after = db
.list_routine_runs(routine.id, 10)
.await
.expect("list_routine_runs");
assert!(
!runs_after.is_empty(),
"cron routine should fire after global slot is released"
);
}
}
+1
View File
@@ -198,6 +198,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
let gateway = Arc::new(TestChannel::new());
@@ -0,0 +1,66 @@
{
"model_name": "test-routine-create-grouped",
"expects": {
"tools_used": ["routine_create", "routine_list"],
"all_tools_succeeded": true,
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_grouped_1",
"name": "routine_create",
"arguments": {
"name": "weekday-digest",
"prompt": "Prepare the morning digest for the ops team.",
"description": "Weekday digest for morning operations",
"request": {
"kind": "cron",
"schedule": "0 0 9 * * MON-FRI",
"timezone": "UTC"
},
"execution": {
"mode": "full_job",
"tool_permissions": ["message", "http"]
},
"delivery": {
"channel": "telegram",
"user": "ops-team"
},
"advanced": {
"cooldown_secs": 30
}
}
}
],
"input_tokens": 130,
"output_tokens": 44
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rl_grouped_1",
"name": "routine_list",
"arguments": {}
}
],
"input_tokens": 190,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.",
"input_tokens": 250,
"output_tokens": 24
}
}
]
}
@@ -0,0 +1,74 @@
{
"model_name": "test-routine-system-event-emit-grouped",
"expects": {
"tools_used": ["routine_create", "event_emit"],
"all_tools_succeeded": true,
"tool_results_contain": {
"event_emit": "fired_routines"
}
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_grouped_system_1",
"name": "routine_create",
"arguments": {
"name": "grouped-gh-issue-watch",
"prompt": "Summarize the new issue and propose next steps.",
"description": "React to important GitHub issue.opened events",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository": "nearai/ironclaw",
"priority": "p1"
}
},
"execution": {
"mode": "full_job",
"tool_permissions": ["shell"]
}
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ee_grouped_1",
"name": "event_emit",
"arguments": {
"event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "nearai/ironclaw",
"priority": "p1",
"issue_number": 123,
"title": "Support grouped routine create requests"
}
}
}
],
"input_tokens": 180,
"output_tokens": 30
}
},
{
"response": {
"type": "text",
"content": "Created the grouped system-event routine and emitted a matching GitHub event.",
"input_tokens": 230,
"output_tokens": 18
}
}
]
}
+18 -4
View File
@@ -24,6 +24,20 @@
"output_tokens": 20
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_routine_create",
"name": "tool_info",
"arguments": { "name": "routine_create", "detail": "summary" }
}
],
"input_tokens": 160,
"output_tokens": 25
}
},
{
"response": {
"type": "tool_calls",
@@ -34,16 +48,16 @@
"arguments": { "name": "time", "include_schema": true }
}
],
"input_tokens": 200,
"input_tokens": 240,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I found the info for both tools. The echo tool has a 'message' parameter. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 400,
"output_tokens": 40
"content": "I found the info for all three tools. The echo tool has a 'message' parameter. routine_create's summary explains that cron needs request.schedule, message_event needs request.pattern, and system_event needs request.source plus request.event_type. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 520,
"output_tokens": 60
}
}
]
+2
View File
@@ -214,6 +214,7 @@ async fn start_test_server_with_provider(
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -705,6 +706,7 @@ async fn test_no_llm_provider_returns_503() {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
+71 -179
View File
@@ -2,18 +2,12 @@
//!
//! Uses real HTTP servers on random ports (no mock framework).
use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering};
use axum::{
Json, Router,
extract::Query,
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
routing::{get, post},
};
use futures::stream;
use ironclaw::channels::relay::client::{RelayClient, RelayError};
use ironclaw::channels::relay::client::{ChannelEvent, RelayClient};
use secrecy::SecretString;
use serde::Deserialize;
use tokio::net::TcpListener;
@@ -37,109 +31,79 @@ fn test_client(base_url: &str) -> RelayClient {
.expect("client build")
}
// ── SSE stream mock ─────────────────────────────────────────────────────
// ── Signing secret fetch ─────────────────────────────────────────────────
#[tokio::test]
async fn test_sse_stream_receives_events() {
async fn test_get_signing_secret_returns_decoded_bytes() {
let secret_hex = hex::encode([1u8; 32]);
let secret_hex_clone = secret_hex.clone();
let app = Router::new().route(
"/stream",
get(
|Query(params): Query<std::collections::HashMap<String, String>>| async move {
// Verify token is passed
assert!(params.contains_key("token"));
let events = vec![
Ok::<_, Infallible>(
Event::default().event("message").data(
serde_json::json!({
"event_type": "message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "C456",
"sender_id": "U789",
"content": "hello world"
})
.to_string(),
),
),
Ok(Event::default().event("message").data(
serde_json::json!({
"event_type": "direct_message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "D001",
"sender_id": "U789",
"content": "dm text"
})
.to_string(),
)),
];
Sse::new(stream::iter(events)).keep_alive(KeepAlive::default())
},
),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let (mut event_stream, handle) = client.connect_stream("test-token", 30).await.unwrap();
use futures::StreamExt;
let first = event_stream.next().await.expect("first event");
assert_eq!(first.event_type, "message");
assert_eq!(first.text(), "hello world");
assert_eq!(first.team_id(), "T123");
let second = event_stream.next().await.expect("second event");
assert_eq!(second.event_type, "direct_message");
assert_eq!(second.text(), "dm text");
handle.abort();
}
// ── Token renewal flow ──────────────────────────────────────────────────
#[tokio::test]
async fn test_token_expired_returns_error() {
let app = Router::new().route("/stream", get(|| async { StatusCode::UNAUTHORIZED }));
let base_url = start_server(app).await;
let client = test_client(&base_url);
match client.connect_stream("expired-token", 30).await {
Err(RelayError::TokenExpired) => {} // expected
Err(other) => panic!("expected TokenExpired, got: {other}"),
Ok(_) => panic!("expected error, got Ok"),
}
}
#[tokio::test]
async fn test_token_renewal() {
let call_count = std::sync::Arc::new(AtomicUsize::new(0));
let call_count_clone = call_count.clone();
let app = Router::new().route(
"/stream/renew",
post(move |Json(body): Json<serde_json::Value>| {
let count = call_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
assert!(body.get("instance_id").is_some());
assert!(body.get("user_id").is_some());
Json(serde_json::json!({
"stream_token": "renewed-token-123"
}))
}
"/relay/signing-secret",
get(move || {
let s = secret_hex_clone.clone();
async move { Json(serde_json::json!({"signing_secret": s})) }
}),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let new_token = client.renew_token("inst-1", "user-1").await.unwrap();
assert_eq!(new_token, "renewed-token-123");
assert_eq!(call_count.load(Ordering::SeqCst), 1);
let secret = client.get_signing_secret("T123").await.unwrap();
assert_eq!(secret, vec![1u8; 32]);
}
#[tokio::test]
async fn test_get_signing_secret_404_returns_error() {
let app = Router::new().route(
"/relay/signing-secret",
get(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") }),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let result = client.get_signing_secret("T123").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_signing_secret_invalid_hex_returns_protocol_error() {
let app = Router::new().route(
"/relay/signing-secret",
get(|| async { Json(serde_json::json!({"signing_secret": "not-hex"})) }),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let err = client
.get_signing_secret("T123")
.await
.unwrap_err()
.to_string();
assert!(err.contains("invalid signing_secret hex"), "got: {err}");
}
#[tokio::test]
async fn test_get_signing_secret_wrong_length_returns_protocol_error() {
let short_secret_hex = hex::encode([7u8; 31]);
let app = Router::new().route(
"/relay/signing-secret",
get(move || {
let s = short_secret_hex.clone();
async move { Json(serde_json::json!({"signing_secret": s})) }
}),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let err = client
.get_signing_secret("T123")
.await
.unwrap_err()
.to_string();
assert!(err.contains("expected 32 bytes"), "got: {err}");
}
// ── Proxy call ──────────────────────────────────────────────────────────
@@ -171,7 +135,7 @@ async fn test_proxy_provider_sends_correct_payload() {
"text": "Hello from test",
});
let resp = client
.proxy_provider("slack", "T123", "chat.postMessage", body, None)
.proxy_provider("slack", "T123", "chat.postMessage", body)
.await
.unwrap();
assert_eq!(resp["ok"], true);
@@ -200,18 +164,18 @@ async fn test_list_connections() {
assert!(!conns[1].connected);
}
// ── API key header ──────────────────────────────────────────────────────
// ── Bearer token auth ────────────────────────────────────────────────────
#[tokio::test]
async fn test_api_key_sent_in_header() {
async fn test_bearer_token_sent_in_header() {
let app = Router::new().route(
"/connections",
get(|headers: axum::http::HeaderMap| async move {
let key = headers
.get("X-API-Key")
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(key, "test-api-key");
assert_eq!(auth, "Bearer test-api-key");
Json(serde_json::json!([]))
}),
);
@@ -233,82 +197,10 @@ fn test_relay_client_new_succeeds() {
assert!(client.is_ok());
}
// ── SSE UTF-8 chunk boundary ────────────────────────────────────────────
/// Verify that multi-byte UTF-8 characters split across SSE chunks are
/// not corrupted (no U+FFFD replacement characters).
#[tokio::test]
async fn test_sse_stream_preserves_multibyte_utf8_across_chunks() {
use std::sync::atomic::{AtomicBool, Ordering};
let sent = std::sync::Arc::new(AtomicBool::new(false));
let sent_clone = sent.clone();
let app = Router::new().route(
"/stream",
get(move |_: Query<std::collections::HashMap<String, String>>| {
let sent = sent_clone.clone();
async move {
// Build SSE payload with emoji that will be split mid-character
let event_data = serde_json::json!({
"event_type": "message",
"provider": "slack",
"provider_scope": "T1",
"channel_id": "C1",
"sender_id": "U1",
"content": "hello 🦀 world"
});
let payload = format!("event: message\ndata: {}\n\n", event_data);
let bytes = payload.into_bytes();
// Split in the middle of the 4-byte crab emoji
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.unwrap();
let split_at = crab_pos + 2;
let chunk1 = bytes[..split_at].to_vec();
let chunk2 = bytes[split_at..].to_vec();
sent.store(true, Ordering::SeqCst);
let events = vec![
Ok::<_, Infallible>(axum::body::Bytes::from(chunk1)),
Ok(axum::body::Bytes::from(chunk2)),
];
axum::response::Response::builder()
.header("content-type", "text/event-stream")
.body(axum::body::Body::from_stream(stream::iter(events)))
.unwrap()
}
}),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let (mut event_stream, handle) = client.connect_stream("tok", 30).await.unwrap();
use futures::StreamExt;
let event = event_stream.next().await.expect("should get event");
assert_eq!(
event.text(),
"hello 🦀 world",
"emoji should not be corrupted"
);
assert!(sent.load(Ordering::SeqCst));
handle.abort();
}
// ── Channel event field validation ──────────────────────────────────────
#[test]
fn test_channel_event_missing_fields_detected() {
use ironclaw::channels::relay::client::ChannelEvent;
// Event with empty sender_id should be detectable
let json = r#"{"event_type": "message", "provider_scope": "T1", "channel_id": "C1", "sender_id": "", "content": "test"}"#;
let event: ChannelEvent = serde_json::from_str(json).unwrap();
@@ -234,6 +234,7 @@ impl GatewayWorkflowHarness {
cost_guard: Some(Arc::clone(&components.cost_guard)),
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let mut agent = Agent::new(
@@ -256,6 +257,7 @@ impl GatewayWorkflowHarness {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
},
channels,
None,
+1
View File
@@ -642,6 +642,7 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
builder: None,
};
// 7. Create TestChannel and ChannelManager.
+1
View File
@@ -62,6 +62,7 @@ async fn start_test_server() -> (
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();