peachsim 0.1.628 → 0.1.630

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +8 -4
  2. package/cli/bridge-flow-runner.ts +10 -3
  3. package/cli/cli-update.ts +2 -7
  4. package/cli/cloud-test-tunnel.ts +16 -3
  5. package/cli/commands/config.ts +1 -1
  6. package/cli/commands/control.ts +28 -3
  7. package/cli/commands/daemon-mac-app.ts +4 -2
  8. package/cli/commands/daemon.ts +4 -4
  9. package/cli/commands/device.ts +2 -3
  10. package/cli/commands/inspect/actions.ts +125 -90
  11. package/cli/commands/inspect/core.ts +5 -0
  12. package/cli/commands/inspect.ts +27 -13
  13. package/cli/commands/platform.ts +1 -1
  14. package/cli/commands/screenshot-layers.ts +1 -1
  15. package/cli/commands/screenshot.ts +1 -1
  16. package/cli/commands/skills.ts +0 -31
  17. package/cli/drivers/playwright-provisioning.ts +3 -2
  18. package/cli/drivers/playwright-sim-host.ts +25 -9
  19. package/cli/main.ts +8 -2
  20. package/cli/outbound-endpoints.ts +0 -4
  21. package/detox/index.ts +74 -43
  22. package/dist-lib/agent-daemon-client.cjs +1 -1
  23. package/dist-lib/agent-events.cjs +1 -1
  24. package/dist-lib/agent-identity.cjs +1 -1
  25. package/dist-lib/agent-sessions.cjs +1 -1
  26. package/dist-lib/attached-projects.cjs +1 -1
  27. package/dist-lib/auth/shared-session.cjs +1 -1
  28. package/dist-lib/backend-origin.cjs +1 -1
  29. package/dist-lib/beta.cjs +1 -1
  30. package/dist-lib/beta.mjs +1 -1
  31. package/dist-lib/bridge-constants.cjs +1 -1
  32. package/dist-lib/bridge-contract-input.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.mjs +1 -1
  34. package/dist-lib/bridge-contract.cjs +1 -1
  35. package/dist-lib/bridge-contract.mjs +1 -1
  36. package/dist-lib/capture-contract.cjs +1 -1
  37. package/dist-lib/capture-contract.mjs +1 -1
  38. package/dist-lib/cli-constants.cjs +1 -1
  39. package/dist-lib/cloud-contract.cjs +1 -1
  40. package/dist-lib/cloud-contract.mjs +1 -1
  41. package/dist-lib/cloud-test-args.cjs +1 -1
  42. package/dist-lib/cloud-test-args.mjs +1 -1
  43. package/dist-lib/cloud.cjs +1 -1
  44. package/dist-lib/cloud.mjs +1 -1
  45. package/dist-lib/config.cjs +1 -1
  46. package/dist-lib/detox/index.cjs +129 -27
  47. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  48. package/dist-lib/home-paths.cjs +1 -1
  49. package/dist-lib/host/bridge-host.cjs +290 -315
  50. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  51. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  53. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  54. package/dist-lib/host/websocket-proxy.cjs +1 -1
  55. package/dist-lib/index.cjs +253 -121
  56. package/dist-lib/jump-to-source-babel.cjs +1 -1
  57. package/dist-lib/jump-to-source-native.cjs +1 -1
  58. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  59. package/dist-lib/menu.cjs +1 -1
  60. package/dist-lib/menu.mjs +1 -1
  61. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  62. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  63. package/dist-lib/metro-production-bundle.cjs +1 -1
  64. package/dist-lib/metro-production-bundle.mjs +1 -1
  65. package/dist-lib/metro.cjs +132 -9
  66. package/dist-lib/native-app-identity.cjs +278 -0
  67. package/dist-lib/native-app-identity.mjs +245 -0
  68. package/dist-lib/profiles.cjs +1 -1
  69. package/dist-lib/public-brand.cjs +1 -1
  70. package/dist-lib/react-native-host-modules.cjs +1 -1
  71. package/dist-lib/react-native-host-modules.mjs +1 -1
  72. package/dist-lib/render-mode.cjs +1 -1
  73. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  74. package/dist-lib/sdk.cjs +110 -87
  75. package/dist-lib/sdk.mjs +110 -87
  76. package/dist-lib/skills.cjs +841 -643
  77. package/dist-lib/swift-compile.cjs +1 -1
  78. package/dist-lib/swift-compile.mjs +1 -1
  79. package/dist-lib/swift-package.cjs +1 -1
  80. package/dist-lib/swift-package.mjs +1 -1
  81. package/dist-lib/swift-submit.cjs +1 -1
  82. package/dist-lib/swift-submit.mjs +1 -1
  83. package/dist-lib/swift.cjs +282 -177
  84. package/dist-lib/vite-swift.cjs +1 -1
  85. package/dist-lib/vite.cjs +11 -28
  86. package/kotlin/README.md +32 -4
  87. package/kotlin/fixtures/counter/Counter.kt +26 -0
  88. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  89. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  90. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  91. package/kotlin/fork/LICENSE +178 -0
  92. package/kotlin/fork/NOTICE +25 -0
  93. package/kotlin/fork/README.md +59 -0
  94. package/kotlin/fork/build.sh +128 -0
  95. package/kotlin/fork/compose-worker-globals.patch +96 -0
  96. package/kotlin/fork/fork.json +33 -0
  97. package/kotlin/fork/publish.ts +77 -0
  98. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  99. package/kotlin/toolchain.json +288 -0
  100. package/package.json +12 -5
  101. package/src/automatic-update-policy.ts +17 -0
  102. package/src/host/bridge-host.ts +3 -1
  103. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  104. package/src/metro-plugin.ts +18 -0
  105. package/src/native-app-identity.ts +376 -0
  106. package/src/native-firebase-config.ts +90 -0
  107. package/src/screenshots/frame-compose.ts +5 -2
  108. package/src/screenshots/registry.ts +1 -1
  109. package/src/screenshots/schema.ts +2 -2
  110. package/src/vite-plugin-one.ts +11 -28
  111. package/src/vite-plugin.ts +6 -1
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.628 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.630 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.628 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.630 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -808,7 +808,7 @@ var swiftHost;
808
808
  var loadSwiftHost = () => swiftHost ??= import("peachsim/vite-swift");
