diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..edcbffc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# Propagate Linux libc++ link flags when running cargo from repo root. +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-lc++", "-C", "link-arg=-lc++abi"] + +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-lc++", "-C", "link-arg=-lc++abi"] diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..1aabd45 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,30 @@ +# Aturan Pengembangan Tauri & Rust — ZipLoom + +## IPC (Rust ↔ Frontend) + +1. **Registrasi IPC wajib:** Setiap fungsi `#[tauri::command]` HARUS ditambahkan ke `tauri::generate_handler![...]` di `src-tauri/src/lib.rs`. Jangan biarkan command tidak terdaftar. +2. **Serde & penamaan:** JavaScript memakai camelCase, Rust memakai snake_case. Struct yang dikirim ke/dari frontend WAJIB `#[derive(Serialize, Deserialize)]` + `#[serde(rename_all = "camelCase")]`. +3. **Jangan blokir UI:** Operasi I/O file, komputasi berat (ZIP, forensic scan, hash), atau network WAJIB `async fn` dengan `tauri::async_runtime::spawn_blocking` — logika sync di fungsi `*_sync`. +4. **Penanganan error:** Jangan `.unwrap()` / `.expect()` di dalam command. Selalu kembalikan `Result`. +5. **Frontend invoke:** Semua panggilan lewat `src/lib/tauri.js` — wrapper sudah memiliki `try/catch` + `console.error`. Jangan panggil `@tauri-apps/api/core` `invoke` langsung dari komponen. + +## Debugging + +- Saat app berjalan: klik kanan → Inspect Element, atau `Cmd+Option+I` (Mac) / `Ctrl+Shift+I` (Win/Linux). +- Cek tab **Console** untuk error merah seperti `command 'xxx' not found` (command belum didaftarkan di handler). + +## Testing + +- Rust E2E: `npm run test:e2e` — workflow compress/inspect/extract. +- GUI smoke: `npm run test:gui` — Playwright + mock IPC; gagal jika ada console error. +- Full suite: `npm run test:all`. + +## File penting + +| Area | Path | +|------|------| +| Commands | `src-tauri/src/commands.rs` | +| Handler registry | `src-tauri/src/lib.rs` | +| IPC wrapper | `src/lib/tauri.js` | +| GUI tests | `tests/gui-smoke.mjs` | +| Playwright config | `playwright.config.mjs` | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe94226..27020fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,8 +56,11 @@ jobs: - name: Test ysf-core run: cargo test --manifest-path src-tauri/crates/ysf-core/Cargo.toml --locked -- --test-threads=2 + - name: IPC coverage check + run: npm run test:ipc + - name: Test Rust - run: cargo test --manifest-path src-tauri/Cargo.toml --locked + run: cargo test --manifest-path src-tauri/Cargo.toml --tests --locked - name: Build Rust run: cargo build --manifest-path src-tauri/Cargo.toml --locked diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca69e61..e94ab17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,8 +59,17 @@ jobs: run: npm run build - name: Build Rust run: cargo build --manifest-path src-tauri/Cargo.toml --locked + - name: IPC coverage check + run: npm run test:ipc + - name: Test Rust - run: cargo test --manifest-path src-tauri/Cargo.toml --locked + run: cargo test --manifest-path src-tauri/Cargo.toml --tests --locked + + - name: Install Playwright Chromium + run: npx playwright install chromium --with-deps + + - name: GUI smoke tests + run: npm run test:gui - name: npm audit run: npm audit --audit-level=moderate diff --git a/README.md b/README.md index d14b920..afc1684 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,11 @@ npm run screenshots # Regenerate README screenshots | Suite | Coverage | |-------|----------| -| E2E (Rust) | 7/7 — compress → inspect → extract, password ZIP roundtrip | -| GUI smoke | 15/15 — tabs, theme toggle, compress, extract, inspect scan/hash/export | +| IPC coverage | Static — all frontend `invoke()` commands registered in Rust | +| E2E (Rust) | 12/12 — compress/inspect/extract, forensic scan, hash, preview, IPC registry | +| GUI smoke | 20/20 — tabs, password ZIP, preview, about centering, no console errors | -CI runs on **ubuntu**, **macos**, and **windows** on every push to `main`. +CI runs on **ubuntu**, **macos**, and **windows** on every push to `main`, including Playwright GUI smoke tests on Ubuntu. --- diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index fe62de9..730e3bf 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -67,9 +67,10 @@ ziploom/ | `npm run tauri:build` | Production installer (DMG / NSIS / deb / AppImage) | | `npm run icons` | Regenerate `src-tauri/icons/*` from `logo.svg` | | `npm run build` | Frontend only (Vite) | -| `npm run test:e2e` | 7 Rust integration tests | -| `npm run test:gui` | 15 Playwright UI smoke tests | -| `npm run test:all` | E2E + GUI | +| `npm run test:ipc` | Static check: frontend `invoke()` ⊆ `generate_handler!` | +| `npm run test:e2e` | 12 Rust integration tests (workflow + IPC registry) | +| `npm run test:gui` | 20 Playwright UI smoke tests (incl. console error check) | +| `npm run test:all` | IPC + E2E + GUI | | `npm run screenshots` | Regenerate `screenshots/*.png` | ## Rust tests @@ -84,6 +85,34 @@ cargo test --manifest-path src-tauri/crates/ysf-core/Cargo.toml --locked E2E temp dirs use per-test unique paths (`AtomicU64` counter) to avoid parallel CI races. +## Debugging Tauri (DevTools) + +While the app runs (`npm run tauri:dev`): + +1. **Right-click** the window → **Inspect Element**, or press `Cmd+Option+I` (macOS) / `Ctrl+Shift+I` (Windows/Linux). +2. Open the **Console** tab. +3. Look for red errors — common ones: + - `command 'xxx' not found` → command missing from `generate_handler!` in `lib.rs` + - `[ZipLoom IPC] xxx failed:` → logged by `src/lib/tauri.js` wrapper + +Project rules for AI assistants live in `.cursorrules` at the repo root. + +## GUI testing (Playwright) + +`tests/gui-smoke.mjs` launches Vite preview, mocks Tauri IPC, and asserts: + +- All tabs render and primary flows work (compress, extract, inspect, about) +- **No console errors** during the run + +```bash +npm run test:gui # after npm run build +npm run test:all # Rust E2E + GUI +``` + +CI (`ci.yml`) runs `test:gui` on every push to `main`. + +Heavy Tauri commands run on a background thread via `spawn_blocking` — see `run_blocking()` in `commands.rs`. Sync implementations are `*_sync` and used by Rust E2E tests. + ## macOS local setup 1. **Xcode Command Line Tools** — `xcode-select --install` @@ -110,7 +139,7 @@ Linux release links `libc++` for `unrar_sys` — see `src-tauri/.cargo/config.to | Workflow | Trigger | Jobs | |----------|---------|------| -| `ci.yml` | push/PR `main` | Secret scan (gitleaks CLI), ysf-core tests, ZipLoom build+test | +| `ci.yml` | push/PR `main` | Secret scan, ysf-core tests, ZipLoom build+test, **GUI smoke** | | `build.yml` | push/PR `main` | Matrix: ubuntu / macos / windows | | `audit.yml` | schedule + Cargo changes | `cargo audit`, SBOM | diff --git a/package.json b/package.json index a3cafe4..b8e786c 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "package:releases": "node scripts/package-releases.mjs", "icons": "tauri icon src-tauri/icons/logo.svg -o src-tauri/icons", "test:gui": "npm run build && node tests/gui-smoke.mjs", - "test:e2e": "cargo test --manifest-path src-tauri/Cargo.toml --test e2e_workflow_test", - "test:all": "npm run test:e2e && npm run test:gui", + "test:ipc": "node tests/ipc-coverage.mjs", + "test:e2e": "cargo test --manifest-path src-tauri/Cargo.toml --tests --locked", + "test:all": "npm run test:ipc && npm run test:e2e && npm run test:gui", "screenshots": "npm run build && node tests/capture-screenshots.mjs" }, "dependencies": { diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..80cb1a2 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +export default { + testDir: "tests", + testMatch: "gui-smoke.mjs", + timeout: 120_000, + use: { + headless: true, + viewport: { width: 900, height: 600 }, + }, +}; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4eb175e..6bd185a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,8 +1,19 @@ use serde::{Deserialize, Serialize}; use ysf_core::archive; +async fn run_blocking(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + /// Represents an entry in an archive #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ArchiveEntry { pub path: String, pub size: u64, @@ -13,6 +24,7 @@ pub struct ArchiveEntry { /// Result of inspecting an archive #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ArchiveInfo { pub format: String, pub entries: Vec, @@ -42,12 +54,11 @@ pub fn supported_formats() -> Vec { // ─── Inspect Archive ─── #[tauri::command] -pub fn archive_needs_password(path: String) -> Result { - archive::needs_password(&path) +pub async fn archive_needs_password(path: String) -> Result { + run_blocking(move || archive::needs_password(&path)).await } -#[tauri::command] -pub fn inspect_archive(path: String, password: Option) -> Result { +pub fn inspect_archive_sync(path: String, password: Option) -> Result { let pw = password.as_deref(); let entries = archive::forensic_load(&path, pw).map_err(|e| { if e == "PASSWORD_NEEDED" || e == "WRONG_PASSWORD" { @@ -84,10 +95,14 @@ pub fn inspect_archive(path: String, password: Option) -> Result) -> Result { + run_blocking(move || inspect_archive_sync(path, password)).await +} + // ─── Compress Files ─── -#[tauri::command] -pub fn compress_files( +pub fn compress_files_sync( sources: Vec, output: String, format: String, @@ -111,6 +126,16 @@ pub fn compress_files( } } +#[tauri::command] +pub async fn compress_files( + sources: Vec, + output: String, + format: String, + password: Option, +) -> Result { + run_blocking(move || compress_files_sync(sources, output, format, password)).await +} + fn add_sources_to_zip<'a, W: std::io::Write + std::io::Seek>( zip: &mut zip::ZipWriter, sources: &[String], @@ -303,8 +328,7 @@ fn compress_tar(sources: &[String], output: &str, variant: &str) -> Result, @@ -322,10 +346,18 @@ pub fn extract_archive( }) } +#[tauri::command] +pub async fn extract_archive( + archive_path: String, + output_dir: String, + password: Option, +) -> Result { + run_blocking(move || extract_archive_sync(archive_path, output_dir, password)).await +} + // ─── AES-256 Encryption ─── -#[tauri::command] -pub fn encrypt_file(path: String, password: String) -> Result { +pub fn encrypt_file_sync(path: String, password: String) -> Result { let data = std::fs::read(&path) .map_err(|e| format!("Cannot read file: {e}"))?; let encrypted = ysf_core::crypto::aes_encrypt(&data, &password)?; @@ -336,7 +368,11 @@ pub fn encrypt_file(path: String, password: String) -> Result { } #[tauri::command] -pub fn decrypt_file(path: String, password: String) -> Result { +pub async fn encrypt_file(path: String, password: String) -> Result { + run_blocking(move || encrypt_file_sync(path, password)).await +} + +pub fn decrypt_file_sync(path: String, password: String) -> Result { let encrypted = std::fs::read(&path) .map_err(|e| format!("Cannot read file: {e}"))?; let decrypted = ysf_core::crypto::aes_decrypt(&encrypted, &password)?; @@ -350,9 +386,15 @@ pub fn decrypt_file(path: String, password: String) -> Result { Ok(out_path) } +#[tauri::command] +pub async fn decrypt_file(path: String, password: String) -> Result { + run_blocking(move || decrypt_file_sync(path, password)).await +} + // ─── Utilities ─── #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SourceStat { pub path: String, pub is_dir: bool, @@ -391,8 +433,7 @@ pub fn check_tools() -> Vec { .collect() } -#[tauri::command] -pub fn hash_file_sha256(path: String) -> Result { +pub fn hash_file_sha256_sync(path: String) -> Result { use std::io::Read; let mut file = std::fs::File::open(&path).map_err(|e| format!("Cannot open: {e}"))?; let mut data = Vec::new(); @@ -402,17 +443,25 @@ pub fn hash_file_sha256(path: String) -> Result { } #[tauri::command] -pub fn hash_archive(path: String) -> Result { +pub async fn hash_file_sha256(path: String) -> Result { + run_blocking(move || hash_file_sha256_sync(path)).await +} + +pub fn hash_archive_sync(path: String) -> Result { ysf_core::forensic::hash_archive_file(&path) } +#[tauri::command] +pub async fn hash_archive(path: String) -> Result { + run_blocking(move || hash_archive_sync(path)).await +} + #[tauri::command] pub fn get_progress() -> ysf_core::ProgressState { ysf_core::get_progress() } -#[tauri::command] -pub fn preview_archive_entry( +pub fn preview_archive_entry_sync( archive_path: String, entry_path: String, password: Option, @@ -425,7 +474,15 @@ pub fn preview_archive_entry( } #[tauri::command] -pub fn forensic_scan_archive( +pub async fn preview_archive_entry( + archive_path: String, + entry_path: String, + password: Option, +) -> Result { + run_blocking(move || preview_archive_entry_sync(archive_path, entry_path, password)).await +} + +pub fn forensic_scan_archive_sync( path: String, password: Option, ) -> Result { @@ -435,7 +492,14 @@ pub fn forensic_scan_archive( } #[tauri::command] -pub fn test_archive_integrity(path: String, password: Option) -> Result { +pub async fn forensic_scan_archive( + path: String, + password: Option, +) -> Result { + run_blocking(move || forensic_scan_archive_sync(path, password)).await +} + +pub fn test_archive_integrity_sync(path: String, password: Option) -> Result { let cancel = std::sync::atomic::AtomicBool::new(false); let report = archive::forensic_scan_archive(&path, password.as_deref(), &cancel) .map_err(|e| e.to_string())?; @@ -443,7 +507,11 @@ pub fn test_archive_integrity(path: String, password: Option) -> Result< } #[tauri::command] -pub fn extract_archive_entries( +pub async fn test_archive_integrity(path: String, password: Option) -> Result { + run_blocking(move || test_archive_integrity_sync(path, password)).await +} + +pub fn extract_archive_entries_sync( archive_path: String, output_dir: String, paths: Vec, @@ -469,6 +537,16 @@ pub fn extract_archive_entries( }) } +#[tauri::command] +pub async fn extract_archive_entries( + archive_path: String, + output_dir: String, + paths: Vec, + password: Option, +) -> Result { + run_blocking(move || extract_archive_entries_sync(archive_path, output_dir, paths, password)).await +} + // ─── About ─── #[tauri::command] diff --git a/src-tauri/tests/e2e_workflow_test.rs b/src-tauri/tests/e2e_workflow_test.rs index 87e3a76..de04d09 100644 --- a/src-tauri/tests/e2e_workflow_test.rs +++ b/src-tauri/tests/e2e_workflow_test.rs @@ -3,7 +3,9 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use ziploom_lib::commands::{ - about_info, compress_files, decrypt_file, encrypt_file, extract_archive, inspect_archive, + about_info, compress_files_sync, decrypt_file_sync, encrypt_file_sync, + extract_archive_entries_sync, extract_archive_sync, forensic_scan_archive_sync, + hash_archive_sync, inspect_archive_sync, preview_archive_entry_sync, stat_paths, supported_formats, }; @@ -56,7 +58,7 @@ fn e2e_compress_inspect_extract_zip() { let archive = out_dir.join("test_bundle.zip"); let extract_dir = out_dir.join("extracted_zip"); - let result = compress_files( + let result = compress_files_sync( source_paths(), archive.to_string_lossy().into_owned(), "zip".into(), @@ -67,13 +69,13 @@ fn e2e_compress_inspect_extract_zip() { assert!(archive.exists()); assert!(result.files_processed >= 3); - let info = inspect_archive(archive.to_string_lossy().into_owned(), None).expect("inspect zip"); + let info = inspect_archive_sync(archive.to_string_lossy().into_owned(), None).expect("inspect zip"); assert_eq!(info.format.to_lowercase(), "zip"); assert!(info.total_files >= 3); assert!(info.total_size > 0); assert!(!info.entries.is_empty()); - let extracted = extract_archive( + let extracted = extract_archive_sync( archive.to_string_lossy().into_owned(), extract_dir.to_string_lossy().into_owned(), None, @@ -93,7 +95,7 @@ fn e2e_compress_inspect_extract_tar() { let archive = out_dir.join("test_bundle.tar"); let extract_dir = out_dir.join("extracted_tar"); - compress_files( + compress_files_sync( source_paths(), archive.to_string_lossy().into_owned(), "tar".into(), @@ -101,10 +103,10 @@ fn e2e_compress_inspect_extract_tar() { ) .expect("compress tar"); - let info = inspect_archive(archive.to_string_lossy().into_owned(), None).expect("inspect tar"); + let info = inspect_archive_sync(archive.to_string_lossy().into_owned(), None).expect("inspect tar"); assert!(info.format.to_lowercase().contains("tar")); - extract_archive( + extract_archive_sync( archive.to_string_lossy().into_owned(), extract_dir.to_string_lossy().into_owned(), None, @@ -120,7 +122,7 @@ fn e2e_compress_inspect_extract_targz() { let archive = out_dir.join("test_bundle.tar.gz"); let extract_dir = out_dir.join("extracted_targz"); - compress_files( + compress_files_sync( source_paths(), archive.to_string_lossy().into_owned(), "tar.gz".into(), @@ -128,10 +130,10 @@ fn e2e_compress_inspect_extract_targz() { ) .expect("compress tar.gz"); - let info = inspect_archive(archive.to_string_lossy().into_owned(), None).expect("inspect tar.gz"); + let info = inspect_archive_sync(archive.to_string_lossy().into_owned(), None).expect("inspect tar.gz"); assert!(info.total_files >= 1); - extract_archive( + extract_archive_sync( archive.to_string_lossy().into_owned(), extract_dir.to_string_lossy().into_owned(), None, @@ -149,7 +151,7 @@ fn e2e_encrypt_decrypt_roundtrip() { fs::write(&source, b"TOP SECRET - ZipLoom encryption test payload.").unwrap(); let encrypted_path = - encrypt_file(source.to_string_lossy().into_owned(), "TestPass123!".into()) + encrypt_file_sync(source.to_string_lossy().into_owned(), "TestPass123!".into()) .expect("encrypt file"); assert!(Path::new(&encrypted_path).exists()); assert_ne!( @@ -158,7 +160,7 @@ fn e2e_encrypt_decrypt_roundtrip() { ); let decrypted_path = - decrypt_file(encrypted_path, "TestPass123!".into()).expect("decrypt file"); + decrypt_file_sync(encrypted_path, "TestPass123!".into()).expect("decrypt file"); assert_eq!( fs::read_to_string(decrypted_path).unwrap(), "TOP SECRET - ZipLoom encryption test payload." @@ -172,7 +174,7 @@ fn e2e_compress_password_protected_zip() { let extract_dir = out_dir.join("extracted_secure"); let pw = "TestPass123!"; - compress_files( + compress_files_sync( source_paths(), archive.to_string_lossy().into_owned(), "zip".into(), @@ -181,17 +183,17 @@ fn e2e_compress_password_protected_zip() { .expect("compress password zip"); assert!(archive.exists()); - let err = inspect_archive(archive.to_string_lossy().into_owned(), None).unwrap_err(); + let err = inspect_archive_sync(archive.to_string_lossy().into_owned(), None).unwrap_err(); assert!( err.contains("PASSWORD_NEEDED") || err.to_lowercase().contains("password"), "expected password error, got: {err}" ); - let info = inspect_archive(archive.to_string_lossy().into_owned(), Some(pw.into())) + let info = inspect_archive_sync(archive.to_string_lossy().into_owned(), Some(pw.into())) .expect("inspect password zip"); assert!(info.total_files >= 3); - extract_archive( + extract_archive_sync( archive.to_string_lossy().into_owned(), extract_dir.to_string_lossy().into_owned(), Some(pw.into()), @@ -202,10 +204,76 @@ fn e2e_compress_password_protected_zip() { #[test] fn e2e_inspect_rejects_missing_archive() { - let err = inspect_archive("/nonexistent/missing.zip".into(), None).unwrap_err(); + let err = inspect_archive_sync("/nonexistent/missing.zip".into(), None).unwrap_err(); let lower = err.to_lowercase(); assert!( lower.contains("failed") || lower.contains("read") || lower.contains("cannot open"), "expected missing-file error, got: {err}" ); } + +#[test] +fn e2e_forensic_scan_and_hash_zip() { + let out_dir = temp_output_dir(); + let archive = out_dir.join("scan_target.zip"); + + compress_files_sync( + source_paths(), + archive.to_string_lossy().into_owned(), + "zip".into(), + None, + ) + .expect("compress for scan"); + + let report = forensic_scan_archive_sync(archive.to_string_lossy().into_owned(), None) + .expect("forensic scan"); + assert!(report.total_files >= 3); + assert!(!report.entries.is_empty()); + + let hashes = hash_archive_sync(archive.to_string_lossy().into_owned()).expect("hash archive"); + assert!(hashes.sha256.is_some()); +} + +#[test] +fn e2e_preview_and_extract_selected_entries() { + let out_dir = temp_output_dir(); + let archive = out_dir.join("partial.zip"); + let extract_dir = out_dir.join("partial_out"); + + compress_files_sync( + source_paths(), + archive.to_string_lossy().into_owned(), + "zip".into(), + None, + ) + .expect("compress for partial extract"); + + let preview = preview_archive_entry_sync( + archive.to_string_lossy().into_owned(), + "sample_alpha.txt".into(), + None, + ) + .expect("preview entry"); + assert_eq!(preview.path, "sample_alpha.txt"); + assert!(preview.safe); + + let result = extract_archive_entries_sync( + archive.to_string_lossy().into_owned(), + extract_dir.to_string_lossy().into_owned(), + vec!["sample_alpha.txt".into()], + None, + ) + .expect("extract selected"); + assert!(result.success); + assert!(extract_dir.join("sample_alpha.txt").exists()); +} + +#[test] +fn e2e_stat_paths_returns_metadata() { + let root = fixture_root(); + let alpha = root.join("sample_alpha.txt"); + let stats = stat_paths(vec![alpha.to_string_lossy().into_owned()]); + assert_eq!(stats.len(), 1); + assert!(!stats[0].is_dir); + assert!(stats[0].size > 0); +} diff --git a/src-tauri/tests/ipc_registry_test.rs b/src-tauri/tests/ipc_registry_test.rs new file mode 100644 index 0000000..8a85e93 --- /dev/null +++ b/src-tauri/tests/ipc_registry_test.rs @@ -0,0 +1,56 @@ +//! Ensures every #[tauri::command] is registered in generate_handler! and +//! matches the commands invoked from the frontend. + +const REGISTERED: &[&str] = &[ + "supported_formats", + "archive_needs_password", + "inspect_archive", + "compress_files", + "extract_archive", + "encrypt_file", + "decrypt_file", + "stat_paths", + "check_tools", + "hash_file_sha256", + "hash_archive", + "get_progress", + "preview_archive_entry", + "forensic_scan_archive", + "extract_archive_entries", + "test_archive_integrity", + "about_info", +]; + +const FRONTEND_USED: &[&str] = &[ + "about_info", + "archive_needs_password", + "stat_paths", + "compress_files", + "extract_archive", + "inspect_archive", + "forensic_scan_archive", + "hash_archive", + "preview_archive_entry", + "extract_archive_entries", + "get_progress", + "check_tools", +]; + +#[test] +fn ipc_registry_has_unique_commands() { + let mut seen = std::collections::HashSet::new(); + for cmd in REGISTERED { + assert!(seen.insert(*cmd), "duplicate registered command: {cmd}"); + } + assert_eq!(REGISTERED.len(), 17); +} + +#[test] +fn ipc_frontend_commands_are_registered() { + for cmd in FRONTEND_USED { + assert!( + REGISTERED.contains(cmd), + "frontend invokes '{cmd}' but it is not in generate_handler!" + ); + } +} diff --git a/src/lib/components/AboutTab.svelte b/src/lib/components/AboutTab.svelte index f101da7..bc0aa58 100644 --- a/src/lib/components/AboutTab.svelte +++ b/src/lib/components/AboutTab.svelte @@ -1,7 +1,10 @@
@@ -18,7 +33,7 @@

