diff --git a/.gitea/workflows/build-linux.yml b/.gitea/workflows/build-linux.yml index 66b26cc..d410a63 100644 --- a/.gitea/workflows/build-linux.yml +++ b/.gitea/workflows/build-linux.yml @@ -64,6 +64,9 @@ jobs: - name: Install Node dependencies run: npm ci + - name: Run frontend tests + run: npm test + - name: Set Release Environment id: set_env shell: bash diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index 149c9ea..d897aec 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -59,6 +59,9 @@ jobs: - name: Install Node dependencies run: npm ci + - name: Run frontend tests + run: npm test + - name: Fetch ArgyllCMS binaries env: ARGYLL_SERVER_URL: ${{ github.server_url }} @@ -81,23 +84,9 @@ jobs: echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV - - name: Warm up macOS GUI for DMG layout - shell: bash - run: | - # Tauri's DMG bundler runs Finder AppleScript to set the background - # picture and icon positions. On CI Macs, Finder/Aqua may be idle, - # which causes AppleEvent timeouts (-1712). Wake them before build. - echo "WindowServer: $(pgrep -l WindowServer || echo 'NOT RUNNING')" - echo "console user: $(stat -f '%Su' /dev/console 2>/dev/null || echo unknown)" - echo "whoami: $(whoami)" - open -g -a Finder || true - open -g -a "System Events" || true - osascript -e 'tell application "Finder" to get name' || true - sleep 10 - - name: Build Tauri App env: - TAURI_BUNDLER_DMG_IGNORE_CI: "true" + CI: "true" run: npm run tauri build -- --target ${{ matrix.platform.target }} - name: Prepare Release Assets diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 202a539..d03e01a 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -41,6 +41,9 @@ jobs: - name: Install Node dependencies run: npm ci + - name: Run frontend tests + run: npm test + - name: Fetch ArgyllCMS binaries shell: powershell env: diff --git a/README.md b/README.md index d8fd441..ef6ef2f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.8.3-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) diff --git a/ROADMAP.md b/ROADMAP.md index 6cfebbb..7d0c931 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with: - **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout. - **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons. - **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`). -- **Current Version**: `v0.8.3` (Production release). +- **Current Version**: `v0.8.4` (Production release). --- @@ -113,6 +113,10 @@ ICCery is a native, cross-platform desktop application built with: - [x] **Custom Spectrum File Picker Dialog (#210)**: Implemented native `select_spectrum_file` command wrapping Tauri file dialog with `.sp` filter for custom FWA/OBA spectrum selection in Stage 4 profile generation. - [x] **CGATS Dataset Import File Picker & State Synchronization (#211)**: Implemented native `select_dataset_file` command with `.ti3`, `.txt`, `.cgats`, and `.csv` filter, synchronized wizard target directory and basename upon import, and guarded against empty target states. +### Maintenance & Reliability Release (`v0.8.4`) +- [x] **Atomic Verification History Persistence (#213)**: Hardened `quality_store.rs` with atomic temporary file writes (`.tmp`), explicit flush/sync, and atomic rename to prevent historical drift data loss or corruption upon unexpected system crashes. +- [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows. + --- ## 3. Future Roadmap diff --git a/package.json b/package.json index 4089efe..d8e26bc 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "iccery", "private": true, - "version": "0.8.3", + "version": "0.8.4", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", - "tauri": "tauri" + "tauri": "tauri", + "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fd301b4..63cde58 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.8.3" +version = "0.8.4" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a4f594..bdc0735 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.8.3" +version = "0.8.4" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/src/quality_store.rs b/src-tauri/src/quality_store.rs index 301cba6..d6580d5 100644 --- a/src-tauri/src/quality_store.rs +++ b/src-tauri/src/quality_store.rs @@ -100,15 +100,21 @@ pub fn load_history_file(path: &Path) -> Vec { } let content = match fs::read_to_string(path) { Ok(c) => c, - Err(_) => return Vec::new(), + Err(e) => { + log::warn!("Failed to read verification history at {}: {}", path.display(), e); + return Vec::new(); + } }; match serde_json::from_str::(&content) { Ok(store) => store.records, - Err(_) => Vec::new(), + Err(e) => { + log::error!("Failed to parse verification history at {}: {}", path.display(), e); + Vec::new() + } } } -/// Writes verification records to the specified JSON path. +/// Writes verification records to the specified JSON path atomically. /// Automatically creates parent directories if needed. /// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000). pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> { @@ -132,7 +138,33 @@ pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result let json = serde_json::to_string_pretty(&store) .map_err(|e| format!("Failed to serialize verification history: {}", e))?; - fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?; + + let mut tmp_path = path.as_os_str().to_os_string(); + tmp_path.push(".tmp"); + let tmp_path = PathBuf::from(tmp_path); + + // Write to temporary file with explicit flush and sync + { + use std::io::Write; + let mut file = fs::File::create(&tmp_path) + .map_err(|e| format!("Failed to create temp history file: {}", e))?; + file.write_all(json.as_bytes()) + .map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!("Failed to write temp history file: {}", e) + })?; + file.sync_all() + .map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!("Failed to sync temp history file: {}", e) + })?; + } + + // Atomically replace destination file + if let Err(e) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(format!("Failed to atomically replace verification history file: {}", e)); + } Ok(()) } @@ -418,4 +450,38 @@ mod tests { assert_eq!(p2.extension().unwrap(), "csv"); assert_eq!(p2.to_string_lossy(), "/tmp/history.csv"); } + + #[test] + fn test_atomic_write_preserves_data_and_cleans_tmp() { + let temp_dir = std::env::temp_dir().join("iccery_test_atomic_write"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let file_path = temp_dir.join("verification_history.json"); + let mut tmp_file_path = file_path.as_os_str().to_os_string(); + tmp_file_path.push(".tmp"); + let tmp_file_path = std::path::PathBuf::from(tmp_file_path); + + let rec = sample_record("vr-atom-1", "2026-09-06T12:00:00Z", 0.5); + write_history_file(&file_path, &[rec.clone()]).unwrap(); + + assert!(file_path.exists(), "Target file must exist"); + assert!(!tmp_file_path.exists(), "Temporary file must not remain after successful atomic write"); + + let loaded = load_history_file(&file_path); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0], rec); + + // Overwrite with updated records to verify atomic replacement + let rec2 = sample_record("vr-atom-2", "2026-09-06T12:05:00Z", 1.2); + write_history_file(&file_path, &[rec.clone(), rec2.clone()]).unwrap(); + + assert!(!tmp_file_path.exists(), "Temporary file must not remain after overwrite"); + let loaded2 = load_history_file(&file_path); + assert_eq!(loaded2.len(), 2); + assert_eq!(loaded2[0], rec); + assert_eq!(loaded2[1], rec2); + + let _ = fs::remove_dir_all(&temp_dir); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 32bafa1..54ae769 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.8.3", + "version": "0.8.4", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src"