fabro/lib/crates/arc-api/tests/openapi_conformance.rs
arc-1e68f1[bot] 61c7541fd5 Add Draft PR Mode Support (#13)
* arc(01KK9VW7TXZQQZPHBC08935H0C): toolchain (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 2
Arc-Checkpoint: 477c304d4b72cb390186195c5357c79d0c62a9bc

* arc(01KK9VW7TXZQQZPHBC08935H0C): preflight_compile (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 3
Arc-Checkpoint: 535652db567b6a31f1a9ae2f2aef8de306c5f447

* arc(01KK9VW7TXZQQZPHBC08935H0C): preflight_lint (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 4
Arc-Checkpoint: dae88bf9dd1765d3753691c73985b21d39841c5a

* arc(01KK9VW7TXZQQZPHBC08935H0C): implement (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 5
Arc-Checkpoint: a7d72489ba62fb15a971ff209d4713aa77892064

* arc(01KK9VW7TXZQQZPHBC08935H0C): simplify (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 6
Arc-Checkpoint: 1c1e8fa045e6d116a1dbdc83ac4677d693e0a4b2

* arc(01KK9VW7TXZQQZPHBC08935H0C): verify (fail)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 7
Arc-Checkpoint: deb4e29bc1e7b5a16a25c0b65733ba65d63875ba

* arc(01KK9VW7TXZQQZPHBC08935H0C): fixup (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 8
Arc-Checkpoint: bace62da4526e4c129766324b7263bf16ca58d57

* arc(01KK9VW7TXZQQZPHBC08935H0C): verify (success)

Arc-Run: 01KK9VW7TXZQQZPHBC08935H0C
Arc-Completed: 9
Arc-Checkpoint: a2cccac34d826070d4d861e6b598e831bea7f9fd

* Default pull request draft to true

PRs are now created as drafts by default. Users can opt out with
`draft = false` in the TOML [pull_request] config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: arc <arc@local>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:20:45 -04:00

397 lines
13 KiB
Rust

//! Conformance tests: spec ↔ router ↔ Rust struct consistency.
use std::collections::BTreeSet;
use std::sync::Arc;
use arc_api::jwt_auth::AuthMode;
use arc_api::server::{build_router, create_app_state};
use arc_api::server_config::*;
use arc_workflows::cli::run_config::*;
use arc_workflows::daytona_sandbox::*;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::hook::*;
use arc_workflows::interviewer::Interviewer;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tower::ServiceExt;
fn test_registry(_interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry
}
async fn test_db() -> sqlx::SqlitePool {
let pool = arc_db::connect_memory().await.unwrap();
arc_db::initialize_db(&pool).await.unwrap();
pool
}
fn load_spec() -> openapiv3::OpenAPI {
let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/arc-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("failed to read spec");
serde_yaml::from_str(&text).expect("failed to parse spec")
}
fn resolve_path(path: &str) -> String {
path.replace("{id}", "test-id")
.replace("{qid}", "test-qid")
.replace("{stageId}", "test-stage")
.replace("{name}", "test-name")
.replace("{slug}", "test-slug")
}
fn methods_for_path_item(item: &openapiv3::PathItem) -> Vec<Method> {
let mut methods = Vec::new();
if item.get.is_some() {
methods.push(Method::GET);
}
if item.post.is_some() {
methods.push(Method::POST);
}
if item.put.is_some() {
methods.push(Method::PUT);
}
if item.delete.is_some() {
methods.push(Method::DELETE);
}
if item.patch.is_some() {
methods.push(Method::PATCH);
}
methods
}
#[tokio::test]
async fn all_spec_routes_are_routable() {
let spec = load_spec();
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
let mut checked = 0;
for (path, item) in &spec.paths.paths {
let path_item = match item {
openapiv3::ReferenceOr::Item(item) => item,
openapiv3::ReferenceOr::Reference { .. } => continue,
};
let uri = resolve_path(path);
for method in methods_for_path_item(path_item) {
let mut builder = Request::builder().method(&method).uri(&uri);
let body = if method == Method::POST {
builder = builder.header("content-type", "application/json");
Body::from("{}")
} else {
Body::empty()
};
let req = builder.body(body).unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_ne!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"Route {method} {path} returned 405 — not registered in the router"
);
checked += 1;
}
}
assert!(checked > 0, "No routes were checked — is the spec empty?");
}
// ── ServerConfig ↔ OpenAPI schema drift detection ──────────────────────
/// Load the spec as serde_json::Value for schema introspection.
fn load_spec_json() -> serde_json::Value {
let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/arc-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("read spec");
serde_yaml::from_str(&text).expect("parse spec")
}
/// Follow a `$ref` pointer, or return the value unchanged.
fn resolve_ref<'a>(
value: &'a serde_json::Value,
root: &'a serde_json::Value,
) -> &'a serde_json::Value {
match value.get("$ref").and_then(|v| v.as_str()) {
Some(ref_str) => {
let mut cur = root;
for seg in ref_str.trim_start_matches("#/").split('/') {
cur = &cur[seg];
}
cur
}
None => value,
}
}
/// Collect property names from an OpenAPI schema object.
fn spec_keys(schema: &serde_json::Value) -> BTreeSet<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
}
/// Recursively compare serialized JSON keys against OpenAPI schema properties.
fn compare_schema(
path: &str,
json: &serde_json::Value,
schema: &serde_json::Value,
root: &serde_json::Value,
errors: &mut Vec<String>,
) {
let obj = match json.as_object() {
Some(o) => o,
None => return,
};
// Skip pure-map schemas (additionalProperties without properties).
if schema.get("additionalProperties").is_some() && schema.get("properties").is_none() {
return;
}
let json_keys: BTreeSet<String> = obj.keys().cloned().collect();
let schema_keys = spec_keys(schema);
for key in json_keys.difference(&schema_keys) {
errors.push(format!(
"{path}.{key}: in Rust but missing from OpenAPI spec"
));
}
for key in schema_keys.difference(&json_keys) {
errors.push(format!(
"{path}.{key}: in OpenAPI spec but missing from Rust"
));
}
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(p) => p,
None => return,
};
for key in json_keys.intersection(&schema_keys) {
let json_val = &obj[key];
let prop_schema = resolve_ref(&properties[key], root);
// Skip maps and union types.
if prop_schema.get("additionalProperties").is_some() || prop_schema.get("oneOf").is_some() {
continue;
}
match json_val {
serde_json::Value::Object(_) => {
compare_schema(
&format!("{path}.{key}"),
json_val,
prop_schema,
root,
errors,
);
}
serde_json::Value::Array(arr) => {
// Union keys across all array elements.
let union: BTreeSet<String> = arr
.iter()
.filter_map(|e| e.as_object())
.flat_map(|o| o.keys().cloned())
.collect();
if union.is_empty() {
continue;
}
let items = match prop_schema.get("items") {
Some(i) => resolve_ref(i, root),
None => continue,
};
let synthetic = serde_json::Value::Object(
union
.into_iter()
.map(|k| (k, serde_json::Value::Null))
.collect(),
);
compare_schema(&format!("{path}.{key}[]"), &synthetic, items, root, errors);
}
_ => {}
}
}
}
/// Build a ServerConfig with every Option set to Some so all keys appear
/// in the serialized JSON.
fn fully_populated_server_config() -> ServerConfig {
ServerConfig {
data_dir: Some("/data".into()),
max_concurrent_runs: Some(10),
web: WebConfig {
url: "https://example.com".into(),
auth: AuthConfig {
provider: AuthProvider::Github,
allowed_usernames: vec!["user".into()],
},
},
api: ApiConfig {
base_url: "https://api.example.com".into(),
authentication_strategies: vec![ApiAuthStrategy::Jwt],
tls: Some(TlsConfig {
cert: "c".into(),
key: "k".into(),
ca: "ca".into(),
}),
},
git: GitConfig {
provider: GitProvider::Github,
app_id: Some("123".into()),
client_id: Some("456".into()),
slug: Some("arc".into()),
author: GitAuthorConfig {
name: Some("bot".into()),
email: Some("bot@x".into()),
},
webhooks: Some(WebhookConfig {
strategy: WebhookStrategy::TailscaleFunnel,
}),
},
feature_flags: FeatureFlags {
session_sandboxes: true,
},
log: LogConfig {
level: Some("debug".into()),
},
run_defaults: RunDefaults {
directory: Some("/work".into()),
llm: Some(LlmConfig {
model: Some("m".into()),
provider: Some("p".into()),
fallbacks: Some(Default::default()),
}),
setup: Some(SetupConfig {
commands: vec!["echo hi".into()],
timeout_ms: Some(5000),
}),
sandbox: Some(SandboxConfig {
provider: Some("daytona".into()),
preserve: Some(true),
local: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: Some(60),
labels: Some(Default::default()),
snapshot: Some(DaytonaSnapshotConfig {
name: "snap".into(),
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(DockerfileSource::Inline("FROM x".into())),
}),
network: Some(DaytonaNetwork::Block),
}),
exe: Some(arc_exe::ExeConfig { image: None }),
env: Some(Default::default()),
}),
vars: Some(Default::default()),
checkpoint: CheckpointConfig {
exclude_globs: vec![],
},
pull_request: Some(PullRequestConfig {
enabled: true,
draft: false,
}),
assets: Some(AssetsConfig {
include: vec!["test-results/**".into()],
}),
},
hook_config: HookConfig {
// One hook per HookType variant so the key union covers all fields.
hooks: vec![
HookDefinition {
name: Some("cmd".into()),
event: HookEvent::RunStart,
command: Some("echo".into()),
hook_type: None,
matcher: Some("*".into()),
blocking: Some(true),
timeout_ms: Some(5000),
sandbox: Some(true),
},
HookDefinition {
name: Some("http".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Http {
url: "http://x".into(),
headers: Some(Default::default()),
allowed_env_vars: vec!["X".into()],
tls: TlsMode::Verify,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
HookDefinition {
name: Some("prompt".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "hi".into(),
model: Some("m".into()),
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
HookDefinition {
name: Some("agent".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Agent {
prompt: "hi".into(),
model: Some("m".into()),
max_tool_rounds: Some(5),
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
],
},
}
}
#[test]
fn server_config_keys_match_openapi_spec() {
let config = fully_populated_server_config();
let json = serde_json::to_value(&config).expect("serialize ServerConfig");
let spec = load_spec_json();
let schema = &spec["components"]["schemas"]["ServerConfiguration"];
let mut errors = Vec::new();
compare_schema("ServerConfiguration", &json, schema, &spec, &mut errors);
if !errors.is_empty() {
panic!(
"ServerConfig ↔ OpenAPI schema drift:\n {}",
errors.join("\n ")
);
}
}