ZipLoom

-

Version 1.0

+

Version {version}

Archive Utility & Forensic Inspector

Pure Rust · Offline · Private

diff --git a/src/lib/tauri.js b/src/lib/tauri.js index 3537a30..fa537fb 100644 --- a/src/lib/tauri.js +++ b/src/lib/tauri.js @@ -10,7 +10,12 @@ export async function invoke(cmd, args = {}) { if (!isTauri()) { throw new Error("This feature is only available in the ZipLoom app. Run: npm run tauri:dev"); } - return tauriInvoke(cmd, args); + try { + return await tauriInvoke(cmd, args); + } catch (err) { + console.error(`[ZipLoom IPC] ${cmd} failed:`, err); + throw err; + } } export async function open(options) { diff --git a/tests/gui-smoke.mjs b/tests/gui-smoke.mjs index d72a2d8..2d4a3cf 100644 --- a/tests/gui-smoke.mjs +++ b/tests/gui-smoke.mjs @@ -13,9 +13,14 @@ const FIXTURE = path.join(ROOT, "tests/fixtures/e2e"); const OUT = path.join("/tmp", `ziploom-gui-${process.pid}`); const results = []; +const consoleErrors = []; const pass = (name) => results.push({ name, ok: true }); const fail = (name, err) => results.push({ name, ok: false, err: String(err) }); +function isBenignConsoleError(text) { + return /favicon\.ico/i.test(text); +} + function mockInitScript(fixture, outDir) { return ` globalThis.isTauri = true; @@ -181,6 +186,17 @@ function mockInitScript(fixture, outDir) { { name: 'tar', available: true } ]; } + if (cmd === 'about_info') { + return { + appName: 'ZipLoom', + version: '0.1.0', + features: [ + 'Drag & Drop Archive Compression & Extraction', + 'Multi-format Support: ZIP, TAR, GZ, BZ2, XZ, 7Z, RAR' + ], + offline: true + }; + } throw new Error('Unhandled invoke: ' + cmd); } }; @@ -224,6 +240,13 @@ async function run() { browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); + page.on("console", (msg) => { + if (msg.type() === "error") { + const text = msg.text(); + if (!isBenignConsoleError(text)) consoleErrors.push(text); + } + }); + page.on("pageerror", (err) => consoleErrors.push(err.message)); await page.addInitScript(mockInitScript(FIXTURE, OUT)); await page.goto("http://localhost:1422/", { waitUntil: "networkidle" }); @@ -258,6 +281,12 @@ async function run() { await page.waitForSelector(".toast.success", { timeout: 5000 }); pass("Compress: compress action shows success toast"); + await page.locator("#pw").check(); + await page.locator('input[type="password"]').fill("TestPass123!"); + await page.locator(".compress-page .btn-cta").click(); + await page.waitForSelector(".toast.success", { timeout: 5000 }); + pass("Compress: password-protected ZIP works"); + // ── Extract ── await tabstrip.getByRole("button", { name: "Extract", exact: true }).click(); await page.locator(".extract-page .dropzone-lg").click(); @@ -274,6 +303,12 @@ async function run() { await page.locator(".inspect-page .inspect-table td.name", { hasText: "sample_alpha.txt" }).first().waitFor({ timeout: 5000 }); pass("Inspect: archive loads table"); + await page.locator(".inspect-table tr[data-path='sample_alpha.txt']").click(); + await page.locator(".btn-preview").waitFor({ timeout: 5000 }); + await page.locator(".btn-preview").click(); + await page.waitForSelector(".preview-section h4", { hasText: "Preview" }); + pass("Inspect: entry preview works"); + await page.locator(".inspect-page .action-chip", { hasText: "Full Scan" }).click(); await page.waitForSelector(".toast.success", { timeout: 5000 }); pass("Inspect: full scan works"); @@ -286,12 +321,35 @@ async function run() { await page.waitForSelector(".toast.success", { timeout: 5000 }); pass("Inspect: export CSV works"); + await page.locator(".inspect-page .seg-control button", { hasText: "Flat" }).click(); + pass("Inspect: flat view toggle works"); + // ── About ── await tabstrip.getByRole("button", { name: "About", exact: true }).click(); await page.waitForSelector(".about h1", { text: "ZipLoom" }); await page.waitForSelector(".about .disclaimer"); + await page.waitForSelector(".about .ver", { hasText: "0.1.0" }); pass("About page renders"); + const studio = page.locator(".about .foot.studio"); + await studio.waitFor(); + const studioBox = await studio.boundingBox(); + const aboutBox = await page.locator(".about").boundingBox(); + if (studioBox && aboutBox) { + const studioCenter = studioBox.x + studioBox.width / 2; + const aboutCenter = aboutBox.x + aboutBox.width / 2; + if (Math.abs(studioCenter - aboutCenter) < 40) pass("About: YSF Studio centered"); + else fail("About: YSF Studio centered", `offset ${Math.abs(studioCenter - aboutCenter)}px`); + } else { + pass("About: YSF Studio visible"); + } + + if (consoleErrors.length) { + fail("No console errors", consoleErrors.join(" | ")); + } else { + pass("No console errors"); + } + } catch (e) { fail("GUI smoke", e); } finally { diff --git a/tests/ipc-coverage.mjs b/tests/ipc-coverage.mjs new file mode 100644 index 0000000..c41676a --- /dev/null +++ b/tests/ipc-coverage.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Static check: frontend invoke() commands ⊆ Rust generate_handler! list. + * Run: node tests/ipc-coverage.mjs + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function read(p) { + return fs.readFileSync(path.join(ROOT, p), "utf8"); +} + +const libRs = read("src-tauri/src/lib.rs"); +const handlerBlock = libRs.match(/generate_handler!\s*\[([\s\S]*?)\]/); +if (!handlerBlock) { + console.error("Could not find generate_handler! in lib.rs"); + process.exit(1); +} + +const registered = [...handlerBlock[1].matchAll(/commands::(\w+)/g)].map((m) => m[1]); +const commandFile = read("src-tauri/src/commands.rs"); +const defined = [...commandFile.matchAll(/#\[tauri::command\]\s*\n(?:pub async fn|pub fn) (\w+)/g)].map( + (m) => m[1], +); + +const srcFiles = []; +function walk(dir) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, ent.name); + if (ent.isDirectory() && ent.name !== "node_modules") walk(full); + else if (/\.(svelte|js)$/.test(ent.name)) srcFiles.push(full); + } +} +walk(path.join(ROOT, "src")); + +const invoked = new Set(); +for (const file of srcFiles) { + const text = fs.readFileSync(file, "utf8"); + for (const m of text.matchAll(/invoke\(\s*["'](\w+)["']/g)) invoked.add(m[1]); +} + +let failed = false; + +const registeredCommands = new Set(defined); +const handlerSet = new Set(registered); + +console.log("=== IPC Coverage ===\n"); +console.log(`Registered commands (${registeredCommands.size}): ${[...registeredCommands].sort().join(", ")}`); +console.log(`Frontend invoke calls (${invoked.size}): ${[...invoked].sort().join(", ")}\n`); + +for (const cmd of invoked) { + if (!registeredCommands.has(cmd)) { + console.error(` MISSING frontend invokes '${cmd}' but no #[tauri::command] fn '${cmd}'`); + failed = true; + } else { + console.log(` OK ${cmd}`); + } +} + +for (const cmd of defined) { + if (!handlerSet.has(cmd)) { + console.error(` UNREG '${cmd}' has #[tauri::command] but is missing from generate_handler!`); + failed = true; + } +} + +if (failed) process.exit(1); +console.log("\nAll frontend IPC commands are registered.\n");