mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
chore(repo): remove dead code and useless tests (#3375)
This commit is contained in:
1 parent
7e177a1c90
commit
1cd62eb661
1391 files changed
+783
-120415
No files matched your search
@@ -9,13 +9,9 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
DEFAULT_CRATES=$(
|
||||
cat <<'ENTRIES'
|
||||
fluxer_desktop/native/rt-thread:tick
|
||||
fluxer_desktop/native/audio-mix:mix
|
||||
fluxer_desktop/native/screen-frame-bus:staging
|
||||
fluxer_desktop/native/screen-frame-bus:frame_pool
|
||||
fluxer_desktop/native/encoder-ring:ring
|
||||
fluxer_desktop/native/linux-audio-capture:end_to_end
|
||||
fluxer_desktop/native/linux-screen-capture:pipewire_callback
|
||||
fluxer_desktop/native/rust:native_core
|
||||
ENTRIES
|
||||
)
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ mode="${1:-}"
|
||||
linux_workspaces=(
|
||||
"app-store/Cargo.toml"
|
||||
"audio-apm/Cargo.toml"
|
||||
"audio-mix/Cargo.toml"
|
||||
"audio-timing/Cargo.toml"
|
||||
"encoder-ring/Cargo.toml"
|
||||
"gateway-socket/Cargo.toml"
|
||||
"gpu-rebuild/Cargo.toml"
|
||||
@@ -25,7 +23,6 @@ linux_workspaces=(
|
||||
"linux-input-hook/Cargo.toml"
|
||||
"linux-notifications/Cargo.toml"
|
||||
"linux-portals/Cargo.toml"
|
||||
"linux-screen-capture/Cargo.toml"
|
||||
"platform-info/Cargo.toml"
|
||||
"rt-thread/Cargo.toml"
|
||||
"rust/Cargo.toml"
|
||||
@@ -34,10 +31,6 @@ linux_workspaces=(
|
||||
"webauthn/Cargo.toml"
|
||||
)
|
||||
|
||||
fuzz_workspaces=(
|
||||
"rust/fuzz/Cargo.toml"
|
||||
)
|
||||
|
||||
macos_workspaces=(
|
||||
"mac-app-audio/Cargo.toml"
|
||||
"mac-clipboard/Cargo.toml"
|
||||
@@ -90,13 +83,6 @@ workspace_category() {
|
||||
fi
|
||||
done
|
||||
|
||||
for classified_manifest in "${fuzz_workspaces[@]}"; do
|
||||
if [ "$relative_manifest" = "$classified_manifest" ]; then
|
||||
printf 'fuzz\n'
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
for classified_manifest in "${macos_workspaces[@]}"; do
|
||||
if [ "$relative_manifest" = "$classified_manifest" ]; then
|
||||
printf 'macos\n'
|
||||
@@ -166,7 +152,6 @@ if [ ! -s "$manifest_list" ]; then
|
||||
fi
|
||||
|
||||
linux_count=0
|
||||
fuzz_count=0
|
||||
macos_count=0
|
||||
windows_count=0
|
||||
|
||||
@@ -184,7 +169,6 @@ while IFS= read -r manifest; do
|
||||
category="$(workspace_category "$relative_manifest")"
|
||||
case "$category" in
|
||||
linux) linux_count=$((linux_count + 1)) ;;
|
||||
fuzz) fuzz_count=$((fuzz_count + 1)) ;;
|
||||
macos) macos_count=$((macos_count + 1)) ;;
|
||||
windows) windows_count=$((windows_count + 1)) ;;
|
||||
*) fail "Unclassified desktop native workspace: $relative_manifest" ;;
|
||||
@@ -194,7 +178,6 @@ done <"$manifest_list"
|
||||
|
||||
printf '%s\n' \
|
||||
"${linux_workspaces[@]}" \
|
||||
"${fuzz_workspaces[@]}" \
|
||||
"${macos_workspaces[@]}" \
|
||||
"${windows_workspaces[@]}" |
|
||||
LC_ALL=C sort >"$expected_inventory"
|
||||
@@ -230,7 +213,6 @@ while IFS= read -r manifest; do
|
||||
clippy)
|
||||
case "$category" in
|
||||
linux) printf 'Clippy full Linux/shared coverage: %s\n' "$relative_manifest" ;;
|
||||
fuzz) printf 'Clippy fuzz-target coverage: %s\n' "$relative_manifest" ;;
|
||||
macos) printf 'Clippy Linux stub/common coverage; macOS backend not validated: %s\n' "$relative_manifest" ;;
|
||||
windows) printf 'Clippy Linux stub/common coverage; Windows backend not validated: %s\n' "$relative_manifest" ;;
|
||||
esac
|
||||
@@ -239,7 +221,6 @@ while IFS= read -r manifest; do
|
||||
test)
|
||||
case "$category" in
|
||||
linux) printf 'Test full Linux/shared coverage: %s\n' "$relative_manifest" ;;
|
||||
fuzz) printf 'Test command is a no-op for test=false fuzz binaries: %s\n' "$relative_manifest" ;;
|
||||
macos) printf 'Test Linux stub/common coverage; macOS backend not validated: %s\n' "$relative_manifest" ;;
|
||||
windows) printf 'Test Linux stub/common coverage; Windows backend not validated: %s\n' "$relative_manifest" ;;
|
||||
esac
|
||||
@@ -249,6 +230,6 @@ while IFS= read -r manifest; do
|
||||
checked_count=$((checked_count + 1))
|
||||
done <"$manifest_list"
|
||||
|
||||
discovered_count=$((linux_count + fuzz_count + macos_count + windows_count))
|
||||
printf 'Desktop native workspace categories: discovered=%s linux-full=%s fuzz=%s macos-stub-common=%s windows-stub-common=%s\n' "$discovered_count" "$linux_count" "$fuzz_count" "$macos_count" "$windows_count"
|
||||
discovered_count=$((linux_count + macos_count + windows_count))
|
||||
printf 'Desktop native workspace categories: discovered=%s linux-full=%s macos-stub-common=%s windows-stub-common=%s\n' "$discovered_count" "$linux_count" "$macos_count" "$windows_count"
|
||||
printf 'Desktop native %s summary: checked=%s\n' "$mode" "$checked_count"
|
||||
@@ -416,8 +416,6 @@ fn gather_wasm_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
PathBuf::from("../tools/ci/src/app_wasm.rs"),
|
||||
PathBuf::from("../tools/ci/src/common.rs"),
|
||||
PathBuf::from("../tools/ci/src/lib.rs"),
|
||||
PathBuf::from("../tools/ci/templates/libfluxcore_wrapper.js"),
|
||||
PathBuf::from("../tools/ci/templates/libfluxcore_wrapper.d.ts"),
|
||||
markdown_parser_rust_dir.join("Cargo.toml"),
|
||||
],
|
||||
)?;
|
||||
@@ -575,41 +573,3 @@ fn quote_arg(arg: &str) -> String {
|
||||
format!("{arg:?}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rel_path_keys_are_posix_like() {
|
||||
assert_eq!(
|
||||
rel_path_key(Path::new("scripts/GenerateColorSystem.ts")),
|
||||
"scripts/GenerateColorSystem.ts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_inputs_cover_the_libfluxwebp_sources_and_headers() {
|
||||
let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fluxer_app");
|
||||
let inputs = gather_wasm_inputs(&app_dir).expect("gather wasm inputs");
|
||||
|
||||
for key in [
|
||||
"rust/libfluxwebp/Cargo.toml",
|
||||
"rust/libfluxwebp/Cargo.lock",
|
||||
"rust/libfluxwebp/src/lib.rs",
|
||||
"rust/libfluxwebp/shim/stdlib.h",
|
||||
"rust/libfluxwebp/simd/emmintrin.h",
|
||||
] {
|
||||
assert!(inputs.contains_key(key), "missing {key}");
|
||||
}
|
||||
assert!(inputs.keys().all(|key| !key.contains("/target/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_command_quotes_globs() {
|
||||
assert_eq!(
|
||||
display_command("tcm", &["src", "--pattern", "**/*.module.css"]),
|
||||
"tcm src --pattern \"**/*.module.css\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -532,7 +532,6 @@ fn tree_differences(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::parse_version_instant;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use std::ffi::OsString;
|
||||
|
||||
@@ -571,16 +570,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_calver_time() {
|
||||
assert_eq!(
|
||||
parse_version_instant("2026.520.246000")
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"Invalid build version date/time: 2026.520.246000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghcr_auth_config_merges_existing_auths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -624,35 +613,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_command_sets_bake_environment() {
|
||||
let command = CommandSpec::new("docker")
|
||||
.args(["buildx", "bake", "-f", "fluxer_app_proxy/docker-bake.hcl"])
|
||||
.env("IMAGE_REPO", "ghcr.io/example/fluxer-app-proxy")
|
||||
.env("BUILD_VERSION", "2026.520.1")
|
||||
.env("PUBLIC_ASSET_BASE_URL", DEFAULT_PUBLIC_ASSET_BASE_URL);
|
||||
|
||||
assert_eq!(command.program, OsString::from("docker"));
|
||||
assert_eq!(
|
||||
command.args,
|
||||
vec![
|
||||
OsString::from("buildx"),
|
||||
OsString::from("bake"),
|
||||
OsString::from("-f"),
|
||||
OsString::from("fluxer_app_proxy/docker-bake.hcl"),
|
||||
]
|
||||
);
|
||||
assert!(command.env.contains(&(
|
||||
OsString::from("BUILD_VERSION"),
|
||||
OsString::from("2026.520.1")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_bake_trims_the_local_asset_tree_by_default() {
|
||||
assert_eq!(DEFAULT_APP_PROXY_BUNDLE_LOCAL_ASSETS, "false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_guards_the_trim_on_an_absolute_asset_base_url() {
|
||||
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||
@@ -967,70 +927,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_injects_one_canonical_asset_tree_into_every_architecture() {
|
||||
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||
for entry in [
|
||||
"ARG APP_ASSETS_REF=app-assets",
|
||||
"ARG APP_ASSETS_PLATFORM=$BUILDPLATFORM",
|
||||
"FROM --platform=${APP_ASSETS_PLATFORM} ${APP_ASSETS_REF} AS app-static",
|
||||
"COPY --from=app-static /assets ./static/",
|
||||
] {
|
||||
assert!(
|
||||
dockerfile.contains(entry),
|
||||
"every architecture must serve the injected canonical tree, so the Dockerfile must contain {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_prepares_the_asset_tree_once_before_the_architecture_stages() {
|
||||
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||
let canonical = dockerfile
|
||||
.split("FROM alpine:3.24 AS app-assets")
|
||||
.nth(1)
|
||||
.expect("app-assets stage");
|
||||
let (canonical, per_architecture) = canonical
|
||||
.split_once("AS app-static")
|
||||
.expect("app-static stage");
|
||||
|
||||
for entry in ["apk add --no-cache brotli", "precompress_assets.sh"] {
|
||||
assert!(
|
||||
canonical.contains(entry),
|
||||
"the canonical asset tree is prepared once, so {entry} must run in the app-assets stage"
|
||||
);
|
||||
assert!(
|
||||
!per_architecture.contains(entry),
|
||||
"{entry} must not run again per architecture or the trees stop being byte-identical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bake_publishes_the_canonical_asset_image() {
|
||||
let bake = include_str!("../../../fluxer_app_proxy/docker-bake.hcl");
|
||||
for entry in [
|
||||
"target \"app-assets-image\"",
|
||||
"${BUILD_VERSION}-assets",
|
||||
"type=registry",
|
||||
"APP_ASSETS_REF = APP_ASSETS_REF",
|
||||
"APP_ASSETS_PLATFORM = APP_ASSETS_PLATFORM",
|
||||
] {
|
||||
assert!(bake.contains(entry), "docker-bake.hcl must contain {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_assets_platform_falls_back_to_the_bake_default() {
|
||||
let bake = include_str!("../../../fluxer_app_proxy/docker-bake.hcl");
|
||||
let declared =
|
||||
format!("variable \"APP_ASSETS_PLATFORM\" {{ default = \"{AMD64_PLATFORM}\" }}");
|
||||
assert!(
|
||||
bake.contains(&declared),
|
||||
"the parity gate reads APP_ASSETS_PLATFORM and falls back to {AMD64_PLATFORM}, so docker-bake.hcl must declare the same default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_hosted_workflow_keeps_assets_same_origin() {
|
||||
let workflow = include_str!("../../../.github/workflows/build-app-proxy-self-hosted.yaml");
|
||||
|
||||
@@ -13,8 +13,9 @@ use tempfile::TempDir;
|
||||
|
||||
const LIBFLUXCORE_WASM_BINDGEN_VERSION: &str = "0.2.128";
|
||||
const LIBFLUXCORE_WASM_SIZE_BUDGET_BYTES: u64 = 300 * 1024;
|
||||
const LIBFLUXCORE_WRAPPER_JS: &str = include_str!("../templates/libfluxcore_wrapper.js");
|
||||
const LIBFLUXCORE_WRAPPER_DTS: &str = include_str!("../templates/libfluxcore_wrapper.d.ts");
|
||||
const LIBFLUXCORE_INDEX: &str = "// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\
|
||||
export * from './libfluxcore_bindgen.js';\n\
|
||||
export {default} from './libfluxcore_bindgen.js';\n";
|
||||
const LIBFLUXWEBP_SIMD_WASM_SIZE_BUDGET_BYTES: u64 = 672 * 1024;
|
||||
const LIBFLUXWEBP_SCALAR_WASM_SIZE_BUDGET_BYTES: u64 = 288 * 1024;
|
||||
const LIBFLUXWEBP_WASM_IMPORT_MODULE: &str = "./libfluxwebp_bg.js";
|
||||
@@ -27,12 +28,6 @@ pub struct BuildAppWasmArgs {
|
||||
app_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct BuildMarkdownParserWasmArgs {
|
||||
#[arg(long)]
|
||||
app_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn run_build_app_wasm(args: BuildAppWasmArgs) -> Result<()> {
|
||||
let app_dir = resolve_app_dir(args.app_dir)?;
|
||||
build_markdown_parser_wasm(&app_dir)?;
|
||||
@@ -40,11 +35,6 @@ pub fn run_build_app_wasm(args: BuildAppWasmArgs) -> Result<()> {
|
||||
build_libfluxwebp_wasm(&app_dir)
|
||||
}
|
||||
|
||||
pub fn run_build_markdown_parser_wasm(args: BuildMarkdownParserWasmArgs) -> Result<()> {
|
||||
let app_dir = resolve_app_dir(args.app_dir)?;
|
||||
build_markdown_parser_wasm(&app_dir)
|
||||
}
|
||||
|
||||
const WASM_CC_ENV: &str = "CC_wasm32_unknown_unknown";
|
||||
const WASM_AR_ENV: &str = "AR_wasm32_unknown_unknown";
|
||||
|
||||
@@ -255,21 +245,13 @@ fn build_libfluxcore_wasm(app_dir: &Path) -> Result<()> {
|
||||
)
|
||||
.with_context(|| format!("Failed to copy {}", bindgen_wasm_dts_path.display()))?;
|
||||
|
||||
fs::write(
|
||||
out_dir.join("libfluxcore.js"),
|
||||
libfluxcore_index_js_content(),
|
||||
)
|
||||
.with_context(|| {
|
||||
fs::write(out_dir.join("libfluxcore.js"), LIBFLUXCORE_INDEX).with_context(|| {
|
||||
format!(
|
||||
"Failed to write {}",
|
||||
out_dir.join("libfluxcore.js").display()
|
||||
)
|
||||
})?;
|
||||
fs::write(
|
||||
out_dir.join("libfluxcore.d.ts"),
|
||||
libfluxcore_index_dts_content(),
|
||||
)
|
||||
.with_context(|| {
|
||||
fs::write(out_dir.join("libfluxcore.d.ts"), LIBFLUXCORE_INDEX).with_context(|| {
|
||||
format!(
|
||||
"Failed to write {}",
|
||||
out_dir.join("libfluxcore.d.ts").display()
|
||||
@@ -680,25 +662,6 @@ fn write_with_spdx(path: &Path, content: &str) -> Result<()> {
|
||||
.with_context(|| format!("Failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn libfluxcore_index_js_content() -> String {
|
||||
format!(
|
||||
"// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\
|
||||
import {{crop_rotate_rgba_raw}} from './libfluxcore_bindgen.js';\n\
|
||||
{LIBFLUXCORE_WRAPPER_JS}\n\
|
||||
export * from './libfluxcore_bindgen.js';\n\
|
||||
export {{default}} from './libfluxcore_bindgen.js';\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn libfluxcore_index_dts_content() -> String {
|
||||
format!(
|
||||
"// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\
|
||||
export * from './libfluxcore_bindgen.js';\n\
|
||||
export {{default}} from './libfluxcore_bindgen.js';\n\n\
|
||||
{LIBFLUXCORE_WRAPPER_DTS}"
|
||||
)
|
||||
}
|
||||
|
||||
fn libfluxcore_package_json_content() -> String {
|
||||
let manifest = serde_json::json!({
|
||||
"name": "libfluxcore",
|
||||
@@ -733,7 +696,7 @@ fn libfluxcore_package_json_content() -> String {
|
||||
fn libfluxcore_readme_content() -> &'static str {
|
||||
"<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->\n\
|
||||
# libfluxcore\n\n\
|
||||
Rust WebAssembly helpers and JavaScript codec wrappers for Fluxer media processing.\n"
|
||||
Rust WebAssembly helpers for Fluxer media processing.\n"
|
||||
}
|
||||
|
||||
fn markdown_wasm_bytes_content(wasm: &[u8]) -> String {
|
||||
@@ -779,16 +742,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_wasm_bytes_content_matches_legacy_node_output() {
|
||||
assert_eq!(
|
||||
markdown_wasm_bytes_content(b"hello"),
|
||||
"// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\
|
||||
export const MARKDOWN_PARSER_WASM_BASE64 =\n\
|
||||
\t'aGVsbG8=';\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libfluxcore_build_keeps_its_output_in_the_crate_target_dir() {
|
||||
let package_dir = Path::new("/repo/fluxer_app/rust/libfluxcore");
|
||||
@@ -817,14 +770,6 @@ export const MARKDOWN_PARSER_WASM_BASE64 =\n\
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libfluxcore_index_reexports_bindgen_module() {
|
||||
let content = libfluxcore_index_js_content();
|
||||
assert!(content.contains("import {crop_rotate_rgba_raw} from './libfluxcore_bindgen.js';"));
|
||||
assert!(content.contains("export * from './libfluxcore_bindgen.js';"));
|
||||
assert!(content.contains("export function crop_rotate_rgba("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libfluxcore_bindgen_js_reset_hook_is_inserted() {
|
||||
let content =
|
||||
|
||||
+2
-17
@@ -1,8 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::common::{
|
||||
CALVER_SCHEME, CalverEnv, append_github_env, append_github_output, micro_segment,
|
||||
month_day_segment, parse_version_instant, resolve_calver, trim_option,
|
||||
CALVER_SCHEME, CalverEnv, append_github_output, micro_segment, month_day_segment,
|
||||
parse_version_instant, resolve_calver, trim_option,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use chrono::{Datelike, Timelike, Utc};
|
||||
@@ -13,10 +13,6 @@ use std::env;
|
||||
pub struct ResolveCalverArgs {
|
||||
#[arg(long)]
|
||||
github_output: bool,
|
||||
#[arg(long)]
|
||||
github_env: bool,
|
||||
#[arg(long, default_value = "BUILD_VERSION")]
|
||||
env_name: String,
|
||||
}
|
||||
|
||||
pub fn run(args: ResolveCalverArgs) -> Result<()> {
|
||||
@@ -37,9 +33,6 @@ pub fn run(args: ResolveCalverArgs) -> Result<()> {
|
||||
("calver_scheme", CALVER_SCHEME),
|
||||
])?;
|
||||
}
|
||||
if args.github_env {
|
||||
append_github_env(&[(args.env_name.as_str(), resolved.as_str())])?;
|
||||
}
|
||||
println!("{resolved}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -128,12 +121,4 @@ mod tests {
|
||||
|
||||
assert_eq!(version, "2026.109.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calver_rejects_invalid_time() {
|
||||
assert_eq!(
|
||||
calver_outputs("2026.520.246000").unwrap_err().to_string(),
|
||||
"Invalid build version date/time: 2026.520.246000"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -333,115 +333,4 @@ mod tests {
|
||||
|
||||
assert_eq!(missing_app_wasm_artifact(root), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_dockerfiles_include_the_release_label_block() {
|
||||
const REQUIRED: [&str; 8] = [
|
||||
"LABEL org.opencontainers.image.vendor=\"Fluxer\"",
|
||||
"LABEL org.opencontainers.image.url=\"https://fluxer.app\"",
|
||||
"LABEL org.opencontainers.image.documentation=\"https://docs.fluxer.app\"",
|
||||
"LABEL org.opencontainers.image.source=\"https://github.com/fluxerapp/fluxer\"",
|
||||
"LABEL org.opencontainers.image.version=\"${BUILD_VERSION}\"",
|
||||
"LABEL org.opencontainers.image.revision=\"${SOURCE_SHA}\"",
|
||||
"LABEL org.opencontainers.image.created=\"${SOURCE_DATE}\"",
|
||||
"LABEL app.fluxer.build-version=\"${BUILD_VERSION}\"",
|
||||
];
|
||||
|
||||
for (name, title, dockerfile) in [
|
||||
(
|
||||
"fluxer_admin",
|
||||
"fluxer-admin",
|
||||
include_str!("../../../fluxer_admin/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_api",
|
||||
"fluxer-api",
|
||||
include_str!("../../../fluxer_api/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_app_proxy",
|
||||
"fluxer-app-proxy",
|
||||
include_str!("../../../fluxer_app_proxy/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_docs",
|
||||
"fluxer-docs",
|
||||
include_str!("../../../fluxer_docs/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_gateway",
|
||||
"fluxer-gateway",
|
||||
include_str!("../../../fluxer_gateway/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_gifs",
|
||||
"fluxer-gifs",
|
||||
include_str!("../../../fluxer_gifs/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_media_proxy",
|
||||
"fluxer-media-proxy",
|
||||
include_str!("../../../fluxer_media_proxy/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_messages",
|
||||
"fluxer-messages",
|
||||
include_str!("../../../fluxer_messages/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_push",
|
||||
"fluxer-push",
|
||||
include_str!("../../../fluxer_push/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_snowflakes",
|
||||
"fluxer-snowflakes",
|
||||
include_str!("../../../fluxer_snowflakes/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_static",
|
||||
"fluxer-static",
|
||||
include_str!("../../../fluxer_static/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_unfurl",
|
||||
"fluxer-unfurl",
|
||||
include_str!("../../../fluxer_unfurl/Dockerfile"),
|
||||
),
|
||||
(
|
||||
"fluxer_users",
|
||||
"fluxer-users",
|
||||
include_str!("../../../fluxer_users/Dockerfile"),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
dockerfile.contains("ARG SOURCE_SHA"),
|
||||
"{name}/Dockerfile must declare ARG SOURCE_SHA"
|
||||
);
|
||||
assert!(
|
||||
dockerfile.contains("ARG SOURCE_DATE"),
|
||||
"{name}/Dockerfile must declare ARG SOURCE_DATE"
|
||||
);
|
||||
assert!(
|
||||
dockerfile.contains(&format!("LABEL org.opencontainers.image.title=\"{title}\"")),
|
||||
"{name}/Dockerfile must declare the title {title}"
|
||||
);
|
||||
let licenses = match name {
|
||||
"fluxer_static" => "AGPL-3.0-or-later AND CC-BY-SA-4.0 AND CC-BY-4.0",
|
||||
_ => "AGPL-3.0-or-later",
|
||||
};
|
||||
assert!(
|
||||
dockerfile.contains(&format!(
|
||||
"LABEL org.opencontainers.image.licenses=\"{licenses}\""
|
||||
)),
|
||||
"{name}/Dockerfile must declare the licenses {licenses}"
|
||||
);
|
||||
for label in REQUIRED {
|
||||
assert!(
|
||||
dockerfile.contains(label),
|
||||
"{name}/Dockerfile must declare {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6077,14 +6077,6 @@ fn print_tree(root: &Path, max_depth: usize) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::parse_version_instant;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
|
||||
.single()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn live_manifest(platform: &str, arch: &str, shell: &str) -> Value {
|
||||
json!({
|
||||
@@ -6249,40 +6241,6 @@ mod tests {
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_explicit_calver_with_precedence() {
|
||||
let calver_env = CalverEnv {
|
||||
build_version: Some("2026.520.1".to_string()),
|
||||
fluxer_build_version: Some("2026.521.2".to_string()),
|
||||
fluxer_build_date: Some("2026-05-22T03:04:05Z".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_calver(&calver_env, dt(2026, 5, 1, 0, 0, 0)).unwrap(),
|
||||
"2026.520.1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_generated_calver_from_date_override() {
|
||||
let calver_env = CalverEnv {
|
||||
fluxer_build_date: Some("2026-05-20T01:02:03Z".to_string()),
|
||||
..CalverEnv::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_calver(&calver_env, dt(2026, 1, 1, 0, 0, 0)).unwrap(),
|
||||
"2026.520.10203"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_explicit_time() {
|
||||
let error = parse_version_instant("2026.520.246000").unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Invalid build version date/time: 2026.520.246000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_skip_flags_filter_individual_arches() {
|
||||
let mut args = matrix_args();
|
||||
@@ -6389,57 +6347,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_build_desktop_workflow_step_dispatches_to_a_desktop_step() {
|
||||
let steps = BUILD_DESKTOP_WORKFLOW
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix("--step "))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(steps.contains(&"stage_handoff"));
|
||||
for step in steps {
|
||||
assert!(
|
||||
<DesktopStep as ValueEnum>::from_str(step, false).is_ok(),
|
||||
"build-desktop.yaml dispatches unknown desktop step {step}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
<DesktopStep as ValueEnum>::from_str("stage_handoff", false),
|
||||
Ok(DesktopStep::StageHandoff)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_github_release_is_the_only_destination_for_built_artifacts() {
|
||||
for job in ["build", "upload", "publish_release"] {
|
||||
let body = workflow_job(job);
|
||||
for forbidden in [
|
||||
"S3_BUCKET",
|
||||
"S3_ENDPOINT",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"DOWNLOADS_S3",
|
||||
"_handoff/",
|
||||
] {
|
||||
assert!(
|
||||
!body.contains(forbidden),
|
||||
"{job} must not reference {forbidden} now that the downloads bucket is gone"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
workflow_step_names(workflow_job("publish_release")),
|
||||
vec![
|
||||
"Checkout source",
|
||||
"Set up Rust toolchain (CI helpers)",
|
||||
"Download GitHub release assets",
|
||||
"Create token",
|
||||
"Publish GitHub desktop release",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_job_handoff_travels_as_github_actions_artifacts() {
|
||||
let build = workflow_job("build");
|
||||
|
||||
@@ -138,16 +138,6 @@ const DESKTOP_NATIVE_ADDONS: &[DesktopNativeAddon] = &[
|
||||
pkg_config: &[],
|
||||
special: DesktopNativeSpecialBuild::None,
|
||||
},
|
||||
DesktopNativeAddon {
|
||||
package_dir: "linux-screen-capture",
|
||||
package_name: "@fluxer/linux-screen-capture",
|
||||
crate_name: "fluxer_linux_screen_capture",
|
||||
node_file_stem: "linux-screen-capture",
|
||||
required_platform: Some("linux"),
|
||||
features: &[],
|
||||
pkg_config: &[],
|
||||
special: DesktopNativeSpecialBuild::None,
|
||||
},
|
||||
DesktopNativeAddon {
|
||||
package_dir: "mac-app-audio",
|
||||
package_name: "@fluxer/mac-app-audio",
|
||||
@@ -1006,7 +996,7 @@ mod tests {
|
||||
assert!(names.contains(&"win-game-capture"));
|
||||
assert!(names.contains(&"app-store"));
|
||||
assert!(names.contains(&"gateway-socket"));
|
||||
assert_eq!(DESKTOP_NATIVE_ADDONS.len(), 23);
|
||||
assert_eq!(DESKTOP_NATIVE_ADDONS.len(), 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1095,45 +1085,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_tags_match_legacy_node_helper() {
|
||||
assert_eq!(platform_tag("linux", "x64").unwrap(), "linux-x64-gnu");
|
||||
assert_eq!(platform_tag("darwin", "arm64").unwrap(), "darwin-arm64");
|
||||
assert_eq!(platform_tag("win32", "x64").unwrap(), "win32-x64-msvc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_targets_match_legacy_node_helper() {
|
||||
assert_eq!(
|
||||
rust_target_for_platform("linux", "arm64").unwrap(),
|
||||
"aarch64-unknown-linux-gnu"
|
||||
);
|
||||
assert_eq!(
|
||||
rust_target_for_platform("darwin", "x64").unwrap(),
|
||||
"x86_64-apple-darwin"
|
||||
);
|
||||
assert_eq!(
|
||||
rust_target_for_platform("win32", "arm64").unwrap(),
|
||||
"aarch64-pc-windows-msvc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_library_names_match_legacy_node_helper() {
|
||||
assert_eq!(
|
||||
cargo_dynamic_library_file_name("fluxer_webauthn", "linux").unwrap(),
|
||||
"libfluxer_webauthn.so"
|
||||
);
|
||||
assert_eq!(
|
||||
cargo_dynamic_library_file_name("fluxer-webauthn", "darwin").unwrap(),
|
||||
"libfluxer_webauthn.dylib"
|
||||
);
|
||||
assert_eq!(
|
||||
cargo_dynamic_library_file_name("fluxer_webauthn", "win32").unwrap(),
|
||||
"fluxer_webauthn.dll"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redistributable_runtime_import_detection_matches_legacy_patterns() {
|
||||
assert!(is_redistributable_runtime_import("vcruntime140.dll"));
|
||||
@@ -1210,11 +1161,4 @@ mod tests {
|
||||
assert!(assert_system32_dependent_load_flag(&unset, "linux").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_library_bundling_denylist_matches_legacy_prefixes() {
|
||||
assert!(!should_bundle_linux_library("libc.so.6"));
|
||||
assert!(!should_bundle_linux_library("ld-linux-x86-64.so.2"));
|
||||
assert!(should_bundle_linux_library("libfido2.so.1"));
|
||||
}
|
||||
}
|
||||
@@ -51,47 +51,3 @@ pub(crate) fn sha256_file(path: &Path) -> Result<String> {
|
||||
fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||
sha256_reader(file).with_context(|| format!("Failed to read {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn remove_file_if_exists_removes_files_and_ignores_missing_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("artifact.txt");
|
||||
fs::write(&path, "artifact").unwrap();
|
||||
|
||||
remove_file_if_exists(&path).unwrap();
|
||||
remove_file_if_exists(&path).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_dir_if_exists_removes_directories_and_ignores_missing_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("dist");
|
||||
fs::create_dir_all(path.join("nested")).unwrap();
|
||||
fs::write(path.join("nested").join("artifact.txt"), "artifact").unwrap();
|
||||
|
||||
remove_dir_if_exists(&path).unwrap();
|
||||
remove_dir_if_exists(&path).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_json_pretty_creates_parent_directories_and_writes_pretty_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("nested").join("manifest.json");
|
||||
|
||||
write_json_pretty(&path, &json!({ "name": "fluxer", "version": 1 })).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).unwrap(),
|
||||
"{\n \"name\": \"fluxer\",\n \"version\": 1\n}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ pub(crate) enum GatewayStep {
|
||||
Eqwalizer,
|
||||
Typecheck,
|
||||
Eunit,
|
||||
Bench,
|
||||
AllChecks,
|
||||
Clean,
|
||||
}
|
||||
@@ -112,7 +111,6 @@ pub(crate) fn run_gateway_step(
|
||||
GatewayStep::Eqwalizer => run_eqwalizer(gateway_dir, eqwalizer_profile),
|
||||
GatewayStep::Typecheck => run_eqwalizer(gateway_dir, eqwalizer_profile),
|
||||
GatewayStep::Eunit => run_rebar(gateway_dir, ["as", "test", "eunit"]),
|
||||
GatewayStep::Bench => run_rebar(gateway_dir, ["eunit", "--module=guild_member_list_bench"]),
|
||||
GatewayStep::AllChecks => {
|
||||
run_gateway_check_step("Step 1/5: Format check (erlfmt)", || {
|
||||
run_gateway_step(gateway_dir, GatewayStep::FmtCheck, eqwalizer_profile)
|
||||
@@ -436,30 +434,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_nif_build_plan_matches_legacy_artifact_layout() {
|
||||
let gateway_dir = Path::new("/repo/fluxer_gateway");
|
||||
let builds = gateway_nif_builds(gateway_dir, NifBuildProfile::Release, "lib", "so");
|
||||
|
||||
assert_eq!(builds.len(), 2);
|
||||
assert_eq!(
|
||||
builds[0],
|
||||
GatewayNifBuild {
|
||||
crate_name: "push_markdown_plaintext_nif".to_string(),
|
||||
native_dir: PathBuf::from(
|
||||
"/repo/fluxer_gateway/native/push_markdown_plaintext_nif"
|
||||
),
|
||||
cargo_args: vec![OsString::from("build"), OsString::from("--release")],
|
||||
artifact_path: PathBuf::from(
|
||||
"/repo/fluxer_gateway/native/push_markdown_plaintext_nif/target/release/libpush_markdown_plaintext_nif.so"
|
||||
),
|
||||
output_path: PathBuf::from(
|
||||
"/repo/fluxer_gateway/priv/push_markdown_plaintext_nif.so"
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_build_plan_omits_release_arg_and_uses_debug_target_dir() {
|
||||
let build = gateway_nif_build(
|
||||
|
||||
@@ -919,29 +919,6 @@ mod tests {
|
||||
assert_eq!(services.len(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_component_has_a_build_workflow() {
|
||||
let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.github/workflows");
|
||||
let mut workflows = String::new();
|
||||
for entry in fs::read_dir(&directory).expect("the workflow directory should be readable") {
|
||||
let path = entry.expect("the workflow entry should be readable").path();
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "yaml")
|
||||
{
|
||||
workflows
|
||||
.push_str(&fs::read_to_string(&path).expect("the workflow should be readable"));
|
||||
}
|
||||
}
|
||||
for component in COMPONENTS {
|
||||
assert!(
|
||||
workflows.contains(component.image),
|
||||
"{} has no build workflow",
|
||||
component.image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_requires_every_component() {
|
||||
let mut resolved = resolved_set();
|
||||
@@ -1302,26 +1279,6 @@ mod tests {
|
||||
assert!(error.contains("no bundle commit can be proven"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_workflow_tags_the_bundle_commit_and_marks_it_latest() {
|
||||
let workflow = include_str!("../../../.github/workflows/release-image-set.yaml");
|
||||
for entry in [
|
||||
"--github-output",
|
||||
"BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}",
|
||||
"--target \"${BUNDLE_COMMIT}\"",
|
||||
"--latest=true",
|
||||
] {
|
||||
assert!(
|
||||
workflow.contains(entry),
|
||||
"release-image-set.yaml must contain {entry}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!workflow.contains("${GITHUB_SHA}"),
|
||||
"the release tag must be cut at the bundle commit, not at the workflow ref"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_filenames_are_stable() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -31,7 +31,6 @@ enum Command {
|
||||
AppDevServer(app_dev_server::AppDevServerArgs),
|
||||
BuildAppWasm(app_wasm::BuildAppWasmArgs),
|
||||
BuildAppProxy(app_proxy::BuildAppProxyArgs),
|
||||
BuildMarkdownParserWasm(app_wasm::BuildMarkdownParserWasmArgs),
|
||||
BuildDesktop(desktop::BuildDesktopArgs),
|
||||
BuildDesktopNativeAddon(desktop_native::BuildDesktopNativeAddonArgs),
|
||||
BuildGatewayNifs(gateway::BuildGatewayNifsArgs),
|
||||
@@ -48,7 +47,6 @@ pub async fn run() -> Result<()> {
|
||||
Command::AppDevServer(args) => app_dev_server::run(args).await,
|
||||
Command::BuildAppWasm(args) => app_wasm::run_build_app_wasm(args),
|
||||
Command::BuildAppProxy(args) => app_proxy::run(args).await,
|
||||
Command::BuildMarkdownParserWasm(args) => app_wasm::run_build_markdown_parser_wasm(args),
|
||||
Command::BuildDesktop(args) => desktop::run(args).await,
|
||||
Command::BuildDesktopNativeAddon(args) => {
|
||||
desktop_native::run_build_desktop_native_addon(args)
|
||||
|
||||
@@ -65,48 +65,3 @@ fn collapse_extra_blank_lines(source: &str) -> String {
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_file(path: &Path, contents: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_import_start_only_matches_line_starts() {
|
||||
assert_eq!(
|
||||
find_import_start("// import nope\nimport {x} from 'x';\n"),
|
||||
Some(15)
|
||||
);
|
||||
assert_eq!(find_import_start("const value = 'import nope';\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_extra_blank_lines_keeps_at_most_one_blank_line() {
|
||||
assert_eq!(collapse_extra_blank_lines("a\n\n\n\nb\n\nc"), "a\n\nb\n\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_generated_files_removes_prelude_and_compacts_blank_lines() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let file = root.join("generated.ts");
|
||||
write_file(
|
||||
&file,
|
||||
"// header\n\n\nimport {x} from 'x';\n\n\n\nexport const y = x;\n\n",
|
||||
);
|
||||
write_file(&root.join("keep.txt"), "// header\n");
|
||||
|
||||
clean_generated_files(root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(file).unwrap(),
|
||||
"import {x} from 'x';\n\nexport const y = x;\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface RgbaTransformResult {
|
||||
rgba: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DecodedFrame {
|
||||
rgba: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
export interface EncodedApngFrame {
|
||||
compressed: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
export interface EncodedGifChunk {
|
||||
data: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function assemble_apng_frames(frames: Array<EncodedApngFrame>): Uint8Array;
|
||||
export function assemble_gif_frame_chunks(chunks: Array<EncodedGifChunk>): Uint8Array;
|
||||
export function crop_and_rotate_apng(
|
||||
input: Uint8Array,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation_deg: number,
|
||||
resize_width?: number | null,
|
||||
resize_height?: number | null,
|
||||
): Uint8Array;
|
||||
export function crop_and_rotate_gif(
|
||||
input: Uint8Array,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation_deg: number,
|
||||
resize_width?: number | null,
|
||||
resize_height?: number | null,
|
||||
): Uint8Array;
|
||||
export function crop_and_rotate_image(
|
||||
input: Uint8Array,
|
||||
format_hint: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation_deg: number,
|
||||
resize_width?: number | null,
|
||||
resize_height?: number | null,
|
||||
): Uint8Array;
|
||||
export function crop_rotate_rgba(
|
||||
input: Uint8Array,
|
||||
src_width: number,
|
||||
src_height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation_deg: number,
|
||||
resize_width?: number | null,
|
||||
resize_height?: number | null,
|
||||
): RgbaTransformResult;
|
||||
export function decode_apng_frames(input: Uint8Array): Array<DecodedFrame>;
|
||||
export function decode_gif_frames(input: Uint8Array): Array<DecodedFrame>;
|
||||
export function encode_apng_frame_payload(frame: DecodedFrame): EncodedApngFrame;
|
||||
export function encode_apng_frames(frames: Array<DecodedFrame>): Uint8Array;
|
||||
export function encode_gif_frame_chunk(frame: DecodedFrame, first?: boolean): EncodedGifChunk;
|
||||
export function encode_gif_frames(frames: Array<DecodedFrame>): Uint8Array;
|
||||
@@ -1,757 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {encode as encodePng} from 'fast-png';
|
||||
import {zlibSync} from 'fflate';
|
||||
import {applyPalette, GIFEncoder, quantize} from 'gifenc';
|
||||
import {decompressFrames, parseGIF} from 'gifuct-js';
|
||||
import {decode as decodeJpeg, encode as encodeJpeg} from 'jpeg-js';
|
||||
import UPNG from 'upng-js';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const NULL_U32 = 0xffffffff;
|
||||
const RGBA_RESULT_HEADER_BYTES = 8;
|
||||
const MAX_ANIMATION_PIXELS = 200_000_000;
|
||||
const MAX_STATIC_DECODE_MEMORY_MB = 1024;
|
||||
|
||||
function inputBytes(input) {
|
||||
if (input == null) return new Uint8Array();
|
||||
return input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
}
|
||||
|
||||
function arrayBufferFor(bytes) {
|
||||
if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes.buffer;
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
function nonNegativeU32(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return 0;
|
||||
return Math.min(NULL_U32, Math.floor(number));
|
||||
}
|
||||
|
||||
function cropCoordU32(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return 0;
|
||||
return Math.min(NULL_U32, Math.floor(number));
|
||||
}
|
||||
|
||||
function optionalDimension(value) {
|
||||
const dimension = nonNegativeU32(value);
|
||||
return dimension > 0 ? dimension : null;
|
||||
}
|
||||
|
||||
function effectiveRotation(rotationDeg) {
|
||||
const rotation = ((Math.floor(Number(rotationDeg) || 0) % 360) + 360) % 360;
|
||||
return rotation === 90 || rotation === 180 || rotation === 270 ? rotation : 0;
|
||||
}
|
||||
|
||||
function normalizedFormat(value) {
|
||||
const format = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (format === 'jpg') return 'jpeg';
|
||||
if (format === 'apng') return 'png';
|
||||
if (format === 'animated_webp') return 'webp';
|
||||
return format;
|
||||
}
|
||||
|
||||
function sniffImageFormat(input) {
|
||||
const bytes = inputBytes(input);
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47 &&
|
||||
bytes[4] === 0x0d &&
|
||||
bytes[5] === 0x0a &&
|
||||
bytes[6] === 0x1a &&
|
||||
bytes[7] === 0x0a
|
||||
) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
|
||||
if (
|
||||
bytes.length >= 6 &&
|
||||
bytes[0] === 0x47 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x38 &&
|
||||
(bytes[4] === 0x37 || bytes[4] === 0x39) &&
|
||||
bytes[5] === 0x61
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[4] === 0x66 &&
|
||||
bytes[5] === 0x74 &&
|
||||
bytes[6] === 0x79 &&
|
||||
bytes[7] === 0x70 &&
|
||||
bytes[8] === 0x61 &&
|
||||
bytes[9] === 0x76 &&
|
||||
bytes[10] === 0x69 &&
|
||||
(bytes[11] === 0x66 || bytes[11] === 0x73)
|
||||
) {
|
||||
return 'avif';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function readU32FromBytes(bytes, offset) {
|
||||
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true);
|
||||
}
|
||||
|
||||
function readU16LE(bytes, offset) {
|
||||
return bytes[offset] | (bytes[offset + 1] << 8);
|
||||
}
|
||||
|
||||
function readU16BE(bytes, offset) {
|
||||
return (bytes[offset] << 8) | bytes[offset + 1];
|
||||
}
|
||||
|
||||
function readU32BE(bytes, offset) {
|
||||
return (bytes[offset] * 0x1000000 + ((bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3])) >>> 0;
|
||||
}
|
||||
|
||||
function parseRgbaTransformResult(bytes) {
|
||||
if (bytes.byteLength < RGBA_RESULT_HEADER_BYTES) throw new Error('libfluxcore returned a truncated RGBA result');
|
||||
const width = readU32FromBytes(bytes, 0);
|
||||
const height = readU32FromBytes(bytes, 4);
|
||||
const expected = RGBA_RESULT_HEADER_BYTES + width * height * 4;
|
||||
if (bytes.byteLength !== expected) throw new Error('libfluxcore returned an invalid RGBA result length');
|
||||
return {rgba: bytes.subarray(RGBA_RESULT_HEADER_BYTES), width, height};
|
||||
}
|
||||
|
||||
function pngDimensions(bytes) {
|
||||
if (bytes.length < 24 || sniffImageFormat(bytes) !== 'png') return null;
|
||||
if (bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52) return null;
|
||||
return {width: readU32BE(bytes, 16), height: readU32BE(bytes, 20)};
|
||||
}
|
||||
|
||||
function gifDimensions(bytes) {
|
||||
if (bytes.length < 10 || sniffImageFormat(bytes) !== 'gif') return null;
|
||||
return {width: readU16LE(bytes, 6), height: readU16LE(bytes, 8)};
|
||||
}
|
||||
|
||||
function jpegDimensions(bytes) {
|
||||
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
||||
let offset = 2;
|
||||
while (offset + 4 <= bytes.length) {
|
||||
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
|
||||
if (offset >= bytes.length) return null;
|
||||
const marker = bytes[offset];
|
||||
offset += 1;
|
||||
if (marker === 0xd9 || marker === 0xda) return null;
|
||||
if (offset + 2 > bytes.length) return null;
|
||||
const length = readU16BE(bytes, offset);
|
||||
if (length < 2 || offset + length > bytes.length) return null;
|
||||
if (
|
||||
marker === 0xc0 ||
|
||||
marker === 0xc1 ||
|
||||
marker === 0xc2 ||
|
||||
marker === 0xc3 ||
|
||||
marker === 0xc5 ||
|
||||
marker === 0xc6 ||
|
||||
marker === 0xc7 ||
|
||||
marker === 0xc9 ||
|
||||
marker === 0xca ||
|
||||
marker === 0xcb ||
|
||||
marker === 0xcd ||
|
||||
marker === 0xce ||
|
||||
marker === 0xcf
|
||||
) {
|
||||
if (length < 7) return null;
|
||||
return {height: readU16BE(bytes, offset + 3), width: readU16BE(bytes, offset + 5)};
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function imageDimensions(bytes, format) {
|
||||
switch (format) {
|
||||
case 'png':
|
||||
return pngDimensions(bytes);
|
||||
case 'gif':
|
||||
return gifDimensions(bytes);
|
||||
case 'jpeg':
|
||||
return jpegDimensions(bytes);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isNoopTransform(imageWidth, imageHeight, x, y, width, height, rotationDeg, resizeWidth, resizeHeight) {
|
||||
const cropX = Math.min(cropCoordU32(x), imageWidth);
|
||||
const cropY = Math.min(cropCoordU32(y), imageHeight);
|
||||
const cropW = Math.min(nonNegativeU32(width), imageWidth - cropX);
|
||||
const cropH = Math.min(nonNegativeU32(height), imageHeight - cropY);
|
||||
const targetW = optionalDimension(resizeWidth) ?? imageWidth;
|
||||
const targetH = optionalDimension(resizeHeight) ?? imageHeight;
|
||||
return (
|
||||
cropX === 0 &&
|
||||
cropY === 0 &&
|
||||
cropW === imageWidth &&
|
||||
cropH === imageHeight &&
|
||||
effectiveRotation(rotationDeg) === 0 &&
|
||||
targetW === imageWidth &&
|
||||
targetH === imageHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function crop_rotate_rgba(
|
||||
input,
|
||||
src_width,
|
||||
src_height,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width,
|
||||
resize_height,
|
||||
) {
|
||||
const sourceWidth = nonNegativeU32(src_width);
|
||||
const sourceHeight = nonNegativeU32(src_height);
|
||||
return parseRgbaTransformResult(
|
||||
// biome-ignore lint/correctness/noUndeclaredVariables: Injected by the generated wasm wrapper at build time.
|
||||
crop_rotate_rgba_raw(
|
||||
inputBytes(input),
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
cropCoordU32(x),
|
||||
cropCoordU32(y),
|
||||
nonNegativeU32(width),
|
||||
nonNegativeU32(height),
|
||||
effectiveRotation(rotation_deg),
|
||||
optionalDimension(resize_width),
|
||||
optionalDimension(resize_height),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function transformFrame(frame, x, y, width, height, rotationDeg, resizeWidth, resizeHeight) {
|
||||
const transformed = crop_rotate_rgba(
|
||||
frame.rgba,
|
||||
frame.width,
|
||||
frame.height,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotationDeg,
|
||||
resizeWidth,
|
||||
resizeHeight,
|
||||
);
|
||||
return {rgba: transformed.rgba, width: transformed.width, height: transformed.height, delayMs: frame.delayMs};
|
||||
}
|
||||
|
||||
function transformFrames(frames, x, y, width, height, rotationDeg, resizeWidth, resizeHeight) {
|
||||
let totalPixels = 0;
|
||||
const transformed = frames.map((frame) => {
|
||||
const out = transformFrame(frame, x, y, width, height, rotationDeg, resizeWidth, resizeHeight);
|
||||
totalPixels += out.width * out.height;
|
||||
if (totalPixels > MAX_ANIMATION_PIXELS) {
|
||||
throw new Error('Animated image is too large to crop. Try reducing its dimensions or number of frames.');
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return transformed;
|
||||
}
|
||||
|
||||
function decodePngFrames(input) {
|
||||
const bytes = inputBytes(input);
|
||||
const decoded = UPNG.decode(arrayBufferFor(bytes));
|
||||
const rgbaFrames = UPNG.toRGBA8(decoded);
|
||||
if (!rgbaFrames.length) throw new Error('PNG has no frames');
|
||||
return rgbaFrames.map((frame, index) => {
|
||||
const delayMs = decoded.frames?.[index]?.delay ?? 0;
|
||||
return {rgba: new Uint8Array(frame), width: decoded.width, height: decoded.height, delayMs};
|
||||
});
|
||||
}
|
||||
|
||||
function decodeJpegFrame(input) {
|
||||
const decoded = decodeJpeg(inputBytes(input), {
|
||||
useTArray: true,
|
||||
formatAsRGBA: true,
|
||||
maxMemoryUsageInMB: MAX_STATIC_DECODE_MEMORY_MB,
|
||||
});
|
||||
return {rgba: new Uint8Array(decoded.data), width: decoded.width, height: decoded.height, delayMs: 0};
|
||||
}
|
||||
|
||||
function drawGifPatch(canvas, canvasWidth, frame) {
|
||||
const dims = frame.dims;
|
||||
const patch = frame.patch;
|
||||
for (let row = 0; row < dims.height; row += 1) {
|
||||
const canvasY = dims.top + row;
|
||||
if (canvasY < 0) continue;
|
||||
const canvasOffset = (canvasY * canvasWidth + dims.left) * 4;
|
||||
const patchOffset = row * dims.width * 4;
|
||||
if (canvasOffset < 0 || canvasOffset >= canvas.length) continue;
|
||||
for (let col = 0; col < dims.width; col += 1) {
|
||||
const source = patchOffset + col * 4;
|
||||
const target = canvasOffset + col * 4;
|
||||
if (target < 0 || target + 4 > canvas.length || source + 4 > patch.length) continue;
|
||||
if (patch[source + 3] === 0) continue;
|
||||
canvas[target] = patch[source];
|
||||
canvas[target + 1] = patch[source + 1];
|
||||
canvas[target + 2] = patch[source + 2];
|
||||
canvas[target + 3] = patch[source + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearRectRgba(canvas, canvasWidth, x, y, width, height) {
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const start = ((y + row) * canvasWidth + x) * 4;
|
||||
const end = start + width * 4;
|
||||
if (start >= 0 && end <= canvas.length) canvas.fill(0, start, end);
|
||||
}
|
||||
}
|
||||
|
||||
function collectGifFrames(input, transformOptions) {
|
||||
const bytes = inputBytes(input);
|
||||
const parsed = parseGIF(arrayBufferFor(bytes));
|
||||
const screenWidth = parsed.lsd.width;
|
||||
const screenHeight = parsed.lsd.height;
|
||||
const decodedFrames = decompressFrames(parsed, true);
|
||||
if (!decodedFrames.length) throw new Error('GIF has no frames');
|
||||
const canvas = new Uint8Array(screenWidth * screenHeight * 4);
|
||||
let previousCanvas = null;
|
||||
const frames = [];
|
||||
for (const frame of decodedFrames) {
|
||||
if (frame.disposalType === 3) previousCanvas = canvas.slice();
|
||||
drawGifPatch(canvas, screenWidth, frame);
|
||||
const sourceFrame = {
|
||||
rgba: transformOptions ? canvas : canvas.slice(),
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
delayMs: frame.delay || 100,
|
||||
};
|
||||
frames.push(
|
||||
transformOptions
|
||||
? transformFrame(
|
||||
sourceFrame,
|
||||
transformOptions.x,
|
||||
transformOptions.y,
|
||||
transformOptions.width,
|
||||
transformOptions.height,
|
||||
transformOptions.rotationDeg,
|
||||
transformOptions.resizeWidth,
|
||||
transformOptions.resizeHeight,
|
||||
)
|
||||
: sourceFrame,
|
||||
);
|
||||
if (frame.disposalType === 2) {
|
||||
clearRectRgba(canvas, screenWidth, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
|
||||
} else if (frame.disposalType === 3 && previousCanvas) {
|
||||
canvas.set(previousCanvas);
|
||||
previousCanvas = null;
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function decodeGifFrames(input) {
|
||||
return collectGifFrames(input, null);
|
||||
}
|
||||
|
||||
function transformGifFrames(input, x, y, width, height, rotationDeg, resizeWidth, resizeHeight) {
|
||||
return collectGifFrames(input, {x, y, width, height, rotationDeg, resizeWidth, resizeHeight});
|
||||
}
|
||||
|
||||
function exactGifFrameData(rgba) {
|
||||
const palette = [];
|
||||
const colorToIndex = new Map();
|
||||
const index = new Uint8Array(rgba.length / 4);
|
||||
let transparentIndex = -1;
|
||||
for (let offset = 0, pixel = 0; offset < rgba.length; offset += 4, pixel += 1) {
|
||||
if (rgba[offset + 3] === 0) {
|
||||
if (transparentIndex === -1) {
|
||||
if (palette.length >= 256) return null;
|
||||
transparentIndex = palette.length;
|
||||
palette.push([0, 0, 0]);
|
||||
}
|
||||
index[pixel] = transparentIndex;
|
||||
continue;
|
||||
}
|
||||
const key = `${rgba[offset]},${rgba[offset + 1]},${rgba[offset + 2]}`;
|
||||
let paletteIndex = colorToIndex.get(key);
|
||||
if (paletteIndex == null) {
|
||||
if (palette.length >= 256) return null;
|
||||
paletteIndex = palette.length;
|
||||
colorToIndex.set(key, paletteIndex);
|
||||
palette.push([rgba[offset], rgba[offset + 1], rgba[offset + 2]]);
|
||||
}
|
||||
index[pixel] = paletteIndex;
|
||||
}
|
||||
if (palette.length === 0) palette.push([0, 0, 0]);
|
||||
return {index, palette, transparentIndex};
|
||||
}
|
||||
|
||||
function quantizedGifFrameData(rgba) {
|
||||
const input = rgba.byteOffset === 0 && rgba.byteLength === rgba.buffer.byteLength ? rgba : new Uint8Array(rgba);
|
||||
const palette = quantize(input, 256, {format: 'rgba4444', oneBitAlpha: true});
|
||||
const index = applyPalette(input, palette, 'rgba4444');
|
||||
const transparentIndex = palette.findIndex((color) => color.length >= 4 && color[3] === 0);
|
||||
const rgbPalette = palette.map((color) => [color[0], color[1], color[2]]);
|
||||
return {index, palette: rgbPalette, transparentIndex};
|
||||
}
|
||||
|
||||
function writeGifFrame(gif, frame, width, height, first) {
|
||||
if (frame.width !== width || frame.height !== height) throw new Error('GIF frame dimensions must match');
|
||||
const frameData = exactGifFrameData(frame.rgba) ?? quantizedGifFrameData(frame.rgba);
|
||||
const transparent = frameData.transparentIndex >= 0;
|
||||
gif.writeFrame(frameData.index, width, height, {
|
||||
palette: frameData.palette,
|
||||
delay: Math.max(0, Math.round(frame.delayMs || 0)),
|
||||
repeat: 0,
|
||||
transparent,
|
||||
transparentIndex: transparent ? frameData.transparentIndex : 0,
|
||||
first,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeGifFrames(frames) {
|
||||
if (!frames.length) throw new Error('GIF encode requires at least one frame');
|
||||
const width = frames[0].width;
|
||||
const height = frames[0].height;
|
||||
const gif = GIFEncoder();
|
||||
for (const frame of frames) writeGifFrame(gif, frame, width, height, false);
|
||||
gif.finish();
|
||||
return gif.bytes();
|
||||
}
|
||||
|
||||
function encodeGifFrameChunk(frame, first) {
|
||||
const gif = GIFEncoder({auto: false});
|
||||
if (first) gif.writeHeader();
|
||||
writeGifFrame(gif, frame, frame.width, frame.height, first);
|
||||
return {data: gif.bytes(), width: frame.width, height: frame.height};
|
||||
}
|
||||
|
||||
function assembleGifFrameChunks(chunks) {
|
||||
if (!chunks.length) throw new Error('GIF chunk assembly requires at least one frame');
|
||||
const width = chunks[0].width;
|
||||
const height = chunks[0].height;
|
||||
const parts = [];
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.width !== width || chunk.height !== height) throw new Error('GIF frame dimensions must match');
|
||||
parts.push(inputBytes(chunk.data));
|
||||
}
|
||||
parts.push(new Uint8Array([0x3b]));
|
||||
return concatBytes(parts);
|
||||
}
|
||||
|
||||
function writeU32BE(bytes, offset, value) {
|
||||
bytes[offset] = (value >>> 24) & 0xff;
|
||||
bytes[offset + 1] = (value >>> 16) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 3] = value & 0xff;
|
||||
}
|
||||
|
||||
function writeU16BE(bytes, offset, value) {
|
||||
bytes[offset] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 1] = value & 0xff;
|
||||
}
|
||||
|
||||
let pngCrcTable = null;
|
||||
function crc32(bytes, start, end) {
|
||||
if (!pngCrcTable) {
|
||||
pngCrcTable = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
pngCrcTable[n] = c >>> 0;
|
||||
}
|
||||
}
|
||||
let c = 0xffffffff;
|
||||
for (let index = start; index < end; index += 1) c = pngCrcTable[(c ^ bytes[index]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, payload) {
|
||||
const typeBytes = textEncoder.encode(type);
|
||||
const chunk = new Uint8Array(12 + payload.length);
|
||||
writeU32BE(chunk, 0, payload.length);
|
||||
chunk.set(typeBytes, 4);
|
||||
chunk.set(payload, 8);
|
||||
writeU32BE(chunk, 8 + payload.length, crc32(chunk, 4, 8 + payload.length));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function concatBytes(parts) {
|
||||
let total = 0;
|
||||
for (const part of parts) total += part.length;
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
output.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function pngScanlines(rgba, width, height) {
|
||||
const rowBytes = width * 4;
|
||||
const output = new Uint8Array((rowBytes + 1) * height);
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const target = row * (rowBytes + 1);
|
||||
output[target] = 0;
|
||||
output.set(rgba.subarray(row * rowBytes, row * rowBytes + rowBytes), target + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function delayFraction(delayMs) {
|
||||
const ms = Math.max(0, Math.round(Number(delayMs) || 0));
|
||||
if (ms === 0) return [0, 100];
|
||||
if (ms <= 655350) return [Math.min(65535, Math.max(1, Math.round(ms / 10))), 100];
|
||||
return [Math.min(65535, Math.max(1, Math.round(ms / 1000))), 1];
|
||||
}
|
||||
|
||||
function encodeApngFramePayload(frame) {
|
||||
return {
|
||||
compressed: zlibSync(pngScanlines(frame.rgba, frame.width, frame.height), {level: 6}),
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
delayMs: frame.delayMs,
|
||||
};
|
||||
}
|
||||
|
||||
function assembleApngFramePayloads(frames) {
|
||||
if (!frames.length) throw new Error('APNG encode requires at least one frame');
|
||||
const width = frames[0].width;
|
||||
const height = frames[0].height;
|
||||
const chunks = [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])];
|
||||
const ihdr = new Uint8Array(13);
|
||||
writeU32BE(ihdr, 0, width);
|
||||
writeU32BE(ihdr, 4, height);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
chunks.push(pngChunk('IHDR', ihdr));
|
||||
const actl = new Uint8Array(8);
|
||||
writeU32BE(actl, 0, frames.length);
|
||||
writeU32BE(actl, 4, 0);
|
||||
chunks.push(pngChunk('acTL', actl));
|
||||
let sequence = 0;
|
||||
for (let index = 0; index < frames.length; index += 1) {
|
||||
const frame = frames[index];
|
||||
if (frame.width !== width || frame.height !== height) throw new Error('APNG frame dimensions must match');
|
||||
const fctl = new Uint8Array(26);
|
||||
writeU32BE(fctl, 0, sequence);
|
||||
sequence += 1;
|
||||
writeU32BE(fctl, 4, width);
|
||||
writeU32BE(fctl, 8, height);
|
||||
writeU32BE(fctl, 12, 0);
|
||||
writeU32BE(fctl, 16, 0);
|
||||
const delay = delayFraction(frame.delayMs);
|
||||
writeU16BE(fctl, 20, delay[0]);
|
||||
writeU16BE(fctl, 22, delay[1]);
|
||||
fctl[24] = 0;
|
||||
fctl[25] = 0;
|
||||
chunks.push(pngChunk('fcTL', fctl));
|
||||
if (index === 0) {
|
||||
chunks.push(pngChunk('IDAT', inputBytes(frame.compressed)));
|
||||
} else {
|
||||
const compressed = inputBytes(frame.compressed);
|
||||
const payload = new Uint8Array(4 + compressed.length);
|
||||
writeU32BE(payload, 0, sequence);
|
||||
sequence += 1;
|
||||
payload.set(compressed, 4);
|
||||
chunks.push(pngChunk('fdAT', payload));
|
||||
}
|
||||
}
|
||||
chunks.push(pngChunk('IEND', new Uint8Array()));
|
||||
return concatBytes(chunks);
|
||||
}
|
||||
|
||||
function encodeApngFrames(frames) {
|
||||
if (!frames.length) throw new Error('APNG encode requires at least one frame');
|
||||
const width = frames[0].width;
|
||||
const height = frames[0].height;
|
||||
for (const frame of frames) {
|
||||
if (frame.width !== width || frame.height !== height) throw new Error('APNG frame dimensions must match');
|
||||
}
|
||||
return assembleApngFramePayloads(frames.map(encodeApngFramePayload));
|
||||
}
|
||||
|
||||
function encodeStaticFrame(frame, outputFormat) {
|
||||
switch (outputFormat) {
|
||||
case 'png':
|
||||
return encodePng({width: frame.width, height: frame.height, data: frame.rgba, depth: 8, channels: 4});
|
||||
case 'jpeg':
|
||||
return encodeJpeg({width: frame.width, height: frame.height, data: frame.rgba}, 92).data;
|
||||
case 'gif':
|
||||
return encodeGifFrames([frame]);
|
||||
default:
|
||||
throw new Error(`Unsupported static output format: ${outputFormat}`);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeStaticImage(input, inputFormat) {
|
||||
switch (inputFormat) {
|
||||
case 'png':
|
||||
return decodePngFrames(input)[0];
|
||||
case 'jpeg':
|
||||
return decodeJpegFrame(input);
|
||||
case 'gif':
|
||||
return decodeGifFrames(input)[0];
|
||||
default:
|
||||
throw new Error(`Unsupported static input format: ${inputFormat}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFrameInput(frame) {
|
||||
if (!frame || frame.rgba == null) throw new Error('Frame is missing RGBA data');
|
||||
return {
|
||||
rgba: inputBytes(frame.rgba),
|
||||
width: nonNegativeU32(frame.width),
|
||||
height: nonNegativeU32(frame.height),
|
||||
delayMs: Math.max(0, Math.round(Number(frame.delayMs) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApngPayloadInput(frame) {
|
||||
if (!frame || frame.compressed == null) throw new Error('APNG frame is missing compressed data');
|
||||
return {
|
||||
compressed: inputBytes(frame.compressed),
|
||||
width: nonNegativeU32(frame.width),
|
||||
height: nonNegativeU32(frame.height),
|
||||
delayMs: Math.max(0, Math.round(Number(frame.delayMs) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGifChunkInput(chunk) {
|
||||
if (!chunk || chunk.data == null) throw new Error('GIF frame chunk is missing data');
|
||||
return {
|
||||
data: inputBytes(chunk.data),
|
||||
width: nonNegativeU32(chunk.width),
|
||||
height: nonNegativeU32(chunk.height),
|
||||
};
|
||||
}
|
||||
|
||||
export function decode_gif_frames(input) {
|
||||
return decodeGifFrames(input);
|
||||
}
|
||||
|
||||
export function decode_apng_frames(input) {
|
||||
return decodePngFrames(input);
|
||||
}
|
||||
|
||||
export function encode_gif_frames(frames) {
|
||||
return encodeGifFrames(frames.map(normalizeFrameInput));
|
||||
}
|
||||
|
||||
export function encode_apng_frames(frames) {
|
||||
return encodeApngFrames(frames.map(normalizeFrameInput));
|
||||
}
|
||||
|
||||
export function encode_gif_frame_chunk(frame, first) {
|
||||
return encodeGifFrameChunk(normalizeFrameInput(frame), Boolean(first));
|
||||
}
|
||||
|
||||
export function assemble_gif_frame_chunks(chunks) {
|
||||
return assembleGifFrameChunks(chunks.map(normalizeGifChunkInput));
|
||||
}
|
||||
|
||||
export function encode_apng_frame_payload(frame) {
|
||||
return encodeApngFramePayload(normalizeFrameInput(frame));
|
||||
}
|
||||
|
||||
export function assemble_apng_frames(frames) {
|
||||
return assembleApngFramePayloads(frames.map(normalizeApngPayloadInput));
|
||||
}
|
||||
|
||||
export function crop_and_rotate_apng(input, x, y, width, height, rotation_deg, resize_width, resize_height) {
|
||||
const bytes = inputBytes(input);
|
||||
const dimensions = pngDimensions(bytes);
|
||||
if (
|
||||
dimensions &&
|
||||
isNoopTransform(dimensions.width, dimensions.height, x, y, width, height, rotation_deg, resize_width, resize_height)
|
||||
) {
|
||||
return bytes.slice();
|
||||
}
|
||||
const frames = decodePngFrames(bytes);
|
||||
if (
|
||||
isNoopTransform(frames[0].width, frames[0].height, x, y, width, height, rotation_deg, resize_width, resize_height)
|
||||
) {
|
||||
return bytes.slice();
|
||||
}
|
||||
return encodeApngFrames(transformFrames(frames, x, y, width, height, rotation_deg, resize_width, resize_height));
|
||||
}
|
||||
|
||||
export function crop_and_rotate_gif(input, x, y, width, height, rotation_deg, resize_width, resize_height) {
|
||||
const bytes = inputBytes(input);
|
||||
const dimensions = gifDimensions(bytes);
|
||||
if (
|
||||
dimensions &&
|
||||
isNoopTransform(dimensions.width, dimensions.height, x, y, width, height, rotation_deg, resize_width, resize_height)
|
||||
) {
|
||||
return bytes.slice();
|
||||
}
|
||||
return encodeGifFrames(transformGifFrames(bytes, x, y, width, height, rotation_deg, resize_width, resize_height));
|
||||
}
|
||||
|
||||
export function crop_and_rotate_image(
|
||||
input,
|
||||
format_hint,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width,
|
||||
resize_height,
|
||||
) {
|
||||
const bytes = inputBytes(input);
|
||||
const inputFormat = sniffImageFormat(bytes);
|
||||
const requestedFormat = normalizedFormat(format_hint);
|
||||
const outputFormat = requestedFormat && requestedFormat !== 'unknown' ? requestedFormat : inputFormat;
|
||||
if (inputFormat === 'webp' || inputFormat === 'avif' || outputFormat === 'webp' || outputFormat === 'avif') {
|
||||
throw new Error('WebP and AVIF crop/encode use the browser or native media bridge');
|
||||
}
|
||||
if (inputFormat === outputFormat) {
|
||||
const dimensions = imageDimensions(bytes, inputFormat);
|
||||
if (
|
||||
dimensions &&
|
||||
isNoopTransform(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width,
|
||||
resize_height,
|
||||
)
|
||||
) {
|
||||
return bytes.slice();
|
||||
}
|
||||
}
|
||||
const frame = decodeStaticImage(bytes, inputFormat);
|
||||
if (
|
||||
inputFormat === outputFormat &&
|
||||
isNoopTransform(frame.width, frame.height, x, y, width, height, rotation_deg, resize_width, resize_height)
|
||||
) {
|
||||
return bytes.slice();
|
||||
}
|
||||
return encodeStaticFrame(
|
||||
transformFrame(frame, x, y, width, height, rotation_deg, resize_width, resize_height),
|
||||
outputFormat,
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
fluxer_common = { path = "../../fluxer_common" }
|
||||
anyhow = "1.0.104"
|
||||
axum = "0.8.9"
|
||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||
|
||||
@@ -111,17 +111,6 @@ fn ensure_desktop_dependencies() -> Result<()> {
|
||||
install_desktop_dependencies()
|
||||
}
|
||||
|
||||
pub fn typecheck_desktop() -> Result<()> {
|
||||
run_command(
|
||||
&["pnpm", "typecheck"],
|
||||
RunOptions {
|
||||
cwd: DESKTOP_DIR.as_path(),
|
||||
..RunOptions::default()
|
||||
},
|
||||
)
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
pub fn development_version(now: SystemTime) -> String {
|
||||
let seconds = now
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -1135,215 +1124,3 @@ fn codesign_ad_hoc(app_bundle: &Path) -> Result<()> {
|
||||
String::from_utf8_lossy(&output.stderr).trim_end()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_no_sandbox_only_by_container_policy() {
|
||||
let args = electron_args(&["--flag".to_owned()]);
|
||||
if cfg!(target_os = "linux")
|
||||
&& Path::new("/.dockerenv").exists()
|
||||
&& env::var("FLUXER_ELECTRON_NO_SANDBOX").as_deref() != Ok("0")
|
||||
{
|
||||
assert_eq!(args[0], "--no-sandbox");
|
||||
}
|
||||
assert!(args.contains(&"--flag".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclaimed_electron_command_re_execs_through_the_launcher() {
|
||||
let command = disclaimed_electron_command(
|
||||
Path::new("/tmp/fluxer-dev"),
|
||||
Path::new("/tmp/Electron.app/Contents/MacOS/Electron"),
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
vec![
|
||||
"/tmp/fluxer-dev",
|
||||
"desktop",
|
||||
"exec-disclaimed",
|
||||
"/tmp/Electron.app/Contents/MacOS/Electron",
|
||||
"."
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_electron_paths_point_inside_the_dev_bundle() {
|
||||
assert!(dev_electron_binary_path().ends_with(
|
||||
"fluxer_desktop/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"
|
||||
));
|
||||
assert!(dev_electron_info_plist_path().ends_with(
|
||||
"fluxer_desktop/node_modules/electron/dist/Electron.app/Contents/Info.plist"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_electron_usage_descriptions_include_audio_capture() {
|
||||
assert!(
|
||||
MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS
|
||||
.iter()
|
||||
.any(|(key, _)| *key == "NSAudioCaptureUsageDescription")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_version_is_a_calver_electron_builder_accepts() {
|
||||
let at = |seconds: u64| development_version(UNIX_EPOCH + Duration::from_secs(seconds));
|
||||
assert_eq!(at(1_791_039_600), "2026.1003.150000");
|
||||
assert_eq!(at(1_767_225_605), "2026.101.5");
|
||||
assert_eq!(at(1_798_761_599), "2026.1231.235959");
|
||||
assert_eq!(at(1_709_208_000), "2024.229.120000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_developer_id_identity_and_skips_other_certificates() {
|
||||
let output = r#" 1) D9E17CA1546F517C814DFA8719A4FED295D7B019 "Apple Development: Someone (TT9JK7QNP2)"
|
||||
2) AA2A51260E57959F00CF6B117CEA2757748756FA "Developer ID Application: Fluxer Platform AB (3G5837T29K)"
|
||||
2 valid identities found"#;
|
||||
assert_eq!(
|
||||
parse_developer_id_identity(output).as_deref(),
|
||||
Some("Fluxer Platform AB (3G5837T29K)")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_developer_id_identity(" 0 valid identities found"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn electron_builder_packages_a_development_dir_for_the_host_without_notarizing() {
|
||||
let args = electron_builder_args(
|
||||
Path::new("/tmp/out"),
|
||||
"arm64",
|
||||
&MacSigning::DeveloperId("Fluxer Platform AB (3G5837T29K)".to_owned()),
|
||||
&[],
|
||||
);
|
||||
assert!(args.contains(&"--dir".to_owned()));
|
||||
assert!(args.contains(&"-c.mac.notarize=false".to_owned()));
|
||||
assert!(args.contains(&"-c.directories.output=/tmp/out".to_owned()));
|
||||
assert!(args.contains(&"--arm64".to_owned()));
|
||||
assert!(
|
||||
!args
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("-c.mac.sign.identity"))
|
||||
);
|
||||
let ad_hoc = electron_builder_args(Path::new("/tmp/out"), "x64", &MacSigning::AdHoc, &[]);
|
||||
assert!(ad_hoc.contains(&"-c.mac.sign.identity=-".to_owned()));
|
||||
assert!(ad_hoc.contains(&"--x64".to_owned()));
|
||||
let linux = electron_builder_args(
|
||||
Path::new("/tmp/out"),
|
||||
"x64",
|
||||
&MacSigning::AdHoc,
|
||||
&["--linux".to_owned()],
|
||||
);
|
||||
assert_eq!(linux.iter().filter(|arg| *arg == "--linux").count(), 1);
|
||||
assert!(!linux.contains(&"--mac".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_development_app_that_claims_the_production_scheme() {
|
||||
assert!(
|
||||
verify_url_schemes(
|
||||
r#"[{"CFBundleURLName":"app.fluxer.development","CFBundleURLSchemes":["fluxer-development"]}]"#
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
verify_url_schemes(r#"[{"CFBundleURLSchemes":["fluxer-development","Fluxer"]}]"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(verify_url_schemes(r#"[{"CFBundleURLSchemes":["fluxer"]}]"#).is_err());
|
||||
assert!(verify_url_schemes("[]").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_info_must_match_the_renderer_delivery() {
|
||||
let info = |channel: &str, offline: bool, modules: bool| {
|
||||
format!(
|
||||
r#"{{"buildVersion":"2026.1003.150000","releaseChannel":"development","buildChannel":"{channel}","offlineBuild":{offline},"modulesEnabled":{modules}}}"#
|
||||
)
|
||||
};
|
||||
let check = |raw: String, version: &str, delivery| {
|
||||
verify_development_build_info(&raw, version, delivery).is_ok()
|
||||
};
|
||||
let version = "2026.1003.150000";
|
||||
assert!(check(
|
||||
info("development", true, false),
|
||||
version,
|
||||
RendererDelivery::Offline
|
||||
));
|
||||
assert!(check(
|
||||
info("development", false, true),
|
||||
version,
|
||||
RendererDelivery::Modules
|
||||
));
|
||||
assert!(!check(
|
||||
info("canary", false, true),
|
||||
version,
|
||||
RendererDelivery::Modules
|
||||
));
|
||||
assert!(!check(
|
||||
info("development", true, false),
|
||||
version,
|
||||
RendererDelivery::Modules
|
||||
));
|
||||
assert!(!check(
|
||||
info("development", false, true),
|
||||
version,
|
||||
RendererDelivery::Offline
|
||||
));
|
||||
assert!(!check(
|
||||
info("development", true, true),
|
||||
version,
|
||||
RendererDelivery::Modules
|
||||
));
|
||||
assert!(!check(
|
||||
info("development", false, true),
|
||||
"2026.1003.150001",
|
||||
RendererDelivery::Modules
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_only_the_exact_main_executable_of_the_installed_app() {
|
||||
let ps = " 501 /Applications/Fluxer Development.app/Contents/MacOS/Fluxer Development\n\
|
||||
502 /Applications/Fluxer Development.app/Contents/Frameworks/Fluxer Development Helper.app/Contents/MacOS/Fluxer Development Helper\n\
|
||||
503 /Applications/Fluxer Canary.app/Contents/MacOS/Fluxer Canary\n\
|
||||
504 /tmp/copy/Fluxer Development.app/Contents/MacOS/Fluxer Development\n";
|
||||
assert_eq!(
|
||||
parse_pids_for_executable(
|
||||
ps,
|
||||
"/Applications/Fluxer Development.app/Contents/MacOS/Fluxer Development"
|
||||
),
|
||||
vec![501]
|
||||
);
|
||||
assert!(
|
||||
parse_pids_for_executable(ps, "/Applications/Fluxer.app/Contents/MacOS/Fluxer")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_under_the_development_bundle_name_only() {
|
||||
let app = installed_app_path(Path::new(DEFAULT_INSTALL_DIR));
|
||||
assert_eq!(app, Path::new("/Applications/Fluxer Development.app"));
|
||||
assert_eq!(
|
||||
app_executable_path(&app),
|
||||
Path::new("/Applications/Fluxer Development.app/Contents/MacOS/Fluxer Development")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_tree_must_be_complete_and_free_of_service_workers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(verify_renderer_tree(dir.path()).is_err());
|
||||
fs::write(dir.path().join("index.html"), "<!doctype html>").unwrap();
|
||||
fs::create_dir(dir.path().join("assets")).unwrap();
|
||||
assert!(verify_renderer_tree(dir.path()).is_ok());
|
||||
fs::write(dir.path().join("sw.js"), "").unwrap();
|
||||
assert!(verify_renderer_tree(dir.path()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -607,113 +607,3 @@ pub fn publish_renderer_modules(
|
||||
ensure_pkgs_server()?;
|
||||
verify_served_manifest(target, metadata_version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metadata_version_always_rises_above_the_live_feed() {
|
||||
assert_eq!(next_metadata_version(100, 50, None), 100);
|
||||
assert_eq!(next_metadata_version(100, 150, None), 150);
|
||||
assert_eq!(next_metadata_version(100, 50, Some(120)), 121);
|
||||
assert_eq!(next_metadata_version(100, 50, Some(99)), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_module_stamps_round_trip() {
|
||||
let stamp = PackedModulesStamp {
|
||||
fingerprint: "abc".to_owned(),
|
||||
version: "2026.1005.171400".to_owned(),
|
||||
pub_date: "2026-10-05T17:14:00Z".to_owned(),
|
||||
};
|
||||
assert_eq!(PackedModulesStamp::parse(&stamp.render()), Some(stamp));
|
||||
assert_eq!(PackedModulesStamp::parse("abc 2026.1005.1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_pub_dates_as_rfc3339_utc() {
|
||||
let at = UNIX_EPOCH + std::time::Duration::from_secs(1_791_220_440);
|
||||
assert_eq!(rfc3339_utc(at), "2026-10-05T17:14:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_one_coordinate_and_restamps_only_on_change() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let payload = temp.path().join("payload");
|
||||
let root = temp.path().join("root");
|
||||
let package_dir = payload.join("development/modules/fluxer_renderer");
|
||||
let package_bytes = b"renderer";
|
||||
let digest = Sha256::digest(package_bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
fs::create_dir_all(package_dir.join(&digest)).unwrap();
|
||||
fs::write(
|
||||
package_dir.join(&digest).join(MODULE_PACKAGE_NAME),
|
||||
package_bytes,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"release_channel": "development",
|
||||
"metadata_version": 10,
|
||||
"modules": {
|
||||
"fluxer_renderer": {
|
||||
"sha256": digest,
|
||||
"bytes": package_bytes.len(),
|
||||
"url": format!("{DEVELOPMENT_PACKAGE_ORIGIN}/desktop/development/modules/fluxer_renderer/{digest}/package.br"),
|
||||
}
|
||||
}
|
||||
});
|
||||
let manifest_dir = payload.join("development/darwin/arm64");
|
||||
fs::create_dir_all(&manifest_dir).unwrap();
|
||||
fs::write(
|
||||
manifest_dir.join(CHANNEL_MANIFEST_NAME),
|
||||
manifest.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let stamp = PackedModulesStamp {
|
||||
fingerprint: "f".to_owned(),
|
||||
version: "2026.1005.1".to_owned(),
|
||||
pub_date: "2026-10-05T00:00:00Z".to_owned(),
|
||||
};
|
||||
let target = ModuleCoordinate {
|
||||
channel: "development".to_owned(),
|
||||
platform: "darwin".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
};
|
||||
let first = publish_modules(&payload, &root, &stamp, &target).unwrap();
|
||||
assert!(first > 10);
|
||||
assert!(
|
||||
root.join(format!(
|
||||
"desktop/development/modules/fluxer_renderer/{digest}/package.br"
|
||||
))
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
root.join("desktop/development/darwin/arm64/2026.1005.1/modules.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
publish_modules(&payload, &root, &stamp, &target).unwrap(),
|
||||
first
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_manifest_whose_package_is_not_at_its_content_address() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"modules": {
|
||||
"fluxer_renderer": {
|
||||
"sha256": "00".repeat(32),
|
||||
"bytes": 1,
|
||||
"url": "https://pkgs.fluxer.com/desktop/development/modules/fluxer_renderer/x/package.br",
|
||||
}
|
||||
}
|
||||
});
|
||||
let error = verify_manifest_packages(temp.path(), "development", &manifest, Path::new("m"))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("not its content address"));
|
||||
}
|
||||
}
|
||||
+1
-270
@@ -14,7 +14,7 @@ use crate::proc::{
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::sleep;
|
||||
@@ -416,12 +416,6 @@ pub fn task_table() -> Result<BTreeMap<&'static str, DevTask>> {
|
||||
args: strings(&["cargo", "run", "-p", "fluxer_app_proxy"]),
|
||||
cwd: ROOT.clone(),
|
||||
env: vec![
|
||||
(
|
||||
"DISCOVERY_UPSTREAM_URL".to_owned(),
|
||||
Some(format!(
|
||||
"http://{LOOPBACK_HOST}:{DEV_PROXY_PORT}/api/.well-known/fluxer"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"FLUXER_APP_PROXY_INDEX_UPSTREAM_URL".to_owned(),
|
||||
Some(format!("http://{LOOPBACK_HOST}:{APP_PORT}/")),
|
||||
@@ -906,266 +900,3 @@ fn check_startup_task_processes(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _cwd_is_root(path: &Path) -> bool {
|
||||
path == ROOT.as_path()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_tasks_use_lightweight_gateway() {
|
||||
assert_eq!(
|
||||
DEFAULT_TASKS,
|
||||
&[
|
||||
"proxy",
|
||||
"services",
|
||||
"media",
|
||||
"push",
|
||||
"admin",
|
||||
"api",
|
||||
"gateway-single",
|
||||
"worker",
|
||||
"app",
|
||||
"app-proxy"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloudflare_tunnel_task_is_deferred_until_after_stack_tasks() {
|
||||
let selected = normalize_selected_tasks(
|
||||
DEFAULT_TASKS
|
||||
.iter()
|
||||
.map(|name| (*name).to_owned())
|
||||
.collect(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
selected.last().map(String::as_str),
|
||||
Some(CLOUDFLARE_TUNNEL_TASK)
|
||||
);
|
||||
assert_eq!(selected[..DEFAULT_TASKS.len()], *DEFAULT_TASKS);
|
||||
|
||||
let selected = normalize_selected_tasks(
|
||||
vec![
|
||||
CLOUDFLARE_TUNNEL_TASK.to_owned(),
|
||||
"proxy".to_owned(),
|
||||
"media".to_owned(),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
selected,
|
||||
vec![
|
||||
"proxy".to_owned(),
|
||||
"media".to_owned(),
|
||||
CLOUDFLARE_TUNNEL_TASK.to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_dependency_preflight_only_runs_for_js_tasks() {
|
||||
assert!(selected_needs_js_dependency_preflight(&["api".to_owned()]));
|
||||
assert!(selected_needs_js_dependency_preflight(&[
|
||||
"worker".to_owned()
|
||||
]));
|
||||
assert!(selected_needs_js_dependency_preflight(&["app".to_owned()]));
|
||||
assert!(selected_needs_js_dependency_preflight(
|
||||
&["admin".to_owned()]
|
||||
));
|
||||
assert!(!selected_needs_js_dependency_preflight(&[
|
||||
"proxy".to_owned(),
|
||||
"gateway".to_owned(),
|
||||
"media".to_owned()
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_store_preflight_runs_for_upload_capable_tasks() {
|
||||
assert!(selected_needs_object_store(&["api".to_owned()]));
|
||||
assert!(selected_needs_object_store(&["worker".to_owned()]));
|
||||
assert!(selected_needs_object_store(&["media".to_owned()]));
|
||||
assert!(!selected_needs_object_store(&[
|
||||
"proxy".to_owned(),
|
||||
"gateway".to_owned(),
|
||||
"app".to_owned()
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_table_contains_recursive_binary_tasks() {
|
||||
let tasks = task_table().unwrap();
|
||||
let expected_public_url = public_url();
|
||||
let expected_app_port = APP_PORT.to_string();
|
||||
let expected_admin_port = ADMIN_PORT.to_string();
|
||||
let expected_app_upstream_url = format!("http://{LOOPBACK_HOST}:{APP_PORT}/");
|
||||
assert!(tasks["proxy"].args.ends_with(&["proxy".to_owned()]));
|
||||
assert!(
|
||||
tasks["gateway-single"]
|
||||
.args
|
||||
.ends_with(&["gateway".to_owned(), "single".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["gateway"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_DISABLE_RATE_LIMITS")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["gateway-single"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_DISABLE_RATE_LIMITS")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["api"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_S3_PUBLIC_ENDPOINT")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some(expected_public_url.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["api"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_S3_FORCE_PATH_STYLE")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some("true")
|
||||
);
|
||||
assert!(crate::manifest::PROXY_ROUTES.iter().any(|route| {
|
||||
route.prefix == "/fluxer-uploads" && route.host == LOOPBACK_HOST && route.port == 8333
|
||||
}));
|
||||
assert!(crate::manifest::PROXY_ROUTES.iter().any(|route| {
|
||||
route.prefix == "/admin"
|
||||
&& route.host == LOOPBACK_HOST
|
||||
&& route.port == ADMIN_PORT
|
||||
&& route.strip_prefix
|
||||
}));
|
||||
assert_eq!(
|
||||
tasks["app"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_APP_DEV_PORT")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some(expected_app_port.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["app"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_STATIC_CDN_ENDPOINT")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some(expected_public_url.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["app-proxy"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_APP_PROXY_INDEX_UPSTREAM_URL")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some(expected_app_upstream_url.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["app"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "PUBLIC_STATIC_CDN_ENDPOINT")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some(expected_public_url.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["app-proxy"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "RELEASE_CHANNEL")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some("canary")
|
||||
);
|
||||
assert!(tasks["media"].args.starts_with(&[
|
||||
"cargo".to_owned(),
|
||||
"run".to_owned(),
|
||||
"-p".to_owned(),
|
||||
"fluxer-media-proxy".to_owned()
|
||||
]));
|
||||
assert!(tasks["push"].args.starts_with(&[
|
||||
"cargo".to_owned(),
|
||||
"run".to_owned(),
|
||||
"-p".to_owned(),
|
||||
"fluxer-push".to_owned()
|
||||
]));
|
||||
assert!(
|
||||
tasks["push"]
|
||||
.args
|
||||
.ends_with(&["--port".to_owned(), PUSH_PORT.to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["admin"].args,
|
||||
vec![
|
||||
"cargo".to_owned(),
|
||||
"watch".to_owned(),
|
||||
"--no-dot-ignores".to_owned(),
|
||||
"-w".to_owned(),
|
||||
"fluxer_admin/src".to_owned(),
|
||||
"-w".to_owned(),
|
||||
"fluxer_admin/Cargo.toml".to_owned(),
|
||||
"-w".to_owned(),
|
||||
"fluxer_admin/build.rs".to_owned(),
|
||||
"-w".to_owned(),
|
||||
"fluxer_admin/openapi-admin.json".to_owned(),
|
||||
"-w".to_owned(),
|
||||
"fluxer_admin/static".to_owned(),
|
||||
"-x".to_owned(),
|
||||
"run -p fluxer_admin".to_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["admin"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_ADMIN_BASE_PATH")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some("/admin")
|
||||
);
|
||||
assert_eq!(
|
||||
tasks["admin"]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "FLUXER_ADMIN_PORT")
|
||||
.unwrap()
|
||||
.1
|
||||
.as_deref(),
|
||||
Some(expected_admin_port.as_str())
|
||||
);
|
||||
assert!(tasks.contains_key(CLOUDFLARE_TUNNEL_TASK));
|
||||
}
|
||||
}
|
||||
@@ -117,102 +117,3 @@ fn parse_env_value(mut value: &str) -> String {
|
||||
}
|
||||
value.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_env_values_with_shell_like_comments() {
|
||||
assert_eq!(parse_env_value("'quoted'"), "quoted");
|
||||
assert_eq!(parse_env_value("\"quoted\""), "quoted");
|
||||
assert_eq!(parse_env_value("value # comment"), "value");
|
||||
assert_eq!(parse_env_value("value#not-comment"), "value#not-comment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_exports_and_reports_invalid_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("env");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"\n# comment\nexport A=1\nB='two words'\nC=value # trailing\n",
|
||||
)
|
||||
.unwrap();
|
||||
let values = read_env_file(&file).unwrap();
|
||||
assert_eq!(values.get("A").unwrap(), "1");
|
||||
assert_eq!(values.get("B").unwrap(), "two words");
|
||||
assert_eq!(values.get("C").unwrap(), "value");
|
||||
|
||||
std::fs::write(&file, "nope\n").unwrap();
|
||||
assert!(
|
||||
read_env_file(&file)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Invalid env line")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_env_overrides_injected_development_defaults() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let development = dir.path().join("development.env");
|
||||
let local = dir.path().join("local.env");
|
||||
let root_local = dir.path().join(".env.local");
|
||||
std::fs::write(&development, "A=default\nB=default\nC=default\n").unwrap();
|
||||
std::fs::write(&local, "A=local\n").unwrap();
|
||||
std::fs::write(&root_local, "B=root\n").unwrap();
|
||||
let current = BTreeMap::from([
|
||||
("A".to_owned(), "default".to_owned()),
|
||||
("B".to_owned(), "custom".to_owned()),
|
||||
("C".to_owned(), "default".to_owned()),
|
||||
]);
|
||||
let merged = merge_default_env_with_current_and_baseline(
|
||||
&development,
|
||||
&local,
|
||||
&root_local,
|
||||
current,
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged.get("A").map(String::as_str), Some("local"));
|
||||
assert_eq!(merged.get("B").map(String::as_str), Some("custom"));
|
||||
assert_eq!(merged.get("C").map(String::as_str), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_container_defaults_yield_to_file_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let development = dir.path().join("development.env");
|
||||
let local = dir.path().join("local.env");
|
||||
let root_local = dir.path().join(".env.local");
|
||||
std::fs::write(
|
||||
&development,
|
||||
"A=new-default\nB=new-default\nC=new-default\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&local, "A=local\nC=local\n").unwrap();
|
||||
std::fs::write(&root_local, "").unwrap();
|
||||
let baseline = BTreeMap::from([
|
||||
("A".to_owned(), "old-default".to_owned()),
|
||||
("B".to_owned(), "old-default".to_owned()),
|
||||
("C".to_owned(), "old-default".to_owned()),
|
||||
]);
|
||||
let current = BTreeMap::from([
|
||||
("A".to_owned(), "old-default".to_owned()),
|
||||
("B".to_owned(), "custom".to_owned()),
|
||||
("C".to_owned(), "old-default".to_owned()),
|
||||
]);
|
||||
let merged = merge_default_env_with_current_and_baseline(
|
||||
&development,
|
||||
&local,
|
||||
&root_local,
|
||||
current,
|
||||
baseline,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged.get("A").map(String::as_str), Some("local"));
|
||||
assert_eq!(merged.get("B").map(String::as_str), Some("custom"));
|
||||
assert_eq!(merged.get("C").map(String::as_str), Some("local"));
|
||||
}
|
||||
}
|
||||
+4
-164
@@ -727,7 +727,7 @@ fn cmdline_has_managed_gateway_config(args: &[String]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cmdline_has_gateway_node(
|
||||
args: &[String],
|
||||
node_names: &std::collections::HashSet<String>,
|
||||
@@ -746,7 +746,7 @@ fn proc_cmdline(pid: i32) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
let mut fields = after_comm.split_ascii_whitespace();
|
||||
@@ -766,13 +766,13 @@ fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> {
|
||||
Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_proc_stat_starttime(stat: &str) -> Option<u64> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
after_comm.split_ascii_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_stat_state_is_dead(state: char) -> bool {
|
||||
state == 'Z' || state == 'X' || state == 'x'
|
||||
}
|
||||
@@ -1004,163 +1004,3 @@ pub fn build_gateway_command(config_dir: &Path) -> Result<Vec<String>> {
|
||||
]);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_default_cluster_nodes() {
|
||||
let nodes = build_gateway_cluster_nodes().unwrap();
|
||||
assert_eq!(nodes.len(), 18);
|
||||
assert_eq!(nodes[0].name(), "websocket-1");
|
||||
assert_eq!(nodes[0].http_port, 8771);
|
||||
assert_eq!(nodes[17].name(), "push-3");
|
||||
assert_eq!(nodes[17].dist_port, 9018);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_env_contains_role_port_and_static_peers() {
|
||||
let node = GatewayNode {
|
||||
role: "calls".to_owned(),
|
||||
ordinal: 2,
|
||||
http_port: 8811,
|
||||
dist_port: 9014,
|
||||
};
|
||||
let env = gateway_node_env(&node, "a,b");
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| key == "FLUXER_GATEWAY_ROLE"
|
||||
&& value.as_deref() == Some("calls"))
|
||||
);
|
||||
assert!(
|
||||
env.iter().any(
|
||||
|(key, value)| key == "FLUXER_GATEWAY_PORT" && value.as_deref() == Some("8811")
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| key == "FLUXER_GATEWAY_CLUSTER_STATIC_PEERS"
|
||||
&& value.as_deref() == Some("a,b"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmdline_gateway_node_detection_matches_exact_name_argument() {
|
||||
let node_names =
|
||||
std::collections::HashSet::from([String::from("[email protected]")]);
|
||||
|
||||
assert!(cmdline_has_gateway_node(
|
||||
&strings(&[
|
||||
"/usr/local/bin/beam.smp",
|
||||
"-name",
|
||||
"[email protected]"
|
||||
]),
|
||||
&node_names
|
||||
));
|
||||
assert!(!cmdline_has_gateway_node(
|
||||
&strings(&[
|
||||
"/usr/local/bin/beam.smp",
|
||||
"-sname",
|
||||
"[email protected]"
|
||||
]),
|
||||
&node_names
|
||||
));
|
||||
assert!(!cmdline_has_gateway_node(
|
||||
&strings(&[
|
||||
"/usr/local/bin/beam.smp",
|
||||
"-name",
|
||||
"[email protected]"
|
||||
]),
|
||||
&node_names
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_extracts_state_and_pgid() {
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("1234 (beam.smp) S 1 1234 1234 0 -1 4194560 0"),
|
||||
Some(('S', 1234))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("77 (erl_child_setup) R 42 42 9000 0 -1"),
|
||||
Some(('R', 42))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_handles_parentheses_and_spaces_in_comm() {
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("99 (weird) comm (name) Z 1 42 42 0 -1"),
|
||||
Some(('Z', 42))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("99 (spaced comm) T 1 99 99 0 -1"),
|
||||
Some(('T', 99))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_rejects_malformed_lines() {
|
||||
assert_eq!(parse_proc_stat_state_and_pgid(""), None);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("1234 (beam.smp)"), None);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("1234 (beam.smp) S"), None);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("1234 (beam.smp) S 1"), None);
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("1234 (beam.smp) S 1 not-a-pgid"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("no comm field here"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_extracts_starttime() {
|
||||
let stat =
|
||||
"1234 (beam.smp) S 1 1234 1234 0 -1 4194560 0 0 0 0 5 3 0 0 20 0 30 0 12345678 4096";
|
||||
assert_eq!(parse_proc_stat_starttime(stat), Some(12_345_678));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_starttime_parsing_handles_parentheses_and_spaces_in_comm() {
|
||||
let stat =
|
||||
"99 (weird) comm (name) S 1 42 42 0 -1 4194560 0 0 0 0 5 3 0 0 20 0 30 0 777 4096";
|
||||
assert_eq!(parse_proc_stat_starttime(stat), Some(777));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_starttime_parsing_rejects_truncated_lines() {
|
||||
assert_eq!(parse_proc_stat_starttime(""), None);
|
||||
assert_eq!(parse_proc_stat_starttime("1234 (beam.smp)"), None);
|
||||
assert_eq!(
|
||||
parse_proc_stat_starttime("1234 (beam.smp) S 1 1234 1234 0 -1 4194560 0"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proc_stat_starttime(
|
||||
"1234 (beam.smp) S 1 1234 1234 0 -1 4194560 0 0 0 0 5 3 0 0 20 0 30 0 not-a-number"
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zombie_and_reaped_states_count_as_dead() {
|
||||
assert!(proc_stat_state_is_dead('Z'));
|
||||
assert!(proc_stat_state_is_dead('X'));
|
||||
assert!(proc_stat_state_is_dead('x'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_states_do_not_count_as_dead() {
|
||||
assert!(!proc_stat_state_is_dead('R'));
|
||||
assert!(!proc_stat_state_is_dead('S'));
|
||||
assert!(!proc_stat_state_is_dead('D'));
|
||||
assert!(!proc_stat_state_is_dead('T'));
|
||||
assert!(!proc_stat_state_is_dead('t'));
|
||||
assert!(!proc_stat_state_is_dead('I'));
|
||||
}
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_owned()).collect()
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ pub mod disclaim;
|
||||
pub mod env;
|
||||
pub mod gateway;
|
||||
pub mod manifest;
|
||||
pub mod media_external;
|
||||
pub mod media_proxy;
|
||||
pub mod object_store;
|
||||
pub mod paths;
|
||||
|
||||
+1
-23
@@ -2,13 +2,10 @@
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use fluxer_dev::cassandra::{
|
||||
apply_schema, compute_diff, render_target_schema, verify_schema, write_diff_file,
|
||||
};
|
||||
use fluxer_dev::cassandra::{apply_schema, compute_diff, verify_schema, write_diff_file};
|
||||
use fluxer_dev::desktop::{
|
||||
DEFAULT_INSTALL_DIR, DesktopAppOptions, RendererDelivery, build_desktop, desktop_app,
|
||||
install_desktop_app, install_desktop_dependencies, package_desktop, run_desktop,
|
||||
typecheck_desktop,
|
||||
};
|
||||
use fluxer_dev::env::merge_default_env_with_current;
|
||||
use fluxer_dev::manifest::{DEV_PROXY_PORT, LOCAL_APP_URL};
|
||||
@@ -114,7 +111,6 @@ enum CassandraCommand {
|
||||
},
|
||||
Apply,
|
||||
Verify,
|
||||
TargetSchema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -132,7 +128,6 @@ enum DesktopCommand {
|
||||
#[arg(long)]
|
||||
skip_native: bool,
|
||||
},
|
||||
Typecheck,
|
||||
Pkgs,
|
||||
Package {
|
||||
#[arg(long)]
|
||||
@@ -194,8 +189,6 @@ enum MediaProxyCommand {
|
||||
#[arg(long)]
|
||||
path: Option<String>,
|
||||
},
|
||||
RustStressSmoke,
|
||||
SignExternalUrl(fluxer_dev::media_external::SignExternalUrlArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -279,7 +272,6 @@ async fn main() -> Result<()> {
|
||||
apply_schema(None).await?;
|
||||
}
|
||||
CassandraCommand::Verify => verify_schema(None, None).await?,
|
||||
CassandraCommand::TargetSchema => print!("{}", render_target_schema("fluxer")),
|
||||
},
|
||||
Command::Desktop(args) => match args.command {
|
||||
DesktopCommand::Deps => install_desktop_dependencies()?,
|
||||
@@ -292,7 +284,6 @@ async fn main() -> Result<()> {
|
||||
RendererDelivery::Offline,
|
||||
)?),
|
||||
DesktopCommand::Pkgs => fluxer_dev::desktop_modules::ensure_pkgs_server()?,
|
||||
DesktopCommand::Typecheck => typecheck_desktop()?,
|
||||
DesktopCommand::Package {
|
||||
rebuild_renderer,
|
||||
ad_hoc,
|
||||
@@ -341,19 +332,6 @@ async fn main() -> Result<()> {
|
||||
fluxer_dev::media_proxy::run_dev_media_doctor(repair, &base_url, path.as_deref())
|
||||
.await?;
|
||||
}
|
||||
MediaProxyCommand::RustStressSmoke => {
|
||||
fluxer_dev::media_proxy::run_rust_stress_smoke()?;
|
||||
}
|
||||
MediaProxyCommand::SignExternalUrl(args) => {
|
||||
println!(
|
||||
"{}",
|
||||
fluxer_dev::media_external::sign_external_url(
|
||||
&args.secret_key,
|
||||
&args.server_url,
|
||||
&args.upstream
|
||||
)?
|
||||
);
|
||||
}
|
||||
},
|
||||
Command::Tunnel(args) => match args.command {
|
||||
TunnelCommand::Configure { public_url, token } => {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct SignExternalUrlArgs {
|
||||
#[arg(long)]
|
||||
pub secret_key: String,
|
||||
#[arg(long)]
|
||||
pub server_url: String,
|
||||
pub upstream: String,
|
||||
}
|
||||
|
||||
pub fn sign_external_url(secret_key: &str, server_url: &str, upstream: &str) -> Result<String> {
|
||||
fluxer_common::external_media_path::build_external_media_proxy_url(
|
||||
server_url.trim_end_matches('/'),
|
||||
upstream,
|
||||
secret_key.as_bytes(),
|
||||
)
|
||||
.context("failed to build the external media proxy url")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signs_external_urls_with_urlsafe_components() {
|
||||
let signed = sign_external_url(
|
||||
"benchmark-secret",
|
||||
"http://127.0.0.1:19110/",
|
||||
"https://example.test/a b.jpg",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(signed.starts_with("http://127.0.0.1:19110/external/"));
|
||||
assert!(signed.ends_with("/https/example.test/a%20b.jpg"));
|
||||
assert_eq!(
|
||||
"https://example.test/a b.jpg",
|
||||
fluxer_common::external_media_path::reconstruct_original_url(
|
||||
signed
|
||||
.split_once("/external/")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once('/')
|
||||
.unwrap()
|
||||
.1
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,63 +64,6 @@ pub async fn ensure_dev_object_store(repair: bool, repair_timeout_secs: u64) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_rust_stress_smoke() -> Result<()> {
|
||||
crate::proc::run(&["cargo", "test", "-p", "fluxer-media-proxy"])?;
|
||||
crate::proc::run(&[
|
||||
"cargo",
|
||||
"bench",
|
||||
"-p",
|
||||
"fluxer-media-proxy",
|
||||
"--bench",
|
||||
"core",
|
||||
"--",
|
||||
"--sample-size",
|
||||
"10",
|
||||
"--warm-up-time",
|
||||
"1",
|
||||
"--measurement-time",
|
||||
"1",
|
||||
])?;
|
||||
|
||||
if which("cargo-fuzz").is_none() {
|
||||
eprintln!("cargo-fuzz not installed; skipping fuzz smoke");
|
||||
return Ok(());
|
||||
}
|
||||
let use_nightly = rustup_has_nightly();
|
||||
for target in ["parsers", "signing_external_path", "thumbhash"] {
|
||||
run_fuzz_smoke(target, use_nightly)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rustup_has_nightly() -> bool {
|
||||
let Ok(output) = Command::new("rustup").args(["toolchain", "list"]).output() else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.any(|line| line.starts_with("nightly"))
|
||||
}
|
||||
|
||||
fn run_fuzz_smoke(target: &str, use_nightly: bool) -> Result<()> {
|
||||
if use_nightly {
|
||||
crate::proc::run(&[
|
||||
"cargo",
|
||||
"+nightly",
|
||||
"fuzz",
|
||||
"run",
|
||||
target,
|
||||
"--",
|
||||
"-runs=1000",
|
||||
])
|
||||
} else {
|
||||
crate::proc::run(&["cargo", "fuzz", "run", target, "--", "-runs=1000"])
|
||||
}
|
||||
}
|
||||
|
||||
fn start_dev_seaweedfs() -> Result<()> {
|
||||
ensure_writable_dev_paths()?;
|
||||
if tcp_reachable(DEV_S3_HOST, DEV_S3_PORT) {
|
||||
|
||||
@@ -178,29 +178,3 @@ pub async fn bootstrap_schema() -> Result<()> {
|
||||
fn cassandra_backend_enabled() -> bool {
|
||||
env::var("FLUXER_DATABASE_BACKEND").as_deref() == Ok("cassandra")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn s3_env_uses_fluxer_defaults() {
|
||||
let env = s3_env();
|
||||
assert!(env.iter().any(
|
||||
|(key, value)| key == "AWS_DEFAULT_REGION" && value.as_deref() == Some("us-east-1")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_already_exists_output_matches_aws_errors() {
|
||||
assert!(s3_bucket_already_exists_output(
|
||||
"An error occurred (BucketAlreadyExists) when calling the CreateBucket operation"
|
||||
));
|
||||
assert!(s3_bucket_already_exists_output(
|
||||
"An error occurred (BucketAlreadyOwnedByYou) when calling the CreateBucket operation"
|
||||
));
|
||||
assert!(!s3_bucket_already_exists_output(
|
||||
"An error occurred (AccessDenied) when calling the CreateBucket operation"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -450,46 +450,3 @@ impl ShutdownSignal {
|
||||
"signal"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_posix_commands_for_logs() {
|
||||
assert_eq!(format_command(&["pnpm", "build"]), "pnpm build");
|
||||
assert_eq!(
|
||||
format_command(&["", "two words", "a'b"]),
|
||||
"'' 'two words' 'a'\"'\"'b'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_budget_allows_limited_restarts_within_window() {
|
||||
let mut restarts = VecDeque::new();
|
||||
let base = Instant::now();
|
||||
for offset in 0..RESTART_LIMIT {
|
||||
assert!(!restart_budget_exceeded(
|
||||
&mut restarts,
|
||||
base + Duration::from_secs(offset as u64)
|
||||
));
|
||||
}
|
||||
assert!(restart_budget_exceeded(
|
||||
&mut restarts,
|
||||
base + Duration::from_secs(RESTART_LIMIT as u64)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_budget_resets_after_window_elapses() {
|
||||
let mut restarts = VecDeque::new();
|
||||
let base = Instant::now();
|
||||
for _ in 0..RESTART_LIMIT {
|
||||
assert!(!restart_budget_exceeded(&mut restarts, base));
|
||||
}
|
||||
assert!(!restart_budget_exceeded(
|
||||
&mut restarts,
|
||||
base + RESTART_WINDOW + Duration::from_secs(1)
|
||||
));
|
||||
}
|
||||
}
|
||||
+1
-121
@@ -1,8 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::manifest::{
|
||||
ANY_HOST, DEV_PROXY_GATEWAY_PORTS_ENV, DEV_PROXY_PORT, GATEWAY_PORT, LOCAL_APP_URL,
|
||||
PROXY_ROUTES, ProxyRoute,
|
||||
DEV_PROXY_GATEWAY_PORTS_ENV, GATEWAY_PORT, LOCAL_APP_URL, PROXY_ROUTES, ProxyRoute,
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use axum::{
|
||||
@@ -664,122 +663,3 @@ pub fn rewrite_head(
|
||||
lines.push(String::new());
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn default_bind() -> (&'static str, u16) {
|
||||
(ANY_HOST, DEV_PROXY_PORT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes_and_rewrites_prefixes() {
|
||||
let api = route_for_path("/api/users?x=1");
|
||||
assert_eq!(api.prefix, "/api");
|
||||
assert_eq!(rewrite_path("/api/users?x=1", api), "/users?x=1");
|
||||
let asset = route_for_path("/assets/app.js");
|
||||
assert_eq!(rewrite_path("/assets/app.js", asset), "/assets/app.js");
|
||||
let lazy = route_for_path("/lazy-compilation-using-foo");
|
||||
assert_eq!(lazy.prefix, "/lazy-compilation-using-");
|
||||
let devmail = route_for_path("/devmail/messages");
|
||||
assert_eq!(devmail.prefix, "/devmail");
|
||||
assert_eq!(
|
||||
rewrite_path("/devmail/messages?x=1", devmail),
|
||||
"/devmail/messages?x=1"
|
||||
);
|
||||
let admin = route_for_path("/admin/users");
|
||||
assert_eq!(admin.prefix, "/admin");
|
||||
assert_eq!(rewrite_path("/admin/users", admin), "/users");
|
||||
let absolute_gateway = route_for_path("https://dev.example.test/gateway?v=1&encoding=json");
|
||||
assert_eq!(absolute_gateway.prefix, "/gateway");
|
||||
assert_eq!(
|
||||
rewrite_path(
|
||||
"https://dev.example.test/gateway?v=1&encoding=json",
|
||||
absolute_gateway
|
||||
),
|
||||
"/?v=1&encoding=json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_host_connection_and_forwarded_for() {
|
||||
let request = RequestHead {
|
||||
method: "GET".to_owned(),
|
||||
path: "/api/_health".to_owned(),
|
||||
version: "HTTP/1.1".to_owned(),
|
||||
headers: vec![
|
||||
("Host".to_owned(), "localhost:8088".to_owned()),
|
||||
("Connection".to_owned(), "keep-alive".to_owned()),
|
||||
("X-Forwarded-For".to_owned(), "1.1.1.1".to_owned()),
|
||||
],
|
||||
};
|
||||
let head = String::from_utf8(rewrite_head(
|
||||
&request,
|
||||
route_for_path("/api/_health"),
|
||||
Some("api"),
|
||||
Some(8080),
|
||||
Some("2.2.2.2"),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(head.contains("GET /_health HTTP/1.1\r\n"));
|
||||
assert!(head.contains("Host: api:8080\r\n"));
|
||||
assert!(head.contains("Connection: close\r\n"));
|
||||
assert!(head.contains("X-Forwarded-For: 1.1.1.1, 2.2.2.2\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirects_local_html_navigation_to_public_tunnel_url() {
|
||||
let request = RequestHead {
|
||||
method: "GET".to_owned(),
|
||||
path: "/channels/@me?x=1".to_owned(),
|
||||
version: "HTTP/1.1".to_owned(),
|
||||
headers: vec![
|
||||
("Host".to_owned(), "localhost:8088".to_owned()),
|
||||
(
|
||||
"Accept".to_owned(),
|
||||
"text/html,application/xhtml+xml".to_owned(),
|
||||
),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
redirect_location_for_public_url(&request, "https://dev.example.test"),
|
||||
Some("https://dev.example.test/channels/@me?x=1".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_tunnel_redirect_ignores_assets_and_local_public_url() {
|
||||
let asset = RequestHead {
|
||||
method: "GET".to_owned(),
|
||||
path: "/avatars/4.png".to_owned(),
|
||||
version: "HTTP/1.1".to_owned(),
|
||||
headers: vec![
|
||||
("Host".to_owned(), "localhost:8088".to_owned()),
|
||||
(
|
||||
"Accept".to_owned(),
|
||||
"image/avif,image/webp,image/apng,image/*,*/*;q=0.8".to_owned(),
|
||||
),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
redirect_location_for_public_url(&asset, "https://dev.example.test"),
|
||||
None
|
||||
);
|
||||
|
||||
let local_public_url = RequestHead {
|
||||
method: "GET".to_owned(),
|
||||
path: "/channels/@me".to_owned(),
|
||||
version: "HTTP/1.1".to_owned(),
|
||||
headers: vec![
|
||||
("Host".to_owned(), "localhost:8088".to_owned()),
|
||||
("Accept".to_owned(), "text/html".to_owned()),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
redirect_location_for_public_url(&local_public_url, LOCAL_APP_URL),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -582,13 +582,6 @@ fn format_pids(pids: &[i32]) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn cmdline_has_service_binary(args: &[String], binaries: &BTreeSet<String>) -> bool {
|
||||
args.first()
|
||||
.map(|arg| binaries.iter().any(|binary| arg.ends_with(binary)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_cmdline(pid: i32) -> Vec<String> {
|
||||
fs::read(format!("/proc/{pid}/cmdline"))
|
||||
@@ -599,7 +592,7 @@ fn proc_cmdline(pid: i32) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
let mut fields = after_comm.split_ascii_whitespace();
|
||||
@@ -619,13 +612,13 @@ fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> {
|
||||
Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_proc_stat_starttime(stat: &str) -> Option<u64> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
after_comm.split_ascii_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_stat_state_is_dead(state: char) -> bool {
|
||||
state == 'Z' || state == 'X' || state == 'x'
|
||||
}
|
||||
@@ -673,173 +666,3 @@ fn signal_process_groups(leaders: &[RustServiceLeader], signal: i32) -> Vec<i32>
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_public_env(
|
||||
overrides: &[(&str, Option<&str>)],
|
||||
assertions: impl FnOnce(Vec<(String, Option<String>)>),
|
||||
) {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let keys = [
|
||||
"FLUXER_MEDIA_PROXY_ENDPOINT",
|
||||
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
|
||||
"FLUXER_MEDIA_ENDPOINT",
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
"FLUXER_PUBLIC_URL",
|
||||
];
|
||||
let saved = keys
|
||||
.iter()
|
||||
.map(|key| (*key, env::var(key).ok()))
|
||||
.collect::<Vec<_>>();
|
||||
for key in keys {
|
||||
unsafe {
|
||||
env::remove_var(key);
|
||||
}
|
||||
}
|
||||
for (key, value) in overrides {
|
||||
if let Some(value) = value {
|
||||
unsafe {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let spec = rust_services()
|
||||
.into_iter()
|
||||
.find(|spec| spec.name == "unfurl")
|
||||
.unwrap();
|
||||
let env = service_env(&spec, "router", spec.port_base);
|
||||
|
||||
for (key, value) in saved {
|
||||
match value {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(key, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(key);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assertions(env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_services() {
|
||||
let err = select_services(&["bogus".to_owned()])
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("Unknown Rust service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_unfurl_env_with_media_endpoints() {
|
||||
with_public_env(&[], |env| {
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| key == "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT"
|
||||
&& value.as_deref() == Some("http://localhost:8088/media"))
|
||||
);
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| key == "FLUXER_STATIC_CDN_ENDPOINT"
|
||||
&& value.as_deref() == Some("http://localhost:8088"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfurl_env_uses_public_url_overrides() {
|
||||
with_public_env(
|
||||
&[
|
||||
(
|
||||
"FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT",
|
||||
Some("https://dev.example.com/media"),
|
||||
),
|
||||
(
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
Some("https://dev.example.com"),
|
||||
),
|
||||
],
|
||||
|env| {
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| key == "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT"
|
||||
&& value.as_deref() == Some("https://dev.example.com/media"))
|
||||
);
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| key == "FLUXER_STATIC_CDN_ENDPOINT"
|
||||
&& value.as_deref() == Some("https://dev.example.com"))
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmdline_binary_match_uses_selected_service_binary_suffix() {
|
||||
let binaries = BTreeSet::from(["target/debug/fluxer_messages".to_owned()]);
|
||||
assert!(cmdline_has_service_binary(
|
||||
&["/workspaces/fluxer/target/debug/fluxer_messages".to_owned()],
|
||||
&binaries
|
||||
));
|
||||
assert!(!cmdline_has_service_binary(
|
||||
&["/workspaces/fluxer/target/debug/fluxer_users".to_owned()],
|
||||
&binaries
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_handles_parentheses_and_spaces_in_comm() {
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("99 (spaced comm) T 1 99 99 0 -1"),
|
||||
Some(('T', 99))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("99 (weird) comm (name) Z 1 42 42 0 -1"),
|
||||
Some(('Z', 42))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_rejects_malformed_lines() {
|
||||
assert_eq!(parse_proc_stat_state_and_pgid(""), None);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("1234 (svc)"), None);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("1234 (svc) S"), None);
|
||||
assert_eq!(parse_proc_stat_state_and_pgid("1234 (svc) S 1"), None);
|
||||
assert_eq!(
|
||||
parse_proc_stat_state_and_pgid("1234 (svc) S 1 not-a-pgid"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_parsing_extracts_starttime() {
|
||||
let stat = "1234 (svc) S 1 1234 1234 0 -1 4194560 0 0 0 0 5 3 0 0 20 0 30 0 12345678 4096";
|
||||
assert_eq!(parse_proc_stat_starttime(stat), Some(12_345_678));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proc_stat_starttime_parsing_rejects_truncated_lines() {
|
||||
assert_eq!(parse_proc_stat_starttime(""), None);
|
||||
assert_eq!(parse_proc_stat_starttime("1234 (svc)"), None);
|
||||
assert_eq!(
|
||||
parse_proc_stat_starttime("1234 (svc) S 1 1234 1234 0 -1 4194560 0"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zombie_and_reaped_states_count_as_dead() {
|
||||
assert!(proc_stat_state_is_dead('Z'));
|
||||
assert!(proc_stat_state_is_dead('X'));
|
||||
assert!(proc_stat_state_is_dead('x'));
|
||||
assert!(!proc_stat_state_is_dead('S'));
|
||||
}
|
||||
}
|
||||
@@ -118,21 +118,3 @@ pub fn run_knip() -> Result<i32> {
|
||||
task_run(&["pnpm", "exec", "knip"])?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_nats_url_switches_inside_container() {
|
||||
let expected_host = if Path::new("/.dockerenv").exists() {
|
||||
"nats"
|
||||
} else {
|
||||
"127.0.0.1"
|
||||
};
|
||||
assert_eq!(
|
||||
default_test_nats_url(),
|
||||
format!("nats://{expected_host}:4222")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -371,43 +371,3 @@ fn set_private_file_mode(path: &Path) -> Result<()> {
|
||||
fn set_private_file_mode(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derives_https_public_url_env() {
|
||||
let env = public_url_env("https://dev.example.test").unwrap();
|
||||
assert!(env.iter().any(|(key, value)| {
|
||||
key == "FLUXER_GATEWAY_ENDPOINT" && value == "wss://dev.example.test/gateway"
|
||||
}));
|
||||
assert!(env.iter().any(|(key, value)| {
|
||||
key == "FLUXER_LIVEKIT_URL" && value == "wss://dev.example.test/livekit"
|
||||
}));
|
||||
assert!(env.iter().any(|(key, value)| {
|
||||
key == "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT"
|
||||
&& value == "https://dev.example.test/media"
|
||||
}));
|
||||
assert!(env.iter().any(|(key, value)| {
|
||||
key == "FLUXER_S3_PUBLIC_ENDPOINT" && value == "https://dev.example.test"
|
||||
}));
|
||||
assert!(
|
||||
env.iter()
|
||||
.any(|(key, value)| { key == "FLUXER_PUBLIC_PORT" && value == "443" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_public_url_with_path() {
|
||||
assert!(parse_public_url("https://example.com/app").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_public_url_arg_normalized() {
|
||||
assert_eq!(
|
||||
resolve_cloudflare_public_url(Some("https://example.com/")).unwrap(),
|
||||
"https://example.com"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import brotli
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import build_fonts as bf # noqa: E402
|
||||
|
||||
CACHE = bf.TOOL_ROOT / ".cache"
|
||||
SFNT_CACHE = CACHE / "measure"
|
||||
|
||||
CORPUS: dict[str, dict[str, str]] = {
|
||||
"FluxerSansSC": {
|
||||
"one glyph (文)": "文",
|
||||
"display name": "小明",
|
||||
"three display names": "小明 李伟 王芳",
|
||||
"README sentence": "这是简体中文的示例文本",
|
||||
"chat message": "我们今天下午三点开个会吧,主要讨论新版本的发布计划和测试进度。",
|
||||
"chat paragraph": (
|
||||
"我们今天下午三点开个会吧,主要讨论新版本的发布计划和测试进度。"
|
||||
"如果有问题,可以随时在这个频道里留言,我会尽快回复大家。谢谢!"
|
||||
),
|
||||
"UI labels": (
|
||||
"设置 通知 频道 消息 好友 语音 视频 屏幕共享 服务器 成员 权限 角色 邀请 退出 "
|
||||
"静音 耳机 麦克风 摄像头 主题 语言 隐私 安全 账号 个人资料 状态 在线 离线 忙碌 勿扰"
|
||||
),
|
||||
},
|
||||
"FluxerSansTC": {
|
||||
"one glyph (文)": "文",
|
||||
"display name": "小明",
|
||||
"three display names": "小明 李偉 王芳",
|
||||
"README sentence": "這是繁體中文的示例文本",
|
||||
"chat message": "我們今天下午三點開個會吧,主要討論新版本的發佈計畫和測試進度。",
|
||||
"chat paragraph": (
|
||||
"我們今天下午三點開個會吧,主要討論新版本的發佈計畫和測試進度。"
|
||||
"如果有問題,可以隨時在這個頻道裡留言,我會盡快回覆大家。謝謝!"
|
||||
),
|
||||
"UI labels": (
|
||||
"設定 通知 頻道 訊息 好友 語音 視訊 螢幕分享 伺服器 成員 權限 角色 邀請 離開 "
|
||||
"靜音 耳機 麥克風 攝影機 主題 語言 隱私 安全 帳號 個人資料 狀態 線上 離線 忙碌 勿擾"
|
||||
),
|
||||
},
|
||||
"FluxerSansJP": {
|
||||
"one glyph (語)": "語",
|
||||
"display name": "田中太郎",
|
||||
"three display names": "田中太郎 佐藤花子 鈴木一郎",
|
||||
"README sentence": "これは日本語のサンプルテキストです",
|
||||
"chat message": "今日の午後三時から会議をしましょう。新しいバージョンの計画を話し合います。",
|
||||
"chat paragraph": (
|
||||
"今日の午後三時から会議をしましょう。主に新しいバージョンのリリース計画と"
|
||||
"テストの進捗について話し合います。質問があれば、いつでもこのチャンネルに"
|
||||
"書き込んでください。なるべく早く返信します。よろしくお願いします。"
|
||||
),
|
||||
"UI labels": (
|
||||
"設定 通知 チャンネル メッセージ フレンド ボイス ビデオ 画面共有 サーバー メンバー "
|
||||
"権限 ロール 招待 退出 ミュート ヘッドセット マイク カメラ テーマ 言語 "
|
||||
"プライバシー セキュリティ アカウント プロフィール ステータス オンライン オフライン 取り込み中 応答不可"
|
||||
),
|
||||
},
|
||||
"FluxerSansKR": {
|
||||
"one syllable (한)": "한",
|
||||
"display name": "김민준",
|
||||
"three display names": "김민준 이서연 박지훈",
|
||||
"README sentence": "이것은 한국어 샘플 텍스트입니다",
|
||||
"chat message": "오늘 오후 세 시에 회의를 합시다. 새 버전의 출시 계획을 이야기합니다.",
|
||||
"chat paragraph": (
|
||||
"오늘 오후 세 시에 회의를 합시다. 주로 새 버전의 출시 계획과 테스트 진행 상황에 "
|
||||
"대해 이야기할 예정입니다. 질문이 있으면 언제든지 이 채널에 남겨 주세요. "
|
||||
"최대한 빨리 답장하겠습니다. 감사합니다."
|
||||
),
|
||||
"UI labels": (
|
||||
"설정 알림 채널 메시지 친구 음성 영상 화면 공유 서버 멤버 권한 역할 초대 나가기 "
|
||||
"음소거 헤드셋 마이크 카메라 테마 언어 개인정보 보안 계정 프로필 상태 온라인 오프라인 방해 금지"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
CJK_FAMILIES = tuple(CORPUS)
|
||||
|
||||
|
||||
def renamed_reference(family: bf.Family, roots: dict[str, Path]) -> bytes:
|
||||
"""The renamed Regular SFNT, cached: the WOFF2 round-trip costs ~60 s a face."""
|
||||
SFNT_CACHE.mkdir(parents=True, exist_ok=True)
|
||||
cached = SFNT_CACHE / f"{family.prefix}-Regular.sfnt"
|
||||
if cached.is_file():
|
||||
return cached.read_bytes()
|
||||
src = roots[family.source] / family.src_subdir / f"{family.prefix}-Regular.woff2"
|
||||
print(f" renaming {family.prefix}-Regular (~60 s, cached afterwards)", flush=True)
|
||||
sfnt = bf.renamed_sfnt(src, family.gasp)
|
||||
cached.write_bytes(sfnt)
|
||||
return sfnt
|
||||
|
||||
|
||||
def glyph_id_order(sfnt: bytes) -> list[int]:
|
||||
"""This face's codepoints in glyph ID order (its inherited CID tiering)."""
|
||||
import io
|
||||
|
||||
font = bf.TTFont(io.BytesIO(sfnt), lazy=True)
|
||||
try:
|
||||
cmap = font.getBestCmap()
|
||||
order = font.getGlyphOrder()
|
||||
rank = {name: index for index, name in enumerate(order)}
|
||||
return [cp for _, cp in sorted((rank[name], cp) for cp, name in cmap.items())]
|
||||
finally:
|
||||
font.close()
|
||||
|
||||
|
||||
def plan_freq000(
|
||||
sfnt: bytes, codepoints: set[int], budget: int, head: int
|
||||
) -> list[tuple[int, ...]]:
|
||||
"""Chunk 000 = the `head` lowest-glyph-ID codepoints; the rest in block order."""
|
||||
ordered = [cp for cp in glyph_id_order(sfnt) if cp in codepoints]
|
||||
common = ordered[:head]
|
||||
if not common:
|
||||
raise SystemExit("empty head chunk")
|
||||
rest = codepoints - set(common)
|
||||
tail = bf.plan_chunks(rest, budget) if rest else []
|
||||
return [tuple(sorted(common)), *tail]
|
||||
|
||||
|
||||
def stylesheet_bytes(family: bf.Family, plan: list[tuple[int, ...]]) -> dict[str, int]:
|
||||
"""Render the real stylesheet for this plan and compress it as the app would.
|
||||
|
||||
Chunk-major, every weight, `render_face_rule` verbatim — so the raw/gzip/
|
||||
brotli numbers are the ones a browser would actually download, not an
|
||||
estimate of the `unicode-range` text alone.
|
||||
"""
|
||||
rules = [bf.SPDX_CSS_HEADER, bf.GENERATED_CSS_HEADER, ""]
|
||||
for index, chunk in enumerate(plan):
|
||||
rendered_range = bf.format_unicode_range(list(chunk))
|
||||
for weight in family.weights:
|
||||
rules.append(
|
||||
bf.render_face_rule(
|
||||
family,
|
||||
{
|
||||
"weight": weight,
|
||||
"path": f"{family.out_dir}/{family.prefix}-{weight}.{index:03d}.woff2",
|
||||
"unicodeRange": rendered_range,
|
||||
},
|
||||
)
|
||||
)
|
||||
text = ("\n".join([*rules, ""])).encode()
|
||||
return {
|
||||
"raw": len(text),
|
||||
"gzip": len(gzip.compress(text, 9, mtime=0)),
|
||||
"brotli": len(brotli.compress(text, quality=11)),
|
||||
}
|
||||
|
||||
|
||||
def build_plan(family: bf.Family, sfnt: bytes, plan: list[tuple[int, ...]]) -> list[dict]:
|
||||
"""Really subset and really encode every chunk, so the bytes are measured."""
|
||||
chunks = []
|
||||
for index, requested in enumerate(plan):
|
||||
produced = bf.subset_face(sfnt, tuple(requested))
|
||||
chunks.append({"index": index, "codepoints": set(requested), "bytes": len(produced)})
|
||||
print(f" chunk {index:03d}: {len(requested)} cp -> {len(produced)} B", flush=True)
|
||||
return chunks
|
||||
|
||||
|
||||
def fetched(chunks: list[dict], text: str) -> tuple[int, int]:
|
||||
"""Bytes and chunk count a browser fetches to render `text` from this family.
|
||||
|
||||
A chunk is fetched when its `unicode-range` intersects the text — which is
|
||||
exactly the browser's rule, and the reason spaces matter: U+0020 lives in the
|
||||
punctuation chunk, so any sentence containing one pays for it.
|
||||
"""
|
||||
wanted = set(ord(c) for c in text)
|
||||
total = 0
|
||||
count = 0
|
||||
for chunk in chunks:
|
||||
if chunk["codepoints"] & wanted:
|
||||
total += chunk["bytes"]
|
||||
count += 1
|
||||
return total, count
|
||||
|
||||
|
||||
def measure_family(family: bf.Family, roots: dict[str, Path], strategies: list[str]) -> dict:
|
||||
sfnt = renamed_reference(family, roots)
|
||||
union: set[int] = set()
|
||||
for name in family.source_files():
|
||||
union |= bf.face_cmap(roots[family.source] / family.src_subdir / name)
|
||||
budget, note = bf.calibrate_chunk_size(sfnt, sorted(union), bf.CHUNK_TARGET_BYTES)
|
||||
print(f"\n{family.out_dir}: {len(union)} codepoints, {note}")
|
||||
|
||||
result: dict[str, dict] = {}
|
||||
for strategy in strategies:
|
||||
if strategy == "block":
|
||||
plan = bf.plan_chunks(union, budget)
|
||||
elif strategy.startswith("freq"):
|
||||
plan = plan_freq000(sfnt, union, budget, int(strategy[4:]))
|
||||
else:
|
||||
raise SystemExit(f"unknown strategy {strategy!r}")
|
||||
print(f" {strategy}: {len(plan)} chunks", flush=True)
|
||||
chunks = build_plan(family, sfnt, plan)
|
||||
result[strategy] = {
|
||||
"chunks": len(plan),
|
||||
"css": stylesheet_bytes(family, plan),
|
||||
"full": sum(c["bytes"] for c in chunks),
|
||||
"scenarios": {
|
||||
label: fetched(chunks, text) for label, text in CORPUS[family.out_dir].items()
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def report(family_id: str, data: dict[str, dict]) -> None:
|
||||
strategies = list(data)
|
||||
base = strategies[0]
|
||||
print(f"\n=== {family_id} ===")
|
||||
header = f"{'scenario':<24}" + "".join(f"{s:>22}" for s in strategies)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for label in CORPUS[family_id]:
|
||||
row = f"{label:<24}"
|
||||
base_bytes = data[base]["scenarios"][label][0]
|
||||
for strategy in strategies:
|
||||
got, count = data[strategy]["scenarios"][label]
|
||||
ratio = f" ({base_bytes / got:.2f}x)" if strategy != base and got else ""
|
||||
row += f"{f'{got:,} B/{count}c{ratio}':>22}"
|
||||
print(row)
|
||||
for key, label in (("full", "full coverage"), ("chunks", "chunk count")):
|
||||
row = f"{label:<24}"
|
||||
for strategy in strategies:
|
||||
value = data[strategy][key]
|
||||
row += f"{f'{value:,}':>22}"
|
||||
print(row)
|
||||
for field in ("raw", "gzip", "brotli"):
|
||||
row = f"{f'stylesheet {field}':<24}"
|
||||
for strategy in strategies:
|
||||
value = data[strategy]["css"][field]
|
||||
row += f"{f'{value:,} B':>22}"
|
||||
print(row)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Measure CJK chunking strategies.")
|
||||
parser.add_argument("--family", action="append", choices=CJK_FAMILIES)
|
||||
parser.add_argument("--strategies", default="block,freq1500,freq3000,freq6000")
|
||||
parser.add_argument("--json", type=Path, help="also write the raw numbers here")
|
||||
args = parser.parse_args()
|
||||
|
||||
families = [bf.FAMILY_BY_DIR[f] for f in (args.family or CJK_FAMILIES)]
|
||||
strategies = [s.strip() for s in args.strategies.split(",") if s.strip()]
|
||||
roots = bf.resolve_sources(CACHE, tuple(families))
|
||||
|
||||
everything: dict[str, dict] = {}
|
||||
for family in families:
|
||||
everything[family.out_dir] = measure_family(family, roots, strategies)
|
||||
report(family.out_dir, everything[family.out_dir])
|
||||
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
fam: {
|
||||
s: {
|
||||
**{k: v for k, v in d.items() if k != "scenarios"},
|
||||
"scenarios": {k: list(v) for k, v in d["scenarios"].items()},
|
||||
}
|
||||
for s, d in strategies_data.items()
|
||||
}
|
||||
for fam, strategies_data in everything.items()
|
||||
},
|
||||
indent="\t",
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -11,6 +11,3 @@ regex = "1.13"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
@@ -41,8 +41,7 @@ pub fn auto_main(args: &[String]) -> Result<u8> {
|
||||
let i18n_llm_model = env_value("I18N_LLM_MODEL", &env_overrides, "")
|
||||
.if_empty(|| env_value("OPENROUTER_MODEL", &env_overrides, DEFAULT_OPENROUTER_MODEL));
|
||||
let bypass_run_gate = args.iter().any(|arg| {
|
||||
matches!(arg.as_str(), "--self-test" | "--help" | "-h" | "--dry-run")
|
||||
|| arg.starts_with("--dry-run=")
|
||||
matches!(arg.as_str(), "--help" | "-h" | "--dry-run") || arg.starts_with("--dry-run=")
|
||||
});
|
||||
let explicitly_disabled = matches!(
|
||||
fluxer_auto_i18n.to_lowercase().as_str(),
|
||||
@@ -191,22 +190,3 @@ impl EmptyFallback for String {
|
||||
if self.is_empty() { fallback() } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_export_lines() {
|
||||
assert_eq!(
|
||||
parse_export_line(" export OPENROUTER_MODEL=\"translator\" "),
|
||||
Some(("OPENROUTER_MODEL".to_string(), "translator".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_export_line("export FLUXER_AUTO_I18N=1"),
|
||||
Some(("FLUXER_AUTO_I18N".to_string(), "1".to_string()))
|
||||
);
|
||||
assert_eq!(parse_export_line("OPENROUTER_MODEL=translator"), None);
|
||||
assert_eq!(parse_export_line("export 1BAD=value"), None);
|
||||
}
|
||||
}
|
||||
@@ -180,112 +180,3 @@ pub fn reset_static_json_translations(
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
render(&config.kind, &cleared)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn simple() -> StaticJsonCatalogConfig {
|
||||
StaticJsonCatalogConfig {
|
||||
kind: StaticTsCatalogKind::SimpleMessages,
|
||||
source_path: PathBuf::from("weblate/messages.json"),
|
||||
}
|
||||
}
|
||||
|
||||
fn email() -> StaticJsonCatalogConfig {
|
||||
StaticJsonCatalogConfig {
|
||||
kind: StaticTsCatalogKind::EmailTemplates,
|
||||
source_path: PathBuf::from("weblate/messages.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_simple_entries_pairing_source_with_locale() {
|
||||
let entries = read_static_json_entries(
|
||||
&simple(),
|
||||
"{\"a.b\":\"Hello\",\"c.d\":\"Bye\"}",
|
||||
"{\"a.b\":\"Hallo\"}",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].msgctxt.as_deref(), Some("a.b"));
|
||||
assert_eq!(entries[0].msgid, "Hello");
|
||||
assert_eq!(entries[0].msgstr, "Hallo");
|
||||
assert_eq!(entries[1].msgstr, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_blanks_every_value() {
|
||||
let entries =
|
||||
read_static_json_entries(&simple(), "{\"a\":\"Hello\"}", "{\"a\":\"Hallo\"}", true)
|
||||
.unwrap();
|
||||
assert_eq!(entries[0].msgstr, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_tab_indented_sorted_json_with_a_trailing_newline() {
|
||||
let rendered = rebuild_static_json_allow_replacing(
|
||||
&simple(),
|
||||
"{\"b\":\"Two\",\"a\":\"One\"}",
|
||||
"{\"a\":\"One\",\"b\":\"Two\"}",
|
||||
&[Translation {
|
||||
msgctxt: Some("a".to_string()),
|
||||
msgid: "One".to_string(),
|
||||
msgstr: "Eins".to_string(),
|
||||
reviewed_unchanged: false,
|
||||
notes: String::new(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rendered, "{\n\t\"a\": \"Eins\",\n\t\"b\": \"Two\"\n}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_email_template_fields_nested() {
|
||||
let entries = read_static_json_entries(
|
||||
&email(),
|
||||
"{\"welcome\":{\"subject\":\"Hi\",\"body\":\"Text\"}}",
|
||||
"{\"welcome\":{\"subject\":\"Hallo\",\"body\":\"\"}}",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(entries[0].msgctxt.as_deref(), Some("welcome.body"));
|
||||
assert_eq!(entries[1].msgctxt.as_deref(), Some("welcome.subject"));
|
||||
let rendered = rebuild_static_json_allow_replacing(
|
||||
&email(),
|
||||
"{\"welcome\":{\"subject\":\"Hi\",\"body\":\"Text\"}}",
|
||||
"{\"welcome\":{\"subject\":\"Hallo\",\"body\":\"\"}}",
|
||||
&[Translation {
|
||||
msgctxt: Some("welcome.body".to_string()),
|
||||
msgid: "Text".to_string(),
|
||||
msgstr: "Inhalt".to_string(),
|
||||
reviewed_unchanged: false,
|
||||
notes: String::new(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"{\n\t\"welcome\": {\n\t\t\"body\": \"Inhalt\",\n\t\t\"subject\": \"Hallo\"\n\t}\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_translations_the_source_no_longer_defines() {
|
||||
let rendered = rebuild_static_json_allow_replacing(
|
||||
&simple(),
|
||||
"{\"a\":\"One\"}",
|
||||
"{\"a\":\"Eins\"}",
|
||||
&[Translation {
|
||||
msgctxt: Some("gone".to_string()),
|
||||
msgid: "Gone".to_string(),
|
||||
msgstr: "Weg".to_string(),
|
||||
reviewed_unchanged: false,
|
||||
notes: String::new(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rendered, "{\n\t\"a\": \"Eins\"\n}\n");
|
||||
}
|
||||
}
|
||||
@@ -86,29 +86,3 @@ fn trim_special_response_tokens(content: &str) -> &str {
|
||||
trimmed = next.trim();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cleans_common_llm_response_wrappers() {
|
||||
assert_eq!(
|
||||
clean_translation_response("Translation: Bonjour"),
|
||||
"Bonjour"
|
||||
);
|
||||
assert_eq!(clean_translation_response("\"Bonjour\""), "Bonjour");
|
||||
assert_eq!(
|
||||
clean_translation_response("{\"localized\":\"Bonjour\"}"),
|
||||
"Bonjour"
|
||||
);
|
||||
assert_eq!(
|
||||
clean_translation_response("```text\nBonjour\n```"),
|
||||
"Bonjour"
|
||||
);
|
||||
assert_eq!(
|
||||
clean_translation_response("```json\n{\"0\":\"bonjour\"}\n```<|im_end|>"),
|
||||
"{\"0\":\"bonjour\"}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -232,26 +232,3 @@ fn truncate_error_body(body: &str) -> String {
|
||||
}
|
||||
truncated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_comma_separated_fallback_models() {
|
||||
assert_eq!(
|
||||
parse_openrouter_fallback_models("a, b,,c "),
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_text_from_openai_style_message() {
|
||||
let data = json!({
|
||||
"choices": [
|
||||
{"message": {"content": [{"type": "text", "text": "Hej"}]}}
|
||||
]
|
||||
});
|
||||
assert_eq!(extract_chat_message_content(&data), Some("Hej".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -134,30 +134,3 @@ fn floor_char_boundary(value: &str, limit: usize) -> usize {
|
||||
}
|
||||
boundary
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn en_gb_prompt_keeps_strict_preservation_guidance() {
|
||||
let prompt = build_system_prompt("en-GB");
|
||||
assert!(prompt.contains("Do not translate \"US\" to \"UK\""));
|
||||
assert!(prompt.contains("Keep our words."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_guidance_truncates_on_clean_boundary() {
|
||||
let content = "First paragraph.\n\nSecond paragraph that should be truncated.\n\nThird.";
|
||||
let compact = compact_guidance_excerpt(content, 35);
|
||||
assert!(compact.ends_with("[excerpt truncated]"));
|
||||
assert!(compact.starts_with("First paragraph."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_guidance_truncates_utf8_safely() {
|
||||
let content = "Brand term: caf\u{00e9} caf\u{00e9} caf\u{00e9} caf\u{00e9}.";
|
||||
let compact = compact_guidance_excerpt(content, 18);
|
||||
assert!(compact.ends_with("[excerpt truncated]"));
|
||||
}
|
||||
}
|
||||
@@ -259,91 +259,3 @@ impl Drop for SidecarLock {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn stores_entries_sorted_and_deduplicated() {
|
||||
let temp = tempdir().unwrap();
|
||||
let path = temp.path().join("reviewed.json");
|
||||
let mut store = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
store.mark("de", Some("button"), "Save");
|
||||
store.mark("de", None, "Audio");
|
||||
store.mark("de", None, "Audio");
|
||||
store.save_if_dirty().unwrap();
|
||||
|
||||
let saved = fs::read_to_string(&path).unwrap();
|
||||
assert!(saved.contains("\"version\": 1"));
|
||||
|
||||
let loaded = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
assert!(loaded.contains("de", None, "Audio"));
|
||||
assert!(loaded.contains("de", Some("button"), "Save"));
|
||||
assert!(!loaded.contains("de", Some("button"), "Audio"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_locale_drops_entries_and_saves_only_that_locale() {
|
||||
let temp = tempdir().unwrap();
|
||||
let path = temp.path().join("reviewed.json");
|
||||
let mut store = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
store.mark("de", None, "Audio");
|
||||
store.mark("de", None, "Gone");
|
||||
store.mark("fr", None, "Gone");
|
||||
store.save_if_dirty().unwrap();
|
||||
|
||||
let mut store = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
assert_eq!(store.retain_locale("de", |entry| entry.msgid != "Gone"), 1);
|
||||
assert_eq!(store.retain_locale("de", |_| true), 0);
|
||||
assert_eq!(store.retain_locale("nl", |_| false), 0);
|
||||
store.save_if_dirty().unwrap();
|
||||
|
||||
let loaded = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
assert!(loaded.contains("de", None, "Audio"));
|
||||
assert!(!loaded.contains("de", None, "Gone"));
|
||||
assert!(loaded.contains("fr", None, "Gone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_locale_removes_emptied_locale() {
|
||||
let temp = tempdir().unwrap();
|
||||
let path = temp.path().join("reviewed.json");
|
||||
let mut store = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
store.mark("de", Some("button"), "Save");
|
||||
store.mark("fr", None, "Avatar");
|
||||
store.save_if_dirty().unwrap();
|
||||
|
||||
let mut store = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
assert_eq!(store.retain_locale("de", |_| false), 1);
|
||||
store.save_if_dirty().unwrap();
|
||||
|
||||
let loaded = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
assert_eq!(
|
||||
loaded
|
||||
.locales()
|
||||
.map(|(locale, _)| locale)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["fr"]
|
||||
);
|
||||
assert!(!fs::read_to_string(&path).unwrap().contains("\"de\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_save_preserves_other_locale_changes() {
|
||||
let temp = tempdir().unwrap();
|
||||
let path = temp.path().join("reviewed.json");
|
||||
let mut first = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
let mut second = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
|
||||
first.mark("de", None, "Audio");
|
||||
second.mark("fr", None, "Avatar");
|
||||
first.save_if_dirty().unwrap();
|
||||
second.save_if_dirty().unwrap();
|
||||
|
||||
let loaded = ReviewedUnchangedStore::load(&path).unwrap();
|
||||
assert!(loaded.contains("de", None, "Audio"));
|
||||
assert!(loaded.contains("fr", None, "Avatar"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user