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
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:
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "local-transcription"
|
||||
version = "1.4.5"
|
||||
version = "1.4.16"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -1894,6 +1894,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -25,3 +25,6 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
chrono = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user