craft-native 0.0.210 → 0.0.212
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 +23 -0
- package/dist/android/templates/MainActivityNative.kt.template +3 -3
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +32 -8
- package/dist/ios/templates/testing/DriverTests.swift.template +8 -2
- package/dist/mobile-testing.d.ts +25 -0
- package/dist/mobile-testing.js +78 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -499,6 +499,29 @@ cd packages/examples-ts
|
|
|
499
499
|
bun run hello-world
|
|
500
500
|
```
|
|
501
501
|
|
|
502
|
+
## Mobile restart and update tests
|
|
503
|
+
|
|
504
|
+
Use the Bun-only `craft-native/testing` entrypoint to interrupt an installed app
|
|
505
|
+
on an explicitly selected iOS Simulator or Android emulator:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
import { createMobileTestDevice } from 'craft-native/testing'
|
|
509
|
+
|
|
510
|
+
const device = createMobileTestDevice({
|
|
511
|
+
platform: 'android', device: 'emulator-5582', appId: 'org.example.app',
|
|
512
|
+
})
|
|
513
|
+
await device.restart()
|
|
514
|
+
const { previousBuild, build } = await device.installUpdate('/path/to/update.apk')
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
For iOS use `platform: 'ios'`, the simulator UUID, and an `.app` directory.
|
|
518
|
+
`restart()` waits for a new boot; `installUpdate()` requires an existing app and
|
|
519
|
+
preserves its data. Android updates must use the original signing key. Supply a
|
|
520
|
+
higher build number and assert the returned installed builds, then run your
|
|
521
|
+
app's UI checks for persisted progress and offline content. Neither operation
|
|
522
|
+
uninstalls an app or clears its data. The controls deliberately refuse physical
|
|
523
|
+
devices and implicit device selectors.
|
|
524
|
+
|
|
502
525
|
## Contributing
|
|
503
526
|
|
|
504
527
|
We love contributions! Please see our [contributing guide](../../CONTRIBUTING.md).
|
|
@@ -142,7 +142,7 @@ import kotlin.math.pow
|
|
|
142
142
|
|
|
143
143
|
private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
internal fun craftNativeOptionalString(value: Any?): String? = value
|
|
146
146
|
?.takeUnless { it == JSONObject.NULL }
|
|
147
147
|
?.toString()
|
|
148
148
|
?.takeUnless { it == "null" || it == "undefined" }
|
|
@@ -2515,7 +2515,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2515
2515
|
}
|
|
2516
2516
|
|
|
2517
2517
|
scroller.setOnScrollChangeListener { _, x, y, _, _ ->
|
|
2518
|
-
if (
|
|
2518
|
+
if (horizontalMode != true) updateStickyHeaders()
|
|
2519
2519
|
onScroll?.invoke(x, y)
|
|
2520
2520
|
}
|
|
2521
2521
|
scroller.setOnTouchListener { _, event ->
|
|
@@ -6222,7 +6222,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6222
6222
|
else -> 400
|
|
6223
6223
|
}
|
|
6224
6224
|
view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
6225
|
-
Typeface.create("sans-serif", weight, false)
|
|
6225
|
+
Typeface.create(Typeface.create("sans-serif", Typeface.NORMAL), weight, false)
|
|
6226
6226
|
} else {
|
|
6227
6227
|
Typeface.create("sans-serif", if (weight >= 600) Typeface.BOLD else Typeface.NORMAL)
|
|
6228
6228
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.212";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -11,9 +11,9 @@ struct CraftLiveActivityWidget: Widget {
|
|
|
11
11
|
var body: some WidgetConfiguration {
|
|
12
12
|
ActivityConfiguration(for: CraftActivityAttributes.self) { context in
|
|
13
13
|
HStack(spacing: 12) {
|
|
14
|
-
|
|
14
|
+
activityIcon(context).font(.title2)
|
|
15
15
|
VStack(alignment: .leading, spacing: 4) {
|
|
16
|
-
Text(context.attributes.title).font(.headline).lineLimit(
|
|
16
|
+
Text(context.attributes.title).font(.headline).lineLimit(2)
|
|
17
17
|
if let detail = context.state.detail, !detail.isEmpty {
|
|
18
18
|
Text(detail).font(.subheadline).lineLimit(1)
|
|
19
19
|
}
|
|
@@ -33,14 +33,14 @@ struct CraftLiveActivityWidget: Widget {
|
|
|
33
33
|
} dynamicIsland: { context in
|
|
34
34
|
DynamicIsland {
|
|
35
35
|
DynamicIslandExpandedRegion(.leading) {
|
|
36
|
-
|
|
36
|
+
activityIcon(context).font(.title2)
|
|
37
37
|
}
|
|
38
38
|
DynamicIslandExpandedRegion(.trailing) {
|
|
39
39
|
timer(context).font(.title2.monospacedDigit().weight(.semibold))
|
|
40
40
|
}
|
|
41
41
|
DynamicIslandExpandedRegion(.center) {
|
|
42
42
|
VStack(spacing: 3) {
|
|
43
|
-
Text(context.attributes.title).font(.headline).lineLimit(
|
|
43
|
+
Text(context.attributes.title).font(.headline).lineLimit(2)
|
|
44
44
|
Text(context.isStale ? context.state.staleStatus ?? context.state.status : context.state.status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -59,17 +59,26 @@ struct CraftLiveActivityWidget: Widget {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
} compactLeading: {
|
|
62
|
-
|
|
62
|
+
activityIcon(context)
|
|
63
63
|
} compactTrailing: {
|
|
64
|
-
timer(context).monospacedDigit().frame(maxWidth: 64)
|
|
64
|
+
timer(context).monospacedDigit().frame(maxWidth: 64, alignment: .trailing)
|
|
65
65
|
} minimal: {
|
|
66
|
-
|
|
66
|
+
activityIcon(context)
|
|
67
67
|
}
|
|
68
68
|
.widgetURL(context.attributes.deepLink.flatMap(URL.init(string:)))
|
|
69
69
|
.keylineTint(tint(context))
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
private func activityIcon(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
|
|
74
|
+
// The pause glyph uses the active symbol's footprint, keeping the
|
|
75
|
+
// title's leading edge steady through state changes.
|
|
76
|
+
Image(systemName: context.attributes.symbol ?? "timer")
|
|
77
|
+
.hidden()
|
|
78
|
+
.overlay { Image(systemName: symbol(context)) }
|
|
79
|
+
.foregroundStyle(tint(context))
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
private func symbol(_ context: ActivityViewContext<CraftActivityAttributes>) -> String {
|
|
74
83
|
context.state.paused == true ? "pause.fill" : context.attributes.symbol ?? "timer"
|
|
75
84
|
}
|
|
@@ -82,8 +91,23 @@ struct CraftLiveActivityWidget: Widget {
|
|
|
82
91
|
|
|
83
92
|
private func date(_ milliseconds: Double) -> Date { Date(timeIntervalSince1970: milliseconds / 1000) }
|
|
84
93
|
|
|
85
|
-
@ViewBuilder
|
|
86
94
|
private func timer(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
|
|
95
|
+
// Measure the hour format in the inherited native font. A widget's
|
|
96
|
+
// ticking Text is horizontally flexible; an overlay bounds it to this
|
|
97
|
+
// footprint so names retain the same width when the timer pauses.
|
|
98
|
+
Text(duration(max(3600, context.state.durationSeconds, context.state.remainingSeconds ?? 0)))
|
|
99
|
+
.lineLimit(1)
|
|
100
|
+
.hidden()
|
|
101
|
+
.overlay(alignment: .trailing) {
|
|
102
|
+
timerContent(context)
|
|
103
|
+
.multilineTextAlignment(.trailing)
|
|
104
|
+
.lineLimit(1)
|
|
105
|
+
.minimumScaleFactor(0.8)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@ViewBuilder
|
|
110
|
+
private func timerContent(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
|
|
87
111
|
if context.state.paused == true {
|
|
88
112
|
Text(duration(context.state.remainingSeconds ?? context.state.durationSeconds))
|
|
89
113
|
} else if let end = context.state.timerEndsAt {
|
|
@@ -167,7 +167,7 @@ final class DriverTests: XCTestCase {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
func testScript() throws {
|
|
170
|
-
continueAfterFailure =
|
|
170
|
+
continueAfterFailure = false
|
|
171
171
|
let data: Data?
|
|
172
172
|
if let resource = Bundle(for: DriverTests.self).url(forResource: "journey", withExtension: "txt") {
|
|
173
173
|
bundledJourney = true
|
|
@@ -219,7 +219,13 @@ final class DriverTests: XCTestCase {
|
|
|
219
219
|
check(element(containing: arg, in: springboard) == nil, "systemabsent \(arg)")
|
|
220
220
|
case "systemtap":
|
|
221
221
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
222
|
-
|
|
222
|
+
// Widget text lives in a remote accessibility host. Its frame can
|
|
223
|
+
// be visible while XCTest cannot resolve an activation point. Tap
|
|
224
|
+
// the containing system button (the actual Live Activity card).
|
|
225
|
+
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier == %@", arg, arg)
|
|
226
|
+
let card = springboard.buttons.containing(predicate).firstMatch
|
|
227
|
+
if card.exists && card.isHittable { press(card); note("systemtap \(arg): ok") }
|
|
228
|
+
else if let target = poll(10, { element(containing: arg, in: springboard) }) { press(target); note("systemtap \(arg): ok") }
|
|
223
229
|
else { XCTFail("System element not found"); note("FAIL systemtap \(arg): NOT FOUND") }
|
|
224
230
|
case "systemtree":
|
|
225
231
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type MobileTestDeviceOptions = {
|
|
2
|
+
platform: 'ios';
|
|
3
|
+
device: string;
|
|
4
|
+
appId: string;
|
|
5
|
+
xcrun?: string;
|
|
6
|
+
} | {
|
|
7
|
+
platform: 'android';
|
|
8
|
+
device: string;
|
|
9
|
+
appId: string;
|
|
10
|
+
adb?: string;
|
|
11
|
+
};
|
|
12
|
+
export interface MobileTestDevice {
|
|
13
|
+
restart: () => Promise<void>;
|
|
14
|
+
installUpdate: (artifact: string) => Promise<{
|
|
15
|
+
previousBuild: string;
|
|
16
|
+
build: string;
|
|
17
|
+
}>;
|
|
18
|
+
installedBuild: () => Promise<string>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Drives an explicitly selected simulator/emulator. Restart preserves its disk;
|
|
22
|
+
* update installs over the existing app, without uninstalling or clearing data.
|
|
23
|
+
* Keep product assertions in the app's journey, after these OS boundaries.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createMobileTestDevice(options: MobileTestDeviceOptions): MobileTestDevice;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/mobile-testing.ts
|
|
3
|
+
import { access } from "fs/promises";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
function createMobileTestDevice(options) {
|
|
6
|
+
if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(options.appId))
|
|
7
|
+
throw new Error("An explicit app identifier is required");
|
|
8
|
+
if (options.platform === "ios" && !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(options.device))
|
|
9
|
+
throw new Error("An explicit iOS simulator UUID is required");
|
|
10
|
+
if (options.platform === "android" && !/^emulator-\d+$/.test(options.device))
|
|
11
|
+
throw new Error("An explicit Android emulator serial is required");
|
|
12
|
+
async function run(args, timeout = 120000) {
|
|
13
|
+
const child = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
14
|
+
const deadline = setTimeout(() => child.kill(), timeout);
|
|
15
|
+
try {
|
|
16
|
+
const [output, errors, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
17
|
+
if (code !== 0)
|
|
18
|
+
throw new Error(`Mobile test command failed: ${args[0]} ${args[1]} (${code})
|
|
19
|
+
${errors || output}`);
|
|
20
|
+
return output.trim();
|
|
21
|
+
} finally {
|
|
22
|
+
clearTimeout(deadline);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const ios = (...args) => run([options.platform === "ios" ? options.xcrun || "xcrun" : "xcrun", "simctl", ...args]);
|
|
26
|
+
const android = (...args) => run([options.platform === "android" ? options.adb || "adb" : "adb", "-s", options.device, ...args]);
|
|
27
|
+
async function installedBuild() {
|
|
28
|
+
if (options.platform === "ios") {
|
|
29
|
+
const app = await ios("get_app_container", options.device, options.appId, "app");
|
|
30
|
+
return run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleVersion", join(app, "Info.plist")]);
|
|
31
|
+
}
|
|
32
|
+
const result = await android("shell", "dumpsys", "package", options.appId);
|
|
33
|
+
const version = result.match(/\bversionCode=(\d+)/)?.[1];
|
|
34
|
+
if (!version)
|
|
35
|
+
throw new Error(`App is not installed: ${options.appId}`);
|
|
36
|
+
return version;
|
|
37
|
+
}
|
|
38
|
+
async function restart() {
|
|
39
|
+
if (options.platform === "ios") {
|
|
40
|
+
await ios("shutdown", options.device);
|
|
41
|
+
await ios("boot", options.device);
|
|
42
|
+
await ios("bootstatus", options.device, "-b");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const previousBoot = await android("shell", "cat", "/proc/sys/kernel/random/boot_id");
|
|
46
|
+
await android("reboot");
|
|
47
|
+
const deadline = Date.now() + 120000;
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
try {
|
|
50
|
+
await android("wait-for-device");
|
|
51
|
+
const boot = await android("shell", "cat", "/proc/sys/kernel/random/boot_id");
|
|
52
|
+
if (boot !== previousBoot && await android("shell", "getprop", "sys.boot_completed") === "1") {
|
|
53
|
+
await android("shell", "input", "keyevent", "82");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
await Bun.sleep(500);
|
|
58
|
+
}
|
|
59
|
+
throw new Error("Android emulator did not complete a new boot");
|
|
60
|
+
}
|
|
61
|
+
async function installUpdate(artifact) {
|
|
62
|
+
const previousBuild = await installedBuild();
|
|
63
|
+
if (options.platform === "ios") {
|
|
64
|
+
const id = await run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", join(artifact, "Info.plist")]);
|
|
65
|
+
if (id !== options.appId)
|
|
66
|
+
throw new Error("Update bundle identifier differs from the installed app");
|
|
67
|
+
await ios("install", options.device, artifact);
|
|
68
|
+
} else {
|
|
69
|
+
await access(artifact);
|
|
70
|
+
await android("install", "-r", artifact);
|
|
71
|
+
}
|
|
72
|
+
return { previousBuild, build: await installedBuild() };
|
|
73
|
+
}
|
|
74
|
+
return { restart, installUpdate, installedBuild };
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
createMobileTestDevice
|
|
78
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "craft-native",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.212",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Build desktop apps with web languages - TypeScript SDK for Craft",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.org>",
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
"default": "./dist/index.cjs"
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
|
+
"./testing": {
|
|
43
|
+
"browser": null,
|
|
44
|
+
"types": "./dist/mobile-testing.d.ts",
|
|
45
|
+
"import": "./dist/mobile-testing.js"
|
|
46
|
+
},
|
|
42
47
|
"./mobile": {
|
|
43
48
|
"types": "./dist/mobile.d.ts",
|
|
44
49
|
"import": "./dist/mobile.js",
|
|
@@ -67,6 +72,9 @@
|
|
|
67
72
|
},
|
|
68
73
|
"typesVersions": {
|
|
69
74
|
"*": {
|
|
75
|
+
"testing": [
|
|
76
|
+
"./dist/mobile-testing.d.ts"
|
|
77
|
+
],
|
|
70
78
|
"mobile": [
|
|
71
79
|
"./dist/mobile.d.ts"
|
|
72
80
|
],
|