Add test suite (63 tests) and CI workflow, fix Settings API bugs
Some checks failed
Release / Bump version and tag (push) Successful in 4s
Sidecar Release / Bump sidecar version and tag (push) Failing after 3s
Tests / Python Backend Tests (push) Failing after 3s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 3m10s

Test suite covering all three layers:

Python backend (25 tests):
- AppController: state machine, start/stop, callbacks, settings reload
- API server: REST endpoints, config CRUD, status, devices
- Config: dot-notation get/set, persistence, nested paths
- Main headless: ready event port format validation

Svelte frontend (14 tests via Vitest):
- Backend store: exported properties/methods, port derivation, URLs
- Config store: method names (fetchConfig not loadConfig), defaults
- Transcriptions store: add/clear/plaintext
- File extension regression: ensures $state runes only in .svelte.ts

Rust sidecar (24 tests via cargo test):
- Platform/arch detection, asset name construction
- Ready event deserialization (with extra fields tolerance)
- Path construction, version read/write, old version cleanup
- Zip extraction, SidecarManager lifecycle

CI workflow (.gitea/workflows/test.yml):
- Runs on push to main and PRs
- Three parallel jobs: Python, Frontend, Rust

Also fixes three bugs found during test planning:
- Settings: /api/check-updates -> GET /api/check-update
- Settings: /api/remote/login -> /api/login
- Settings: /api/remote/register -> /api/register

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-04-07 07:48:34 -07:00
parent 9d78fce3f0
commit 5a674ed199
19 changed files with 2372 additions and 10 deletions

View File