809
809
  var KOTLIN_ROUTE_PREFIX = "/__peach/kotlin";
810
810
  var kotlinHost;
811
- var loadKotlinHost = () => kotlinHost ??= import("peachsim/vite-kotlin");
811
+ var loadKotlinHost = () => kotlinHost ??= import("peachsim/kotlin-host");
812
812
  function rnxPlugin(options = {}) {
813
813
  const prefix = options.prefix || "/__soot";
814
814
  return {
@@ -891,34 +891,17 @@ function rnxPlugin(options = {}) {
891
891
  const pathname = (req.url ?? "").split("?")[0];
892
892
  if (!pathname.startsWith(KOTLIN_ROUTE_PREFIX)) return next();
893
893
  loadKotlinHost().then(async (host) => {
894
- if (host.serveKotlinArtifact(pathname, res)) return;
895
- if (pathname !== "/__peach/kotlin-package") return next();
896
894
  if (!kotlinModuleRoot) {
897
- const moduleRoot = import_path2.default.join(
898
- import_path2.default.dirname(
899
- (0, import_node_module.createRequire)(import_path2.default.join(server.config.root, "package.json")).resolve(
900
- "peachsim/package.json"
901
- )
902
- ),
903
- "kotlin"
904
- );
905
- kotlinModuleRoot = moduleRoot;
906
- server.watcher.add(import_path2.default.join(moduleRoot, "Sources"));
907
- }
908
- try {
909
- const artifact = await host.kotlinArtifact(
910
- server.config.root,
911
- kotlinModuleRoot,
912
- devOrigin()
913
- );
914
- res.setHeader("access-control-allow-origin", "*");
915
- res.setHeader("content-type", "application/json");
916
- res.end(JSON.stringify({ ...artifact, hmrPath }));
917
- } catch (error) {
918
- res.statusCode = 500;
919
- res.setHeader("access-control-allow-origin", "*");
920
- res.end(error instanceof Error ? error.message : String(error));
895
+ kotlinModuleRoot = host.peachKotlinModuleRoot(server.config.root);
896
+ server.watcher.add(import_path2.default.join(kotlinModuleRoot, "Sources"));
921
897
  }
898
+ const served = await host.serveKotlinRequest(pathname, res, {
899
+ root: server.config.root,
900
+ moduleRoot: kotlinModuleRoot,
901
+ origin: devOrigin(),
902
+ hmrPath
903
+ });
904
+ if (!served) next();
922
905
  }).catch(next);
923
906
  });
924
907
  server.watcher.on("change", (file) => {
package/kotlin/README.md CHANGED
@@ -1,12 +1,22 @@
1
1
  # peachsim kotlin
2
2
 
3
3
  The Android side of a One app's directly imported Kotlin in Peach. Owner spec:
4
- [`specs/rnx-swift.md`](../../../specs/rnx-swift.md) (direct native source imports).
4
+ [`specs/peach-kotlin.md`](../../../specs/peach-kotlin.md).
5
5
 
6
- - `toolchain.json`: the pinned Kotlin compiler and the kotlinx-coroutines
7
- Kotlin/Wasm klibs, with their sha256. `peachsim/vite-kotlin` fetches them once
6
+ Kotlin and Compose must add zero bytes to a native-free baseline bundle. Compiler
7
+ hosts and plugins, runtime, libraries and mounts stay behind lazy entry points.
8
+ Verify emitted chunks and static imports, plus the native-free twin's network
9
+ traffic in `scripts/debug/peach-swift-lazy-network.sh`.
10
+
11
+ - `toolchain.json`: the pinned Kotlin compiler, kotlinx-coroutines and official
12
+ AndroidX runtime and Compose Multiplatform UI Kotlin/Wasm klibs, with their
13
+ sha256 and explicit matching Android pins. Skiko's browser resources and
14
+ kotlinx-datetime's declared JS module are verified and served beside the wasm.
15
+ Compose libraries and the compiler's bundled Compose plugin are selected only
16
+ for sources referencing `androidx.compose`. `peachsim/kotlin-host` fetches libraries once
8
17
  into `~/.peach/kotlin/<version>`. kotlinc needs a Java runtime (17 or newer)
9
- from `JAVA_HOME` or `PATH`, the one an Android build uses.
18
+ from `JAVA_HOME` or `PATH`, the one an Android build uses. Its heap is capped
19
+ at 2 GiB for the complete Compose closure.
10
20
  - `Sources/`: the Android and One declarations app Kotlin and One's generated
11
21
  glue compile against: `org.json` with Android's parse and print rules,
12
22
  `androidx.annotation.Keep`, `dev.onejs.one.OneNativeSourceDispatch` and
@@ -16,3 +26,21 @@ The dev server compiles every `.kt` source of the app, One's glue for each and
16
26
  a generated entry into one Kotlin/Wasm module, held to the Kotlin language
17
27
  version React Native's Android build pins. Replay:
18
28
  `scripts/debug/peach-one-native-source-guest.sh`.
29
+
30
+ `fixtures/runtime-state/RuntimeState.kt` runs the actual Compose recomposer with
31
+ two roots and shared state through generated One module dispatch. It checks
32
+ props retain remembered state and node/effect identity, shared frame timestamps,
33
+ and disposal cancels only the removed root's LaunchedEffect, DisposableEffect
34
+ and remembered coroutine scope. The surviving root must still recompose.
35
+
36
+ Compile on Linux with
37
+ `bun remote bun scripts/debug/peach-compose-runtime.ts compile`, then run the
38
+ delivered archive on a Mac GPU with
39
+ `bun heavy --priority -10 --cores 2 --admission-timeout-seconds 120 -- bun scripts/debug/peach-compose-runtime.ts run .remote-out/compose-runtime-host`.
40
+ The source and archive hashes must match; the browser leg uses an isolated home
41
+ and never compiles. Negative controls disable scheduling, recreate a root on
42
+ props changes and skip disposal. A Kotlin Error must retain its dispatch envelope.
43
+ The eager Vite entry must remain byte-identical. Compose dispatch runs in a
44
+ browser worker with no document, after official Skiko initializes. This proves
45
+ runtime behavior, with no Compose UI or Android pixel grade. UI fixtures still
46
+ use the paired Compose oracle.
@@ -0,0 +1,26 @@
1
+ package dev.contrast.composefixtures
2
+
3
+ import dev.onejs.one.source.OneModule
4
+ import kotlinx.coroutines.delay
5
+
6
+ object Counter : OneModule {
7
+ private var count = 0
8
+
9
+ fun read(): Int = count
10
+
11
+ fun increment(): Int {
12
+ count += 1
13
+ return count
14
+ }
15
+
16
+ fun reset(): Int {
17
+ count = 0
18
+ return count
19
+ }
20
+
21
+ suspend fun delayedRead(ms: Int): Int {
22
+ require(ms >= 0) { "delay must be nonnegative" }
23
+ delay(ms.toLong())
24
+ return count
25
+ }
26
+ }
@@ -0,0 +1,40 @@
1
+ package dev.contrast.composefixtures
2
+
3
+ import androidx.compose.foundation.layout.Arrangement
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.foundation.layout.padding
7
+ import androidx.compose.material3.Button
8
+ import androidx.compose.material3.MaterialTheme
9
+ import androidx.compose.material3.Text
10
+ import androidx.compose.runtime.Composable
11
+ import androidx.compose.runtime.getValue
12
+ import androidx.compose.runtime.mutableStateOf
13
+ import androidx.compose.runtime.remember
14
+ import androidx.compose.runtime.setValue
15
+ import androidx.compose.ui.Modifier
16
+ import androidx.compose.ui.graphics.Color
17
+ import androidx.compose.ui.platform.testTag
18
+ import androidx.compose.ui.unit.dp
19
+ import androidx.compose.material3.Surface
20
+ import androidx.compose.material3.lightColorScheme
21
+
22
+ @Composable
23
+ fun CounterScreen() {
24
+ var count by remember { mutableStateOf(Counter.read()) }
25
+ MaterialTheme(colorScheme = lightColorScheme(primary = Color(0xFF6750A4))) {
26
+ Surface(modifier = Modifier.fillMaxSize()) {
27
+ Column(
28
+ modifier = Modifier.fillMaxSize().padding(24.dp),
29
+ verticalArrangement = Arrangement.spacedBy(16.dp),
30
+ ) {
31
+ Text("Kotlin counter", style = MaterialTheme.typography.headlineSmall)
32
+ Text("Count: $count", modifier = Modifier.testTag("counter-value"))
33
+ Button(
34
+ onClick = { count = Counter.increment() },
35
+ modifier = Modifier.testTag("counter-increment"),
36
+ ) { Text("Increment") }
37
+ }
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,228 @@
1
+ @file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.ui.InternalComposeUiApi::class, androidx.compose.ui.text.ExperimentalTextApi::class, kotlin.js.ExperimentalWasmJsInterop::class)
2
+ package dev.contrast.composefixtures
3
+
4
+ import androidx.compose.runtime.Composable
5
+ import androidx.compose.runtime.CompositionLocalProvider
6
+ import androidx.compose.runtime.getValue
7
+ import androidx.compose.runtime.mutableStateOf
8
+ import androidx.compose.runtime.remember
9
+ import androidx.compose.runtime.setValue
10
+ import androidx.compose.runtime.snapshots.Snapshot
11
+ import androidx.compose.ui.graphics.asComposeCanvas
12
+ import androidx.compose.ui.input.InputMode
13
+ import androidx.compose.ui.input.InputModeManager
14
+ import androidx.compose.ui.input.pointer.PointerEventType
15
+ import androidx.compose.ui.input.pointer.PointerType
16
+ import androidx.compose.ui.platform.FrameRecomposer
17
+ import androidx.compose.ui.platform.LocalFontFamilyResolver
18
+ import androidx.compose.ui.platform.LocalLocaleList
19
+ import androidx.compose.ui.platform.PlatformContext
20
+ import androidx.compose.ui.platform.WindowInfo
21
+ import androidx.compose.ui.platform.registerSkikoComposeImplementation
22
+ import androidx.compose.ui.scene.CanvasLayersComposeScene
23
+ import androidx.compose.ui.scene.hasInvalidations
24
+ import androidx.compose.ui.semantics.SemanticsNode
25
+ import androidx.compose.ui.semantics.SemanticsOwner
26
+ import androidx.compose.ui.semantics.SemanticsProperties
27
+ import androidx.compose.ui.semantics.getOrNull
28
+ import androidx.compose.ui.text.font.FontFamily
29
+ import androidx.compose.ui.text.font.FontStyle
30
+ import androidx.compose.ui.text.font.FontSynthesis
31
+ import androidx.compose.ui.text.font.FontWeight
32
+ import androidx.compose.ui.text.font.PlatformTypefacesLoader
33
+ import androidx.compose.ui.text.font.createPlatformFontFamilyResolver
34
+ import androidx.compose.ui.text.font.platformTypefacesLoader
35
+ import androidx.compose.ui.text.intl.LocaleList
36
+ import androidx.compose.ui.text.platform.Font
37
+ import androidx.compose.ui.unit.Density
38
+ import androidx.compose.ui.unit.DpSize
39
+ import androidx.compose.ui.unit.IntSize
40
+ import androidx.compose.ui.unit.LayoutDirection
41
+ import dev.onejs.one.source.OneModule
42
+ import kotlin.io.encoding.Base64
43
+ import kotlin.js.js
44
+ import kotlin.time.TimeSource
45
+ import kotlinx.coroutines.Dispatchers
46
+ import org.jetbrains.skia.Color
47
+ import org.jetbrains.skia.Surface
48
+ import org.json.JSONArray
49
+ import org.json.JSONObject
50
+
51
+ private fun workerGlobals(): String =
52
+ js("JSON.stringify({ window: typeof window, document: typeof document, languages: globalThis.navigator.languages })")
53
+
54
+ // the simulated Android device as Compose's platform sees it: its screen,
55
+ // its locale, touch mode (an Android device starts in it, so a tap shows no
56
+ // focus) and the semantics owners inspection reads. everything else is
57
+ // Compose's own default for a platform that supplies nothing.
58
+ private class PeachPlatformContext(
59
+ size: IntSize,
60
+ density: Density,
61
+ override val localeList: LocaleList,
62
+ ) : PlatformContext by PlatformContext.Empty(), PlatformContext.SemanticsOwnerListener {
63
+ val semanticsOwners = mutableSetOf<SemanticsOwner>()
64
+ override val windowInfo: WindowInfo = object : WindowInfo {
65
+ override val isWindowFocused: Boolean get() = true
66
+ override val containerSize: IntSize = size
67
+ override val containerDpSize: DpSize = with(density) { DpSize(size.width.toDp(), size.height.toDp()) }
68
+ }
69
+ override val inputModeManager: InputModeManager = object : InputModeManager {
70
+ override var inputMode: InputMode by mutableStateOf(InputMode.Touch)
71
+ override fun requestInputMode(inputMode: InputMode): Boolean {
72
+ this.inputMode = inputMode
73
+ return true
74
+ }
75
+ }
76
+ override val semanticsOwnerListener: PlatformContext.SemanticsOwnerListener get() = this
77
+ override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { semanticsOwners.add(semanticsOwner) }
78
+ override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { semanticsOwners.remove(semanticsOwner) }
79
+ override fun onSemanticsChange(semanticsOwner: SemanticsOwner) = Unit
80
+ override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) = Unit
81
+ }
82
+
83
+ // a Pixel 8 screen, rendered to a raster surface one 60 Hz frame at a time
84
+ private class DeviceScene(locale: String, content: @Composable () -> Unit) : AutoCloseable {
85
+ private val size = IntSize(1080, 2400)
86
+ private val density = Density(420f / 160f)
87
+ init {
88
+ registerSkikoComposeImplementation()
89
+ }
90
+ val context = PeachPlatformContext(size, density, LocaleList(locale))
91
+ private val recomposer = FrameRecomposer(Dispatchers.Unconfined)
92
+ private val scene = CanvasLayersComposeScene(recomposer, density, LayoutDirection.Ltr, size, context)
93
+ .also { it.setContent(content = content) }
94
+ private val surface = Surface.makeRasterN32Premul(size.width, size.height)
95
+ var nanos = 0L
96
+ private set
97
+
98
+ fun frame(): ByteArray {
99
+ Snapshot.sendApplyNotifications()
100
+ surface.canvas.clear(Color.TRANSPARENT)
101
+ recomposer.performFrame(nanos)
102
+ scene.measureAndLayout()
103
+ scene.draw(surface.canvas.asComposeCanvas())
104
+ nanos += 16_666_667L
105
+ val image = surface.makeImageSnapshot()
106
+ try {
107
+ val data = checkNotNull(image.encodeToData())
108
+ try { return data.bytes } finally { data.close() }
109
+ } finally { image.close() }
110
+ }
111
+
112
+ fun settle(from: ByteArray): ByteArray {
113
+ var previous = from
114
+ repeat(180) {
115
+ val next = frame()
116
+ if (!recomposer.hasPendingWork() && !scene.hasInvalidations() && next.contentEquals(previous)) return next
117
+ previous = next
118
+ }
119
+ error("scene never settled")
120
+ }
121
+
122
+ private fun nodes(merged: Boolean): List<SemanticsNode> {
123
+ fun collect(node: SemanticsNode): List<SemanticsNode> = listOf(node) + node.children.flatMap { collect(it) }
124
+ return context.semanticsOwners.flatMap {
125
+ collect(if (merged) it.rootSemanticsNode else it.unmergedRootSemanticsNode)
126
+ }
127
+ }
128
+
129
+ fun node(tag: String): SemanticsNode =
130
+ nodes(merged = true).single { it.config.getOrNull(SemanticsProperties.TestTag) == tag }
131
+
132
+ // every text node with its bounds, unmerged as Android's accessibility
133
+ // hierarchy reports a button's label apart from the button
134
+ fun texts(): JSONArray {
135
+ val texts = JSONArray()
136
+ for (node in nodes(merged = false)) {
137
+ val value = text(node) ?: continue
138
+ val bounds = node.boundsInRoot
139
+ texts.put(JSONObject().put("text", value).put("left", bounds.left).put("top", bounds.top)
140
+ .put("right", bounds.right).put("bottom", bounds.bottom))
141
+ }
142
+ return texts
143
+ }
144
+
145
+ fun tap(tag: String) {
146
+ val center = node(tag).boundsInRoot.center
147
+ scene.sendPointerEvent(PointerEventType.Press, center, timeMillis = nanos / 1_000_000L, type = PointerType.Touch)
148
+ scene.sendPointerEvent(PointerEventType.Release, center, timeMillis = nanos / 1_000_000L + 20L, type = PointerType.Touch)
149
+ }
150
+
151
+ override fun close() {
152
+ scene.close()
153
+ recomposer.close()
154
+ surface.close()
155
+ }
156
+ }
157
+
158
+ // Android's default and sans-serif families are Roboto. the app's own
159
+ // families resolve as Compose resolves them.
160
+ @Composable
161
+ private fun AndroidFonts(roboto: FontFamily, content: @Composable () -> Unit) {
162
+ val parent = LocalFontFamilyResolver.current
163
+ val resolver = remember(parent) {
164
+ val backend = checkNotNull(parent.platformTypefacesLoader())
165
+ createPlatformFontFamilyResolver(object : PlatformTypefacesLoader by backend {
166
+ override val cacheKey: Any get() = this
167
+ override fun loadPlatformTypes(fontFamily: FontFamily, fontWeight: FontWeight, fontStyle: FontStyle): Any =
168
+ if (fontFamily == FontFamily.Default || fontFamily == FontFamily.SansSerif)
169
+ parent.resolve(roboto, fontWeight, fontStyle, FontSynthesis.All).value
170
+ else backend.loadPlatformTypes(fontFamily, fontWeight, fontStyle)
171
+ })
172
+ }
173
+ CompositionLocalProvider(LocalFontFamilyResolver provides resolver, content = content)
174
+ }
175
+
176
+ private fun text(node: SemanticsNode): String? =
177
+ node.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text }
178
+
179
+ object Headless : OneModule {
180
+ suspend fun capture(regular: String, medium: String, bold: String): String {
181
+ try {
182
+ val roboto = FontFamily(
183
+ Font("Roboto-400", Base64.decode(regular), FontWeight.Normal),
184
+ Font("Roboto-500", Base64.decode(medium), FontWeight.Medium),
185
+ Font("Roboto-700", Base64.decode(bold), FontWeight.Bold),
186
+ )
187
+ Counter.reset()
188
+ val start = TimeSource.Monotonic.markNow()
189
+ DeviceScene("en-US") { AndroidFonts(roboto) { CounterScreen() } }.use { device ->
190
+ val first = device.frame()
191
+ val firstFrameMs = start.elapsedNow().inWholeMicroseconds / 1000.0
192
+ val main = device.settle(first)
193
+ val mainTexts = device.texts()
194
+ val value = device.node("counter-value")
195
+ check(text(value) == "Count: 0")
196
+ val buttonBounds = device.node("counter-increment").boundsInRoot
197
+ val tapStart = TimeSource.Monotonic.markNow()
198
+ device.tap("counter-increment")
199
+ val incremented = device.settle(main)
200
+ val interactiveMs = tapStart.elapsedNow().inWholeMicroseconds / 1000.0
201
+ check(text(device.node("counter-value")) == "Count: 1")
202
+ check(Counter.read() == 1)
203
+ val incrementedTexts = device.texts()
204
+ check(!main.contentEquals(incremented))
205
+ return JSONObject().put("main", Base64.encode(main)).put("incremented", Base64.encode(incremented))
206
+ .put("mainTexts", mainTexts).put("incrementedTexts", incrementedTexts)
207
+ .put("firstFrameMs", firstFrameMs).put("interactiveMs", interactiveMs)
208
+ .put("buttonBounds", buttonBounds.toString()).put("valueTop", value.boundsInRoot.top)
209
+ .put("settledNanos", device.nanos)
210
+ .put("globals", workerGlobals()).toString()
211
+ }
212
+ } catch (error: Throwable) {
213
+ throw IllegalStateException(error.stackTraceToString(), error)
214
+ }
215
+ }
216
+
217
+ // the locale the composition sees is the device's, not the browser's
218
+ fun locale(tag: String): String {
219
+ try {
220
+ var seen: LocaleList? = null
221
+ DeviceScene(tag) { seen = LocalLocaleList.current }.use { it.frame() }
222
+ return JSONObject().put("device", tag).put("composition", checkNotNull(seen).joinToString(",") { it.toLanguageTag() })
223
+ .put("globals", workerGlobals()).toString()
224
+ } catch (error: Throwable) {
225
+ throw IllegalStateException(error.stackTraceToString(), error)
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,200 @@
1
+ package dev.contrast.composefixtures
2
+
3
+ import dev.onejs.one.source.OneModule
4
+ import androidx.compose.runtime.*
5
+ import androidx.compose.runtime.snapshots.Snapshot
6
+ import kotlinx.coroutines.*
7
+ import org.json.JSONObject
8
+
9
+ private class Node {
10
+ var text = ""
11
+ var click: () -> Unit = {}
12
+ val children = mutableListOf<Node>()
13
+ }
14
+ private class TreeApplier(root: Node) : AbstractApplier<Node>(root) {
15
+ override fun insertTopDown(index: Int, instance: Node) { current.children.add(index, instance) }
16
+ override fun insertBottomUp(index: Int, instance: Node) {}
17
+ override fun remove(index: Int, count: Int) { repeat(count) { current.children.removeAt(index) } }
18
+ override fun move(from: Int, to: Int, count: Int) { current.children.move(from, to, count) }
19
+ override fun onClear() { root.children.clear() }
20
+ }
21
+ private val shared = mutableStateOf(0)
22
+ @Composable
23
+ private fun CounterTree() {
24
+ var count by remember { mutableStateOf(0) }
25
+ val value = "local=$count shared=${shared.value}"
26
+ ComposeNode<Node, TreeApplier>(
27
+ factory = { Node() },
28
+ update = {
29
+ set(value) { text = it }
30
+ set({ count += 1; shared.value += 1 }) { click = it }
31
+ }
32
+ )
33
+ }
34
+
35
+ private class RootEffects {
36
+ val launched = CompletableDeferred<Unit>()
37
+ val cancelled = CompletableDeferred<Unit>()
38
+ val frame = CompletableDeferred<Long>()
39
+ val scopeStarted = CompletableDeferred<Unit>()
40
+ val scopeCancelled = CompletableDeferred<Unit>()
41
+ var starts = 0
42
+ var disposals = 0
43
+ var scope: CoroutineScope? = null
44
+ var scopeJob: Job? = null
45
+ }
46
+
47
+ @Composable
48
+ private fun LifecycleTree(label: String, effects: RootEffects) {
49
+ var count by remember { mutableStateOf(0) }
50
+ val scope = rememberCoroutineScope()
51
+ SideEffect { effects.scope = scope }
52
+ DisposableEffect(Unit) {
53
+ effects.starts += 1
54
+ onDispose { effects.disposals += 1 }
55
+ }
56
+ LaunchedEffect(Unit) {
57
+ effects.launched.complete(Unit)
58
+ try {
59
+ effects.frame.complete(withFrameNanos { it })
60
+ awaitCancellation()
61
+ } finally {
62
+ effects.cancelled.complete(Unit)
63
+ }
64
+ }
65
+ ComposeNode<Node, TreeApplier>(
66
+ factory = { Node() },
67
+ update = {
68
+ set("$label local=$count shared=${shared.value}") { text = it }
69
+ set({
70
+ count += 1
71
+ shared.value += 1
72
+ if (effects.scopeJob == null) effects.scopeJob = scope.launch {
73
+ effects.scopeStarted.complete(Unit)
74
+ try { awaitCancellation() } finally { effects.scopeCancelled.complete(Unit) }
75
+ }
76
+ }) { click = it }
77
+ }
78
+ )
79
+ }
80
+
81
+ object RuntimeState : OneModule {
82
+ fun failError(): Int = throw AssertionError("error boundary")
83
+
84
+ suspend fun checkLifecycle(retainRoot: Boolean, disposeRoot: Boolean): String = withTimeout(5_000) {
85
+ shared.value = 0
86
+ val a = Node()
87
+ val b = Node()
88
+ val leftEffects = RootEffects()
89
+ val rightEffects = RootEffects()
90
+ var label by mutableStateOf("original")
91
+ var frame = 0L
92
+ lateinit var clock: BroadcastFrameClock
93
+ clock = BroadcastFrameClock { launch { clock.sendFrame(++frame) } }
94
+ val recomposer = Recomposer(coroutineContext + clock)
95
+ val runner = launch(clock, CoroutineStart.UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() }
96
+ val left = Composition(TreeApplier(a), recomposer)
97
+ val right = Composition(TreeApplier(b), recomposer)
98
+ try {
99
+ left.setContent {
100
+ if (retainRoot) LifecycleTree(label, leftEffects)
101
+ else key(label) { LifecycleTree(label, leftEffects) }
102
+ }
103
+ right.setContent { LifecycleTree("right", rightEffects) }
104
+ leftEffects.launched.await()
105
+ rightEffects.launched.await()
106
+ val leftFrame = leftEffects.frame.await()
107
+ val rightFrame = rightEffects.frame.await()
108
+ check(leftFrame == rightFrame) { "roots received different frame times: $leftFrame/$rightFrame" }
109
+ recomposer.awaitIdle()
110
+ check(a.children.single().text == "original local=0 shared=0")
111
+ check(b.children.single().text == "right local=0 shared=0")
112
+ val original = a.children.single()
113
+ original.click()
114
+ b.children.single().click()
115
+ leftEffects.scopeStarted.await()
116
+ rightEffects.scopeStarted.await()
117
+ Snapshot.sendApplyNotifications()
118
+ recomposer.awaitIdle()
119
+ label = "updated"
120
+ Snapshot.sendApplyNotifications()
121
+ recomposer.awaitIdle()
122
+ check(a.children.single().text == "updated local=1 shared=2") {
123
+ "props update reset remembered state: ${a.children.single().text}"
124
+ }
125
+ check(a.children.single() === original) { "props update replaced the root node" }
126
+ check(b.children.single().text == "right local=1 shared=2") { "props update touched the other root" }
127
+ check(leftEffects.starts == 1 && leftEffects.disposals == 0) { "props update restarted effects" }
128
+ check(leftEffects.scope?.isActive == true && rightEffects.scope?.isActive == true)
129
+ if (disposeRoot) left.dispose()
130
+ check(leftEffects.disposals == 1) { "left disposal did not run DisposableEffect" }
131
+ leftEffects.cancelled.await()
132
+ leftEffects.scopeCancelled.await()
133
+ check(a.children.isEmpty()) { "left disposal retained nodes" }
134
+ check(leftEffects.scope?.isActive == false && leftEffects.scopeJob?.isCancelled == true)
135
+ check(rightEffects.disposals == 0 && !rightEffects.cancelled.isCompleted && !rightEffects.scopeCancelled.isCompleted) {
136
+ "left disposal cancelled the other root"
137
+ }
138
+ check(rightEffects.scope?.isActive == true && rightEffects.scopeJob?.isActive == true)
139
+ b.children.single().click()
140
+ Snapshot.sendApplyNotifications()
141
+ recomposer.awaitIdle()
142
+ check(b.children.single().text == "right local=2 shared=3") { "surviving root stopped recomposing" }
143
+ right.dispose()
144
+ rightEffects.cancelled.await()
145
+ rightEffects.scopeCancelled.await()
146
+ check(b.children.isEmpty() && rightEffects.disposals == 1)
147
+ check(rightEffects.scope?.isActive == false && rightEffects.scopeJob?.isCancelled == true)
148
+ JSONObject().put("passed", true).put("propsRetainState", true).put("propsRetainNode", true)
149
+ .put("effectsRetainIdentity", true).put("sharedFrameNanos", leftFrame)
150
+ .put("launchedEffectCancelled", true).put("disposableEffectDisposed", true)
151
+ .put("rememberedScopeCancelled", true).put("survivingRootRecomposes", true)
152
+ .put("uiProven", false).toString()
153
+ } finally {
154
+ left.dispose()
155
+ right.dispose()
156
+ recomposer.cancel()
157
+ runner.cancelAndJoin()
158
+ }
159
+ }
160
+
161
+ suspend fun checkState(startScheduler: Boolean): String = coroutineScope {
162
+ shared.value = 0
163
+ val a = Node()
164
+ val b = Node()
165
+ var frame = 0L
166
+ lateinit var clock: BroadcastFrameClock
167
+ clock = BroadcastFrameClock { launch { clock.sendFrame(++frame) } }
168
+ val recomposer = Recomposer(coroutineContext + clock)
169
+ val runner = launch(clock, if (startScheduler) CoroutineStart.UNDISPATCHED else CoroutineStart.LAZY) { recomposer.runRecomposeAndApplyChanges() }
170
+ val left = Composition(TreeApplier(a), recomposer)
171
+ val right = Composition(TreeApplier(b), recomposer)
172
+ try {
173
+ left.setContent { CounterTree() }
174
+ right.setContent { CounterTree() }
175
+ check(a.children.single().text == "local=0 shared=0") { "left initial: ${a.children.single().text}" }
176
+ check(b.children.single().text == "local=0 shared=0") { "right initial: ${b.children.single().text}" }
177
+ val original = a.children.single()
178
+ original.click()
179
+ Snapshot.sendApplyNotifications()
180
+ recomposer.awaitIdle()
181
+ check(a.children.single() === original) { "left node identity changed" }
182
+ check(a.children.single().text == "local=1 shared=1") { "left after click: ${a.children.single().text}" }
183
+ check(b.children.single().text == "local=0 shared=1") { "right shared update: ${b.children.single().text}" }
184
+ left.dispose()
185
+ check(a.children.isEmpty()) { "left dispose retained ${a.children.size} nodes" }
186
+ b.children.single().click()
187
+ Snapshot.sendApplyNotifications()
188
+ recomposer.awaitIdle()
189
+ check(b.children.single().text == "local=1 shared=2") { "right after click: ${b.children.single().text}" }
190
+ right.dispose()
191
+ check(b.children.isEmpty()) { "right dispose retained ${b.children.size} nodes" }
192
+ "{\"passed\":true,\"runtime\":\"androidx.compose.runtime:1.13.0-alpha02\",\"localState\":[0,1],\"sharedState\":[0,1,2],\"twoRoots\":true,\"retainedNode\":true,\"dispose\":true,\"uiProven\":false}"
193
+ } finally {
194
+ left.dispose()
195
+ right.dispose()
196
+ recomposer.cancel()
197
+ runner.cancelAndJoin()
198
+ }
199
+ }
200
+ }