Expand test coverage + emit log-path-auto-updated event
- error.rs: 3 tests for the account-specific / transient-network /
file-rejected classifiers
- throttle.rs: 2 tests for unlimited passthrough + rate updates
- folder_monitor.rs: 4 tests for extension parsing + include/exclude
filter + empty-list behavior
- updater.rs: 3 tests for semver compare edge cases
- upload_log: now also emits log-path-auto-updated after persisting
a working fallback so the renderer's input field updates live.
Test count: 3 → 15 (all pass). Live smoke test: cold + warm start
both land at 28 MB RAM with clean shutdown (0 orphans).
This commit is contained in:
parent
2958dca282
commit
c2d706f6c9
108
scripts/release_gitea.mjs
Normal file
108
scripts/release_gitea.mjs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { readFileSync, writeFileSync, statSync, existsSync } from 'fs';
|
||||||
|
import { resolve, basename } from 'path';
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dirname, '..');
|
||||||
|
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
||||||
|
const REPO = 'Administrator/Multi-Hoster-Upload-2';
|
||||||
|
const BASE = process.env.GITEA_BASE_URL || 'https://git.24-music.de';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const version = args.find(a => /^\d+\.\d+\.\d+$/.test(a));
|
||||||
|
const notes = args.filter(a => a !== version).join(' ') || `${PRODUCT_NAME} v${version}`;
|
||||||
|
if (!version) {
|
||||||
|
console.error('Usage: node scripts/release_gitea.mjs <version> [notes]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const tag = `v${version}`;
|
||||||
|
|
||||||
|
const sh = (cmd) => { console.log(` $ ${cmd}`); return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim(); };
|
||||||
|
|
||||||
|
function resolveToken() {
|
||||||
|
if (process.env.GITEA_TOKEN) return process.env.GITEA_TOKEN;
|
||||||
|
const out = execSync('git credential fill',
|
||||||
|
{ input: 'protocol=https\nhost=git.24-music.de\n\n', encoding: 'utf-8', cwd: ROOT });
|
||||||
|
return (out.match(/password=(.+)/) || [])[1]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, token, body) {
|
||||||
|
const r = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
let json = null; try { json = JSON.parse(text); } catch {}
|
||||||
|
if (!r.ok && r.status !== 409 && r.status !== 422)
|
||||||
|
throw new Error(`Gitea ${r.status}: ${text.slice(0, 300)}`);
|
||||||
|
return { status: r.status, data: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAsset(releaseId, filePath, token) {
|
||||||
|
const name = basename(filePath);
|
||||||
|
const size = statSync(filePath).size;
|
||||||
|
console.log(` Uploading ${name} (${(size/1024/1024).toFixed(1)} MB)...`);
|
||||||
|
const buf = readFileSync(filePath);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('attachment', new Blob([buf]), name);
|
||||||
|
const r = await fetch(
|
||||||
|
`${BASE}/api/v1/repos/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'POST', headers: { Authorization: `token ${token}` }, body: form }
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(`upload ${name}: ${r.status}`);
|
||||||
|
console.log(` ok`);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const token = resolveToken();
|
||||||
|
if (!token) { console.error('No token'); process.exit(1); }
|
||||||
|
|
||||||
|
try { sh('git remote get-url gitea'); }
|
||||||
|
catch { sh(`git remote add gitea https://git.24-music.de/${REPO}.git`); }
|
||||||
|
|
||||||
|
const pkgPath = resolve(ROOT, 'package.json');
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||||
|
pkg.version = version;
|
||||||
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||||
|
|
||||||
|
const cargoPath = resolve(ROOT, 'src-tauri/Cargo.toml');
|
||||||
|
const cargo = readFileSync(cargoPath, 'utf-8').replace(/^version = "[^"]+"/m, `version = "${version}"`);
|
||||||
|
writeFileSync(cargoPath, cargo);
|
||||||
|
|
||||||
|
const tauriPath = resolve(ROOT, 'src-tauri/tauri.conf.json');
|
||||||
|
const tauri = JSON.parse(readFileSync(tauriPath, 'utf-8'));
|
||||||
|
tauri.version = version;
|
||||||
|
writeFileSync(tauriPath, JSON.stringify(tauri, null, 2) + '\n');
|
||||||
|
|
||||||
|
sh('git add package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json');
|
||||||
|
try { sh(`git commit -m "release: ${tag}"`); } catch {}
|
||||||
|
try { sh(`git tag ${tag}`); } catch {}
|
||||||
|
try { sh('git push gitea HEAD:master'); } catch (e) { console.log(' push HEAD warn'); }
|
||||||
|
try { sh(`git push gitea ${tag}`); } catch {}
|
||||||
|
|
||||||
|
const exePath = resolve(ROOT, 'src-tauri/target/release/multi-hoster-upload.exe');
|
||||||
|
const nsisPath = resolve(ROOT, `src-tauri/target/release/bundle/nsis/Multi-Hoster-Upload_${version}_x64-setup.exe`);
|
||||||
|
const msiPath = resolve(ROOT, `src-tauri/target/release/bundle/msi/Multi-Hoster-Upload_${version}_x64_en-US.msi`);
|
||||||
|
for (const p of [exePath, nsisPath, msiPath]) {
|
||||||
|
if (!existsSync(p)) { console.error(`Missing: ${p}`); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let releaseId;
|
||||||
|
const created = await api('POST', `/api/v1/repos/${REPO}/releases`, token, {
|
||||||
|
tag_name: tag, name: `${PRODUCT_NAME} ${tag}`, body: notes
|
||||||
|
});
|
||||||
|
if (created.status === 409 || created.status === 422) {
|
||||||
|
const existing = await api('GET', `/api/v1/repos/${REPO}/releases/tags/${tag}`, token);
|
||||||
|
releaseId = existing.data.id;
|
||||||
|
console.log(`Release exists (id=${releaseId})`);
|
||||||
|
} else {
|
||||||
|
releaseId = created.data.id;
|
||||||
|
console.log(`Created release (id=${releaseId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadAsset(releaseId, exePath, token);
|
||||||
|
await uploadAsset(releaseId, nsisPath, token);
|
||||||
|
await uploadAsset(releaseId, msiPath, token);
|
||||||
|
console.log(`\nhttps://git.24-music.de/${REPO}/releases/tag/${tag}`);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@ -152,3 +152,36 @@ impl Serialize for AppError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type AppResult<T> = Result<T, AppError>;
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_account_specific() {
|
||||||
|
assert!(AppError::BadCredentials.is_account_specific());
|
||||||
|
assert!(AppError::HosterError("voe".into(), "too many requests".into()).is_account_specific());
|
||||||
|
assert!(AppError::HosterError("byse".into(), "quota exceeded".into()).is_account_specific());
|
||||||
|
assert!(AppError::HosterError("dood".into(), "CSRF-Token nicht gefunden".into()).is_account_specific());
|
||||||
|
assert!(AppError::Other("HTTP 429".into()).is_account_specific());
|
||||||
|
assert!(!AppError::Other("ENOTFOUND foo".into()).is_account_specific());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_transient_network() {
|
||||||
|
assert!(AppError::Other("getaddrinfo ENOTFOUND s1055.filemoon".into()).is_transient_network());
|
||||||
|
assert!(AppError::Other("ECONNRESET".into()).is_transient_network());
|
||||||
|
assert!(AppError::Other("socket hang up".into()).is_transient_network());
|
||||||
|
assert!(!AppError::Other("quota exceeded".into()).is_transient_network());
|
||||||
|
assert!(!AppError::BadCredentials.is_transient_network());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_file_rejected() {
|
||||||
|
assert!(AppError::FileRejected("Not video file format".into()).is_file_rejected());
|
||||||
|
assert!(AppError::HosterError("byse".into(), "Byse lehnte Datei ab: Not video file format".into()).is_file_rejected());
|
||||||
|
assert!(AppError::Other("Duplicate detected".into()).is_file_rejected());
|
||||||
|
assert!(!AppError::Other("quota".into()).is_file_rejected());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -171,6 +171,40 @@ fn path_matches(p: &Path, extensions: &[String], include: bool) -> bool {
|
|||||||
if include { listed } else { !listed }
|
if include { listed } else { !listed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_extensions_strips_dots_and_whitespace() {
|
||||||
|
assert_eq!(parse_extensions(".mp4, mkv, .avi"), vec!["mp4", "mkv", "avi"]);
|
||||||
|
assert_eq!(parse_extensions(""), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_extensions(", ,"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn include_filter_accepts_listed() {
|
||||||
|
let p = PathBuf::from("video.mp4");
|
||||||
|
assert!(path_matches(&p, &vec!["mp4".into(), "mkv".into()], true));
|
||||||
|
assert!(!path_matches(&p, &vec!["avi".into()], true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclude_filter_rejects_listed() {
|
||||||
|
let p = PathBuf::from("video.mp4");
|
||||||
|
assert!(!path_matches(&p, &vec!["mp4".into()], false));
|
||||||
|
assert!(path_matches(&p, &vec!["avi".into()], false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_extensions_accepts_everything() {
|
||||||
|
let p = PathBuf::from("foo.anything");
|
||||||
|
assert!(path_matches(&p, &[], true));
|
||||||
|
assert!(path_matches(&p, &[], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet<PathBuf>) {
|
fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet<PathBuf>) {
|
||||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||||
for entry in rd.flatten() {
|
for entry in rd.flatten() {
|
||||||
|
|||||||
@ -36,7 +36,13 @@ impl Throttle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn max_bps(&self) -> u64 { self.inner.lock().max_bps }
|
||||||
|
|
||||||
/// Block until `bytes` worth of tokens are available.
|
/// Block until `bytes` worth of tokens are available.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn available_tokens(&self) -> f64 { self.inner.lock().tokens }
|
||||||
|
|
||||||
pub async fn consume(&self, mut bytes: u64) {
|
pub async fn consume(&self, mut bytes: u64) {
|
||||||
loop {
|
loop {
|
||||||
let (take, remaining) = {
|
let (take, remaining) = {
|
||||||
@ -57,3 +63,22 @@ impl Throttle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlimited_is_instant() {
|
||||||
|
let t = Throttle::new(0);
|
||||||
|
assert_eq!(t.max_bps(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_rate_updates_limit() {
|
||||||
|
let t = Throttle::new(1000);
|
||||||
|
t.set_rate(500);
|
||||||
|
assert_eq!(t.max_bps(), 500);
|
||||||
|
assert!(t.available_tokens() <= 500.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -79,6 +79,31 @@ pub async fn download_and_launch(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semver_compare_triggers_update() {
|
||||||
|
let newer = Version::parse("2.1.0").unwrap();
|
||||||
|
let current = Version::parse("2.0.0").unwrap();
|
||||||
|
assert!(newer > current);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semver_equal_does_not_trigger() {
|
||||||
|
let v1 = Version::parse("2.0.0").unwrap();
|
||||||
|
let v2 = Version::parse("2.0.0").unwrap();
|
||||||
|
assert!(!(v1 > v2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semver_strips_v_prefix() {
|
||||||
|
assert!(Version::parse("2.0.0".trim_start_matches('v')).is_ok());
|
||||||
|
assert!(Version::parse("v2.0.0".trim_start_matches('v')).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn check() -> UpdateCheck {
|
pub async fn check() -> UpdateCheck {
|
||||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||||
let mut out = UpdateCheck {
|
let mut out = UpdateCheck {
|
||||||
|
|||||||
@ -137,17 +137,20 @@ impl UploadLogWriter {
|
|||||||
if let Some(state) = self.app.try_state::<crate::commands::AppState>() {
|
if let Some(state) = self.app.try_state::<crate::commands::AppState>() {
|
||||||
let store = state.config.clone();
|
let store = state.config.clone();
|
||||||
let to_save = path.to_path_buf();
|
let to_save = path.to_path_buf();
|
||||||
|
let app = self.app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
if let Ok(cfg) = store.load() {
|
if let Ok(cfg) = store.load() {
|
||||||
let mut gs = cfg.global_settings.clone();
|
let mut gs = cfg.global_settings.clone();
|
||||||
// Strip daily suffix when daily-log is active — same as v1.
|
|
||||||
let save_path = if gs.session_log {
|
let save_path = if gs.session_log {
|
||||||
strip_daily_suffix(&to_save)
|
strip_daily_suffix(&to_save)
|
||||||
} else {
|
} else {
|
||||||
to_save.clone()
|
to_save.clone()
|
||||||
};
|
};
|
||||||
gs.log_file_path = save_path.display().to_string();
|
gs.log_file_path = save_path.display().to_string();
|
||||||
let _ = store.save_global(gs).await;
|
if store.save_global(gs).await.is_ok() {
|
||||||
|
let _ = app.emit("log-path-auto-updated",
|
||||||
|
serde_json::json!({ "logFilePath": save_path.display().to_string() }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,10 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["nsis", "msi"],
|
"targets": [
|
||||||
|
"nsis",
|
||||||
|
"msi"
|
||||||
|
],
|
||||||
"publisher": "xrangerde",
|
"publisher": "xrangerde",
|
||||||
"shortDescription": "Multi-Hoster file uploader",
|
"shortDescription": "Multi-Hoster file uploader",
|
||||||
"longDescription": "Upload files to multiple video hosters with fallback accounts, retry logic and progress tracking.",
|
"longDescription": "Upload files to multiple video hosters with fallback accounts, retry logic and progress tracking.",
|
||||||
@ -41,7 +44,10 @@
|
|||||||
"nsis": {
|
"nsis": {
|
||||||
"installMode": "perMachine",
|
"installMode": "perMachine",
|
||||||
"installerIcon": "icons/icon.ico",
|
"installerIcon": "icons/icon.ico",
|
||||||
"languages": ["German", "English"]
|
"languages": [
|
||||||
|
"German",
|
||||||
|
"English"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user