@@ -578,3 +578,364 @@ pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), Strin
mgr.stop();
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
// -----------------------------------------------------------------------
// 1. Platform / arch detection
// -----------------------------------------------------------------------
#[test]
fn platform_token_returns_valid_value() {
let token = platform_token();
assert!(
["windows", "macos", "linux"].contains(&token),
"unexpected platform token: {token}"
);
}
#[test]
fn arch_token_returns_valid_value() {
let token = arch_token();
assert!(
["x86_64", "aarch64"].contains(&token),
"unexpected arch token: {token}"
);
}
// -----------------------------------------------------------------------
// 2. Asset name construction
// -----------------------------------------------------------------------
#[test]
fn asset_prefix_cpu() {
let prefix = asset_prefix("cpu");
let expected = format!("sidecar-{}-{}-cpu", platform_token(), arch_token());
assert_eq!(prefix, expected);
}
#[test]
fn asset_prefix_cuda() {
let prefix = asset_prefix("cuda");
let expected = format!("sidecar-{}-{}-cuda", platform_token(), arch_token());
assert_eq!(prefix, expected);
}
#[test]
fn asset_prefix_format_matches_zip_convention() {
// The download function looks for assets matching
// `{prefix}*.zip`, so verify the prefix starts with "sidecar-"
// and contains exactly three hyphens (sidecar-OS-ARCH-VARIANT).
let prefix = asset_prefix("cpu");
assert!(prefix.starts_with("sidecar-"));
assert_eq!(prefix.matches('-').count(), 3, "expected 3 hyphens in '{prefix}'");
}
// -----------------------------------------------------------------------
// 3. Version parsing — tag_name format "sidecar-vX.Y.Z"
// -----------------------------------------------------------------------
#[test]
fn sidecar_tag_starts_with_expected_prefix() {
// The code filters releases by `tag_name.starts_with("sidecar-v")`.
// Verify the convention: a version string like "sidecar-v1.0.2" passes
// the filter, while "v1.0.2" does not.
let tag = "sidecar-v1.0.2";
assert!(tag.starts_with("sidecar-v"));
let bad_tag = "v1.0.2";
assert!(!bad_tag.starts_with("sidecar-v"));
}
#[test]
fn strip_sidecar_v_prefix() {
// The codebase stores the full tag as the version (e.g. "sidecar-v1.0.2").
// Verify we can strip the prefix to get just "1.0.2" when needed.
let tag = "sidecar-v1.0.2";
let semver = tag.strip_prefix("sidecar-v").unwrap();
assert_eq!(semver, "1.0.2");
}
// -----------------------------------------------------------------------
// 4. ReadyEvent deserialization
// -----------------------------------------------------------------------
#[test]
fn ready_event_deserializes_basic() {
let json = r#"{"event": "ready", "port": 8081}"#;
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
assert_eq!(evt.event, "ready");
assert_eq!(evt.port, 8081);
}
#[test]
fn ready_event_deserializes_with_extra_fields() {
// The backend may emit additional fields like `obs_port`.
// serde should ignore unknown fields by default (deny_unknown_fields
// is NOT set on ReadyEvent).
let json = r#"{"event": "ready", "port": 8081, "obs_port": 8080}"#;
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
assert_eq!(evt.event, "ready");
assert_eq!(evt.port, 8081);
}
#[test]
fn ready_event_rejects_missing_port() {
let json = r#"{"event": "ready"}"#;
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
}
#[test]
fn ready_event_rejects_invalid_port_type() {
let json = r#"{"event": "ready", "port": "not_a_number"}"#;
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
}
// -----------------------------------------------------------------------
// Helper: initialise DIRS with a temp directory so path-related functions
// work. Because OnceLock can only be set once per process, all tests that
// need DIRS must coordinate. We use std::sync::Once + a global temp path.
// -----------------------------------------------------------------------
static TEST_DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Ensure `DIRS` is initialised (idempotent within a test run).
/// Returns the data_dir path.
fn ensure_dirs_initialised() -> PathBuf {
TEST_DATA_DIR
.get_or_init(|| {
let tmp = tempfile::tempdir().expect("create tempdir");
let data = tmp.path().to_path_buf();
// We intentionally leak `tmp` so the directory lives for the
// entire test-run.
std::mem::forget(tmp);
let resource = data.join("resource"); // dummy
std::fs::create_dir_all(&resource).ok();
init_dirs(resource, data.clone());
data
})
.clone()
}
// -----------------------------------------------------------------------
// 5. Path construction (requires init_dirs)
// -----------------------------------------------------------------------
#[test]
fn version_file_path_is_in_data_dir() {
let data = ensure_dirs_initialised();
let vf = version_file();
assert_eq!(vf, data.join("sidecar-version.txt"));
}
#[test]
fn sidecar_dir_for_version_contains_version() {
let data = ensure_dirs_initialised();
let dir = sidecar_dir_for_version("sidecar-v1.2.3");
assert_eq!(dir, data.join("sidecar-sidecar-v1.2.3"));
}
#[test]
fn binary_path_for_version_has_correct_filename() {
let _data = ensure_dirs_initialised();
let bin = binary_path_for_version("sidecar-v1.2.3");
assert_eq!(bin.file_name().unwrap(), BINARY_NAME);
}
#[test]
fn read_installed_version_none_when_missing() {
let _data = ensure_dirs_initialised();
// The version file should not exist yet (clean temp dir).
// If another test wrote it, this still validates the function
// doesn't panic.
let _ = read_installed_version(); // should not panic
}
#[test]
fn write_then_read_installed_version() {
let _data = ensure_dirs_initialised();
let vf = version_file();
std::fs::write(&vf, "sidecar-v2.0.0\n").unwrap();
let v = read_installed_version().expect("should read version");
assert_eq!(v, "sidecar-v2.0.0");
}
// -----------------------------------------------------------------------
// 6. Cleanup old versions
// -----------------------------------------------------------------------
/// Cleanup tests are combined into one function because
/// `cleanup_old_versions` operates on the shared `data_dir()` and
/// tests run in parallel, so separate tests would race.
#[test]
fn cleanup_old_versions_behaviour() {
let data = ensure_dirs_initialised();
// -- Part A: removes stale version dirs, keeps current ----------------
let dirs_to_create = ["sidecar-v1.0.0", "sidecar-v1.0.1", "sidecar-v1.0.2"];
for d in &dirs_to_create {
std::fs::create_dir_all(data.join(d)).unwrap();
}
// cleanup_old_versions builds `current_dir_name = "sidecar-{version}"`.
// Passing "v1.0.2" produces "sidecar-v1.0.2" which matches our dir name.
cleanup_old_versions("v1.0.2");
assert!(
!data.join("sidecar-v1.0.0").exists(),
"sidecar-v1.0.0 should be removed"
);
assert!(
!data.join("sidecar-v1.0.1").exists(),
"sidecar-v1.0.1 should be removed"
);
assert!(
data.join("sidecar-v1.0.2").exists(),
"sidecar-v1.0.2 should be kept"
);
// -- Part B: ignores non-sidecar directories --------------------------
let other = data.join("some-other-dir");
std::fs::create_dir_all(&other).unwrap();
cleanup_old_versions("v1.0.2"); // run again — should leave other alone
assert!(other.exists(), "non-sidecar dir should not be removed");
// Clean up so we don't affect other tests that share data_dir.
let _ = std::fs::remove_dir_all(data.join("sidecar-v1.0.2"));
let _ = std::fs::remove_dir_all(&other);
}
// -----------------------------------------------------------------------
// 7. Zip extraction
// -----------------------------------------------------------------------
#[test]
fn extract_zip_creates_files() {
let tmp = tempfile::tempdir().unwrap();
let zip_path = tmp.path().join("test.zip");
let dest_dir = tmp.path().join("output");
std::fs::create_dir_all(&dest_dir).unwrap();
// Build a simple zip in memory.
{
let file = std::fs::File::create(&zip_path).unwrap();
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer.start_file("hello.txt", options).unwrap();
writer.write_all(b"Hello, world!").unwrap();
writer.start_file("subdir/nested.txt", options).unwrap();
writer.write_all(b"Nested content").unwrap();
writer.finish().unwrap();
}
extract_zip(&zip_path, &dest_dir).expect("extraction should succeed");
let hello = dest_dir.join("hello.txt");
assert!(hello.exists(), "hello.txt should exist");
assert_eq!(std::fs::read_to_string(&hello).unwrap(), "Hello, world!");
let nested = dest_dir.join("subdir/nested.txt");
assert!(nested.exists(), "subdir/nested.txt should exist");
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "Nested content");
}
#[test]
fn extract_zip_error_on_invalid_file() {
let tmp = tempfile::tempdir().unwrap();
let bad_zip = tmp.path().join("bad.zip");
std::fs::write(&bad_zip, b"not a zip file").unwrap();
let dest = tmp.path().join("dest");
std::fs::create_dir_all(&dest).unwrap();
let result = extract_zip(&bad_zip, &dest);
assert!(result.is_err(), "should fail on invalid zip");
}
// -----------------------------------------------------------------------
// SidecarManager unit tests (no process spawning)
// -----------------------------------------------------------------------
#[test]
fn sidecar_manager_new_is_not_running() {
let mut mgr = SidecarManager::new();
assert!(!mgr.is_running());
assert!(mgr.port().is_none());
}
#[test]
fn sidecar_manager_stop_when_not_running() {
let mut mgr = SidecarManager::new();
mgr.stop(); // should not panic
assert!(!mgr.is_running());
}
// -----------------------------------------------------------------------
// GiteaRelease / GiteaAsset deserialization
// -----------------------------------------------------------------------
#[test]
fn gitea_release_deserializes() {
let json = r#"{
"tag_name": "sidecar-v1.0.0",
"assets": [
{
"name": "sidecar-linux-x86_64-cuda.zip",
"browser_download_url": "https://example.com/file.zip",
"size": 12345
}
]
}"#;
let release: GiteaRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "sidecar-v1.0.0");
assert_eq!(release.assets.len(), 1);
assert_eq!(release.assets[0].name, "sidecar-linux-x86_64-cuda.zip");
assert_eq!(release.assets[0].size, 12345);
}
#[test]
fn gitea_release_with_extra_fields() {
// Gitea responses include many more fields; serde should ignore them.
let json = r#"{
"id": 42,
"tag_name": "sidecar-v2.0.0",
"name": "Release 2.0.0",
"body": "changelog here",
"draft": false,
"prerelease": false,
"assets": []
}"#;
let release: GiteaRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "sidecar-v2.0.0");
assert!(release.assets.is_empty());
}
// -----------------------------------------------------------------------
// DownloadProgress serialization round-trip
// -----------------------------------------------------------------------
#[test]
fn download_progress_serializes() {
let progress = DownloadProgress {
downloaded: 1024,
total: 4096,
phase: "downloading".into(),
message: "50%".into(),
};
let json = serde_json::to_string(&progress).unwrap();
assert!(json.contains("\"downloaded\":1024"));
assert!(json.contains("\"phase\":\"downloading\""));
}
}