use std::path::{Path, PathBuf};
fn crate_source(path: &str) -> String {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(crate_dir.join(path)).expect("failed to read cranpose source file")
}
fn voiceover_value_source() -> String {
assert!(
crate_source("src/ios_accessibility.rs")
.contains("accessibility::voiceover_value(element)"),
"VoiceOver uses the shared value projection"
);
crate_source("src/accessibility.rs")
}
fn strip_xml_comments(source: &str) -> String {
let mut remaining = source;
let mut out = String::with_capacity(source.len());
while let Some(open) = remaining.find("<!--") {
out.push_str(&remaining[..open]);
remaining = match remaining[open..].find("-->") {
Some(close) => &remaining[open + close + "-->".len()..],
None => "",
};
}
out.push_str(remaining);
out
}
const CRANPOSE_GRADLE_PLUGIN: &str = "crates/cranpose/android/cranpose-gradle-plugin/src/main/kotlin/dev/cranpose/gradle/CranposeAndroidPlugin.kt";
const CRANPOSE_CAPABILITIES: &str = "crates/cranpose-capabilities/src/lib.rs";
fn cranpose_manifest(service: &str) -> String {
format!("crates/cranpose/android/manifests/{service}.xml")
}
fn pascal(service: &str) -> String {
service
.split('-')
.map(|word| {
let mut letters = word.chars();
match letters.next() {
Some(first) => first.to_uppercase().collect::<String>() + letters.as_str(),
None => String::new(),
}
})
.collect()
}
const ANDROID_APPLICATION_BUILD_FILES: [&str; 2] = [
"apps/android-demo/android/app/build.gradle.kts",
"apps/isolated-demo/android/app/build.gradle.kts",
];
const ANDROID_APPLICATION_MANIFESTS: [&str; 2] = [
"apps/android-demo/android/app/src/main/AndroidManifest.xml",
"apps/isolated-demo/android/app/src/main/AndroidManifest.xml",
];
fn workspace_source(path: &str) -> String {
std::fs::read_to_string(workspace_path(path)).expect("failed to read workspace source file")
}
fn workspace_path(path: &str) -> PathBuf {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
workspace_dir.join(path)
}
#[test]
fn ci_architecture_budget_runs_required_gates() {
let workflow = workspace_source(".github/workflows/rust.yml");
let heavy_workflow = workspace_source(".github/workflows/heavy-selfhosted.yml");
let release_workflow = workspace_source(".github/workflows/release.yml");
let pages_workflow = workspace_source(".github/workflows/deploy-pages.yml");
let nightly_workflow = workspace_source(".github/workflows/nightly.yml");
let justfile = workspace_source("justfile");
for (job, name) in [
("architecture-budget:", "name: architecture budgets (linux)"),
("android-apk:", "name: Android release APK (macOS)"),
] {
assert!(
heavy_workflow.contains(job) && heavy_workflow.contains(name),
"{job} should be its own job on the board a merge triggers"
);
assert!(
!nightly_workflow.contains(job),
"{job} moved off the nightly board; two boards running it is the duplication \
the nightly board was just trimmed of"
);
}
let budget_block = workflow_job_block(&heavy_workflow, "architecture-budget");
let apk_block = workflow_job_block(&heavy_workflow, "android-apk");
for (label, block) in [
("architecture-budget", &budget_block),
("android-apk", &apk_block),
] {
assert!(
block.contains("if: github.event_name != 'pull_request'"),
"{label} must not run on a pull request: it is exactly the string \
scripts/ci/pr_budget_test.sh reads as `no pull request waits for this`"
);
}
for recipe in [
"run: just fmt-check",
"run: just typos",
"run: just versions",
"run: just test",
"run: just clippy",
"run: just doc",
"run: just clippy-wasm",
"run: just web",
] {
assert!(
workflow.contains(recipe),
"Rust CI should invoke `{recipe}` rather than spelling the gate inline"
);
}
assert!(
nightly_workflow.contains("run: just robot-linux serial"),
"the nightly board should invoke the recipe rather than spelling it inline"
);
assert!(
!nightly_workflow.contains("run: just robot-linux\n")
&& !nightly_workflow.contains("run: just robot-linux all"),
"nightly runs the measuring half only; the parallel class is already covered per push"
);
assert!(
!workflow.contains("run: just budgets"),
"architecture budgets are not part of the board a pull request waits for"
);
for recipe in ["run: just budgets", "run: just android"] {
assert!(
heavy_workflow.contains(recipe),
"the merge board should invoke `{recipe}` rather than spelling it inline"
);
}
assert!(
!heavy_workflow.contains("run: just robot-linux\n"),
"the load-sensitive robot examples belong to the nightly board: they run one at a \
time on a machine the exclusive host lock has emptied, which no merge should wait for"
);
let provision = workspace_source("scripts/ci/provision_toolchain.sh");
assert!(
workflow.contains("run: scripts/ci/provision_toolchain.sh"),
"every CI job that runs a recipe must provision the toolchain first"
);
assert!(
provision.contains("command -v just >/dev/null || cargo install just --locked"),
"provisioning must install `just`, since every gate is a recipe"
);
assert!(
justfile.contains("cargo build --workspace --no-default-features"),
"the budgets recipe should prove the workspace builds with default features disabled"
);
assert!(
justfile.contains("cargo check --workspace --all-features"),
"the budgets recipe should prove the all-features graph still type-checks"
);
assert!(
justfile.contains("cargo xtask dependency-budget --explain"),
"the budgets recipe should print duplicate dependency owner details"
);
assert!(
!justfile.contains("dependency-budget --strict"),
"the dependency budget has no strict mode; the single gate rejects unrecorded and stale duplicate families"
);
assert!(
justfile.contains("cargo xtask binary-size")
&& justfile.contains("--package isolated-demo")
&& justfile.contains("--bin isolated-demo")
&& justfile.contains("--profile release-small")
&& justfile.contains("--max-bytes 16777216"),
"the budgets recipe should enforce the accessibility-enabled release-small binary size ceiling"
);
assert!(
justfile.contains("apps/desktop-demo/build-web.sh --release"),
"the web recipe must pass --release explicitly so the wasm-release profile and \
WASM size budget cannot depend on ambient CI defaults"
);
assert!(
workflow.contains("wasm-build:")
&& workflow.contains("wasm-opt --version")
&& workflow.contains("cargo install wasm-pack --version 0.13.1 --locked"),
"Rust CI should keep the web release build job provisioned with a pinned wasm-pack"
);
assert!(
pages_workflow.contains("Deploy to GitHub Pages")
&& pages_workflow.contains("Install binaryen (wasm-opt) for size optimization")
&& pages_workflow.contains("cargo install wasm-pack --version 0.13.1")
&& pages_workflow.contains("./build-web.sh --release")
&& pages_workflow.contains("actions/upload-pages-artifact@"),
"GitHub Pages deployment must publish the same budgeted optimized WASM produced by build-web.sh --release"
);
assert!(
!workflow.contains("android-actions/setup-android")
&& !release_workflow.contains("android-actions/setup-android"),
"Android CI should install only required SDK packages instead of running the broad setup-android action"
);
assert!(
heavy_workflow.contains("ANDROID_NDK_HOME=$sdk_root/ndk/27.0.12077973")
&& heavy_workflow.contains("sdkmanager \"ndk;27.0.12077973\"")
&& heavy_workflow.contains("test -f \"$ANDROID_NDK_HOME/source.properties\"")
&& release_workflow.contains("bash scripts/ci/install_android_ndk.sh 27.0.12077973"),
"self-hosted Android CI and hosted release builds should provision and validate the pinned NDK"
);
}
fn workflow_concurrency(name: &str) -> Option<(String, String)> {
let workflow = workspace_source(&format!(".github/workflows/{name}"));
let mut lines = workflow.lines().skip_while(|line| *line != "concurrency:");
lines.next()?;
let mut group = String::new();
let mut cancel = String::new();
for line in lines {
if !line.starts_with(' ') {
break;
}
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("group:") {
group = value.trim().to_string();
} else if let Some(value) = trimmed.strip_prefix("cancel-in-progress:") {
cancel = value.trim().to_string();
}
}
Some((group, cancel))
}
#[test]
fn every_workflow_says_what_happens_when_it_overlaps_itself() {
for name in ["rust.yml", "heavy-selfhosted.yml", "build-one.yml"] {
let (group, cancel) = workflow_concurrency(name)
.unwrap_or_else(|| panic!("{name} must declare a concurrency group"));
assert!(
group.contains("github.ref"),
"{name} groups per ref so a new push supersedes the old one, found {group:?}"
);
assert_eq!(
cancel, "true",
"{name} answers a merge; an older commit's answer is dead weight on five runners"
);
}
for name in [
"nightly.yml",
"publish.yml",
"release.yml",
"deploy-pages.yml",
"cancel-superseded.yml",
] {
let (group, cancel) = workflow_concurrency(name)
.unwrap_or_else(|| panic!("{name} must declare a concurrency group"));
assert!(
!group.is_empty(),
"{name} must name its concurrency group so two of them cannot run at once"
);
assert_eq!(
cancel, "false",
"{name} publishes or deploys; killing one halfway leaves the result half written"
);
}
}
fn workflow_job_block(workflow: &str, job: &str) -> String {
let header = format!(" {job}:");
let mut block = Vec::new();
let mut inside = false;
for line in workflow.lines() {
if line == header {
inside = true;
continue;
}
if inside {
let starts_a_job = line.starts_with(" ")
&& !line.starts_with(" ")
&& line.trim_end().ends_with(':');
if starts_a_job {
break;
}
block.push(line);
}
}
assert!(
inside,
"{job} is no longer a job in this workflow; the assertions below would read nothing"
);
block.join("\n")
}
#[test]
fn the_publish_job_checks_out_the_tree_the_tag_names() {
let workflow = workspace_source(".github/workflows/publish.yml");
let publish = workflow_job_block(&workflow, "publish");
assert!(
publish.contains(
"ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
),
"the publish job must check out the tag, not the default branch: every release races every merge"
);
assert!(
!publish.contains("ref: ${{ github.event.repository.default_branch }}"),
"the publish job must not follow the default branch"
);
let isolated = workflow_job_block(&workflow, "bump_isolated_demo");
assert!(
isolated.contains("ref: ${{ github.event.repository.default_branch }}"),
"the job that commits the isolated demo pointer still belongs on the default branch"
);
}
#[test]
fn the_release_board_can_be_pointed_at_a_tag() {
let workflow = workspace_source(".github/workflows/release.yml");
assert!(
workflow.contains(" workflow_dispatch:") && workflow.contains(" tag:"),
"the release board must be nameable by tag, or a recovered publish leaves no release"
);
let create = workflow_job_block(&workflow, "create-release");
assert!(
create.contains("^v[0-9]+\\.[0-9]+\\.[0-9]+$"),
"a hand-typed tag must be checked for shape before it opens a release under that name"
);
assert!(
create.contains("tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag"),
"create-release must publish the resolved tag as an output for the jobs below it"
);
for job in ["build", "build-windows", "build-android"] {
let block = workflow_job_block(&workflow, job);
assert!(
block.contains("needs: create-release"),
"{job} must depend on create-release to read its tag"
);
assert!(
!block.contains("github.event.workflow_run.head_branch"),
"{job} must take the tag from create-release, not from the event that started the board"
);
}
}
fn workflow_job_names(workflow: &str) -> Vec<String> {
let body = workflow
.split_once("\njobs:\n")
.expect("a workflow must declare jobs")
.1;
body.lines()
.filter(|line| {
line.starts_with(" ")
&& !line.starts_with(" ")
&& line.trim_end().ends_with(':')
&& !line.trim_start().starts_with('#')
})
.map(|line| line.trim().trim_end_matches(':').to_string())
.collect()
}
#[test]
fn every_nightly_job_waits_for_the_duplicate_check() {
let workflow = workspace_source(".github/workflows/nightly.yml");
assert!(
workflow.contains(" force:"),
"a human must be able to force the board even when tonight is covered"
);
let decide = workflow_job_block(&workflow, "decide");
assert!(
decide.contains("runs-on: ubuntu-latest"),
"the duplicate check must not take one of the five self-hosted runners"
);
assert!(
decide.contains("run: ${{ steps.check.outputs.run }}")
&& decide.contains("scripts/ci/nightly_should_run.sh"),
"decide must publish the answer the jobs below it read"
);
let names = workflow_job_names(&workflow);
assert!(
names.len() >= 2 && names.contains(&"decide".to_string()),
"expected to inspect every nightly job, saw only {names:?}: the parser has drifted"
);
for name in names.iter().filter(|name| *name != "decide") {
let block = workflow_job_block(&workflow, name);
assert!(
block.contains("needs: decide")
&& block.contains("if: needs.decide.outputs.run == 'true'"),
"nightly job {name} must wait for the duplicate check, or two triggers run it twice"
);
}
}
#[test]
fn workflow_actions_are_pinned_to_commit_shas() {
let mut unpinned = Vec::new();
let mut seen = 0usize;
for name in [
"rust.yml",
"heavy-selfhosted.yml",
"nightly.yml",
"cancel-superseded.yml",
"publish.yml",
"release.yml",
"deploy-pages.yml",
"build-one.yml",
] {
let workflow = workspace_source(&format!(".github/workflows/{name}"));
for line in workflow.lines() {
let trimmed = line.trim();
let Some(reference) = trimmed
.strip_prefix("- uses:")
.or_else(|| trimmed.strip_prefix("uses:"))
else {
continue;
};
let reference = reference.trim();
seen += 1;
if reference.starts_with('.') {
continue;
}
let Some((_, git_ref)) = reference.split_once('@') else {
unpinned.push(format!("{name}: {reference} (no ref at all)"));
continue;
};
let git_ref = git_ref.split_whitespace().next().unwrap_or(git_ref);
let pinned = git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit());
if !pinned {
unpinned.push(format!("{name}: {reference}"));
}
}
}
assert!(
seen >= 10,
"expected to inspect many action references, saw only {seen}: the parser has drifted"
);
assert!(
unpinned.is_empty(),
"every workflow action must be pinned to a 40-character commit SHA; found movable refs: {unpinned:?}"
);
}
#[test]
fn release_jobs_require_every_expected_asset() {
let workflow = workspace_source(".github/workflows/release.yml");
for file in [
"files: ${{ matrix.artifact_name }}.tar.gz",
"files: cranpose-demo-windows-x86_64.zip",
"files: apps/android-demo/android/app/build/outputs/apk/release/app-release.apk",
] {
assert!(
workflow.contains(&format!("{file}\n fail_on_unmatched_files: true")),
"release uploads must name `{file}` exactly and fail when it is missing"
);
}
assert!(
!workflow.contains("*.tar.gz")
&& !workflow.contains("*.zip")
&& !workflow.contains("app-*-release.apk"),
"release uploads must not use globs that can silently match no files"
);
}
#[test]
fn release_artifacts_wait_for_publish_to_finalize_the_tag() {
let workflow = workspace_source(".github/workflows/release.yml");
let finalized_tag = "${{ needs.create-release.outputs.tag }}";
let resolved_tag = concat!(
"${{ github.event_name == 'workflow_dispatch' && inputs.tag",
" || github.event.workflow_run.head_branch }}"
);
assert!(
workflow.contains("workflow_run:\n workflows: [\"Publish\"]\n types: [completed]")
&& !workflow.contains("push:\n tags: [\"v*\"]"),
"release artifacts must start only after Publish has finalized the release tag"
);
assert!(
workflow.contains("github.event.workflow_run.conclusion == 'success'")
&& workflow.contains("startsWith(github.event.workflow_run.head_branch, 'v')"),
"release artifacts must reject failed Publish runs and a Publish that did not run from a tag"
);
assert_eq!(
workflow.matches(&format!("ref: {finalized_tag}")).count(),
3,
"every release build definition must check out the finalized tag name"
);
assert_eq!(
workflow
.matches(&format!("tag_name: {finalized_tag}"))
.count()
+ workflow
.matches(&format!("tag_name: {resolved_tag}"))
.count(),
4,
"every release action must explicitly target the finalized tag"
);
assert!(
!workflow.contains("github.ref_name"),
"workflow_run release jobs must not resolve the default branch as the release tag"
);
}
#[test]
fn render_common_package_embeds_crate_owned_text_assets() {
let software_text_source =
workspace_source("crates/cranpose-render/common/src/software_text_raster.rs");
let font_layout_source = workspace_source("crates/cranpose-render/common/src/font_layout.rs");
let wgpu_lib_source = workspace_source("crates/cranpose-render/wgpu/src/lib.rs");
let wgpu_test_support_source = workspace_source("crates/cranpose-render/wgpu/tests/support.rs");
for (path, source) in [
(
"crates/cranpose-render/common/src/software_text_raster.rs",
software_text_source.as_str(),
),
(
"crates/cranpose-render/common/src/font_layout.rs",
font_layout_source.as_str(),
),
(
"crates/cranpose-render/wgpu/src/lib.rs",
wgpu_lib_source.as_str(),
),
(
"crates/cranpose-render/wgpu/tests/support.rs",
wgpu_test_support_source.as_str(),
),
] {
assert!(
!source.contains("apps/desktop-demo/assets"),
"{path} must not embed demo-app assets; library crates must package their own fallback fonts"
);
}
for path in [
"crates/cranpose-render/common/assets/NotoSansMerged.ttf",
"crates/cranpose-render/common/assets/NotoSansBold.ttf",
"crates/cranpose-render/common/assets/TwemojiMozilla.ttf",
] {
let metadata = std::fs::metadata(workspace_path(path)).unwrap_or_else(|error| {
panic!("{path} should be packaged with render-common: {error}")
});
assert!(
metadata.len() > 1024,
"{path} should contain the fallback font bytes"
);
}
}
#[test]
fn app_shell_frame_schedule_targets_platform_frame_driver() {
let source = workspace_source("crates/cranpose-app-shell/src/lib.rs");
let surface = workspace_source("crates/cranpose-app-shell/src/surface.rs");
assert!(
source.contains("pub trait PlatformFrameDriver")
&& source.contains("pub struct FrameScheduler"),
"AppShell scheduling should expose a scheduler and platform driver boundary"
);
assert!(
source.contains("impl FrameSchedule")
&& source.contains("pub fn apply_to<D>(self, driver: &D)")
&& source.contains("pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)")
&& source.contains("pub fn schedule_platform_frame<D>(&self, driver: &D)")
&& source.contains("self.surfaces[0].frame_scheduler.schedule(schedule, driver)")
&& surface.contains("pub fn schedule_platform_frame<D>(&self, driver: &D)")
&& surface.contains("self.surface().frame_scheduler.schedule(schedule, driver)")
&& source.contains("driver.request_frame()")
&& source.contains("driver.request_wake_at(deadline)")
&& source.contains("driver.clear_wake()"),
"FrameSchedule should be interpreted through the scheduler each surface owns and the platform driver contract"
);
}
#[test]
fn desktop_no_vsync_chains_dirty_presented_frames_only() {
let source = crate_source("src/desktop.rs");
assert!(
source.contains(
"fn should_chain_no_vsync_redraw(frame_interval: Option<Duration>, needs_frame: bool) -> bool"
) && source.contains("frame_interval.is_none() && needs_frame"),
"desktop no-vsync frame chaining must require both an uncapped present mode and pending frame work"
);
assert!(
source.contains(
"if !robot_driven\n && should_chain_no_vsync_redraw(\n frame_interval,\n app.frame_schedule().needs_frame,"
)
&& source.contains("request_redraw_once(window, &mut self.primary_redraw_pending);"),
"primary desktop frames should chain dirty no-vsync redraws while allowing robot commands to advance between presented frames"
);
assert!(
source.contains(
"if should_chain_no_vsync_redraw(native.frame_interval(app.frame_pacing_mode()), needs_frame)"
) && source.contains("native.window.request_redraw();"),
"native desktop frames should use the same no-vsync redraw chaining rule"
);
}
#[test]
fn surface_present_decision_is_shared_across_platform_loops() {
let shared = crate_source("src/wgpu_surface.rs");
assert!(
shared.contains("pub(crate) fn surface_present_required(")
&& shared.contains("surface_dirty || update_visual_changed || app_needs_redraw"),
"the shared desktop_input module must own the single surface present decision"
);
}
#[test]
fn desktop_renderer_warmup_reaches_primary_and_native_surfaces() {
let source = crate_source("src/desktop.rs");
assert!(
source.contains(
"surface_present_required(native.surface_dirty, frame_owed, surface.needs_redraw())"
),
"native windows must still render when renderer-side warmup is the only pending frame work"
);
assert!(
source.contains(
"surface_present_required(\n primary_surface_dirty_before_update || robot_surface_dirty_before_update,\n frame_owed,\n app.needs_redraw(),"
),
"primary windows must not skip a redraw requested only by renderer-side warmup"
);
}
#[test]
fn web_first_frame_is_forced_through_surface_dirty() {
let source = crate_source("src/web.rs");
assert!(
source.contains("let surface_dirty = Rc::new(Cell::new(true));"),
"web surface_dirty must start true so the first frame is always presented"
);
assert!(
source.contains(
"let present_required = surface_present_required(\n surface_dirty_for_loop.get(),\n update_result.visual_changed,\n app.borrow().needs_redraw(),\n );"
),
"web render loop must gate the present through the shared surface_present_required helper"
);
assert!(
source.contains("surface_dirty_for_loop.set(false);"),
"web surface_dirty must be cleared only after a successful present"
);
}
#[test]
fn android_first_frame_is_forced_through_surface_dirty() {
let source = crate_source("src/android.rs");
assert!(
source.contains("surface_dirty: true,"),
"android GpuResources must start with a dirty surface so the first frame presents"
);
assert!(
source.contains(
"if surface_present_required(\n resources.surface_dirty,\n update_result.visual_changed,\n shell.needs_redraw(),\n )"
),
"android render loop must gate the present through the shared surface_present_required helper"
);
assert!(
source.contains("resources.surface_dirty = false;"),
"android surface_dirty must be cleared only after a successful present"
);
}
#[test]
fn android_resume_robot_contract_retains_gpu_and_marks_shell_dirty() {
let source = crate_source("src/android.rs");
assert!(
!source.contains(
"drop_present_surface(&mut gpu_resources, &mut app_shell);\n } else {\n gpu_resources = None;"
),
"the resume robot must not discard the device and renderer on TerminateWindow"
);
assert!(
source.contains("resources.surface = None;"),
"the resume robot must detach only the native surface"
);
assert!(
source.contains("setup.resources.surface_dirty = true;\n shell.mark_dirty();"),
"the resume robot must force a composition before the first resumed present"
);
}
#[test]
fn web_idle_does_not_request_recursive_raf() {
let source = crate_source("src/web.rs");
assert!(
source.contains("struct WebPlatformFrameDriver")
&& source.contains("impl PlatformFrameDriver for WebPlatformFrameDriver"),
"web runtime should own a concrete platform frame driver"
);
assert!(
!source.contains("request_animation_frame(render_loop.borrow().as_ref().unwrap())"),
"web runtime must not recursively request RAF every frame"
);
assert!(
source.contains("app.borrow().schedule_platform_frame(&frame_driver)")
&& source.contains("request_web_frame_at_deadline")
&& source.contains("clear_web_frame_wake")
&& source.contains("set_timeout_with_callback_and_timeout_and_arguments_0"),
"web runtime should translate idle frame deadlines into timeout-driven one-shot RAF requests"
);
}
#[test]
fn web_frame_request_scheduling_does_not_panic_on_browser_api_failures() {
let source = crate_source("src/web.rs");
let start = source
.find("fn request_animation_frame")
.expect("web frame scheduling helper should exist");
let end = source
.find("fn clear_web_frame_wake")
.expect("web frame wake clearer should exist");
let scheduling_source = &source[start..end];
assert!(
!scheduling_source.contains(".unwrap()") && !scheduling_source.contains(".expect("),
"web frame scheduling should log and clear pending state instead of panicking on browser API failures"
);
}
#[test]
fn web_frame_waker_is_shell_owned_without_thread_local_router() {
let web_source = crate_source("src/web.rs");
let app_shell_source = workspace_source("crates/cranpose-app-shell/src/lib.rs");
assert!(
!web_source.contains("WEB_FRAME_REQUESTER")
&& !web_source.contains("install_web_frame_requester")
&& !web_source.contains("request_current_web_frame"),
"web frame wakeups must not route through a process-global/thread-local requester"
);
assert!(
app_shell_source
.contains("#[cfg(target_arch = \"wasm32\")]\n pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static)"),
"wasm AppShell frame wakers should be single-threaded instead of requiring Send"
);
assert!(
web_source.contains("app.borrow_mut().set_frame_waker({")
&& web_source.contains("move || request_frame()"),
"web runtime should install the per-shell frame requester directly on AppShell"
);
}
#[test]
fn web_surface_capabilities_are_checked_before_indexing() {
let source = crate_source("src/web.rs");
assert!(
!source.contains("surface_caps.formats[0]")
&& !source.contains("surface_caps.alpha_modes[0]"),
"web renderer startup should return an error for empty surface capabilities instead of indexing directly"
);
}
#[test]
fn native_surface_capabilities_are_checked_before_indexing() {
for path in ["src/android.rs", "src/desktop.rs"] {
let source = crate_source(path);
assert!(
!source.contains("surface_caps.formats[0]")
&& !source.contains("surface_caps.alpha_modes[0]"),
"{path} should return typed errors for empty surface capabilities instead of indexing directly"
);
}
}
#[test]
fn platform_surface_reconfigure_uses_fallible_renderer_device_access() {
for path in ["src/desktop.rs", "src/web.rs"] {
let source = crate_source(path);
assert!(
!source.contains(".renderer().device()"),
"{path} should not panic on surface reconfiguration when renderer GPU state is unavailable"
);
assert!(
source.contains(".renderer().try_device()"),
"{path} should use fallible renderer device access for surface reconfiguration"
);
}
}
#[test]
fn desktop_initial_shell_render_enters_native_window_registry() {
let source = crate_source("src/desktop.rs");
assert!(
source.contains("let mut app = native_window::with_native_window_registry(®istry, || {")
&& source.contains("AppShell::new_with_size_and_density("),
"desktop run_windows uses a hidden primary declaration host, so AppShell construction must enter the native-window registry before the first stable render"
);
}
#[test]
fn android_idle_does_not_poll_16ms() {
let source = crate_source("src/android.rs");
assert!(
source.contains("app_waker.wake()"),
"android runtime frame waker should wake the Android looper"
);
let offscreen_period = "const OFFSCREEN_UPDATE_PERIOD: Duration = Duration::from_millis(16);";
assert!(
source.contains(offscreen_period),
"the off-screen work pace is the one 16 ms period this file may hold"
);
assert_eq!(
source.matches("from_millis(16)").count(),
1,
"android runtime must not poll at 16 ms while idle; the only 16 ms period is OFFSCREEN_UPDATE_PERIOD, which paces work for an app that asked to keep running off screen"
);
assert!(
source.contains("let offscreen = no_surface && cranpose_services::background_active();"),
"the off-screen pass must run only when an app asked to keep working with no surface"
);
assert!(
source.contains("struct AndroidFrameDriver")
&& source.contains("impl PlatformFrameDriver for AndroidFrameDriver")
&& source.contains("shell.schedule_platform_frame(&android_frame_driver)")
&& source.contains("android_frame_driver.deadline_timeout()")
&& source.contains("earliest_android_poll_timeout"),
"android runtime should route AppShell schedules through the platform frame driver"
);
}
#[test]
fn android_overlay_events_are_runtime_owned() {
let overlay_source = crate_source("src/android_overlay_window.rs");
let jni_source = crate_source("src/android_jni.rs");
let java_source = workspace_source(
"crates/cranpose/android/java/dev/cranpose/android/CranposeOverlayWindow.java",
);
let runtime_source = crate_source("src/android.rs");
assert!(
overlay_source.contains("pub(crate) struct AndroidOverlayEventQueue")
&& overlay_source.contains("pub(crate) struct AndroidOverlayEventQueueHandle")
&& overlay_source.contains("retain_android_overlay_event_queue_handle"),
"Android overlay callbacks should route through an explicit handle to a runtime-owned event queue"
);
assert!(
!overlay_source.contains("OnceLock<Mutex<VecDeque<AndroidOverlayWindowEvent>>>")
&& !overlay_source.contains("fn overlay_events() -> &'static Mutex<VecDeque")
&& !overlay_source.contains("OnceLock<")
&& !overlay_source.contains("register_android_overlay_event_queue")
&& !overlay_source.contains("lock_overlay_event_queue_slot"),
"Android overlay events and helper classes must not be retained in process-global Rust storage"
);
assert!(
jni_source.contains("nativeOverlayReleaseQueue")
&& jni_source.contains("push_overlay_event_for_handle"),
"Android JNI callbacks should release and dispatch explicit overlay queue handles"
);
assert!(
java_source.contains("long eventQueueHandle")
&& java_source.contains("nativeOverlayReleaseQueue")
&& java_source.contains("nativeOverlaySurfaceChanged(eventQueueHandle"),
"Android overlay Java helper should carry the runtime queue handle through callbacks"
);
assert!(
runtime_source.contains("let overlay_event_queue = Arc::new")
&& !runtime_source.contains("let _overlay_event_queue_registration =")
&& runtime_source.contains("drain_android_overlay_window_events(&overlay_event_queue)"),
"Android runtime should own and explicitly drain overlay events"
);
assert!(
jni_source.contains("jni_str!(\"getClassLoader\")")
&& !jni_source.contains("jni_str!(\"getClass\")")
&& overlay_source.contains("load_cranpose_java_class")
&& !overlay_source.contains("jni_str!(\"getClass\")"),
"Android Java bridge loading must use the Activity context classloader (via the shared \
android_jni helper); android.app.NativeActivity itself is framework-loaded by the boot \
classloader"
);
}
#[test]
fn android_activity_jni_attaches_the_caller_without_recreating_the_vm() {
let jni_source = crate_source("src/android_jni.rs");
assert!(
jni_source.contains("JavaVM::singleton()")
&& jni_source.contains("vm.attach_current_thread")
&& jni_source.contains("env.as_cast_raw::<JObject>")
&& jni_source.contains("env.new_local_ref"),
"Android activity JNI access must reach the activity through the process JavaVM singleton and attach the calling thread (cheap when android_main is already attached, required when called from a worker thread such as audio playback opening a content:// document), creating a scoped local Activity reference from the global Activity handle"
);
assert!(
!jni_source.contains("JavaVM::from_raw(app.vm_as_ptr"),
"Android activity JNI access must not recreate the JavaVM from AndroidApp; it must reuse the JavaVM singleton"
);
}
#[test]
fn android_launch_arguments_reach_the_service_registry() {
let services_source = crate_source("src/android_services.rs");
let decoder_source = crate_source("src/android_launch_args.rs");
let environment_source = crate_source("src/platform_env.rs");
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("public String cranposeEncodeLaunchArguments()")
&& java_source.contains("ApplicationInfo.FLAG_DEBUGGABLE")
&& java_source
.contains("private static native void nativeOnLaunchArguments(String payload);")
&& java_source.contains("nativeOnLaunchArguments(cranposeEncodeLaunchArguments());"),
"CranposeActivity should encode the launching intent's extras with the debuggable flag and re-push them from onNewIntent; a NativeActivity has no other way to see them"
);
assert!(
java_source.contains("private void loadCranposeNativeLibrary()")
&& java_source.contains("System.loadLibrary(libraryName)"),
"the Java-declared launch-argument callback only resolves because CranposeActivity loads the library itself; libnativeloader does not register it with ART's JNI resolver"
);
assert!(
services_source.contains("jni_str!(\"cranposeEncodeLaunchArguments\")")
&& services_source
.contains("set_platform_launch_args(Rc::new(read_launch_arguments(&app)))"),
"the Android backend should pull the launching intent's extras at startup, where getIntent() is already populated, instead of racing a push from onCreate"
);
assert!(
services_source
.contains("Java_dev_cranpose_android_CranposeActivity_nativeOnLaunchArguments")
&& services_source.contains("PENDING_LAUNCH_ARGS")
&& services_source.contains("shell.request_root_render()"),
"onNewIntent extras should be parked for the native loop, which owns the snapshot, and force a root render once applied"
);
assert!(
decoder_source.contains("pub(crate) fn decode_launch_arguments"),
"the intent-extra wire format should be decoded in safe Rust, outside the JNI boundary"
);
assert!(
environment_source.contains("local_launch_args().provides(launch_args)"),
"the platform environment should publish the launch arguments so composition observes a replacement intent"
);
}
#[test]
fn android_play_billing_reaches_the_purchase_registry() {
let services_source = crate_source("src/android_services.rs");
let backend_source = crate_source("src/android_purchases.rs");
let wire_source = crate_source("src/android_purchase_wire.rs");
let java_source = workspace_source(
"crates/cranpose/android/java-billing/dev/cranpose/android/CranposeBilling.java",
);
assert!(
services_source.contains("crate::android_purchases::register(app.clone())"),
"the Android backend should install the Play Billing purchase backend alongside the other platform services"
);
assert!(
backend_source.contains("set_platform_purchases(Arc::new(AndroidPurchases {")
&& backend_source.contains("load_cranpose_java_class(env, &activity, BILLING_CLASS)"),
"the Play Billing backend should reach its Java bridge through the activity class loader and register itself into cranpose_services::purchases"
);
assert!(
backend_source.contains("jni_str!(\"cranposeBillingConfigure\")")
&& backend_source.contains("jni_str!(\"cranposeBillingPurchase\")")
&& backend_source.contains("jni_str!(\"cranposeBillingRestore\")"),
"querying products, buying and restoring should each be one non-blocking JNI call into the Java bridge"
);
assert!(
backend_source.contains("Java_dev_cranpose_android_CranposeBilling_nativeBillingSnapshot")
&& backend_source
.contains("Java_dev_cranpose_android_CranposeBilling_nativeBillingEvent")
&& backend_source.contains("wake_native_loop()"),
"store answers arrive on Play Billing worker threads and must be parked for the native loop, which is woken so the frame that reads them happens"
);
assert!(
wire_source.contains("pub(crate) fn decode_store_snapshot")
&& wire_source.contains("pub(crate) fn decode_purchase_event"),
"the Play Billing wire format should be decoded in safe Rust, outside the JNI boundary"
);
assert!(
java_source.contains("private static native void nativeBillingSnapshot(String payload);")
&& java_source.contains("activity.runOnUiThread")
&& java_source.contains("client.launchBillingFlow(activity, flow)"),
"the Java bridge should flatten the whole store snapshot into one JNI call and launch the payment sheet on the Java UI thread"
);
assert!(
java_source.contains("acknowledgePurchase"),
"Play refunds an unacknowledged purchase, so the bridge must acknowledge every entitlement it sees"
);
}
#[test]
fn android_accessibility_record_width_agrees_across_the_jni_boundary() {
let wire_source = crate_source("src/android_accessibility_wire.rs");
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let rust_fields = wire_source
.lines()
.find(|line| line.trim_start().starts_with("\"{}\\t"))
.map(|line| line.matches("{}").count())
.expect("the accessibility record format string should be one line");
let java_fields = java_source
.lines()
.find(|line| line.contains("ACCESSIBILITY_FIELDS ="))
.and_then(|line| {
line.rsplit('=')
.next()
.map(|value| value.trim().trim_end_matches(';').to_string())
})
.and_then(|value| value.parse::<usize>().ok())
.expect("CranposeActivity should declare the accessibility record width");
assert_eq!(
rust_fields, java_fields,
"the encoder writes {rust_fields} fields but CranposeActivity parses {java_fields}"
);
assert!(
java_source.contains("if (fields.length != ACCESSIBILITY_FIELDS) continue;"),
"a record of the wrong width should be skipped, not indexed past its end"
);
}
#[test]
fn android_accessibility_custom_actions_reach_the_frame_loop() {
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let boundary_source = crate_source("src/android_accessibility.rs");
let loop_source = crate_source("src/android.rs");
let projection_source = crate_source("src/accessibility.rs");
let identity_source = crate_source("src/accessibility_identity.rs");
assert!(
java_source.contains(
"private static native void nativeOnAccessibilityCustomAction(int virtualViewId, int actionIndex);"
) && java_source.contains("nativeOnAccessibilityCustomAction(element.id, customIndex);"),
"the provider should route a custom action back by identity rather than by synthesising a tap it has no position for"
);
assert!(
boundary_source.contains(
"Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilityCustomAction"
) && boundary_source.contains("pub(crate) fn drain_custom_actions()"),
"the JNI boundary should park custom actions for the frame loop instead of running app code on the Java thread"
);
assert!(
loop_source.contains("crate::android_accessibility::drain_custom_actions()")
&& loop_source.contains("elements.identity(virtual_id)")
&& loop_source.contains("crate::accessibility::perform_custom_action("),
"the frame loop should resolve the virtual view id and run the action against the live semantics tree"
);
assert!(
projection_source.contains("pub(crate) fn perform_custom_action(")
&& identity_source.contains("pub(crate) struct AccessibilitySnapshot")
&& identity_source.contains("pub(crate) fn identity("),
"resolving an accessibility id and running its action are platform-neutral and belong outside the JNI boundary"
);
}
#[test]
fn every_platform_bridge_carries_focus_both_ways() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) focusable: bool")
&& projection_source.contains("pub(crate) focused: bool")
&& projection_source.contains("pub(crate) fn focus_node("),
"a platform bridge reads focus off the element and hands a reader's focus back through one platform-neutral call"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("Action::Focus => self.pending_focus.push(target)")
&& desktop_source.contains("pub(crate) fn run_focus_requests(")
&& !desktop_source.contains("focus: ROOT_ID,"),
"accesskit should report the focused control and take a Focus action back, instead of naming the window every time"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("accessibilityElementDidBecomeFocused")
&& ios_source.contains("UIAccessibilityLayoutChangedNotification")
&& ios_source.contains("pub(crate) fn drain_focus<R>("),
"VoiceOver should move the app's focus when its cursor lands, and follow the app when the app moves focus"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android_accessibility.rs");
let loop_source = crate_source("src/android.rs");
assert!(
java_source
.contains("private static native void nativeOnAccessibilityFocus(int virtualViewId);")
&& java_source.contains("private void followAppFocus()")
&& android_source
.contains("Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilityFocus")
&& android_source.contains("pub(crate) fn drain_focus_requests()")
&& loop_source.contains("crate::android_accessibility::drain_focus_requests()"),
"TalkBack should report where its cursor landed and follow the app's focus, with the frame loop resolving the id"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("\"focusin\"")
&& web_source.contains("node.focus()")
&& web_source.contains("\"tabindex\",")
&& web_source.contains("if element.enabled")
&& web_source.contains("&& element.tab_stop")
&& web_source
.contains("&& (element.focusable || element.adjustable || element.clickable)"),
"the web mirror should take Tab focus, follow the app's focus, and report a focus back"
);
}
#[test]
fn every_platform_bridge_lets_a_reader_move_an_adjustable_control() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) progress: Option<ProgressBarRangeInfo>")
&& projection_source.contains("pub(crate) adjustable: bool")
&& projection_source.contains("pub(crate) fn set_progress("),
"the range a control holds and the way back to move it are platform-neutral"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("node.set_numeric_value(progress.current as f64)")
&& desktop_source.contains("node.add_action(Action::SetValue)")
&& desktop_source.contains("pub(crate) fn run_value_requests("),
"accesskit should carry the value and take SetValue, Increment and Decrement back"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("UIAccessibilityTraitAdjustable")
&& ios_source.contains("accessibilityIncrement")
&& ios_source.contains("accessibilityDecrement")
&& ios_source.contains("fn drain_value_steps<R>("),
"VoiceOver moves an adjustable control with a swipe up and down, not with a value"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android_accessibility.rs");
let wire_source = crate_source("src/android_accessibility_wire.rs");
assert!(
java_source.contains("info.setRangeInfo(AccessibilityNodeInfo.RangeInfo.obtain(")
&& java_source.contains("ACTION_ARGUMENT_PROGRESS_VALUE")
&& android_source.contains(
"Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilitySetProgress"
)
&& wire_source.contains("i32::from(element.adjustable)"),
"TalkBack reads a RangeInfo and hands a new value back through the wire"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("\"aria-valuenow\"")
&& web_source.contains("fn attach_key_listener(")
&& web_source.contains("accessibility::set_progress("),
"the web mirror should read as a slider and move on the arrow keys"
);
}
#[test]
fn every_platform_bridge_reads_announcements_out() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) fn drain_app_announcements(")
&& projection_source.contains("pub(crate) fn live_region_announcements("),
"text to read out and a live region change are platform-neutral and belong outside every platform boundary"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("node.set_live(accesskit_live(mode))")
&& desktop_source.contains("fn announcement_node("),
"accesskit carries a live region of its own, and an announcement rides along as a live node"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("UIAccessibilityAnnouncementNotification")
&& ios_source.contains("accessibility::live_region_announcements("),
"iOS has no live region, so both an announcement and a live region change are posted to VoiceOver"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android_accessibility.rs");
assert!(
java_source.contains("public void cranposeAnnounceForAccessibility(String text)")
&& java_source.contains("announceForAccessibility(text)")
&& android_source.contains("jni_str!(\"cranposeAnnounceForAccessibility\")")
&& android_source.contains("accessibility::live_region_announcements("),
"TalkBack reads a virtual view's live region only through the host view, so both paths go out as one spoken line"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("region.set_attribute(\"aria-live\", politeness)?")
&& web_source.contains("accessibility::live_region_announcements("),
"the web mirror is rebuilt on every change, so its live regions must outlive the mirror"
);
}
#[test]
fn the_play_billing_bridge_sends_the_order_id_that_granted_each_entitlement() {
let java_source = workspace_source(
"crates/cranpose/android/java-billing/dev/cranpose/android/CranposeBilling.java",
);
assert!(
java_source.contains("purchase.getOrderId()"),
"the bridge must read Play's order id; the product id is what the app already knew"
);
assert!(
java_source.contains("escape(orderId)"),
"the order id must be escaped onto the owned row like every other field: the row is \
tab separated and nothing in Play's format forbids a tab"
);
let apply = java_source
.split("private int apply(")
.nth(1)
.expect("the snapshot bridge should apply purchase lists in one place");
let apply = apply
.split("\n private")
.next()
.expect("a method body is delimited by the next member");
assert!(
apply.contains("owned.clear()") && apply.contains("orders.clear()"),
"ownership and its order ids must be replaced together, or an order id survives the \
purchase it belongs to"
);
}
#[test]
fn android_native_input_is_drained_on_input_available_event() {
let source = crate_source("src/android.rs");
assert!(
source.contains("MainEvent::InputAvailable")
&& source.contains("drain_android_input_events(")
&& source.contains("push_pending_inputs_from_android_event(")
&& source.contains("android_activity::InputStatus::Handled"),
"Android NativeActivity input must be drained from MainEvent::InputAvailable so every input event reaches finish_event before the platform ANR timeout"
);
assert!(
!source
.contains("println!(\n \"[TOUCH]")
&& !source.contains("println!(\"[TOUCH]"),
"Android input acknowledgement must not perform synchronous stdout logging in the event-finish path"
);
}
#[test]
fn android_host_window_layout_is_dispatched_on_java_ui_thread() {
let runtime_source = crate_source("src/android.rs");
let java_source = workspace_source(
"crates/cranpose/android/java/dev/cranpose/android/CranposeOverlayWindow.java",
);
assert!(
runtime_source.contains("setActivityWindowLayout")
&& runtime_source.contains("find_android_overlay_class")
&& !runtime_source.contains("jni_str!(\"setLayout\")"),
"Android host-window layout requests must go through the Java bridge instead of touching Window.setLayout from android_main"
);
assert!(
java_source.contains("setActivityWindowLayout")
&& java_source.contains("activity.runOnUiThread")
&& java_source.contains("activity.getWindow().setLayout"),
"Android Activity window layout changes must execute on the Java UI thread"
);
}
#[test]
fn platform_drivers_set_density_through_app_shell() {
for path in ["src/android.rs", "src/desktop.rs", "src/web.rs"] {
let source = crate_source(path);
assert!(
!source.contains("cranpose_ui::set_density("),
"{path} must update density through AppShell so the per-shell AppContext owns the value"
);
}
}
#[test]
fn web_primary_pointer_stream_is_captured_until_release_or_cancel() {
let source = crate_source("src/web.rs");
assert!(
source.contains("set_pointer_capture(event.pointer_id())"),
"web pointer-down must capture the pointer so selection handles keep ownership outside the canvas"
);
assert!(
source
.matches("release_pointer_capture(event.pointer_id())")
.count()
>= 2,
"web pointer-up and pointer-cancel must both release canvas pointer capture"
);
}
#[test]
fn web_haptics_treats_the_vibration_api_as_optional() {
let source = crate_source("src/web_services.rs");
assert!(
!source.contains("navigator.vibrate_with_"),
"web haptics must not call Navigator.vibrate directly: browsers without the optional Vibration API throw out of the WASM pointer callback"
);
assert!(
source.contains("Reflect::get(navigator.as_ref(), &JsValue::from_str(\"vibrate\"))")
&& source.contains("dyn_ref::<js_sys::Function>()")
&& source.contains("vibrate.call1(navigator.as_ref(), pattern)"),
"web haptics must feature-detect a callable vibration function and contain invocation failures"
);
}
#[test]
fn android_cancel_terminates_the_primary_pointer_stream() {
let source = crate_source("src/android.rs");
assert!(
source.contains("MotionAction::Cancel")
&& source.contains("PendingInput::PointerCancel")
&& source.contains("shell.cancel_gesture()"),
"Android ACTION_CANCEL must reach AppShell::cancel_gesture instead of leaving a selection handle captured"
);
}
#[test]
fn desktop_frame_cap_deadline_is_option_checked() {
let source = crate_source("src/desktop.rs");
assert!(
!source.contains("native frame cap deadline should exist"),
"desktop frame pacing should carry frame-cap deadlines through Option instead of panicking"
);
}
#[test]
fn desktop_x11_client_is_app_owned() {
let source = crate_source("src/desktop.rs");
assert!(
source.contains("native_window_platform_probe: NativeWindowPlatformProbe")
&& source.contains("struct NativeWindowPlatformProbe"),
"desktop runtime should own native-window platform probing inside App"
);
assert!(
!source.contains("static X11_WINDOW_CLIENT")
&& !source.contains("fn with_x11_window_client<R>"),
"X11 connection probing must not live in a process/thread-local cache"
);
}
#[test]
fn ios_backend_is_wired_without_aliasing_desktop() {
let cranpose_manifest = crate_source("Cargo.toml");
assert!(
!cranpose_manifest.contains("ios = []"),
"cranpose ios feature must be wired to the real backend, not reserved"
);
assert!(
!cranpose_manifest.contains("ios = [\"desktop\"]"),
"ios must not alias the desktop feature"
);
let facade = crate_source("src/lib.rs");
assert!(
facade.contains("pub mod ios;"),
"cranpose must expose the iOS backend module"
);
assert!(
!facade.contains("backend and is unavailable"),
"the iOS-unavailable compile_error must be gone"
);
let ios = crate_source("src/ios.rs");
assert!(
ios.contains("ApplicationHandler") && ios.contains("winit"),
"ios backend should drive its own winit event loop"
);
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let demo_manifest = std::fs::read_to_string(workspace_dir.join("apps/desktop-demo/Cargo.toml"))
.expect("failed to read desktop-demo manifest");
assert!(
demo_manifest
.lines()
.any(|line| line.trim_start().starts_with("ios =")),
"desktop-demo should advertise an iOS app feature"
);
assert!(
demo_manifest.contains("name = \"cranpose-ios\""),
"desktop-demo should declare the cranpose-ios binary"
);
let build_script =
std::fs::read_to_string(workspace_dir.join("apps/ios-demo/ios/build-app.sh"))
.expect("failed to read ios build script");
assert!(
build_script.contains("--features ios"),
"ios build script should build the ios feature"
);
}
#[test]
fn wgpu_backend_features_are_target_specific() {
for manifest in [
"crates/cranpose/Cargo.toml",
"crates/cranpose-render/wgpu/Cargo.toml",
] {
let source = workspace_source(manifest);
assert!(
!source.contains(
"[target.'cfg(all(not(target_arch = \"wasm32\"), not(target_os = \"android\")))'.dependencies]"
),
"{manifest} must not use one broad native WGPU backend dependency for every desktop OS"
);
let linux = manifest_section(
&source,
"[target.'cfg(all(target_os = \"linux\", not(target_arch = \"wasm32\")))'.dependencies]",
);
assert!(
linux.contains("\"vulkan\"")
&& !linux.contains("\"gles\"")
&& !linux.contains("\"dx12\"")
&& !linux.contains("\"metal\""),
"{manifest} Linux WGPU backend set should hardcode Vulkan only; GLES is opt-in via backend-gles"
);
let android = manifest_section(
&source,
"[target.'cfg(target_os = \"android\")'.dependencies]",
);
assert!(
android.contains("\"vulkan\"")
&& !android.contains("\"gles\"")
&& !android.contains("\"dx12\"")
&& !android.contains("\"metal\""),
"{manifest} Android WGPU backend set should hardcode Vulkan only; GLES comes from the android feature enabling backend-gles"
);
let windows = manifest_section(
&source,
"[target.'cfg(target_os = \"windows\")'.dependencies]",
);
assert!(
windows.contains("\"dx12\"")
&& !windows.contains("\"metal\"")
&& !windows.contains("\"gles\"")
&& !windows.contains("\"vulkan\""),
"{manifest} Windows WGPU backend set should be DX12 only"
);
let macos = manifest_section(
&source,
"[target.'cfg(target_os = \"macos\")'.dependencies]",
);
assert!(
macos.contains("\"metal\"")
&& !macos.contains("\"dx12\"")
&& !macos.contains("\"gles\"")
&& !macos.contains("\"vulkan\""),
"{manifest} macOS WGPU backend set should be Metal only"
);
}
let render_wgpu = workspace_source("crates/cranpose-render/wgpu/Cargo.toml");
assert!(
render_wgpu.contains("backend-gles = [\"wgpu/gles\", \"naga/glsl-out\"]"),
"cranpose-render-wgpu must expose the GLES fallback as backend-gles (wgpu/gles + naga/glsl-out)"
);
let facade = workspace_source("crates/cranpose/Cargo.toml");
assert!(
facade.contains("renderer-wgpu-gles = ["),
"cranpose must expose renderer-wgpu-gles for the desktop GLES fallback"
);
let android_feature_start = facade
.find("android = [")
.expect("cranpose android feature is missing");
let android_feature = &facade[android_feature_start..];
let android_feature = &android_feature[..android_feature
.find(']')
.expect("cranpose android feature array is unterminated")];
assert!(
android_feature.contains("cranpose-render-wgpu?/backend-gles"),
"the cranpose android feature must keep the GLES fallback enabled on Android"
);
}
#[test]
fn render_state_has_no_process_global_fallback() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let source =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-ui/src/render_state.rs"))
.expect("failed to read render_state.rs");
assert!(
!source.contains("OnceLock<RenderState>"),
"render_state fallback must not be a process-global RenderState"
);
assert!(
source.contains("fn require_current_app_context(operation: &str) -> Rc<AppContext>")
&& source.contains("panic!(\"{operation} requires an active AppContext\")")
&& !source.contains("static UNIT_TEST_APP_CONTEXT")
&& !source.contains("Box::leak(Box::new(AppContext::new()))")
&& !source.contains("cfg(any(test, feature = \"test-helpers\"))]\nfn require_current_app_context_without_scope")
&& !source.contains("cfg(not(any(test, feature = \"test-helpers\")))]\nfn require_current_app_context_without_scope")
&& !source.contains("with_fallback_render_state")
&& !source.contains("FALLBACK_"),
"render_state must route production runtime access through the active AppContext without hidden fallback state"
);
}
fn manifest_section<'a>(source: &'a str, header: &str) -> &'a str {
let start = source
.find(header)
.unwrap_or_else(|| panic!("manifest section `{header}` is missing"));
let tail = &source[start + header.len()..];
let end = tail.find("\n[").unwrap_or(tail.len());
&tail[..end]
}
#[test]
fn fps_monitor_runtime_state_is_shell_owned() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let source =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-app-shell/src/fps_monitor.rs"))
.expect("failed to read fps_monitor.rs");
assert!(
source.contains("pub(crate) struct FpsMonitor"),
"fps monitoring state should be owned by an AppShell field"
);
assert!(
!source.contains("static FPS_TRACKER") && !source.contains("static RECOMPOSITION_COUNT"),
"fps monitor counters must not be authoritative process state"
);
assert!(
!source.contains("PUBLISHED_STATS")
&& !source.contains("pub fn fps_stats()")
&& !source.contains("pub fn current_fps()"),
"public FPS snapshots must come from the owning AppShell, not from process-global publication"
);
}
#[test]
fn fps_monitor_counts_presented_frames_not_shell_updates() {
let shell_frame = workspace_source("crates/cranpose-app-shell/src/shell_frame.rs");
let app_shell = workspace_source("crates/cranpose-app-shell/src/lib.rs");
let desktop = workspace_source("crates/cranpose/src/desktop.rs");
assert!(
!shell_frame.contains("record_frame_work"),
"AppShell update processing must not mutate presented-frame FPS stats"
);
assert!(
app_shell.contains("pub fn record_presented_frame"),
"AppShell should expose an explicit presented-frame sampling boundary"
);
assert!(
desktop.contains("record_presented_frame"),
"desktop presentation paths should record FPS after real redraws"
);
}
#[test]
fn render_hit_diagnostics_are_scene_owned() {
let source = workspace_source("crates/cranpose-render/common/src/graph_scene.rs");
assert!(
source.contains("pub struct RenderDiagnostics")
&& source.contains("live_modifier_slice_lookup_miss_count"),
"render hit diagnostics should be represented as retained scene diagnostics"
);
assert!(
!source.contains("LIVE_MODIFIER_SLICE_LOOKUP_MISS_COUNT")
&& !source.contains("AtomicUsize"),
"render hit diagnostics must not use process-global counters"
);
}
#[test]
fn pointer_input_task_registry_is_app_context_owned() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let pointer_input_source = std::fs::read_to_string(
workspace_dir.join("crates/cranpose-ui/src/modifier/pointer_input.rs"),
)
.expect("failed to read pointer_input.rs");
let render_state_source =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-ui/src/render_state.rs"))
.expect("failed to read render_state.rs");
assert!(
!pointer_input_source.contains("static POINTER_INPUT_TASKS"),
"pointer input task wakeups must not use a module-local task table"
);
assert!(
render_state_source.contains("pointer_input_tasks:")
&& render_state_source.contains("register_pointer_input_task")
&& render_state_source.contains("request_pointer_input_task_poll")
&& render_state_source.contains("context.enter(||")
&& render_state_source
.contains("context.pointer_input_tasks.request_poll(task_id, owner)"),
"pointer input task wakeups should run inside the owning AppContext"
);
}
#[test]
fn fling_velocity_diagnostics_are_app_context_owned() {
let scroll_source = workspace_source("crates/cranpose-ui/src/modifier/scroll.rs");
let render_state_source = workspace_source("crates/cranpose-ui/src/render_state.rs");
let desktop_source = crate_source("src/desktop.rs");
assert!(
!scroll_source.contains("LAST_FLING_VELOCITY")
&& !scroll_source.contains("This global state means parallel tests could interfere"),
"fling velocity diagnostics must not use process-global test state"
);
assert!(
render_state_source.contains("last_fling_velocity_bits")
&& render_state_source.contains("record_last_fling_velocity")
&& render_state_source.contains("debug_last_fling_velocity")
&& render_state_source.contains("debug_reset_last_fling_velocity"),
"fling velocity diagnostics should be stored on the owning AppContext"
);
assert!(
desktop_source.contains("GetLastFlingVelocity")
&& desktop_source.contains("ResetLastFlingVelocity")
&& desktop_source
.contains("app.debug_enter_app_context(cranpose_ui::debug_last_fling_velocity)")
&& desktop_source.contains(
"app.debug_enter_app_context(cranpose_ui::debug_reset_last_fling_velocity)"
),
"desktop robots should query fling diagnostics through the app-thread robot channel"
);
}
#[test]
fn text_measurer_installation_requires_app_context() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let render_state_source =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-ui/src/render_state.rs"))
.expect("failed to read render_state.rs");
let text_measure_source =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-ui/src/text/measure.rs"))
.expect("failed to read text measure source");
assert!(
render_state_source.contains("text: crate::text::measure::TextService::new()"),
"AppContext should create its own text service instead of cloning fallback text setup"
);
assert!(
render_state_source.contains("panic!(\"set_text_measurer requires an active AppContext\")"),
"public text measurer installation should require an active AppContext"
);
assert!(
!text_measure_source.contains("fallback_text_measurer_snapshot")
&& !text_measure_source.contains("set_fallback_text_measurer"),
"fallback text service must not be a mutable setup path for future AppContexts"
);
}
#[test]
fn render_text_hyphenation_dictionaries_are_measurer_owned() {
let source = workspace_source("crates/cranpose-render/common/src/text_hyphenation.rs");
assert!(
source.contains("pub struct HyphenationDictionaryStore"),
"hyphenation dictionaries should live in an explicit store owned by the text measurer"
);
assert!(
!source.contains("static DICTIONARIES")
&& !source.contains("OnceLock<RwLock<HashMap<Language, Standard>>>")
&& !source.contains("fn dictionaries() -> &'static"),
"hyphenation dictionaries must not be retained in process-global mutable state"
);
}
#[test]
fn wasm_framework_sources_use_browser_safe_time() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let source_roots = [
"crates/cranpose-core/src",
"crates/cranpose-runtime-std/src",
"crates/cranpose-app-shell/src",
"crates/cranpose-ui/src",
"crates/cranpose-foundation/src",
"crates/cranpose-render/common/src",
"crates/cranpose-render/wgpu/src",
"crates/cranpose-platform/web/src",
];
let source_files = ["crates/cranpose/src/web.rs"];
let mut offenders = Vec::new();
for root in source_roots {
for path in rust_sources(&workspace_dir.join(root)) {
collect_forbidden_time_source_offenders(workspace_dir, &path, &mut offenders);
}
}
for file in source_files {
collect_forbidden_time_source_offenders(
workspace_dir,
&workspace_dir.join(file),
&mut offenders,
);
}
assert!(
offenders.is_empty(),
"wasm-delivered framework code must use web_time for clocks; found unsupported std time in:\n{}",
offenders.join("\n")
);
}
#[test]
fn wasm_time_source_detection_catches_std_time_import_shapes() {
let cases = [
("direct", "use std::time::Instant;\n"),
("alias", "use std::time::Instant as StdInstant;\n"),
(
"grouped_multiline",
"use std::time::{\n Duration,\n Instant,\n};\n",
),
(
"nested_group",
"use std::{collections::HashMap, time::{Duration, SystemTime}};\n",
),
(
"qualified_now",
"fn tick() { let _now = std::time::Instant::now(); }\n",
),
(
"qualified_type",
"fn tick(now: std::time::SystemTime) { let _ = now; }\n",
),
];
for (name, source) in cases {
let mut offenders = Vec::new();
collect_forbidden_time_source_offenders_from_source(
Path::new(name),
source,
&mut offenders,
);
assert_eq!(
offenders.len(),
1,
"{name} should report exactly one std::time offender, got {offenders:?}"
);
}
}
#[test]
fn wasm_time_source_detection_allows_duration_and_web_time() {
let mut offenders = Vec::new();
collect_forbidden_time_source_offenders_from_source(
Path::new("allowed"),
"\
use std::time::Duration;
use web_time::Instant;
fn tick() {
let _delay = Duration::from_millis(16);
let _now = Instant::now();
}
",
&mut offenders,
);
assert!(
offenders.is_empty(),
"Duration and web_time::Instant should remain valid in wasm framework code: {offenders:?}"
);
}
#[test]
fn android_properties_expose_recomposition_diagnostics() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = std::fs::read_to_string(crate_dir.join("src/android_frame_telemetry.rs"))
.expect("read android frame telemetry source");
assert!(source.contains("(\"debug.cranpose.recomp_diag\", \"CRANPOSE_RECOMP_DIAG\")"));
}
#[test]
fn android_presented_frames_feed_the_app_shell_monitor() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = std::fs::read_to_string(crate_dir.join("src/android.rs"))
.expect("read Android host source");
assert!(source.contains("shell.record_presented_frame(frame_started_at, frame_finished_at);"));
}
#[test]
fn android_presented_frame_intervals_are_ordered_without_producer_timing() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = std::fs::read_to_string(crate_dir.join("src/android.rs"))
.expect("read Android host source");
assert!(source.contains(
"let frame_started_at = if frame_started_at_ns > 0 {\n instant_at(frame_started_at_ns).min(frame_finished_at)\n } else {\n frame_finished_at\n };"
));
}
#[test]
fn unsafe_code_stays_in_reviewed_platform_boundary_modules() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let source_dir = crate_dir.join("src");
let allowed = [
"android_display.rs",
"android_entry.rs",
"android_frame_rate.rs",
"android_perf_hint.rs",
"android_frame_telemetry.rs",
"android_jni.rs",
"android_accessibility.rs",
"android_camera.rs",
"android_host.rs",
"android_media.rs",
"android_services.rs",
"android_surface.rs",
"android_file_picker.rs",
"android_purchases.rs",
"android_text_input.rs",
"android_vsync.rs",
"android_writable_folder.rs",
"process_info.rs",
"ios_file_picker.rs",
"ios_uri_handler.rs",
"ios_clipboard.rs",
"ios_share_sheet.rs",
"ios_image_picker.rs",
"ios_pick_future.rs",
"ios_notifier.rs",
"ios_writable_folder.rs",
"apple_camera.rs",
"ios_media.rs",
"ios_keyboard.rs",
"ios_back_gesture.rs",
"ios_scene.rs",
"ios_background.rs",
"ios_host.rs",
"ios_accessibility.rs",
"desktop_accessibility.rs",
];
let mut offenders = Vec::new();
for path in rust_sources(&source_dir) {
let relative = path
.strip_prefix(&source_dir)
.expect("source path should be under src");
let file_name = relative
.file_name()
.and_then(|name| name.to_str())
.expect("source file should have a UTF-8 name");
if allowed.contains(&file_name) {
continue;
}
let source = std::fs::read_to_string(&path).expect("failed to read cranpose source file");
if source_has_unsafe_boundary_escape(&source) {
offenders.push(relative.display().to_string());
}
}
assert!(
offenders.is_empty(),
"unsafe code must stay in reviewed platform boundary modules; found in {offenders:?}"
);
}
#[test]
fn android_surface_boundary_returns_typed_errors() {
let source = crate_source("src/android_surface.rs");
assert!(
source.contains("enum AndroidSurfaceError")
&& source.contains("Result<wgpu::Surface<'static>, AndroidSurfaceError>"),
"Android WGPU surface creation should expose a typed error from the unsafe boundary"
);
assert!(
!source.contains(".expect("),
"Android WGPU surface creation must not panic inside the unsafe boundary"
);
}
#[test]
fn android_gpu_initialization_returns_typed_errors() {
let runtime_source = crate_source("src/android.rs");
let surface_source = crate_source("src/android_surface.rs");
assert!(
!runtime_source.contains(".expect(\"Failed to find suitable adapter\")")
&& !runtime_source.contains(".expect(\"Failed to create device\")"),
"Android GPU initialization should return typed adapter/device errors instead of panicking"
);
assert!(
surface_source.contains("RequestAdapter(#[from] wgpu::RequestAdapterError)")
&& surface_source.contains("RequestDevice(#[from] wgpu::RequestDeviceError)"),
"Android GPU initialization errors should be represented in AndroidSurfaceError"
);
}
#[test]
fn desktop_native_window_gpu_context_absence_returns_launch_error() {
let desktop_source = crate_source("src/desktop.rs");
let launcher_source = crate_source("src/app_launcher.rs");
assert!(
!desktop_source.contains("native windows require an initialized desktop GPU context"),
"native peer-window creation should return LaunchError when the desktop GPU context is unavailable"
);
assert!(
launcher_source.contains("GpuContextUnavailable"),
"LaunchError should represent missing desktop GPU context explicitly"
);
}
#[test]
fn desktop_launch_content_unavailable_returns_launch_error() {
let desktop_source = crate_source("src/desktop.rs");
let launcher_source = crate_source("src/app_launcher.rs");
assert!(
!desktop_source.contains("content already taken"),
"desktop startup should return LaunchError when the content closure is unavailable"
);
assert!(
desktop_source.contains("LaunchError::ContentUnavailable")
&& launcher_source.contains("ContentUnavailable"),
"LaunchError should represent an unavailable desktop content closure explicitly"
);
}
#[test]
fn desktop_run_wrappers_do_not_repanic_typed_launch_errors() {
let desktop_source = crate_source("src/desktop.rs");
let launcher_source = crate_source("src/app_launcher.rs");
assert!(
launcher_source.contains("fn exit_after_launch_error")
&& launcher_source.contains("std::process::exit(1)"),
"desktop run wrappers should share an explicit process-exit boundary for launch failures"
);
assert!(
!launcher_source.contains("panic!(\"desktop launch failed")
&& !desktop_source.contains("panic!(\"failed to launch desktop app"),
"desktop run wrappers should not turn typed LaunchError values back into panics"
);
assert!(
launcher_source.contains("exit_after_launch_error(\"desktop launch failed\", error)")
&& desktop_source.contains(
"crate::app_launcher::exit_after_launch_error(\"desktop launch failed\", error)"
),
"AppLauncher::run, AppLauncher::run_windows, and desktop::run should use the same launch-error exit path"
);
}
#[test]
fn wasm_runtime_scheduler_is_single_threaded() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let platform =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-core/src/platform.rs"))
.expect("failed to read platform.rs");
let runtime =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-core/src/runtime.rs"))
.expect("failed to read runtime.rs");
let std_runtime =
std::fs::read_to_string(workspace_dir.join("crates/cranpose-runtime-std/src/lib.rs"))
.expect("failed to read cranpose-runtime-std");
assert!(
platform.contains(
"#[cfg(not(target_arch = \"wasm32\"))]\npub trait RuntimeScheduler: Send + Sync"
) && platform.contains("#[cfg(target_arch = \"wasm32\")]\npub trait RuntimeScheduler"),
"RuntimeScheduler must keep Send+Sync on native and avoid fake Sync on wasm"
);
assert!(
runtime.contains("runtime_id: RuntimeId")
&& runtime.contains("REGISTERED_RUNTIMES.with")
&& runtime.contains("#[cfg(target_arch = \"wasm32\")]\n fn wake_by_ref"),
"wasm task wakers should route by runtime id instead of storing a Send+Sync scheduler"
);
assert!(
std_runtime.contains("RefCell<Option<Box<dyn Fn() + 'static>>>")
&& std_runtime.contains("pub fn set_frame_waker(&self, waker: impl Fn() + 'static)"),
"wasm frame wakers should not require Send or Sync"
);
}
#[test]
fn workspace_ffi_boundaries_are_explicit() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let source_roots = ["crates", "apps", "xtask"];
let allowed = [
"crates/cranpose/src/android_display.rs",
"crates/cranpose/src/android_entry.rs",
"crates/cranpose/src/android_frame_rate.rs",
"crates/cranpose/src/android_perf_hint.rs",
"crates/cranpose/src/android_frame_telemetry.rs",
"crates/cranpose/src/android_jni.rs",
"crates/cranpose/src/android_accessibility.rs",
"crates/cranpose/src/android_camera.rs",
"crates/cranpose/src/android_host.rs",
"crates/cranpose/src/android_media.rs",
"crates/cranpose/src/android_services.rs",
"crates/cranpose/src/android_surface.rs",
"crates/cranpose/src/android_file_picker.rs",
"crates/cranpose/src/android_purchases.rs",
"crates/cranpose/src/android_text_input.rs",
"crates/cranpose-macros/src/branch_groups.rs",
"crates/cranpose/src/android_vsync.rs",
"crates/cranpose/src/android_writable_folder.rs",
"crates/cranpose/src/process_info.rs",
"crates/cranpose/src/ios_file_picker.rs",
"crates/cranpose/src/ios_uri_handler.rs",
"crates/cranpose/src/ios_clipboard.rs",
"crates/cranpose/src/ios_share_sheet.rs",
"crates/cranpose/src/ios_image_picker.rs",
"crates/cranpose/src/ios_pick_future.rs",
"crates/cranpose/src/ios_notifier.rs",
"crates/cranpose/src/ios_writable_folder.rs",
"crates/cranpose/src/apple_camera.rs",
"crates/cranpose/src/ios_media.rs",
"crates/cranpose/src/ios_keyboard.rs",
"crates/cranpose/src/ios_back_gesture.rs",
"crates/cranpose/src/ios_scene.rs",
"crates/cranpose/src/ios_background.rs",
"crates/cranpose/src/ios_host.rs",
"crates/cranpose/src/ios_accessibility.rs",
"crates/cranpose/src/desktop_accessibility.rs",
"crates/cranpose-storekit/src/apple.rs",
"crates/cranpose-audio/src/ring.rs",
"crates/cranpose-audio/src/backend/aaudio.rs",
"crates/cranpose-render/wgpu/src/worker_pool.rs",
"crates/cranpose-render/wgpu/src/pipeline_disk_cache.rs",
"crates/cranpose-render/wgpu/src/run_entry.rs",
"crates/cranpose-render/wgpu/src/stage_executor.rs",
];
let guard_source = Path::new("crates/cranpose/tests/platform_scheduling_static.rs");
let mut offenders = Vec::new();
for root in source_roots {
for path in rust_sources(&workspace_dir.join(root)) {
let relative = path
.strip_prefix(workspace_dir)
.expect("source path should be under workspace");
if relative == guard_source {
continue;
}
let relative_display = relative.display().to_string();
if allowed.contains(&relative_display.as_str()) {
continue;
}
let source = std::fs::read_to_string(&path).expect("failed to read source file");
if source_has_unsafe_boundary_escape(&source) {
offenders.push(relative_display);
}
}
}
assert!(
offenders.is_empty(),
"workspace unsafe/FFI boundary code must stay in reviewed boundary modules; found in {offenders:?}"
);
}
#[test]
fn unsafe_blocks_have_nearby_safety_invariants() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let boundary_modules = [
"crates/cranpose/src/android_jni.rs",
"crates/cranpose/src/android_surface.rs",
"crates/cranpose/src/ios_accessibility.rs",
"crates/cranpose-audio/src/ring.rs",
"crates/cranpose-audio/src/backend/aaudio.rs",
"crates/cranpose-render/wgpu/src/pipeline_disk_cache.rs",
"crates/cranpose/src/android_entry.rs",
];
let mut offenders = Vec::new();
for module in boundary_modules {
let source = std::fs::read_to_string(workspace_dir.join(module))
.unwrap_or_else(|err| panic!("failed to read {module}: {err}"));
offenders.extend(
unsafe_lines_without_safety_invariant(&source)
.into_iter()
.map(|line| format!("{module}:{line}")),
);
}
assert!(
offenders.is_empty(),
"unsafe blocks must include a nearby SAFETY invariant:\n{}",
offenders.join("\n")
);
}
#[test]
fn workspace_sources_do_not_cfg_on_robot_app_feature() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let source_roots = ["crates", "apps"];
let cfg_feature = ["cfg(feature = \"", "robot-app", "\")"].concat();
let cfg_feature_tight = ["cfg(feature=\"", "robot-app", "\")"].concat();
let cfg_attr_feature = ["cfg_attr(feature = \"", "robot-app", "\""].concat();
let cfg_attr_feature_tight = ["cfg_attr(feature=\"", "robot-app", "\""].concat();
let blocked_patterns = [
cfg_feature,
cfg_feature_tight,
cfg_attr_feature,
cfg_attr_feature_tight,
];
let guard_source = Path::new("crates/cranpose/tests/platform_scheduling_static.rs");
let mut offenders = Vec::new();
for root in source_roots {
for path in rust_sources(&workspace_dir.join(root)) {
let relative = path
.strip_prefix(workspace_dir)
.expect("source path should be under workspace");
if relative == guard_source {
continue;
}
let source = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", relative.display()));
for (line_number, line) in source.lines().enumerate() {
if blocked_patterns
.iter()
.any(|pattern| line.contains(pattern))
{
offenders.push(format!("{}:{}", relative.display(), line_number + 1));
}
}
}
}
assert!(
offenders.is_empty(),
"runtime/source behavior must not be gated on the desktop robot-app feature:\n{}",
offenders.join("\n")
);
}
#[test]
fn every_workspace_member_inherits_the_unsafe_code_denial() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let manifest = std::fs::read_to_string(workspace_dir.join("Cargo.toml"))
.expect("failed to read workspace manifest");
assert!(
manifest.contains("[workspace.lints.rust]"),
"workspace manifest must declare a `[workspace.lints.rust]` table"
);
assert!(
manifest.contains("unsafe_code = \"deny\""),
"`[workspace.lints.rust]` must deny `unsafe_code`"
);
let members = workspace_members(&manifest);
assert!(
!members.is_empty(),
"workspace manifest declared no members"
);
let missing = members
.iter()
.filter(|member| {
let path = workspace_dir.join(member).join("Cargo.toml");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
!manifest_inherits_workspace_lints(&text)
})
.cloned()
.collect::<Vec<_>>();
assert!(
missing.is_empty(),
"every workspace member must carry `[lints] workspace = true`; missing in {missing:?}"
);
}
fn workspace_members(manifest: &str) -> Vec<String> {
let Some(rest) = manifest.split_once("members = [").map(|(_, rest)| rest) else {
return Vec::new();
};
let Some((list, _)) = rest.split_once(']') else {
return Vec::new();
};
list.lines()
.filter_map(|line| {
let trimmed = line.trim();
let inner = trimmed.strip_prefix('"')?;
inner.split('"').next().map(str::to_owned)
})
.collect()
}
fn manifest_inherits_workspace_lints(manifest: &str) -> bool {
let Some(rest) = manifest.split_once("[lints]").map(|(_, rest)| rest) else {
return false;
};
rest.lines()
.take_while(|line| !line.trim_start().starts_with('['))
.any(|line| {
let normalised = line.split_whitespace().collect::<String>();
normalised == "workspace=true"
})
}
#[test]
fn workspace_sources_avoid_half_state_language() {
let cranpose_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_dir = cranpose_dir
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under workspace crates directory");
let source_roots = ["crates", "apps", "docs"];
let single_files = ["README.md"];
let blocked_terms = [
("TO", "DO:"),
("TO", "DO!("),
("FIX", "ME"),
("leg", "acy"),
("old ", "way"),
("when ", "implemented"),
("migra", "tion"),
("work", "around"),
("backward ", "compat"),
("backwards ", "compat"),
]
.map(|(left, right)| format!("{left}{right}").to_lowercase());
let guard_source = Path::new("crates/cranpose/tests/platform_scheduling_static.rs");
let mut offenders = Vec::new();
for root in source_roots {
for path in text_sources(&workspace_dir.join(root)) {
let relative = path
.strip_prefix(workspace_dir)
.expect("source path should be under workspace");
if relative == guard_source {
continue;
}
collect_blocked_language_offenders(
workspace_dir,
relative,
&blocked_terms,
&mut offenders,
);
}
}
for file in single_files {
collect_blocked_language_offenders(
workspace_dir,
Path::new(file),
&blocked_terms,
&mut offenders,
);
}
assert!(
offenders.is_empty(),
"workspace text should describe the current architecture directly; found prohibited half-state wording:\n{}",
offenders.join("\n")
);
}
fn source_has_unsafe_boundary_escape(source: &str) -> bool {
source.lines().any(|line| {
let trimmed = line.trim();
if trimmed.starts_with("//") {
return false;
}
line_has_unsafe_token(&strip_quoted_spans(trimmed))
})
}
fn strip_quoted_spans(line: &str) -> String {
let bytes = line.as_bytes();
let mut out = String::with_capacity(line.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'\'' => match char_literal_len(bytes, index) {
Some(len) => index += len,
None => {
out.push('\'');
index += 1;
}
},
b'"' => index += string_literal_len(bytes, index),
byte => {
out.push(byte as char);
index += 1;
}
}
}
out
}
fn char_literal_len(bytes: &[u8], start: usize) -> Option<usize> {
let mut index = start + 1;
if index < bytes.len() && bytes[index] == b'\\' {
index += 1;
}
index += 1;
if index < bytes.len() && bytes[index] == b'\'' {
return Some(index + 1 - start);
}
None
}
fn string_literal_len(bytes: &[u8], start: usize) -> usize {
let mut index = start + 1;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
b'"' => return index + 1 - start,
_ => index += 1,
}
}
index - start
}
#[test]
fn the_unsafe_guard_still_catches_real_unsafe_code() {
for source in [
"unsafe { ffi_call() }",
" unsafe fn raw(&self) {}",
"let message = \"ok\"; unsafe { ffi_call() }",
"if quote == '\"' { unsafe { ffi_call() } }",
"unsafe impl Send for Handle {}",
] {
assert!(
source_has_unsafe_boundary_escape(source),
"guard missed real unsafe code: {source:?}"
);
}
}
#[test]
fn the_unsafe_guard_ignores_the_word_inside_a_literal() {
for source in [
"const RUST_KEYWORDS: &[&str] = &[\"async\", \"unsafe\", \"extern\"];",
" \"unsafe\" | \"extern\" => TokenKind::Keyword,",
"// unsafe in a comment",
"let label = \"unsafe\";",
] {
assert!(
!source_has_unsafe_boundary_escape(source),
"guard fired on a literal, not on code: {source:?}"
);
}
}
fn line_has_unsafe_token(line: &str) -> bool {
let bytes = line.as_bytes();
let mut from = 0;
while let Some(found) = line[from..].find("unsafe") {
let start = from + found;
let end = start + "unsafe".len();
let word = |byte: u8| byte.is_ascii_alphanumeric() || byte == b'_';
let starts_token = start == 0 || !word(bytes[start - 1]);
let ends_token = end >= bytes.len() || !word(bytes[end]);
if starts_token && ends_token {
return true;
}
from = end;
}
false
}
fn unsafe_lines_without_safety_invariant(source: &str) -> Vec<usize> {
let lines = source.lines().collect::<Vec<_>>();
lines
.iter()
.enumerate()
.filter_map(|(index, line)| {
if !line_requires_safety_invariant(line) {
return None;
}
let start = index.saturating_sub(3);
let has_safety = lines[start..index]
.iter()
.any(|previous| previous.trim_start().starts_with("// SAFETY:"));
(!has_safety).then_some(index + 1)
})
.collect()
}
fn line_requires_safety_invariant(line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("#![") || trimmed.starts_with("#[") {
return false;
}
trimmed.contains("unsafe {")
|| trimmed.contains("unsafe{")
|| trimmed.starts_with("unsafe fn ")
|| trimmed.contains(" unsafe fn ")
|| trimmed.starts_with("unsafe impl ")
|| trimmed.contains(" unsafe impl ")
}
fn collect_blocked_language_offenders(
workspace_dir: &Path,
relative: &Path,
blocked_terms: &[String],
offenders: &mut Vec<String>,
) {
let path = workspace_dir.join(relative);
let source = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", relative.display()));
for (line_number, line) in source.lines().enumerate() {
let lower = line.to_lowercase();
if let Some(term) = blocked_terms
.iter()
.find(|term| lower.contains(term.as_str()))
{
offenders.push(format!(
"{}:{}: contains `{}`",
relative.display(),
line_number + 1,
term
));
}
}
}
fn collect_forbidden_time_source_offenders(
workspace_dir: &Path,
path: &Path,
offenders: &mut Vec<String>,
) {
let relative = path
.strip_prefix(workspace_dir)
.expect("source path should be under workspace");
let source = std::fs::read_to_string(path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", relative.display()));
collect_forbidden_time_source_offenders_from_source(relative, &source, offenders);
}
fn collect_forbidden_time_source_offenders_from_source(
relative: &Path,
source: &str,
offenders: &mut Vec<String>,
) {
let mut pending_use = String::new();
let mut pending_use_start_line = 0;
for (index, line) in source.lines().enumerate() {
let line_number = index + 1;
let Some(code) = rust_code_before_line_comment(line) else {
continue;
};
let trimmed = code.trim_start();
if trimmed.is_empty() || trimmed.starts_with("#![") || trimmed.starts_with("#[") {
continue;
}
if !pending_use.is_empty() {
pending_use.push(' ');
pending_use.push_str(trimmed);
if trimmed.contains(';') {
if let Some(reason) = forbidden_std_time_import_reason(&pending_use) {
offenders.push(format!(
"{}:{}: {reason}",
relative.display(),
pending_use_start_line
));
}
pending_use.clear();
pending_use_start_line = 0;
}
continue;
}
if starts_use_statement(trimmed) {
pending_use_start_line = line_number;
pending_use.push_str(trimmed);
if trimmed.contains(';') {
if let Some(reason) = forbidden_std_time_import_reason(&pending_use) {
offenders.push(format!(
"{}:{}: {reason}",
relative.display(),
pending_use_start_line
));
}
pending_use.clear();
pending_use_start_line = 0;
}
continue;
}
let normalized = rust_path_source(trimmed);
if let Some(fragment) = forbidden_std_time_path_fragment(&normalized) {
offenders.push(format!(
"{}:{}: uses `{fragment}`",
relative.display(),
line_number
));
}
}
if !pending_use.is_empty()
&& let Some(reason) = forbidden_std_time_import_reason(&pending_use)
{
offenders.push(format!(
"{}:{}: {reason}",
relative.display(),
pending_use_start_line
));
}
}
fn rust_code_before_line_comment(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("///") || trimmed.starts_with("//!") {
return None;
}
line.split_once("//")
.map(|(before_comment, _)| before_comment)
.or(Some(line))
}
fn starts_use_statement(trimmed: &str) -> bool {
trimmed.starts_with("use ")
|| trimmed.starts_with("pub use ")
|| (trimmed.starts_with("pub(") && trimmed.contains(" use "))
}
fn forbidden_std_time_import_reason(statement: &str) -> Option<&'static str> {
let normalized = rust_path_source(statement);
if forbidden_std_time_path_fragment(&normalized).is_some() {
return Some("imports unsupported std::time::Instant/SystemTime");
}
if std_time_group_contains_forbidden_member(&normalized) {
return Some("imports unsupported std::time::Instant/SystemTime");
}
if std_nested_group_contains_forbidden_time_member(&normalized) {
return Some("imports unsupported std::time::Instant/SystemTime");
}
None
}
fn forbidden_std_time_path_fragment(normalized: &str) -> Option<&'static str> {
if normalized.contains("std::time::Instant") {
return Some("std::time::Instant");
}
if normalized.contains("std::time::SystemTime") {
return Some("std::time::SystemTime");
}
None
}
fn std_time_group_contains_forbidden_member(normalized: &str) -> bool {
group_contents_after(normalized, "std::time::{").is_some_and(contains_forbidden_time_member)
}
fn std_nested_group_contains_forbidden_time_member(normalized: &str) -> bool {
group_contents_after(normalized, "std::{").is_some_and(|std_group| {
std_group.contains("time::Instant")
|| std_group.contains("time::SystemTime")
|| group_contents_after(std_group, "time::{")
.is_some_and(contains_forbidden_time_member)
})
}
fn contains_forbidden_time_member(group: &str) -> bool {
rust_path_segment_exists(group, "Instant") || rust_path_segment_exists(group, "SystemTime")
}
fn rust_path_segment_exists(source: &str, segment: &str) -> bool {
let mut remaining = source;
while let Some(offset) = remaining.find(segment) {
let before = remaining[..offset].chars().next_back();
let after = remaining[offset + segment.len()..].chars().next();
if before.is_none_or(|ch| !rust_identifier_char(ch))
&& after.is_none_or(|ch| !rust_identifier_char(ch))
{
return true;
}
remaining = &remaining[offset + segment.len()..];
}
false
}
fn rust_identifier_char(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphanumeric()
}
fn group_contents_after<'a>(source: &'a str, prefix: &str) -> Option<&'a str> {
let start = source.find(prefix)? + prefix.len();
let mut depth = 1usize;
for (offset, ch) in source[start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&source[start..start + offset]);
}
}
_ => {}
}
}
Some(&source[start..])
}
fn rust_path_source(source: &str) -> String {
source.chars().filter(|ch| !ch.is_whitespace()).collect()
}
fn text_sources(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
collect_text_sources(root, &mut out);
out
}
fn collect_text_sources(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).expect("failed to read source directory") {
let path = entry.expect("failed to read source directory entry").path();
if path.is_dir() {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if matches!(name, "target" | ".git" | ".gradle" | "build") {
continue;
}
collect_text_sources(&path, out);
continue;
}
let extension = path.extension().and_then(|extension| extension.to_str());
if matches!(extension, Some("rs" | "md" | "toml" | "sh")) {
out.push(path);
}
}
}
fn rust_sources(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
collect_rust_sources(root, &mut out);
out
}
#[test]
fn every_store_backend_tells_the_app_rather_than_leaving_it_to_ask() {
let android = crate_source("src/android_purchases.rs");
let apple = workspace_source("crates/cranpose-storekit/src/apple.rs");
for (backend, source) in [("android", &android), ("apple", &apple)] {
assert!(
source.contains("note_store_news()"),
"the {backend} store backend must announce news through note_store_news()"
);
}
for entry_point in [
"Java_dev_cranpose_android_CranposeBilling_nativeBillingSnapshot",
"Java_dev_cranpose_android_CranposeBilling_nativeBillingEvent",
] {
let body = android
.split(entry_point)
.nth(1)
.unwrap_or_else(|| panic!("{entry_point} should exist in android_purchases.rs"));
let body = body
.split("pub extern \"system\"")
.next()
.expect("an entry point body should be delimited by the next one");
assert!(
body.contains("note_store_news()"),
"{entry_point} decodes store news and must announce it, not only wake the loop"
);
}
}
#[test]
fn storekit_bridge_exposes_listener_liveness_and_rebuilds_it() {
let apple = workspace_source("crates/cranpose-storekit/src/apple.rs");
let swift = workspace_source("crates/cranpose-storekit/swift/storekit.swift");
assert!(apple.contains("cranpose_storekit_is_connected"));
assert!(apple.contains("fn is_connected(&self) -> bool"));
assert!(swift.contains("cranpose_storekit_is_connected"));
assert!(swift.contains("_listenerActive"));
assert!(swift.contains("_listenerActive = false"));
assert!(swift.contains("if !_listenerActive"));
}
#[test]
fn android_service_registration_replaces_the_relaunch_waker() {
let services = crate_source("src/android_services.rs");
assert!(services.contains("LOOP_WAKER.get_or_init"));
assert!(services.contains("*waker = Some(app.create_waker())"));
assert!(!services.contains("let _ = LOOP_WAKER.set"));
}
#[test]
fn the_gradle_plugin_declares_the_jni_library_directory_cargo_writes() {
let plugin = workspace_source(CRANPOSE_GRADLE_PLUGIN);
assert!(
plugin.contains("jniLibs.directories.add(nativeOutput.absolutePath)"),
"the plugin must point the Android source sets at the directory cargo-ndk writes"
);
assert!(
plugin.contains("outputs.dir(nativeOutput)"),
"the cargo-ndk task must declare the directory it writes as its output, or Gradle \
keeps a stale snapshot of the jniLibs directory and the APK ships the previous \
build's .so"
);
assert!(
plugin.contains("outputs.upToDateWhen { false }"),
"the cargo-ndk task declares an output directory, so it also needs \
outputs.upToDateWhen {{ false }} or Gradle will skip the cargo build whenever that \
directory happens to be unchanged"
);
assert!(
plugin.contains("task.name.contains(\"NativeLibs\")")
&& plugin.contains("task.name.contains(\"JniLibFolders\")"),
"the cargo build must be wired to mergeJniLibFolders as well as mergeNativeLibs -- \
both consume the directory it writes"
);
}
#[test]
fn android_applications_build_their_native_library_through_the_plugin() {
for relative in ANDROID_APPLICATION_BUILD_FILES {
let source = workspace_source(relative);
assert!(
source.contains("id(\"dev.cranpose.android\")"),
"{relative} must apply the Cranpose Gradle plugin rather than configuring an \
Android application by hand"
);
assert!(
!source.contains("cargo ndk"),
"{relative} runs cargo ndk itself; the plugin owns the native build, the ABIs, \
the Cargo profiles and the output declaration that keeps the APK from shipping \
a stale library"
);
assert!(
!source.contains("jniLibs.directories.add"),
"{relative} points a source set at the native output itself; the plugin does \
that, together with declaring the task output that keeps it fresh"
);
}
}
#[test]
fn android_applications_do_not_declare_the_framework_activity() {
for relative in ANDROID_APPLICATION_MANIFESTS {
let manifest = strip_xml_comments(&workspace_source(relative));
assert!(
!manifest.contains("<activity"),
"{relative} declares an activity; the Cranpose library contributes the activity, \
its launcher filter and its lib_name metadata to every application's manifest"
);
assert!(
!manifest.contains("android.app.lib_name"),
"{relative} names the cdylib itself; the plugin supplies that name from \
cranpose {{ cargoPackage }} so it cannot drift from what Cargo builds"
);
}
}
#[test]
fn the_browser_host_shares_the_wheel_policy() {
let web = crate_source("src/web.rs");
assert!(
web.contains("app_mut.wheel_scrolled(wheel)"),
"the browser wheel listener must go through the shell's shared wheel policy, \
so zoom, rotary and scroll mean the same thing they do on every other host"
);
assert!(
!web.contains("app_mut.pointer_scrolled("),
"the browser host must not reach past wheel_scrolled to the scroll step: that \
skips rotary and re-opens the sign question the shared policy settles"
);
}
#[test]
fn the_browser_host_installs_a_platform_clipboard() {
assert!(
crate_source("src/web.rs").contains("crate::web_clipboard::install("),
"the browser host must install a platform clipboard, or the in-tree selection \
menu's Copy/Cut never leave the page"
);
}
fn collect_rust_sources(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).expect("failed to read cranpose source directory") {
let path = entry.expect("failed to read source directory entry").path();
if path.is_dir() {
collect_rust_sources(&path, out);
} else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") {
out.push(path);
}
}
}
#[test]
fn every_host_reports_its_surface_the_same_way() {
for (relative, host) in [
("src/android.rs", "Android"),
("src/desktop.rs", "the desktop"),
("src/ios.rs", "iOS"),
("src/web_host_surface.rs", "the browser"),
] {
let source = crate_source(relative);
assert!(
source.contains("publish_host_surface_size("),
"{host} must publish its surface size, or `host_density` and \
`rememberHostSurfaceSize` answer for every target but this one"
);
}
}
#[test]
fn the_launcher_resolves_the_platform_font_directory_itself() {
let launcher = crate_source("src/app_launcher.rs");
assert!(
launcher.contains("crate::system_font_directory()"),
"with_system_fonts must resolve the platform's font directory rather than \
asking the application for a path"
);
assert!(
crate_source("src/host_environment.rs").contains("ANDROID_SYSTEM_FONT_DIR"),
"the resolver must name Android's directory rather than leaving the app to"
);
}
#[test]
fn the_android_host_takes_its_log_tag_from_the_launcher() {
let android = crate_source("src/android.rs");
assert!(
android.contains("settings.log_tag.as_deref().unwrap_or(DEFAULT_LOG_TAG)"),
"the Android host must log under the tag the application named"
);
assert!(
!android.contains("\"ComposeRS\""),
"the framework is Cranpose; a stale name in logcat sends anyone reading \
them looking for the wrong project"
);
}
#[test]
fn applications_declare_their_android_entry_through_the_macro() {
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("cranpose crate should live under the workspace crates directory");
let mut offenders = Vec::new();
let mut declarations = 0usize;
for root in ["apps"] {
let mut sources = Vec::new();
collect_rust_sources(&workspace.join(root), &mut sources);
for path in sources {
let source = std::fs::read_to_string(&path).expect("failed to read application source");
let relative = path
.strip_prefix(workspace)
.expect("source should live under the workspace")
.display()
.to_string();
if source.contains("cranpose::android_main!") {
declarations += 1;
}
if source.contains("pub fn android_main(") || source.contains("fn android_main(") {
offenders.push(relative);
}
}
}
assert!(
offenders.is_empty(),
"an application must declare its entry point with `cranpose::android_main!` rather \
than exporting the symbol itself; found in {offenders:?}"
);
assert!(
declarations >= 2,
"expected the demo and the standalone starter to declare entry points, saw \
{declarations}"
);
}
#[test]
fn the_android_installer_verifies_a_package_before_committing_it() {
let java =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let install = java
.split("public void cranposeInstallUpdate(")
.nth(1)
.expect("the Android installer entry point");
let commit = install
.find("session.commit(")
.expect("the installer must commit a session");
let verify = install
.find("digest.digest()")
.expect("the installer must compute the package's digest");
assert!(
verify < commit,
"the digest must be checked before the session is committed, or the check \
happens after the package is already on its way to being installed"
);
assert!(
install.contains("does not match its digest"),
"a mismatch must fail the install rather than being logged and ignored"
);
assert!(
java.contains("throw new IOException(\"unsupported package digest algorithm: \""),
"a digest this platform cannot compute must fail rather than being skipped: a \
check nobody performs reads as a package that was verified"
);
assert!(
!install.contains("digest != null"),
"there is no unverified path through the installer: a package reaches it with a \
digest or it does not reach it at all"
);
}
#[test]
fn the_framework_owns_one_package_digest() {
let update = workspace_source("crates/cranpose-services/src/app_update.rs");
assert!(
update.contains("pub struct DigestVerifier"),
"the framework must own a package verifier rather than leaving each platform \
installer to write its own"
);
assert!(
update.contains("pub fn install_app_update(package: &UpdatePackage)"),
"an install must take the package the feed described — its size and digest \
included — rather than a bare URL nothing can be checked against"
);
let install = update
.split("pub fn install_app_update(package: &UpdatePackage)")
.nth(1)
.expect("the install entry point");
assert!(
install.contains("AppUpdateError::Unverifiable"),
"a package the framework cannot check must be refused: this is the one download \
that replaces the application, and a feed that publishes no digest is a feed to \
fix rather than a check to skip"
);
}
#[test]
fn the_android_camera_pushes_frames_rather_than_writing_them_to_files() {
let camera =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java");
let activity =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
camera.contains("CranposeActivity.onCameraFrame("),
"preview frames must be pushed to native code rather than left in a file to be found"
);
assert!(
!camera.contains("compressToJpeg"),
"a preview frame must not be JPEG-encoded to cross the language boundary"
);
for name in ["preview.jpg", "capture.jpg", "capture.ok"] {
assert!(
!camera.contains(name) && !activity.contains(name),
"the camera must not transport {name} through the filesystem"
);
}
assert!(
!activity.contains("Thread.sleep(20)"),
"a still must arrive rather than be waited for in a sleep loop"
);
assert!(
camera.contains("CranposeActivity.onCameraFrameDropped()"),
"a frame the device produced while the previous one was in flight must be counted, \
so a detector that falls behind falls behind by frames rather than by memory"
);
}
#[test]
fn the_camera_service_is_published_to_rather_than_polled() {
let camera = workspace_source("crates/cranpose-services/src/camera.rs");
assert!(
!camera.contains("fn latest_frame(&self)"),
"a camera backend must publish frames, not answer polls for them"
);
assert!(
!camera.contains("fn capture_still(&self)"),
"a still must be asked for and arrive, not be returned by a call that waits"
);
assert!(
camera.contains("pub fn publish_camera_frame(")
&& camera.contains("fn request_still(&self)"),
"the contract is publish-a-frame and ask-for-a-still"
);
}
const MEDIA_BACKENDS: [&str; 4] = [
"crates/cranpose-media/src/player.rs",
ANDROID_MEDIA_BACKEND,
"crates/cranpose/src/ios_media.rs",
"crates/cranpose/src/web_media.rs",
];
const TRANSPORT_BACKENDS: [&str; 3] = [
"crates/cranpose-media/src/player.rs",
"crates/cranpose/src/ios_media.rs",
"crates/cranpose/src/web_media.rs",
];
const ANDROID_MEDIA_BACKEND: &str = "crates/cranpose/src/android_media.rs";
#[test]
fn the_media_service_is_published_to_rather_than_polled() {
let media = workspace_source("crates/cranpose-services/src/media.rs");
assert!(
!media.contains("fn position(&self)") && !media.contains("fn state(&self)"),
"a media backend must publish where it is and what it is doing, not answer polls for them"
);
assert!(
media.contains("pub fn publish_playback_state(")
&& media.contains("pub fn publish_playback_progress("),
"the contract is publish-what-happened"
);
for backend in TRANSPORT_BACKENDS {
let source = workspace_source(backend);
assert!(
source.contains("publish_playback_state"),
"{backend} must publish what it is doing"
);
}
}
#[test]
fn the_android_media_backend_wraps_the_in_process_player_rather_than_decoding() {
let source = workspace_source(ANDROID_MEDIA_BACKEND);
assert!(
source.contains("SoftwareMediaPlayer"),
"the Android backend must play through the framework's own decoder"
);
let java =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeMedia.java");
assert!(
!java.contains("android.media.MediaPlayer"),
"Android's MediaPlayer cannot read a document a provider streams, so the \
session class must not reach for it again"
);
assert!(
java.contains("AudioManager") && java.contains("MediaSession"),
"what is left to Java is the half only Java has: audio focus and the lock screen"
);
}
#[test]
fn every_media_backend_states_what_it_can_do() {
for backend in MEDIA_BACKENDS {
let source = workspace_source(backend);
assert!(
source.contains("fn capabilities(&self)"),
"{backend} must report its capabilities rather than let a screen assume them"
);
}
}
#[test]
fn every_media_backend_states_the_equalizer_it_has() {
for backend in TRANSPORT_BACKENDS {
let source = workspace_source(backend);
assert!(
source.contains("equalizer:"),
"{backend} must state whether it has an equalizer in its capabilities"
);
if source.contains("equalizer: false") {
continue;
}
assert!(
source.contains("fn equalizer_bands(&self)"),
"{backend} claims an equalizer but never reports the bands it has"
);
assert!(
source.contains("fn set_equalizer(&self"),
"{backend} claims an equalizer but never applies a curve"
);
}
}
#[test]
fn the_audio_focus_policy_lives_in_the_framework() {
let media = workspace_source("crates/cranpose-services/src/media.rs");
assert!(
media.contains("pub fn publish_audio_focus(") && media.contains("PAUSED_BY_FOCUS"),
"the framework decides what a lost focus means for playback, and remembers whether it \
was the one that paused"
);
for backend in MEDIA_BACKENDS {
let source = workspace_source(backend);
for decision in ["pause_media(", "stop_media(", "play_media("] {
assert!(
!source.contains(decision),
"{backend} must publish what the device did and leave `{decision}` to the \
framework's one policy"
);
}
}
}
#[test]
fn android_media_declares_the_foreground_service_it_needs() {
let manifest = workspace_source(&cranpose_manifest("media"));
assert!(
manifest.contains("android:foregroundServiceType=\"mediaPlayback\""),
"playback that outlives the surface needs a mediaPlayback service"
);
assert!(
!manifest.contains("uses-permission"),
"the framework declares no permission; the application states what it asks for"
);
let plugin = workspace_source(CRANPOSE_GRADLE_PLUGIN);
assert!(
plugin.contains("\"media\","),
"an application asks for the media service by name, so the plugin must know it"
);
assert!(
plugin.contains("android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"),
"the plugin must know the permission the mediaPlayback service needs"
);
let capabilities = workspace_source(CRANPOSE_CAPABILITIES);
assert!(
capabilities.contains("android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"),
"an application declaring the media service must get that permission written for it"
);
}
#[test]
fn the_android_background_service_ask_survives_launch_shaped_pauses() {
let java =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let on_pause = method_body(&java, "protected void onPause()");
assert!(
!on_pause.contains("startCranposeBackgroundService(")
&& !on_pause.contains("startForegroundService("),
"onPause must not ask for the foreground service synchronously: a launch with the \
screen off pauses without ever resuming, the deferred service is never created, \
and the framework ends the process for the missing startForeground"
);
assert!(
on_pause.contains("askForCranposeBackgroundService("),
"a pause with background work active must still route through the guarded ask, \
or backgrounded work loses its foreground service entirely"
);
let ask = method_body(&java, "private void askForCranposeBackgroundService()");
assert!(
ask.contains("if (!cranposeEverResumed)"),
"a lifetime that never reached the foreground must not ask: Android accepts \
startForegroundService from it, defers creating the service, and ends the \
process when nothing calls startForeground"
);
assert!(
ask.contains("postDelayed("),
"the ask must wait on a handler: an EMUI launch delivers pause and resume \
together, and a synchronous ask from that pause is the same broken promise"
);
let on_resume = method_body(&java, "protected void onResume()");
assert!(
on_resume.contains("cranposeEverResumed = true"),
"onResume is what turns a lifetime into one that may ask"
);
assert!(
on_resume.contains("removeCallbacks("),
"a resume must cancel a pending ask, or a passing pause still promises a \
startForeground nothing will deliver"
);
let fire = method_body(&java, "private void startCranposeBackgroundService()");
assert!(
fire.contains("if (!cranposeBackgroundActive || !cranposePaused)"),
"the deferred ask must re-check at fire time: work that finished or an activity \
that resumed while the ask waited must not leave an orphan foreground service"
);
assert!(
fire.contains("catch (RuntimeException"),
"a start the platform refuses outright (background-start restrictions past the \
grace window) is the platform's answer, to be logged rather than to crash"
);
let set_active = method_body(
&java,
"public void cranposeSetBackgroundActive(boolean active)",
);
let confined = set_active
.find("runOnUiThread(")
.zip(set_active.find("cranposeBackgroundActive = active"))
.is_some_and(|(post, write)| post < write);
assert!(
confined,
"the JNI thread must not write cranposeBackgroundActive itself: onPause reads it \
on the UI thread, and an unsynchronized cross-thread write is a data race"
);
assert!(
set_active.contains("removeCallbacks("),
"work that finishes must cancel a pending ask along with stopping the service, \
or the ask fires later and starts a foreground service with nothing to protect"
);
}
#[test]
fn the_android_background_service_never_stops_before_start_foreground() {
let activity =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
!activity.contains("stopService("),
"the activity must route every stop through CranposeBackgroundService.stop: a \
Context.stopService that lands before the service's startForeground is a \
deliberate framework kill, and the activity cannot know whether it would"
);
assert!(
activity.contains("CranposeBackgroundService.stop(this)"),
"the stop the activity wants still has to happen — through the handshake"
);
assert!(
activity.contains("CranposeBackgroundService.noteStartRequested()"),
"every start must arm the obligation record first, so a stale stop from the \
previous cycle cannot end the service the moment it comes up"
);
let service = workspace_source(
"crates/cranpose/android/java/dev/cranpose/android/CranposeBackgroundService.java",
);
let stop = method_body(&service, "static void stop(Context context)");
assert!(
stop.contains("if (obligationArmed)") && stop.contains("context.stopService("),
"stop() may only forward to Context.stopService once startForeground has met \
every armed obligation; before that it records the wish and waits"
);
let enter = method_body(&service, "private void enterForeground()");
let met = enter.find("obligationArmed = false");
let honoured = enter.find("if (stopRequested)");
assert!(
met.zip(honoured)
.is_some_and(|(met, honoured)| met < honoured)
&& enter.contains("startForeground("),
"the service itself honours a deferred stop, and only after its startForeground \
has run — the order is the whole point"
);
}
fn method_body<'a>(java: &'a str, signature: &str) -> &'a str {
let start = java
.find(signature)
.unwrap_or_else(|| panic!("CranposeActivity should declare `{signature}`"));
let body = &java[start..];
let open = body
.find('{')
.unwrap_or_else(|| panic!("`{signature}` should have a body"));
let mut depth = 0usize;
for (offset, byte) in body.bytes().enumerate().skip(open) {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return &body[..=offset];
}
}
_ => {}
}
}
panic!("`{signature}` body should close");
}
#[test]
fn applications_ask_for_platform_permissions_by_service() {
for relative in ANDROID_APPLICATION_MANIFESTS {
let manifest = strip_xml_comments(&workspace_source(relative));
for permission in [
"android.permission.VIBRATE",
"android.permission.POST_NOTIFICATIONS",
"android.permission.CAMERA",
"android.permission.FOREGROUND_SERVICE",
"android.permission.SYSTEM_ALERT_WINDOW",
] {
assert!(
!manifest.contains(permission),
"{relative} declares {permission}; a Cranpose application asks for the service \
that needs it through `cranpose {{ services }}` instead"
);
}
}
}
#[test]
fn the_framework_packages_its_own_billing_java() {
let plugin = workspace_source(CRANPOSE_GRADLE_PLUGIN);
assert!(
plugin.contains("\"billing\" to \"java-billing\""),
"the plugin must add the framework's billing sources for the billing service"
);
assert!(
plugin.contains("com.android.billingclient:billing"),
"the plugin must add the library that class compiles against"
);
for relative in ANDROID_APPLICATION_BUILD_FILES {
let source = workspace_source(relative);
assert!(
!source.contains("java-billing"),
"{relative} must not point a source set at the framework's billing sources"
);
}
}
#[test]
fn the_framework_declares_the_provider_its_own_sharing_needs() {
let library = workspace_source(&cranpose_manifest("base"));
assert!(
library.contains("dev.cranpose.android.CranposeShareProvider"),
"the library manifest must declare the provider that serves shared files"
);
assert!(
library.contains("${applicationId}.cranpose.share"),
"the share provider authority must be derived from the application id"
);
for relative in ANDROID_APPLICATION_MANIFESTS {
let manifest = strip_xml_comments(&workspace_source(relative));
assert!(
!manifest.contains("CranposeShareProvider"),
"{relative} declares the framework's share provider; the library declares it"
);
}
}
#[test]
fn installing_an_update_asks_for_its_permission_through_a_service() {
let plugin = workspace_source(CRANPOSE_GRADLE_PLUGIN);
assert!(
plugin.contains("\"update\" to listOf(\"android.permission.REQUEST_INSTALL_PACKAGES\")"),
"the plugin must know the permission PackageInstaller requires of the update service"
);
let capabilities = workspace_source(CRANPOSE_CAPABILITIES);
assert!(
capabilities.contains("android.permission.REQUEST_INSTALL_PACKAGES"),
"an application declaring the update service must get that permission written for it"
);
let library = workspace_source(&cranpose_manifest("base"));
assert!(
!library.contains("REQUEST_INSTALL_PACKAGES"),
"every Cranpose application would ask to install packages; keep it in the update module"
);
for relative in ANDROID_APPLICATION_MANIFESTS {
let manifest = strip_xml_comments(&workspace_source(relative));
assert!(
!manifest.contains("REQUEST_INSTALL_PACKAGES"),
"{relative} declares REQUEST_INSTALL_PACKAGES; add the `update` service instead"
);
}
}
#[test]
fn the_plugin_drives_abi_splits_from_the_architectures_it_builds() {
let plugin = workspace_source(CRANPOSE_GRADLE_PLUGIN);
assert!(
plugin.contains("split.include(*releaseAbis.toTypedArray())"),
"the plugin must write the release architectures into an enabled ABI split"
);
for relative in ANDROID_APPLICATION_BUILD_FILES {
let source = workspace_source(relative);
assert!(
!source.contains("abiFilters"),
"{relative} sets abiFilters; the plugin constrains packaging to what it builds"
);
}
}
#[test]
fn every_service_the_plugin_offers_says_what_it_needs() {
let plugin = workspace_source(CRANPOSE_GRADLE_PLUGIN);
let known = plugin
.split("val KNOWN_SERVICES = setOf(")
.nth(1)
.and_then(|rest| rest.split(')').next())
.expect("the plugin should list the services it knows");
let services: Vec<&str> = known
.split(',')
.map(|entry| entry.trim().trim_matches('"'))
.filter(|entry| !entry.is_empty())
.collect();
assert!(
services.len() >= 5,
"the plugin should know several services, found {services:?}"
);
let capabilities = workspace_source(CRANPOSE_CAPABILITIES);
for service in services {
let components = workspace_path(&cranpose_manifest(service)).is_file();
let permissions = plugin.contains(&format!("\"{service}\" to listOf("));
assert!(
components || permissions,
"the plugin offers `{service}` but neither contributes components for it nor knows \
what it asks the device for"
);
assert!(
capabilities.contains(&format!("Service::{}", pascal(service))),
"the plugin offers `{service}` but an application cannot declare it in Rust"
);
}
}
#[test]
fn every_platform_bridge_pages_a_scroll_container() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) vertical_scroll: Option<ScrollAxisRange>")
&& projection_source.contains("pub(crate) fn scroll_by(")
&& projection_source.contains("pub(crate) fn page_delta("),
"a scroll container and the page it moves by are platform-neutral"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("Role::ScrollView")
&& desktop_source.contains("Action::ScrollDown")
&& desktop_source.contains("pub(crate) fn run_scroll_requests("),
"accesskit should read a scroll view and take a scroll action back"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("#[unsafe(method(accessibilityScroll:))]")
&& ios_source.contains("fn drain_scrolls<R>(")
&& ios_source.contains("accessibility::scroll_container_for("),
"VoiceOver pages with a three-finger swipe on the element under its cursor"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android_accessibility.rs");
let rust_shell_source = crate_source("src/android.rs");
let wire_source = crate_source("src/android_accessibility_wire.rs");
assert!(
java_source.contains("AccessibilityNodeInfo.ACTION_SCROLL_FORWARD")
&& java_source.contains("nativeOnAccessibilityScroll(")
&& android_source
.contains("Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilityScroll")
&& rust_shell_source
.contains("drain_accessibility_scrolls(shell, &accessibility_elements);"),
"TalkBack pages a scrollable node through the wire and the shell drains it each frame"
);
assert!(
java_source.contains("info.setParent(host, element.scrollParent);")
&& java_source
.contains("if (child.scrollParent == element.id) info.addChild(host, child.id);")
&& wire_source.contains("fn scroll_parent_ids("),
"a row sits under its list in the virtual view tree, so TalkBack's page gesture on the row reaches the list"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("\"PageDown\"")
&& web_source.contains("data-cranpose-page")
&& web_source.contains("attach_page_listener(&root"),
"a keyboard reader pages the mirror with Page Down and Page Up"
);
}
#[test]
fn every_platform_lets_a_reader_leave_a_dialog() {
let shell_source = workspace_source("crates/cranpose-app-shell/src/shell_input.rs");
assert!(
shell_source.contains("fn on_escape_key(")
&& shell_source.contains("pub fn dismiss_top_modal(")
&& shell_source.contains("cranpose_ui::dispatch_modal_back()")
&& shell_source.contains("cranpose_ui::dismiss_top_popup()"),
"Escape on a keyboard closes the modal on top through the same stack the back gesture uses"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("#[unsafe(method(accessibilityPerformEscape))]")
&& ios_source.contains("fn drain_escapes<R>(")
&& ios_source.contains("shell.dismiss_top_modal() || accessibility::request_back()"),
"a VoiceOver two-finger scrub closes the dialog on top, or goes back"
);
let android_source = crate_source("src/android.rs");
assert!(
android_source.contains("android_activity::input::Keycode::Back")
&& android_source.contains("cranpose_services::push_back_request()"),
"TalkBack's back gesture is the system back key, which the shell already takes"
);
let web_source = crate_source("src/web.rs");
assert!(
web_source.contains("(\"Escape\", KeyCode::Escape)")
&& web_source.contains("document.add_event_listener_with_callback(\"keydown\""),
"Escape reaches the shell from the mirror too, because the key listener sits on the document"
);
}
#[test]
fn every_reader_action_runs_inside_the_app_context() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) fn run_reader_action<R>(")
&& projection_source.contains("let context = std::rc::Rc::clone(shell.app_context());")
&& projection_source.contains("cranpose_core::run_in_mutable_snapshot(|| {"),
"a reader action writes state and invalidates layout, which the render state refuses outside the app context"
);
for path in [
"src/desktop_accessibility.rs",
"src/ios_accessibility.rs",
"src/android.rs",
"src/web_accessibility.rs",
] {
let source = crate_source(path);
assert!(
source.contains("run_reader_action("),
"{path} should run its reader actions through the app context wrapper"
);
let action_source = source.replace(
"accessibility::find_semantics_node(tree.root(), element.node_id)",
"",
);
assert!(
!action_source.contains("(tree.root(),") && !action_source.contains("(tree.root()"),
"{path} reaches the live tree outside the app context; route it through run_reader_action"
);
}
}
#[test]
fn every_platform_bridge_offers_custom_actions() {
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("CustomAction")
&& desktop_source.contains("pub(crate) fn run_custom_actions("),
"accesskit lists custom actions and takes one back"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("#[unsafe(method(performAccessibilityCustomAction:))]")
&& ios_source.contains("setAccessibilityCustomActions(")
&& ios_source.contains("fn drain_custom_actions<R>("),
"VoiceOver lists custom actions in its actions rotor and hands one back by name"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android.rs");
assert!(
java_source.contains("AccessibilityAction(")
&& android_source.contains("fn drain_accessibility_custom_actions("),
"TalkBack lists custom actions on the node and the shell drains them each frame"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("data-cranpose-action")
&& web_source.contains("fn update_actions(")
&& web_source.contains("attach_action_listener(&root"),
"the web mirror puts one button per custom action after the control"
);
}
#[test]
fn every_platform_offers_the_long_press_of_a_control() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) fn reader_actions(")
&& projection_source.contains("pub(crate) fn long_click("),
"the projection names the long press for the readers that list it and runs it for the one that does not"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android.rs");
assert!(
java_source.contains("AccessibilityNodeInfo.ACTION_LONG_CLICK, element.longClickLabel")
&& java_source.contains("nativeOnAccessibilityLongClick(element.id);")
&& android_source.contains("fn drain_accessibility_long_clicks("),
"TalkBack gets Android's own long-click action with its label, and the shell drains it each frame"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
let ios_source = crate_source("src/ios_accessibility.rs");
let web_source = crate_source("src/web_accessibility.rs");
assert!(
desktop_source.contains("accessibility::reader_actions(element)")
&& ios_source.contains("accessibility::reader_actions(element)")
&& web_source.contains("accessibility::reader_actions(element)"),
"accesskit, VoiceOver and ARIA have no long press of their own, so the three list it as the last action"
);
}
#[test]
fn a_dialog_takes_the_reader_along_when_it_opens() {
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("accessibility::opened_dialog(")
&& ios_source.contains("UIAccessibilityPostNotification(notification, landing);"),
"VoiceOver gets a screen change aimed at the dialog that opened"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("accessibility::opened_dialog(")
&& web_source.contains("if opened_dialog == Some(element.node_id) {"),
"the web mirror focuses the dialog node that opened"
);
}
#[test]
fn every_platform_takes_a_reader_to_a_row_by_number() {
let scroll_source = workspace_source("crates/cranpose-ui/src/modifier/scroll.rs");
assert!(
scroll_source.contains("config.scroll_to_index = Some(")
&& scroll_source.contains("fn jump_to_row("),
"a lazy list says on its own what it does when a reader names a row"
);
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) scroll_to_index: bool")
&& projection_source.contains("pub(crate) fn scroll_to_index(")
&& projection_source.contains("pub(crate) fn row_count("),
"the row a reader asked for and the rows a list holds are platform-neutral"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("Action::SetScrollOffset")
&& desktop_source.contains("fn apply_row_offset(")
&& desktop_source.contains("pub(crate) fn run_jump_requests("),
"accesskit has no scroll-to-index action, so the list carries its rows as a scroll offset"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("#[unsafe(method(cranposeJumpToFirstRow:))]")
&& ios_source.contains("#[unsafe(method(cranposeJumpToLastRow:))]")
&& ios_source.contains("fn drain_jumps<R>("),
"VoiceOver has no scroll-to-index action, so the two ends of the list sit in the actions rotor"
);
let java_source =
workspace_source("crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java");
let android_source = crate_source("src/android_accessibility.rs");
let rust_shell_source = crate_source("src/android.rs");
let wire_source = crate_source("src/android_accessibility_wire.rs");
assert!(
java_source.contains(
"info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_TO_POSITION);"
) && java_source.contains("nativeOnAccessibilityScrollToIndex(element.id, row);")
&& android_source.contains(
"Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilityScrollToIndex"
)
&& rust_shell_source
.contains("drain_accessibility_jumps(shell, &accessibility_elements);")
&& wire_source.contains("i32::from(element.scroll_to_index),"),
"TalkBack names a row through Android's own scroll-to-position action"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("\"Home\"")
&& web_source.contains("\"End\"")
&& web_source.contains("data-cranpose-last-row")
&& web_source.contains("fn jump_mirror("),
"ARIA has no scroll-to-index action, so Home and End reach the ends of the list"
);
}
#[test]
fn every_lazy_list_tells_android_how_many_rows_it_holds() {
let scroll_source = workspace_source("crates/cranpose-ui/src/modifier/scroll.rs");
assert!(
scroll_source.contains("config.collection = Some(cranpose_foundation::CollectionInfo {"),
"a lazy list declares its row count with its scroll range"
);
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("info.setCollectionInfo(AccessibilityNodeInfo.CollectionInfo.obtain(")
&& java_source.contains("private static final int ACCESSIBILITY_FIELDS = 41;"),
"the Android host hands TalkBack the row count of a list"
);
}
#[test]
fn every_platform_lets_a_reader_find_an_empty_text_field() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("let label = node.accessibility_label();")
&& workspace_source("crates/cranpose-ui/src/layout/semantics_labels.rs")
.contains("self.editable_text.then_some(Cow::Borrowed(\"\"))"),
"the projection publishes an editable field even with nothing to read"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("!element.label.is_empty() || element.role.is_text_field()"),
"VoiceOver stops on an empty text field"
);
}
#[test]
fn every_platform_speaks_a_control_that_changed_under_the_cursor() {
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("fn respeak_under_cursor(&self, changed: &[bool]) {")
&& ios_source.contains(
"let changed = accessibility::spoken_changes(&self.snapshot.elements, &next);"
),
"VoiceOver reads the element under its cursor again when its words changed"
);
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("| AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION);")
&& java_source.contains("private void announceChanges() {"),
"TalkBack gets a content-changed event for a control that says something new"
);
let bridge_source = crate_source("src/android_accessibility.rs");
assert!(
bridge_source.contains(
"let changed = accessibility::spoken_changes(&previous.elements, &elements);"
),
"the Android bridge marks the controls that changed"
);
}
#[test]
fn every_platform_says_which_tab_of_how_many() {
let tab_bar = workspace_source("crates/cranpose-liquid/src/widgets/tab_bar.rs");
assert!(
tab_bar.contains(" .selectable_group()"),
"the liquid tab bar declares its tabs as one group"
);
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains(
"info.setCollectionItemInfo(AccessibilityNodeInfo.CollectionItemInfo.obtain("
),
"TalkBack gets each tab's place in its group"
);
let ios_source = voiceover_value_source();
assert!(
ios_source.contains("parts.push(format!(\"{} of {}\", item.position, item.count));"),
"VoiceOver reads the tab's place as its value"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("node.set_attribute(\"aria-posinset\", &item.position.to_string())?;"),
"the web mirror sets the tab's position in its set"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("node.set_position_in_set(item.position.saturating_sub(1));"),
"accesskit gets the tab's position in its set"
);
}
#[test]
fn a_reader_can_hand_a_field_its_text_on_android_and_the_desktop() {
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("info.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT);")
&& java_source.contains(
"nativeOnAccessibilitySetText(element.id, text == null ? \"\" : text.toString());"
),
"the Android host offers and forwards the set-text action"
);
let bridge_source = crate_source("src/android_accessibility.rs");
assert!(
bridge_source.contains(
"fn Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilitySetText("
),
"the Android bridge takes the text"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("accessibility::set_text(root, node_id, &text)")
&& desktop_source.contains("(Action::SetValue, Some(ActionData::Value(text))) => {"),
"accesskit's set-value action reaches the field"
);
}
#[test]
fn every_platform_reads_a_pane_title_when_the_app_moves_on() {
for source in [
crate_source("src/android_accessibility.rs"),
crate_source("src/ios_accessibility.rs"),
crate_source("src/web_accessibility.rs"),
crate_source("src/desktop_accessibility.rs"),
] {
assert!(
source.contains("accessibility::pane_title_announcements("),
"every bridge reads a changed pane title out"
);
}
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("info.setPaneTitle(element.paneTitle);"),
"the Android host carries the pane title on its node"
);
}
#[test]
fn the_desktop_tree_keeps_rows_under_their_list() {
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("fn nested_children(")
&& desktop_source.contains("node.set_children(below);"),
"accesskit gets each row under its list and each tab under its group"
);
}
#[test]
fn every_platform_lets_a_reader_move_the_caret_of_a_field() {
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("pub(crate) fn set_text_selection(")
&& projection_source.contains("text_selection: node"),
"the projection publishes the caret and takes a new selection"
);
let text_field = workspace_source("crates/cranpose-ui/src/text_field_modifier_node.rs");
assert!(
text_field.contains(
"config.set_selection = Some(cranpose_foundation::SemanticsSetSelection::new("
),
"the text field declares the set-selection action"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains(
"(Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => {"
) && desktop_source.contains("run.set_character_lengths(")
&& desktop_source
.contains("accessibility::set_text_selection_chars(root, node_id, anchor, focus)"),
"accesskit gets text runs and its set-text-selection action reaches the field"
);
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source
.contains("info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);")
&& java_source.contains("info.setTextSelection(")
&& java_source
.contains("nativeOnAccessibilitySetSelection(element.id, anchor, moved);"),
"the Android host walks text by granularity and forwards the caret"
);
let bridge_source = crate_source("src/android_accessibility.rs");
assert!(
bridge_source.contains(
"fn Java_dev_cranpose_android_CranposeActivity_nativeOnAccessibilitySetSelection("
),
"the Android bridge takes the selection"
);
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("crate::ios_keyboard::describe_for_reader("),
"VoiceOver gets the focused field as the keyboard's UITextInput view"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("\"selectionchange\"")
&& web_source.contains("accessibility::set_text_selection(")
&& web_source.contains("accessibility::byte_offset_for_utf16(&value, anchor)")
&& web_source.contains("accessibility::byte_offset_for_utf16(&value, focus)")
&& web_source.contains("fn reconcile_children(")
&& web_source.contains("attach_input_listener(&root")
&& !web_source.contains("set_inner_html(\"\")"),
"the web mirror is an input whose caret goes both ways without a rebuild"
);
}
#[test]
fn every_platform_with_a_reader_signal_reports_it_and_voiceover_takes_the_magic_tap() {
let ios_source = crate_source("src/ios_accessibility.rs");
assert!(
ios_source.contains("screen_reader_on: UIAccessibilityIsVoiceOverRunning(),")
&& ios_source.contains("#[unsafe(method(accessibilityPerformMagicTap))]")
&& ios_source
.contains("native.setAccessibilityUserInputLabels(input_labels.as_deref(), mtm);")
&& ios_source.contains("native.setAccessibilityLanguage("),
"VoiceOver reports its state and takes the magic tap, the input labels and the language"
);
for (source, platform) in [
(crate_source("src/android_accessibility.rs"), "Android"),
(crate_source("src/desktop_accessibility.rs"), "accesskit"),
] {
assert!(
source.contains("cranpose_services::set_platform_accessibility_state(reader_on)"),
"{platform} reports whether a reader is on"
);
}
let ios_loop = crate_source("src/ios.rs");
assert!(
ios_loop.contains("accessibility.drain_magic_taps(shell);"),
"the iOS frame loop runs the magic tap"
);
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains(".chain(element.magic_tap_label.clone())"),
"the other platforms list the magic tap by its label"
);
let web_source = crate_source("src/web_accessibility.rs");
assert!(
web_source.contains("node.set_attribute(\"lang\", language)?;"),
"the web mirror carries the language"
);
let desktop_source = crate_source("src/desktop_accessibility.rs");
assert!(
desktop_source.contains("node.set_language(language.as_str());"),
"accesskit carries the language"
);
}
#[test]
fn the_android_host_names_every_role_the_projection_has() {
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
for expected in [
"case 14: return \"android.widget.EditText\";",
"case 15: return \"android.widget.ProgressBar\";",
"case 16: return \"android.widget.ToggleButton\";",
"case 22: return \"android.widget.ListView\";",
"case 13: return \"link\";",
"case 20: return \"menu item\";",
"case 21: return \"tab bar\";",
"return role == 3 || role == 14;",
"return role == 10 || role == 18 || role == 19 || role == 21 || role == 22 || role == 24;",
] {
assert!(
java_source.contains(expected),
"the Android host should carry `{expected}`"
);
}
let projection_source = crate_source("src/accessibility.rs");
assert!(
projection_source.contains("(AccessibilityRole::ListItem, 23),"),
"the projection numbers every role for the wire"
);
}
#[test]
fn every_platform_says_why_a_field_is_wrong() {
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("info.setContentInvalid(true);")
&& java_source.contains("info.setError(element.error);"),
"TalkBack gets the node's error"
);
for source in [
crate_source("src/web_accessibility.rs"),
crate_source("src/desktop_accessibility.rs"),
] {
assert!(
source.contains("accessibility::state_with_error(element)"),
"every other bridge reads the reason after the state"
);
}
assert!(voiceover_value_source().contains("parts.extend(state_with_error(element));"));
}
#[test]
fn no_platform_reads_a_password_out() {
assert!(
crate_source("android/java/dev/cranpose/android/CranposeActivity.java")
.contains("if (element.password) info.setPassword(true);"),
"TalkBack reads the node as a password"
);
assert!(
crate_source("src/desktop_accessibility.rs").contains("Role::PasswordInput"),
"accesskit reads the node as a password input"
);
assert!(
crate_source("src/web_accessibility.rs").contains(r#""aria-roledescription", "password""#),
"the web mirror says the field is a password"
);
assert!(
crate_source("src/accessibility.rs").contains(".filter(|_| !node.password),"),
"the text never leaves the projection"
);
}
#[test]
fn every_platform_reads_the_traversal_order_from_the_projection() {
assert!(
crate_source("src/accessibility.rs")
.contains("for child in node.accessibility_children() {"),
"the projection puts the nodes in the order a reader walks them"
);
for source in [
crate_source("src/ios_accessibility.rs"),
crate_source("src/web_accessibility.rs"),
crate_source("src/desktop_accessibility.rs"),
crate_source("src/android_accessibility_wire.rs"),
] {
assert!(
!source.contains(".sort_by_key(|element| element.bounds"),
"no bridge sorts the elements again on its own"
);
}
}
#[test]
fn every_platform_opens_and_closes_a_control() {
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("AccessibilityNodeInfo.ACTION_EXPAND")
&& java_source.contains("nativeOnAccessibilityExpand(element.id,"),
"TalkBack offers the ask and it crosses back"
);
assert!(
crate_source("src/desktop_accessibility.rs").contains("node.set_expanded(expanded);"),
"accesskit says whether the control is open"
);
assert!(
crate_source("src/web_accessibility.rs").contains(r#""aria-expanded""#),
"the web mirror says whether the control is open"
);
assert!(
voiceover_value_source()
.contains("parts.extend(expansion_word(element).map(str::to_owned));"),
"VoiceOver hears the word"
);
}
#[test]
fn every_platform_sends_a_control_away() {
let java_source = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
assert!(
java_source.contains("AccessibilityNodeInfo.ACTION_DISMISS")
&& java_source.contains("nativeOnAccessibilityDismiss(element.id);"),
"TalkBack offers the ask and it crosses back"
);
assert!(
crate_source("src/ios_accessibility.rs").contains("native.set_dismissable(element."),
"a VoiceOver two-finger scrub reaches the control itself"
);
for source in [
crate_source("src/desktop_accessibility.rs"),
crate_source("src/web_accessibility.rs"),
] {
assert!(
source.contains("accessibility::listed_actions(element)"),
"accesskit and ARIA carry no dismiss action, so the way out is listed beside the named actions"
);
assert!(
source.contains("accessibility::perform_listed_action("),
"the picked action runs against the live tree"
);
}
}
#[test]
fn every_platform_names_a_dropdown_and_a_picker() {
assert!(
crate_source("android/java/dev/cranpose/android/CranposeActivity.java")
.contains("android.widget.Spinner"),
"TalkBack reads the node as a dropdown"
);
assert!(
crate_source("src/desktop_accessibility.rs").contains("Role::ComboBox"),
"accesskit reads the node as a combo box"
);
assert!(
crate_source("src/accessibility.rs").contains(r#""combobox""#),
"the web mirror names the role"
);
assert!(
crate_source("src/ios_accessibility.rs").contains("AccessibilityRole::ValuePicker"),
"VoiceOver steps a value picker"
);
}
#[test]
fn an_icon_cannot_be_drawn_without_an_answer_about_its_name() {
for source in [
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../cranpose-ui/src/widgets/icon.rs"),
)
.expect("the ui icon source"),
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../cranpose-liquid/src/icons.rs"),
)
.expect("the liquid icon source"),
] {
assert!(
source.contains("content_description: Option<String>,"),
"the caller says what the icon is, or says it is decoration"
);
}
}
#[test]
fn a_keyboard_presses_and_moves_among_controls_and_a_ring_shows_where_it_is() {
let shell_input = workspace_source("crates/cranpose-app-shell/src/shell_input.rs");
assert!(
shell_input.contains("matches!(event.key_code, KeyCode::Enter | KeyCode::Space)"),
"Enter and Space press the focused control"
);
assert!(
shell_input.contains("cranpose_ui::selectable_group_of(layout_tree, focused)?"),
"an arrow key moves focus inside a selectable group"
);
assert!(
shell_input.contains("self.note_focus_moved_by_keyboard(false);"),
"a pointer press takes the focus ring away"
);
let clickable = workspace_source("crates/cranpose-ui/src/modifier/clickable.rs");
assert!(
clickable.contains(".focusable()")
&& clickable.matches("self.then(pressable(modifier))").count() == 2,
"every clickable control takes keyboard focus and shows the ring"
);
for path in [
"crates/cranpose-liquid/src/widgets/tab_bar.rs",
"crates/cranpose-liquid/src/widgets/segmented.rs",
"crates/cranpose-liquid/src/widgets/menu.rs",
"crates/cranpose-liquid/src/widgets/toggle.rs",
"crates/cranpose-liquid/src/widgets/button.rs",
] {
assert!(
workspace_source(path).contains(".focusable()"),
"{path} takes keyboard focus"
);
}
}
#[test]
fn a_test_audits_a_screen_and_a_robot_prints_what_a_reader_speaks() {
let audit = workspace_source("crates/cranpose-app-shell/src/accessibility_audit.rs");
for kind in [
"NoName",
"SameName",
"SmallTarget",
"OutOfOrder",
"NoPaneTitle",
"UnnamedImage",
] {
assert!(
audit.contains(&format!("AccessibilityIssueKind::{kind}")),
"the audit reports {kind}"
);
}
let demo_test = workspace_source("apps/desktop-demo/tests/accessibility_audit.rs");
assert!(
demo_test.contains("for info in DEMO_TAB_INFO.iter()")
&& demo_test.contains("audit_accessibility(&placed)"),
"every demo tab runs under the audit"
);
let desktop_loop = crate_source("src/desktop.rs");
assert!(
desktop_loop.contains("RobotCommand::AuditAccessibility => audit_response(app)")
&& desktop_loop.contains("crate::accessibility::spoken_tree(app)"),
"the robot answers spoken_tree and the audit from the app thread"
);
let android_test = workspace_source(
"apps/android-demo/android/app/src/androidTest/java/com/compose_rs/demo/CranposeAccessibilityAuditTest.java",
);
let android_build = workspace_source("apps/android-demo/android/app/build.gradle.kts");
assert!(
android_test.contains("AccessibilityCheckPreset.LATEST")
&& android_build.contains("accessibility-test-framework"),
"the Android instrumented tests run the Accessibility Test Framework"
);
}
#[test]
fn every_platform_reports_the_display_options_and_the_framework_acts_on_them() {
let ios = crate_source("src/ios_accessibility.rs");
assert!(
ios.contains("reduce_motion: UIAccessibilityIsReduceMotionEnabled(),")
&& ios.contains("font_scale: dynamic_type_scale(&category),")
&& ios.contains("host_view.setAccessibilityIgnoresInvertColors(true);"),
"iOS reads the five switches and Dynamic Type, and inverts its own colors"
);
let android_java = crate_source("android/java/dev/cranpose/android/CranposeActivity.java");
let android_rust = crate_source("src/android_accessibility.rs");
assert!(
android_java
.contains("nativeOnAccessibilityOptions(reduceMotion, increaseContrast, boldText);")
&& android_rust
.contains("accessibility::apply_accessibility_options(shell, system_options());"),
"Android reports animations off, high contrast text and bold text"
);
let web = crate_source("src/web_accessibility_options.rs");
for query in [
"(prefers-reduced-motion: reduce)",
"(prefers-reduced-transparency: reduce)",
"(prefers-contrast: more)",
] {
assert!(web.contains(query), "the web watches {query}");
}
let desktop = crate_source("src/desktop_accessibility_options.rs");
assert!(
desktop.contains("\"defaults\"")
&& desktop.contains("\"gsettings\"")
&& desktop.contains("\"reg\"")
&& desktop.contains("CRANPOSE_REDUCE_MOTION")
&& crate_source("src/desktop_accessibility.rs")
.contains("OptionsProbe::start(!robot_drives)"),
"the desktop asks macOS, GNOME and Windows, takes an environment override, and leaves the host alone under a robot"
);
let animation = workspace_source("crates/cranpose-animation/src/animation.rs");
let material = workspace_source("crates/cranpose-liquid/src/material.rs");
let theme = workspace_source("crates/cranpose-liquid/src/theme.rs");
assert!(
animation.contains("platform_accessibility_options().reduce_motion")
&& material.contains("options.reduce_transparency")
&& theme.contains("options.increase_contrast")
&& theme.contains("options.bold_text")
&& theme.contains("!= options.invert_colors"),
"animations, glass and the theme follow the options"
);
}