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.
- package/README.md +8 -4
- package/cli/bridge-flow-runner.ts +10 -3
- package/cli/cli-update.ts +2 -7
- package/cli/cloud-test-tunnel.ts +16 -3
- package/cli/commands/config.ts +1 -1
- package/cli/commands/control.ts +28 -3
- package/cli/commands/daemon-mac-app.ts +4 -2
- package/cli/commands/daemon.ts +4 -4
- package/cli/commands/device.ts +2 -3
- package/cli/commands/inspect/actions.ts +125 -90
- package/cli/commands/inspect/core.ts +5 -0
- package/cli/commands/inspect.ts +27 -13
- package/cli/commands/platform.ts +1 -1
- package/cli/commands/screenshot-layers.ts +1 -1
- package/cli/commands/screenshot.ts +1 -1
- package/cli/commands/skills.ts +0 -31
- package/cli/drivers/playwright-provisioning.ts +3 -2
- package/cli/drivers/playwright-sim-host.ts +25 -9
- package/cli/main.ts +8 -2
- package/cli/outbound-endpoints.ts +0 -4
- package/detox/index.ts +74 -43
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +129 -27
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +290 -315
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +253 -121
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +132 -9
- package/dist-lib/native-app-identity.cjs +278 -0
- package/dist-lib/native-app-identity.mjs +245 -0
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +110 -87
- package/dist-lib/sdk.mjs +110 -87
- package/dist-lib/skills.cjs +841 -643
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +282 -177
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +11 -28
- package/kotlin/README.md +32 -4
- package/kotlin/fixtures/counter/Counter.kt +26 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
- package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
- package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
- package/kotlin/fork/LICENSE +178 -0
- package/kotlin/fork/NOTICE +25 -0
- package/kotlin/fork/README.md +59 -0
- package/kotlin/fork/build.sh +128 -0
- package/kotlin/fork/compose-worker-globals.patch +96 -0
- package/kotlin/fork/fork.json +33 -0
- package/kotlin/fork/publish.ts +77 -0
- package/kotlin/fork/skiko-worker-clock.patch +33 -0
- package/kotlin/toolchain.json +288 -0
- package/package.json +12 -5
- package/src/automatic-update-policy.ts +17 -0
- package/src/host/bridge-host.ts +3 -1
- package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
- package/src/metro-plugin.ts +18 -0
- package/src/native-app-identity.ts +376 -0
- package/src/native-firebase-config.ts +90 -0
- package/src/screenshots/frame-compose.ts +5 -2
- package/src/screenshots/registry.ts +1 -1
- package/src/screenshots/schema.ts +2 -2
- package/src/vite-plugin-one.ts +11 -28
- package/src/vite-plugin.ts +6 -1
package/dist-lib/vite-swift.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
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.
|
|
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/
|
|
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
|
-
|
|
898
|
-
|
|
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/
|
|
4
|
+
[`specs/peach-kotlin.md`](../../../specs/peach-kotlin.md).
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
}
|