commit ae2677b73b3c4d3f134027b35dfffe6862dc85ec
parent 36e08b79fbfb53d106532e78f3cb0bf781b8ed02
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 16:26:52 +0000
test: lock HarvestCircle visual regressions
- add deterministic light and dark macOS reference surfaces
- provide a governed golden refresh command and host guard
- lock screenshot digests into the owned design-source baseline
- reject superseded adapters, Material imports, and missing design modules
Diffstat:
7 files changed, 250 insertions(+), 7 deletions(-)
diff --git a/Makefile b/Makefile
@@ -17,14 +17,17 @@ else
override BUILD_RUNNER :=
endif
-.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean
+.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean
help:
- @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check notarization-check release-check clean
+ @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check notarization-check release-check clean
design-source-check: doctor
HARVESTCIRCLE_BUILD_MODE=$(BUILD_MODE) $(BUILD_RUNNER) $(CARGO) run --manifest-path $(XTASK_MANIFEST) --locked -- design-source-audit
+design-goldens-update: doctor
+ $(BUILD_RUNNER) $(GRADLE) --no-daemon -Pharvestcircle.updateMacosGoldens=true :app:shared:desktopTest --tests org.harvestcircle.ui.shell.HarvestCircleMacGoldenTest
+
doctor:
@printf '%s\n' "harvestcircle.build.mode=$(BUILD_MODE)"
$(BUILD_RUNNER) java -version
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
@@ -1,3 +1,10 @@
plugins {
id("org.harvestcircle.build.kmp-shared")
}
+
+tasks.named<Test>("desktopTest") {
+ systemProperty("harvestcircle.projectDir", rootProject.layout.projectDirectory.asFile.absolutePath)
+ if (providers.gradleProperty("harvestcircle.updateMacosGoldens").orNull == "true") {
+ systemProperty("harvestcircle.updateMacosGoldens", "true")
+ }
+}
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt
@@ -0,0 +1,160 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.toPixelMap
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant
+import org.harvestcircle.designsystem.component.HarvestCircleContentTone
+import org.harvestcircle.designsystem.component.HarvestCircleTextRole
+import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton
+import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge
+import org.harvestcircle.designsystem.component.feedback.HarvestCircleBanner
+import org.harvestcircle.designsystem.component.feedback.HarvestCircleBannerTone
+import org.harvestcircle.designsystem.component.navigation.HarvestCircleNavigationItem
+import org.harvestcircle.designsystem.layout.HarvestCirclePane
+import org.harvestcircle.designsystem.layout.HarvestCircleSidebar
+import org.harvestcircle.designsystem.layout.HarvestCircleSidebarSectionHeader
+import org.harvestcircle.designsystem.layout.HarvestCircleToolbar
+import org.harvestcircle.designsystem.primitive.HarvestCircleText
+import org.harvestcircle.designsystem.theme.HarvestCircleDesignTheme
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+import org.harvestcircle.designsystem.theme.HarvestCircleThemeConfig
+import org.harvestcircle.designsystem.theme.HarvestCircleThemeMode
+import java.awt.image.BufferedImage
+import java.io.File
+import javax.imageio.ImageIO
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+@OptIn(ExperimentalTestApi::class)
+class HarvestCircleMacGoldenTest {
+ @Test
+ fun macosAarch64LightAndDarkReferenceSurfacesMatch() {
+ val updating = System.getProperty("harvestcircle.updateMacosGoldens") == "true"
+ if (!isMacosAarch64()) {
+ check(!updating) { "HarvestCircle design goldens may only be updated on macos-aarch64" }
+ return
+ }
+
+ listOf(HarvestCircleThemeMode.Light, HarvestCircleThemeMode.Dark).forEach { mode ->
+ val actual = captureReferenceSurface(mode)
+ val resourceName = "goldens/macos-aarch64/design-surface-${mode.name.lowercase()}.png"
+ if (updating) {
+ val projectDir = assertNotNull(System.getProperty("harvestcircle.projectDir"))
+ val output = File(projectDir, "app/shared/src/desktopTest/resources/$resourceName")
+ output.parentFile.mkdirs()
+ check(ImageIO.write(actual, "png", output))
+ } else {
+ val stream =
+ assertNotNull(
+ javaClass.classLoader.getResourceAsStream(resourceName),
+ "Missing golden resource: $resourceName",
+ )
+ val expected = assertNotNull(stream.use { ImageIO.read(it) }, "Unreadable golden resource: $resourceName")
+ assertEquals(expected.width, actual.width, "$resourceName width")
+ assertEquals(expected.height, actual.height, "$resourceName height")
+ assertContentEquals(expected.argb(), actual.argb(), resourceName)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTestApi::class)
+private fun captureReferenceSurface(mode: HarvestCircleThemeMode): BufferedImage {
+ lateinit var captured: ImageBitmap
+ runComposeUiTest {
+ setContent {
+ HarvestCircleDesignTheme(HarvestCircleThemeConfig(mode = mode)) {
+ Row(Modifier.size(width = 760.dp, height = 420.dp).testTag("golden-surface")) {
+ HarvestCircleSidebar(Modifier.width(180.dp)) {
+ HarvestCircleSidebarSectionHeader("HarvestCircle")
+ HarvestCircleNavigationItem(true, {}, "Today")
+ HarvestCircleNavigationItem(false, {}, "Network")
+ HarvestCircleNavigationItem(false, {}, "Settings")
+ Spacer(Modifier.weight(1f))
+ HarvestCircleText(
+ "Local-first ยท Nostr",
+ modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.md),
+ role = HarvestCircleTextRole.LabelSmall,
+ tone = HarvestCircleContentTone.Muted,
+ )
+ }
+ Column(Modifier.weight(1f).fillMaxHeight()) {
+ HarvestCircleToolbar {
+ HarvestCircleText("Today", role = HarvestCircleTextRole.SectionTitle)
+ Spacer(Modifier.weight(1f))
+ HarvestCircleBadge("Signed out")
+ }
+ HarvestCirclePane(
+ modifier = Modifier.fillMaxSize(),
+ role = org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole.Canvas,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
+ HarvestCircleText("Coordinate local food", role = HarvestCircleTextRole.PageTitle)
+ HarvestCircleText("Clear, signed terms for farms and nearby buyers.")
+ HarvestCircleBanner(
+ message = "No managed HarvestCircle service is configured.",
+ tone = HarvestCircleBannerTone.Info,
+ title = "Local runtime",
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) {
+ HarvestCircleLabeledButton("Open a Nostr reference", "Open a Nostr reference", {})
+ HarvestCircleLabeledButton(
+ "Explore circles",
+ "Explore circles",
+ {},
+ enabled = false,
+ variant = HarvestCircleButtonVariant.Secondary,
+ )
+ }
+ HarvestCircleText(
+ "Not available in this build.",
+ role = HarvestCircleTextRole.BodySmall,
+ tone = HarvestCircleContentTone.Secondary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ waitForIdle()
+ captured = onNodeWithTag("golden-surface").captureToImage()
+ }
+ return captured.toBufferedImage()
+}
+
+private fun ImageBitmap.toBufferedImage(): BufferedImage {
+ val pixels = toPixelMap()
+ return BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).also { image ->
+ for (y in 0 until height) {
+ for (x in 0 until width) {
+ image.setRGB(x, y, pixels[x, y].toArgb())
+ }
+ }
+ }
+}
+
+private fun BufferedImage.argb(): IntArray = getRGB(0, 0, width, height, null, 0, width)
+
+private fun isMacosAarch64(): Boolean =
+ System.getProperty("os.name").equals("Mac OS X", ignoreCase = true) &&
+ System.getProperty("os.arch").lowercase() in setOf("aarch64", "arm64")
diff --git a/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png b/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png
Binary files differ.
diff --git a/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png b/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png
Binary files differ.
diff --git a/config/design/source_baseline_v1.toml b/config/design/source_baseline_v1.toml
@@ -7,8 +7,10 @@ snapshot_sha256 = "914c5d3b81c95bded19e36044cece8cff93e74cce0d4089324bb2effa4eeb
snapshot_file_count = 91
source_license = "GPL-3.0-only"
golden_host = "macos-aarch64"
-golden_status = "pending"
+golden_status = "verified"
golden_source_snapshot_sha256 = "914c5d3b81c95bded19e36044cece8cff93e74cce0d4089324bb2effa4eebb93"
+golden_light_sha256 = "844a42a37d05671a9665a75214ee40f9c23dafd90c8a5e199d831daede3faf20"
+golden_dark_sha256 = "ce06a1ae820005adddebd8f627b5402279d3365762305f8dd64004612322eae7"
[[mapping]]
source = "core/designsystem/src/commonMain"
diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs
@@ -419,6 +419,10 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St
"app/shared/src/commonMain/kotlin/org/harvestcircle/product/SurfaceRegistry.kt",
"app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt",
"app/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt",
+ "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTheme.kt",
+ "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt",
+ "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/feedback/HarvestCircleBadge.kt",
+ "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt",
"app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt",
"app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt",
"app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt",
@@ -636,6 +640,28 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St
.filter(|character| !character.is_whitespace())
.collect::<String>();
for (shape, diagnostic) in [
+ ("funShellText(", "superseded shell text adapter"),
+ ("funShellButton(", "superseded shell button adapter"),
+ ("funShellTextField(", "superseded shell field adapter"),
+ (
+ "enumclassShellTextRole",
+ "superseded shell text-role adapter",
+ ),
+ (
+ "enumclassShellButtonKind",
+ "superseded shell button-kind adapter",
+ ),
+ ] {
+ if compact.contains(shape) {
+ findings.push(format!("{path}: {diagnostic}"));
+ }
+ }
+ if source.contains("androidx.compose.material") {
+ findings.push(format!(
+ "{path}: Material component dependency is forbidden"
+ ));
+ }
+ for (shape, diagnostic) in [
(
"dataobjectConfirmIdentityRemoval",
"retired parameterless confirmation source shape",
@@ -708,8 +734,6 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St
));
}
if is_production_compose(path, &source)
- && path
- != "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt"
&& !path.starts_with(
"app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/",
)
@@ -755,6 +779,16 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St
}
}
}
+ for forbidden in [
+ "app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt",
+ "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt",
+ ] {
+ if inventory.paths.iter().any(|path| path == forbidden) {
+ findings.push(format!(
+ "{forbidden}: superseded product-shell authority returned"
+ ));
+ }
+ }
}
fn is_forbidden_documentation_or_workflow_path(path: &str) -> bool {
@@ -875,7 +909,7 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St
"snapshot_file_count = 91",
"source_license = \"GPL-3.0-only\"",
"golden_host = \"macos-aarch64\"",
- "golden_status = \"pending\"",
+ "golden_status = \"verified\"",
];
for scalar in required_scalars {
if source.lines().filter(|line| line.trim() == scalar).count() != 1 {
@@ -893,6 +927,33 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St
findings.push(format!("{PATH}: {key} must match the governed snapshot"));
}
}
+ for (path, key, sha256) in [
+ (
+ "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png",
+ "golden_light_sha256",
+ "844a42a37d05671a9665a75214ee40f9c23dafd90c8a5e199d831daede3faf20",
+ ),
+ (
+ "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png",
+ "golden_dark_sha256",
+ "ce06a1ae820005adddebd8f627b5402279d3365762305f8dd64004612322eae7",
+ ),
+ ] {
+ let authority = format!("{key} = \"{sha256}\"");
+ if source
+ .lines()
+ .filter(|line| line.trim() == authority)
+ .count()
+ != 1
+ {
+ findings.push(format!("{PATH}: {key} must match the governed golden"));
+ }
+ if !inventory.paths.iter().any(|candidate| candidate == path)
+ || sha256_file(&root.join(path)).as_deref() != Some(sha256)
+ {
+ findings.push(format!("{path}: macOS golden is missing or changed"));
+ }
+ }
let mappings = design_source_mappings(&source, PATH, findings);
let required = [
(
@@ -1539,7 +1600,7 @@ mod tests {
write(
&root,
path,
- "import androidx.compose.runtime.Composable\n@Composable fun Moved() { Color(0xFF000000); BasicText(\"bypass\"); BasicTextField(\"\", {}) }\n",
+ "import androidx.compose.runtime.Composable\nimport androidx.compose.material.Button\n@Composable fun ShellButton() { Color(0xFF000000); BasicText(\"bypass\"); BasicTextField(\"\", {}) }\n",
);
let inventory = Inventory::load(&root).expect("moved UI inventory");
let mut findings = Vec::new();
@@ -1558,6 +1619,16 @@ mod tests {
assert!(findings.iter().any(|finding| {
finding.contains("BasicTextField bypasses the shell primitive adapter")
}));
+ assert!(
+ findings
+ .iter()
+ .any(|finding| finding.contains("Material component dependency is forbidden"))
+ );
+ assert!(
+ findings
+ .iter()
+ .any(|finding| finding.contains("superseded shell button adapter"))
+ );
fs::remove_dir_all(root).expect("remove fixture");
}