craft-native 0.0.154 → 0.0.155

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/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.154";
3306
+ var version = "0.0.155";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.154";
313
+ var version = "0.0.155";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.154";
61
+ var version = "0.0.155";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -412,6 +412,8 @@ export declare function installRuntime(output: string, runtimeDir: string): Prom
412
412
  * valid Swift type.
413
413
  */
414
414
  export declare function productName(name: string): string;
415
+ /** Restore package-owned Swift into an existing project, without changing its Xcode metadata or bundled screens. */
416
+ export declare function restoreShell(output: string): void;
415
417
  export declare function init(options: InitOptions): Promise<void>;
416
418
  /**
417
419
  * Build web assets and generate Xcode project
@@ -475,3 +477,5 @@ export declare function bootSimulator(device: SimulatorDevice): Promise<void>;
475
477
  */
476
478
  export declare function showSimulator(): Promise<void>;
477
479
  export declare function run(options: RunOptions): Promise<void>;
480
+ export { createNativeMutationValidator, initSimulatorDriver, serializeSimulatorSteps } from './testing.js';
481
+ export type { NativeMutationValidator, NativeValidationResult, SimulatorDriverOptions, SimulatorStep } from './testing.js';
@@ -1,12 +1,149 @@
1
1
  // @bun
2
2
  // ../ios/src/index.ts
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
4
- import { dirname, join, resolve } from "path";
3
+ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
4
+ import { dirname as dirname2, join as join2, resolve } from "path";
5
5
  var {$ } = globalThis.Bun;
6
- var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
6
+
7
+ // ../ios/src/testing.ts
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
9
+ import { tmpdir } from "os";
10
+ import { dirname, join } from "path";
11
+ import { spawnSync } from "child_process";
12
+ var templates = join(dirname(import.meta.dir), "templates/testing");
13
+ function bundleIdentifier(value) {
14
+ if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value))
15
+ throw new Error(`Invalid bundle identifier: ${value}`);
16
+ }
17
+ function swiftString(value) {
18
+ return JSON.stringify(value).replace(/\\u([a-f\d]{4})/gi, "\\u{$1}");
19
+ }
20
+ function initSimulatorDriver(options) {
21
+ const { output, bundleId } = options;
22
+ const name = options.projectName ?? "CraftDriver";
23
+ const prefix = options.bundleIdPrefix ?? `${bundleId}.driver`;
24
+ const version = options.deploymentTarget ?? "17.0";
25
+ const control = options.controlUrl ?? "http://127.0.0.1:3401";
26
+ bundleIdentifier(bundleId);
27
+ bundleIdentifier(prefix);
28
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name))
29
+ throw new Error("projectName must be an alphanumeric Xcode name starting with a letter");
30
+ if (!/^\d+\.\d+$/.test(version) || Number(version) < 17)
31
+ throw new Error("The simulator driver requires iOS 17.0 or newer");
32
+ if (!["http:", "https:"].includes(new URL(control).protocol))
33
+ throw new Error("controlUrl must use HTTP or HTTPS");
34
+ const replacements = {
35
+ PROJECT_NAME: name,
36
+ BUNDLE_PREFIX: prefix,
37
+ IOS_VERSION: version,
38
+ BUNDLE_ID: swiftString(bundleId),
39
+ CONTROL_URL: swiftString(control),
40
+ TABS: `[${(options.tabs ?? []).map(swiftString).join(", ")}]`
41
+ };
42
+ for (const [source, destination] of [
43
+ ["HostApp.swift", "Host/HostApp.swift"],
44
+ ["DriverTests.swift.template", "UITests/DriverTests.swift"],
45
+ ["project.yml.template", "project.yml"]
46
+ ]) {
47
+ const path = join(output, destination);
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ const content = readFileSync(join(templates, source), "utf8").replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => replacements[key]);
50
+ let previous;
51
+ try {
52
+ previous = readFileSync(path, "utf8");
53
+ } catch {}
54
+ if (previous !== content)
55
+ writeFileSync(path, content);
56
+ }
57
+ }
58
+ function serializeSimulatorSteps(steps) {
59
+ const number = (value) => {
60
+ if (!Number.isFinite(value) || value < 0)
61
+ throw new Error("Simulator values must be finite and nonnegative");
62
+ return value;
63
+ };
64
+ const token = (value) => {
65
+ if (!value || /\s/.test(value))
66
+ throw new Error("Simulator identifiers must be nonempty single tokens");
67
+ return value;
68
+ };
69
+ return steps.map((step) => {
70
+ let arg = "";
71
+ if ("seconds" in step && !("text" in step))
72
+ arg = String(number(step.seconds));
73
+ else if ("milliseconds" in step)
74
+ arg = String(number(step.milliseconds));
75
+ else if ("state" in step)
76
+ arg = step.state;
77
+ else if ("direction" in step)
78
+ arg = step.direction;
79
+ else if ("x" in step) {
80
+ if (number(step.x) > 1 || number(step.y) > 1)
81
+ throw new Error("tapxy coordinates must be between 0 and 1");
82
+ arg = `${step.x} ${step.y}`;
83
+ } else if ("index" in step) {
84
+ if (!Number.isInteger(number(step.index)))
85
+ throw new Error("field index must be an integer");
86
+ arg = `${step.index} ${step.text}`;
87
+ } else if ("id" in step)
88
+ arg = `${token(step.id)} ${step.text}`;
89
+ else if ("text" in step) {
90
+ arg = step.text;
91
+ if ("seconds" in step && step.seconds !== undefined) {
92
+ if (arg.includes("|"))
93
+ throw new Error("A timed expectation cannot contain |");
94
+ arg += ` | ${number(step.seconds)}`;
95
+ }
96
+ } else if ("name" in step)
97
+ arg = token(step.name);
98
+ else if ("url" in step)
99
+ arg = step.url;
100
+ if (/[\r\n]/.test(arg))
101
+ throw new Error("Simulator arguments cannot contain newlines");
102
+ return `${step.command}${arg ? ` ${arg}` : ""}`;
103
+ }).join(`
104
+ `) + `
105
+ `;
106
+ }
107
+ function createNativeMutationValidator() {
108
+ const output = mkdtempSync(join(tmpdir(), "craft-native-mutation-"));
109
+ const binary = join(output, "validate");
110
+ try {
111
+ writeFileSync(join(output, "main.swift"), readFileSync(join(templates, "MutationValidator.swift")));
112
+ const build = Bun.spawnSync(["swiftc", join(templates, "../CraftNativeMutation.swift"), join(output, "main.swift"), "-o", binary]);
113
+ if (build.exitCode !== 0)
114
+ throw new Error(build.stderr.toString());
115
+ } catch (error) {
116
+ rmSync(output, { recursive: true, force: true });
117
+ throw error;
118
+ }
119
+ let disposed = false;
120
+ return {
121
+ validate(messages) {
122
+ if (disposed)
123
+ throw new Error("The native mutation validator has been disposed");
124
+ const input = join(output, "messages.json");
125
+ writeFileSync(input, JSON.stringify(messages));
126
+ const report = join(output, "result.txt");
127
+ rmSync(report, { force: true });
128
+ const result = spawnSync(binary, [input, report], { encoding: "utf8" });
129
+ let stdout = result.stdout ?? "";
130
+ try {
131
+ stdout = readFileSync(report, "utf8");
132
+ } catch {}
133
+ return { exitCode: result.status ?? 1, stdout, stderr: result.stderr || result.error?.message || (result.signal ? `Validator terminated by ${result.signal}` : "") };
134
+ },
135
+ dispose() {
136
+ disposed = true;
137
+ rmSync(output, { recursive: true, force: true });
138
+ }
139
+ };
140
+ }
141
+
142
+ // ../ios/src/index.ts
143
+ var TEMPLATES_DIR = join2(dirname2(import.meta.dir), "templates");
7
144
  var CRAFT_VERSION = (() => {
8
145
  try {
9
- return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
146
+ return JSON.parse(readFileSync2(join2(dirname2(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
10
147
  } catch {
11
148
  return "0.0.0";
12
149
  }
@@ -122,8 +259,8 @@ function hybridConfigProblems(config) {
122
259
  function installNativeBundle(source, output) {
123
260
  if (!existsSync(source))
124
261
  throw new Error(`Native bundle not found: ${source}`);
125
- const destination = join(output, "dist", "native-screen.js");
126
- mkdirSync(dirname(destination), { recursive: true });
262
+ const destination = join2(output, "dist", "native-screen.js");
263
+ mkdirSync2(dirname2(destination), { recursive: true });
127
264
  if (resolve(source) !== resolve(destination))
128
265
  cpSync(source, destination);
129
266
  }
@@ -471,7 +608,7 @@ function pngHasAlpha(bytes) {
471
608
  return false;
472
609
  }
473
610
  function writeOpaqueIcon(source, target) {
474
- const bytes = new Uint8Array(readFileSync(source));
611
+ const bytes = new Uint8Array(readFileSync2(source));
475
612
  if (!pngHasAlpha(bytes) || process.platform !== "darwin") {
476
613
  cpSync(source, target);
477
614
  return;
@@ -480,27 +617,27 @@ function writeOpaqueIcon(source, target) {
480
617
  const run = (args) => Bun.spawnSync(["sips", ...args], { stdout: "ignore", stderr: "pipe" });
481
618
  const toJpeg = run(["-s", "format", "jpeg", "-s", "formatOptions", "100", source, "--out", flattened]);
482
619
  const toPng = toJpeg.exitCode === 0 ? run(["-s", "format", "png", flattened, "--out", target]) : toJpeg;
483
- rmSync(flattened, { force: true });
620
+ rmSync2(flattened, { force: true });
484
621
  if (toPng.exitCode !== 0) {
485
622
  cpSync(source, target);
486
623
  console.warn(`[craft] Could not remove the app icon's alpha channel; App Store Connect will refuse it: ${toPng.stderr.toString().trim()}`);
487
624
  }
488
625
  }
489
626
  function renderAssetCatalog(output, config) {
490
- const catalog = join(output, "Assets.xcassets");
491
- const appIcon = join(catalog, "AppIcon.appiconset");
492
- const launchBackground = join(catalog, "LaunchBackground.colorset");
493
- mkdirSync(appIcon, { recursive: true });
494
- mkdirSync(launchBackground, { recursive: true });
495
- writeFileSync(join(catalog, "Contents.json"), `${JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2)}
627
+ const catalog = join2(output, "Assets.xcassets");
628
+ const appIcon = join2(catalog, "AppIcon.appiconset");
629
+ const launchBackground = join2(catalog, "LaunchBackground.colorset");
630
+ mkdirSync2(appIcon, { recursive: true });
631
+ mkdirSync2(launchBackground, { recursive: true });
632
+ writeFileSync2(join2(catalog, "Contents.json"), `${JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2)}
496
633
  `);
497
634
  const iconFilename = config.appIconPath ? "AppIcon-1024.png" : undefined;
498
635
  if (config.appIconPath) {
499
636
  if (!existsSync(config.appIconPath))
500
637
  throw new Error(`App icon not found: ${config.appIconPath}`);
501
- writeOpaqueIcon(config.appIconPath, join(appIcon, iconFilename));
638
+ writeOpaqueIcon(config.appIconPath, join2(appIcon, iconFilename));
502
639
  }
503
- writeFileSync(join(appIcon, "Contents.json"), `${JSON.stringify({
640
+ writeFileSync2(join2(appIcon, "Contents.json"), `${JSON.stringify({
504
641
  images: iconFilename ? [{
505
642
  filename: iconFilename,
506
643
  idiom: "universal",
@@ -527,7 +664,7 @@ function renderAssetCatalog(output, config) {
527
664
  idiom: "universal"
528
665
  });
529
666
  }
530
- writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
667
+ writeFileSync2(join2(launchBackground, "Contents.json"), `${JSON.stringify({
531
668
  colors,
532
669
  info: { author: "xcode", version: 1 }
533
670
  }, null, 2)}
@@ -536,11 +673,11 @@ function renderAssetCatalog(output, config) {
536
673
  }
537
674
  var LAUNCH_LOGO = "LaunchLogo";
538
675
  function renderLaunchLogo(catalog, config) {
539
- const imageset = join(catalog, `${LAUNCH_LOGO}.imageset`);
540
- rmSync(imageset, { recursive: true, force: true });
676
+ const imageset = join2(catalog, `${LAUNCH_LOGO}.imageset`);
677
+ rmSync2(imageset, { recursive: true, force: true });
541
678
  if (!config.splashImagePath)
542
679
  return;
543
- mkdirSync(imageset, { recursive: true });
680
+ mkdirSync2(imageset, { recursive: true });
544
681
  const images = [];
545
682
  let vector = false;
546
683
  for (const [source, dark] of [[config.splashImagePath, false], [config.splashImagePathDark, true]]) {
@@ -552,14 +689,14 @@ function renderLaunchLogo(catalog, config) {
552
689
  if (extension === "svg" || extension === "pdf")
553
690
  vector = true;
554
691
  const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
555
- cpSync(source, join(imageset, filename));
692
+ cpSync(source, join2(imageset, filename));
556
693
  images.push({
557
694
  ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
558
695
  filename,
559
696
  idiom: "universal"
560
697
  });
561
698
  }
562
- writeFileSync(join(imageset, "Contents.json"), `${JSON.stringify({
699
+ writeFileSync2(join2(imageset, "Contents.json"), `${JSON.stringify({
563
700
  images,
564
701
  info: { author: "xcode", version: 1 },
565
702
  ...vector ? { properties: { "preserves-vector-representation": true } } : {}
@@ -673,15 +810,15 @@ function syncWebAssets(source, output) {
673
810
  const sourcePath = resolve(source);
674
811
  if (!existsSync(sourcePath))
675
812
  throw new Error(`Web asset path not found: ${source}`);
676
- const distDir = join(output, "dist");
677
- rmSync(distDir, { recursive: true, force: true });
678
- mkdirSync(distDir, { recursive: true });
813
+ const distDir = join2(output, "dist");
814
+ rmSync2(distDir, { recursive: true, force: true });
815
+ mkdirSync2(distDir, { recursive: true });
679
816
  if (statSync(sourcePath).isDirectory()) {
680
817
  cpSync(sourcePath, distDir, { recursive: true });
681
818
  } else {
682
- cpSync(sourcePath, join(distDir, "index.html"));
819
+ cpSync(sourcePath, join2(distDir, "index.html"));
683
820
  }
684
- if (!existsSync(join(distDir, "index.html"))) {
821
+ if (!existsSync(join2(distDir, "index.html"))) {
685
822
  throw new Error(`Web asset directory must contain index.html: ${source}`);
686
823
  }
687
824
  }
@@ -703,32 +840,32 @@ var RUNTIME_ARCHIVES = {
703
840
  };
704
841
  async function installRuntime(output, runtimeDir) {
705
842
  const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => {
706
- const present = archives.filter((a) => existsSync(join(runtimeDir, a)));
843
+ const present = archives.filter((a) => existsSync(join2(runtimeDir, a)));
707
844
  if (present.length === 0) {
708
845
  throw new Error(`${runtimeDir} has none of ${archives.join(", ")}. ` + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`);
709
846
  }
710
847
  return { sdk, archives, present };
711
848
  });
712
- const dest = join(output, "Runtime");
713
- rmSync(dest, { recursive: true, force: true });
849
+ const dest = join2(output, "Runtime");
850
+ rmSync2(dest, { recursive: true, force: true });
714
851
  for (const { sdk, archives, present } of resolved) {
715
- const sdkDir = join(dest, sdk);
716
- mkdirSync(sdkDir, { recursive: true });
717
- const target = join(sdkDir, "libcraft-ios.a");
852
+ const sdkDir = join2(dest, sdk);
853
+ mkdirSync2(sdkDir, { recursive: true });
854
+ const target = join2(sdkDir, "libcraft-ios.a");
718
855
  if (present.length === 1) {
719
856
  const missing = archives.filter((a) => !present.includes(a));
720
857
  if (missing.length > 0) {
721
858
  console.warn(` \u26A0 ${sdk}: only ${present[0]} was found; ${missing.join(", ")} is missing. ` + `The generated project will not link on the other architecture.`);
722
859
  }
723
- cpSync(join(runtimeDir, present[0]), target);
860
+ cpSync(join2(runtimeDir, present[0]), target);
724
861
  } else {
725
- await $`lipo -create ${present.map((a) => join(runtimeDir, a))} -output ${target}`.quiet();
862
+ await $`lipo -create ${present.map((a) => join2(runtimeDir, a))} -output ${target}`.quiet();
726
863
  }
727
864
  }
728
865
  return true;
729
866
  }
730
867
  async function refreshRuntime(output, override) {
731
- if (!existsSync(join(output, "Runtime")))
868
+ if (!existsSync(join2(output, "Runtime")))
732
869
  return;
733
870
  const dir = resolveRuntimeDir(override);
734
871
  if (!dir) {
@@ -745,6 +882,43 @@ function productName(name) {
745
882
  return "Craft";
746
883
  return /^\d/.test(joined) ? `App${joined}` : joined;
747
884
  }
885
+ function restoreShell(output) {
886
+ const config = JSON.parse(readFileSync2(join2(output, "craft.config.json"), "utf8"));
887
+ writeShell(output, config);
888
+ }
889
+ function writeShell(output, config) {
890
+ if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
891
+ throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
892
+ const name = productName(config.appName);
893
+ mkdirSync2(join2(output, "Sources"), { recursive: true });
894
+ mkdirSync2(join2(output, "Shared"), { recursive: true });
895
+ const swiftTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
896
+ const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
897
+ writeFileSync2(join2(output, "Sources", `${name}App.swift`), swiftSource);
898
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeScreen.swift"), join2(output, "Sources", "CraftNativeScreen.swift"));
899
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join2(output, "Sources", "CraftNativeFlatList.swift"));
900
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeActions.swift"), join2(output, "Sources", "CraftNativeActions.swift"));
901
+ cpSync(join2(TEMPLATES_DIR, "CraftHealth.swift"), join2(output, "Sources", "CraftHealth.swift"));
902
+ cpSync(join2(TEMPLATES_DIR, "CraftMusic.swift"), join2(output, "Sources", "CraftMusic.swift"));
903
+ cpSync(join2(TEMPLATES_DIR, "CraftHeartRate.swift"), join2(output, "Sources", "CraftHeartRate.swift"));
904
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeMutation.swift"), join2(output, "Sources", "CraftNativeMutation.swift"));
905
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeUI.swift"), join2(output, "Sources", "CraftNativeUI.swift"));
906
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeServices.swift"), join2(output, "Sources", "CraftNativeServices.swift"));
907
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeFiles.swift"), join2(output, "Sources", "CraftNativeFiles.swift"));
908
+ cpSync(join2(TEMPLATES_DIR, "CraftWebAudio.swift"), join2(output, "Sources", "CraftWebAudio.swift"));
909
+ cpSync(join2(TEMPLATES_DIR, "CraftHybrid.swift"), join2(output, "Sources", "CraftHybrid.swift"));
910
+ cpSync(join2(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join2(output, "Shared", "CraftActivityAttributes.swift"));
911
+ for (const [enabled, folder, template, filename] of [
912
+ [config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
913
+ [config.enableWatchApp, "WatchExtension", "CraftWatchApp.swift.template", `${name}WatchApp.swift`]
914
+ ]) {
915
+ if (!enabled)
916
+ continue;
917
+ mkdirSync2(join2(output, folder), { recursive: true });
918
+ const source = readFileSync2(join2(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
919
+ writeFileSync2(join2(output, folder, filename), source);
920
+ }
921
+ }
748
922
  async function init(options) {
749
923
  const { name: displayName, bundleId, teamId, output } = options;
750
924
  const name = productName(displayName);
@@ -752,12 +926,12 @@ async function init(options) {
752
926
  \u26A1 Initializing Craft iOS project: ${displayName}`);
753
927
  console.log(` Output: ${output}
754
928
  `);
755
- const dirs = [output, join(output, "Sources"), join(output, "Shared"), join(output, "dist")];
929
+ const dirs = [output, join2(output, "Sources"), join2(output, "Shared"), join2(output, "dist")];
756
930
  if (options.config?.enableWatchApp)
757
- dirs.push(join(output, "WatchApp"), join(output, "WatchExtension"));
931
+ dirs.push(join2(output, "WatchApp"), join2(output, "WatchExtension"));
758
932
  for (const dir of dirs) {
759
933
  if (!existsSync(dir)) {
760
- mkdirSync(dir, { recursive: true });
934
+ mkdirSync2(dir, { recursive: true });
761
935
  }
762
936
  }
763
937
  const finalBundleId = bundleId || `com.craft.${name.toLowerCase()}`;
@@ -781,29 +955,15 @@ async function init(options) {
781
955
  `));
782
956
  if (Object.keys(config.shareStorage ?? {}).length)
783
957
  config.enableSecureStorage = true;
784
- writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
785
- const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
786
- const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
787
- writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
788
- cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
789
- cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
790
- cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
791
- cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
792
- cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
793
- cpSync(join(TEMPLATES_DIR, "CraftHeartRate.swift"), join(output, "Sources", "CraftHeartRate.swift"));
794
- cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
795
- cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
796
- cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
797
- cpSync(join(TEMPLATES_DIR, "CraftNativeFiles.swift"), join(output, "Sources", "CraftNativeFiles.swift"));
798
- cpSync(join(TEMPLATES_DIR, "CraftWebAudio.swift"), join(output, "Sources", "CraftWebAudio.swift"));
799
- cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
800
- const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
958
+ writeFileSync2(join2(output, "craft.config.json"), JSON.stringify(config, null, 2));
959
+ writeShell(output, config);
960
+ const infoPlistTemplate = readFileSync2(join2(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
801
961
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
802
962
  <true/>
803
963
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
804
964
  <true/>` : "");
805
- writeFileSync(join(output, "Info.plist"), infoPlist);
806
- const projectYmlTemplate = readFileSync(join(TEMPLATES_DIR, "project.yml.template"), "utf-8");
965
+ writeFileSync2(join2(output, "Info.plist"), infoPlist);
966
+ const projectYmlTemplate = readFileSync2(join2(TEMPLATES_DIR, "project.yml.template"), "utf-8");
807
967
  const nativeDependencies = [];
808
968
  if (config.enableLiveActivities)
809
969
  nativeDependencies.push(` - target: ${name}LiveActivity`);
@@ -860,35 +1020,30 @@ async function init(options) {
860
1020
  if (hasRuntime) {
861
1021
  console.log(" Linked the Zig runtime from", runtimeDir);
862
1022
  } else {
863
- rmSync(join(output, "Runtime"), { recursive: true, force: true });
1023
+ rmSync2(join2(output, "Runtime"), { recursive: true, force: true });
864
1024
  }
865
1025
  const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
866
1026
  ${nativeDependencies.join(`
867
1027
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
868
1028
  `));
869
- writeFileSync(join(output, "project.yml"), projectYml);
870
- writeFileSync(join(output, "Craft.entitlements"), renderEntitlements(config));
871
- writeFileSync(join(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
1029
+ writeFileSync2(join2(output, "project.yml"), projectYml);
1030
+ writeFileSync2(join2(output, "Craft.entitlements"), renderEntitlements(config));
1031
+ writeFileSync2(join2(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
872
1032
  renderAssetCatalog(output, config);
873
- cpSync(join(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join(output, "Shared", "CraftActivityAttributes.swift"));
874
1033
  if (config.enableLiveActivities) {
875
- mkdirSync(join(output, "WidgetExtension"), { recursive: true });
876
- const widgetSource = readFileSync(join(TEMPLATES_DIR, "CraftLiveActivityWidget.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
877
- writeFileSync(join(output, "WidgetExtension", `${name}LiveActivity.swift`), widgetSource);
878
- const widgetInfo = readFileSync(join(TEMPLATES_DIR, "WidgetExtension.Info.plist"), "utf8");
879
- writeFileSync(join(output, "WidgetExtension", "Info.plist"), widgetInfo);
1034
+ mkdirSync2(join2(output, "WidgetExtension"), { recursive: true });
1035
+ const widgetInfo = readFileSync2(join2(TEMPLATES_DIR, "WidgetExtension.Info.plist"), "utf8");
1036
+ writeFileSync2(join2(output, "WidgetExtension", "Info.plist"), widgetInfo);
880
1037
  }
881
1038
  if (config.enableWatchApp) {
882
- const watchSource = readFileSync(join(TEMPLATES_DIR, "CraftWatchApp.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
883
- writeFileSync(join(output, "WatchExtension", `${name}WatchApp.swift`), watchSource);
884
- const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
885
- writeFileSync(join(output, "WatchApp", "Info.plist"), watchInfo);
886
- writeFileSync(join(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
887
- const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
888
- writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
889
- writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1039
+ const watchInfo = readFileSync2(join2(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1040
+ writeFileSync2(join2(output, "WatchApp", "Info.plist"), watchInfo);
1041
+ writeFileSync2(join2(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
1042
+ const watchExtensionInfo = readFileSync2(join2(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1043
+ writeFileSync2(join2(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
1044
+ writeFileSync2(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
890
1045
  }
891
- writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
1046
+ writeFileSync2(join2(output, "dist", "index.html"), renderOfflinePage(config));
892
1047
  if (isHybrid(config)) {
893
1048
  if (config.nativeBundle)
894
1049
  installNativeBundle(config.nativeBundle, output);
@@ -913,11 +1068,11 @@ async function build(options) {
913
1068
  const { htmlPath, nativeBundlePath, devServer, output, generateProject = true } = options;
914
1069
  console.log(`
915
1070
  \uD83D\uDCE6 Building Craft iOS project...`);
916
- const configPath = join(output, "craft.config.json");
1071
+ const configPath = join2(output, "craft.config.json");
917
1072
  if (!existsSync(configPath)) {
918
1073
  throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
919
1074
  }
920
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
1075
+ const config = JSON.parse(readFileSync2(configPath, "utf-8"));
921
1076
  if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
922
1077
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
923
1078
  }
@@ -926,11 +1081,11 @@ async function build(options) {
926
1081
  throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
927
1082
  }
928
1083
  if (nativeBundlePath) {
929
- const destination = join(output, "dist", "native-screen.js");
1084
+ const destination = join2(output, "dist", "native-screen.js");
930
1085
  if (resolve(nativeBundlePath) !== resolve(destination))
931
1086
  cpSync(nativeBundlePath, destination);
932
1087
  }
933
- if (!existsSync(join(output, "dist", "native-screen.js"))) {
1088
+ if (!existsSync(join2(output, "dist", "native-screen.js"))) {
934
1089
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
935
1090
  }
936
1091
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -944,7 +1099,7 @@ async function build(options) {
944
1099
  config.devServerURL = devServer;
945
1100
  const origin = new URL(devServer).origin;
946
1101
  config.trustedOrigins = [...new Set([...config.trustedOrigins ?? [], origin])];
947
- writeFileSync(configPath, JSON.stringify(config, null, 2));
1102
+ writeFileSync2(configPath, JSON.stringify(config, null, 2));
948
1103
  console.log(` Dev server: ${devServer}`);
949
1104
  }
950
1105
  if (htmlPath) {
@@ -956,7 +1111,7 @@ async function build(options) {
956
1111
  if (bundle) {
957
1112
  installNativeBundle(bundle, output);
958
1113
  console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
959
- } else if (!existsSync(join(output, "dist", "native-screen.js"))) {
1114
+ } else if (!existsSync(join2(output, "dist", "native-screen.js"))) {
960
1115
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
961
1116
  }
962
1117
  }
@@ -986,7 +1141,7 @@ async function open(options) {
986
1141
  if (!xcodeproj) {
987
1142
  throw new Error(`No Xcode project found in ${output}. Run 'craft ios build' first.`);
988
1143
  }
989
- const projectPath = join(output, xcodeproj);
1144
+ const projectPath = join2(output, xcodeproj);
990
1145
  console.log(`\uD83D\uDE80 Opening ${xcodeproj}...`);
991
1146
  await $`open ${projectPath}`;
992
1147
  }
@@ -1027,7 +1182,7 @@ async function showSimulator() {
1027
1182
  const selected = await $`xcode-select -p`.quiet().nothrow();
1028
1183
  const developer = selected.exitCode === 0 ? selected.stdout.toString().trim() : "";
1029
1184
  if (developer) {
1030
- const app = join(developer, "Applications", "Simulator.app");
1185
+ const app = join2(developer, "Applications", "Simulator.app");
1031
1186
  if (existsSync(app)) {
1032
1187
  await $`open ${app}`.quiet().nothrow();
1033
1188
  return;
@@ -1043,12 +1198,12 @@ async function run(options) {
1043
1198
  if (!xcodeproj) {
1044
1199
  throw new Error(`No Xcode project found in ${output}`);
1045
1200
  }
1046
- const projectPath = join(output, xcodeproj);
1201
+ const projectPath = join2(output, xcodeproj);
1047
1202
  const appName = xcodeproj.replace(".xcodeproj", "");
1048
- const configPath = join(output, "craft.config.json");
1203
+ const configPath = join2(output, "craft.config.json");
1049
1204
  if (!existsSync(configPath))
1050
1205
  throw new Error(`No craft.config.json found in ${output}. Run 'craft-ios init' first.`);
1051
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
1206
+ const config = JSON.parse(readFileSync2(configPath, "utf-8"));
1052
1207
  if (simulator) {
1053
1208
  console.log("\uD83D\uDCF1 Building and running on simulator...");
1054
1209
  const device = await pickSimulator();
@@ -1058,8 +1213,8 @@ async function run(options) {
1058
1213
  console.log(` Device: ${device.name} (${device.runtime})`);
1059
1214
  try {
1060
1215
  await bootSimulator(device);
1061
- const derivedData = join(output, "build");
1062
- const product = join(derivedData, "Build", "Products", "Debug-iphonesimulator", `${appName}.app`);
1216
+ const derivedData = join2(output, "build");
1217
+ const product = join2(derivedData, "Build", "Products", "Debug-iphonesimulator", `${appName}.app`);
1063
1218
  await $`xcodebuild -project ${projectPath} -scheme ${appName} -configuration Debug -destination ${`id=${device.udid}`} -derivedDataPath ${derivedData} build`;
1064
1219
  if (!existsSync(product)) {
1065
1220
  throw new Error(`xcodebuild reported success but ${product} does not exist. ` + "The scheme may build a different product name than the project.");
@@ -1085,7 +1240,9 @@ async function run(options) {
1085
1240
  export {
1086
1241
  syncWebAssets,
1087
1242
  showSimulator,
1243
+ serializeSimulatorSteps,
1088
1244
  run,
1245
+ restoreShell,
1089
1246
  resolveRuntimeDir,
1090
1247
  resolveAppearance,
1091
1248
  renderWatchEntitlements,
@@ -1112,8 +1269,10 @@ export {
1112
1269
  matchNativePath,
1113
1270
  isHybrid,
1114
1271
  installRuntime,
1272
+ initSimulatorDriver,
1115
1273
  init,
1116
1274
  hybridConfigProblems,
1275
+ createNativeMutationValidator,
1117
1276
  build,
1118
1277
  bootSimulator,
1119
1278
  backgroundRefreshIdentifier,
@@ -0,0 +1,71 @@
1
+ export interface SimulatorDriverOptions {
2
+ output: string;
3
+ /** The installed app to drive; the test host is a separate application. */
4
+ bundleId: string;
5
+ projectName?: string;
6
+ bundleIdPrefix?: string;
7
+ deploymentTarget?: string;
8
+ controlUrl?: string;
9
+ tabs?: readonly string[];
10
+ }
11
+ /** Generate the reusable XCUITest shell from package templates. No app-owned Swift. */
12
+ export declare function initSimulatorDriver(options: SimulatorDriverOptions): void;
13
+ export type SimulatorStep = {
14
+ command: 'launch' | 'activate' | 'terminate' | 'home' | 'back' | 'return' | 'unlabelled';
15
+ } | {
16
+ command: 'background' | 'wait';
17
+ seconds: number;
18
+ } | {
19
+ command: 'delay';
20
+ milliseconds: number;
21
+ } | {
22
+ command: 'net';
23
+ state: 'online' | 'offline';
24
+ } | {
25
+ command: 'swipe';
26
+ direction: 'up' | 'down' | 'left' | 'right';
27
+ } | {
28
+ command: 'tabbar';
29
+ state: 'shown' | 'hidden';
30
+ } | {
31
+ command: 'tapxy';
32
+ x: number;
33
+ y: number;
34
+ } | {
35
+ command: 'field';
36
+ index: number;
37
+ text: string;
38
+ } | {
39
+ command: 'typeid';
40
+ id: string;
41
+ text: string;
42
+ } | {
43
+ command: 'expect' | 'expectexact' | 'expectid';
44
+ text: string;
45
+ seconds?: number;
46
+ } | {
47
+ command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log';
48
+ text: string;
49
+ } | {
50
+ command: 'shot' | 'tree' | 'audit' | 'exec';
51
+ name: string;
52
+ } | {
53
+ command: 'openurl';
54
+ url: string;
55
+ };
56
+ /** Typed journeys use the same wire protocol as existing line-based scripts. */
57
+ export declare function serializeSimulatorSteps(steps: readonly SimulatorStep[]): string;
58
+ export interface NativeValidationResult {
59
+ exitCode: number;
60
+ stdout: string;
61
+ stderr: string;
62
+ }
63
+ export interface NativeMutationValidator {
64
+ validate(messages: readonly {
65
+ type: string;
66
+ payload?: unknown;
67
+ }[]): NativeValidationResult;
68
+ dispose(): void;
69
+ }
70
+ /** Compile the actual Foundation mutation host once, then validate captured STX messages. Requires macOS and swiftc. */
71
+ export declare function createNativeMutationValidator(): NativeMutationValidator;
@@ -0,0 +1,305 @@
1
+ import XCTest
2
+
3
+ /// Drives the installed app from a script the Mac serves (netswitch.ts
4
+ /// `/script`), one command a line:
5
+ ///
6
+ /// launch | activate | terminate | home
7
+ /// background <secs> Home, wait, back to the app
8
+ /// wait <seconds>
9
+ /// net offline|online
10
+ /// delay <ms> every proxied request waits this long (0 = off)
11
+ /// exec <hook> runs the switch's HOOKS/<hook>.sh on the Mac (restart the API, …)
12
+ /// openurl <url> opens a link through the system, answering "Open in the app?"
13
+ /// shot <name> screenshot → shots/<name>.png
14
+ /// tree <name> accessibility tree → shots/<name>.txt
15
+ /// tap <text> first hittable element whose label contains text
16
+ /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
17
+ /// tapid <identifier>
18
+ /// tapxy <x> <y> normalised 0…1 of the screen
19
+ /// tapexact <label> a button whose label is exactly this
20
+ /// back the navigation bar's back button, else an edge swipe
21
+ /// type <text> into the focused element
22
+ /// return the keyboard's return key (Go on a sign-in form)
23
+ /// field <n> <text> tap the n-th text field (0-based, secure fields after plain) and type
24
+ /// typeid <identifier> <text> tap the element with this identifier and type
25
+ /// expect <text> [| secs] waits for an element containing text; logs PASS/FAIL
26
+ /// expectexact <label> [| secs] a button or text whose label is exactly this
27
+ /// absent <text> logs PASS when no element contains text
28
+ /// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID)
29
+ /// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
30
+ /// selected <tab> the tab bar's selected button is this one
31
+ /// tab <name> tap a tab bar button
32
+ /// scrollto <text> swipes up (at most 8 times) until text is hittable
33
+ /// swipe up|down|left|right
34
+ /// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
35
+ /// unlabelled logs every hittable button without a label (FAIL if any)
36
+ /// dismiss [label] a system prompt's button (default Not Now: "Save Password?")
37
+ /// log <text>
38
+ ///
39
+ /// Checks log `PASS …` / `FAIL …` lines into driver.log; matrix.sh counts them.
40
+ final class DriverTests: XCTestCase {
41
+ let control = ProcessInfo.processInfo.environment["CRAFT_TEST_CONTROL"] ?? {{CONTROL_URL}}
42
+ let app = XCUIApplication(bundleIdentifier: {{BUNDLE_ID}})
43
+ let tabs: [String] = {{TABS}}
44
+
45
+ func get(_ path: String, timeout: Double = 15) -> Data? {
46
+ var result: Data?
47
+ let done = expectation(description: path)
48
+ URLSession.shared.dataTask(with: URL(string: control + path)!) { data, _, _ in result = data; done.fulfill() }.resume()
49
+ wait(for: [done], timeout: timeout)
50
+ return result
51
+ }
52
+
53
+ func post(_ path: String, _ body: Data) {
54
+ var request = URLRequest(url: URL(string: control + path)!)
55
+ request.httpMethod = "POST"
56
+ request.httpBody = body
57
+ let done = expectation(description: path)
58
+ URLSession.shared.dataTask(with: request) { _, _, _ in done.fulfill() }.resume()
59
+ wait(for: [done], timeout: 15)
60
+ }
61
+
62
+ func note(_ text: String) {
63
+ print("DRIVER \(text)")
64
+ post("/note", Data(text.utf8))
65
+ }
66
+
67
+ func check(_ ok: Bool, _ what: String) {
68
+ note(ok ? "PASS \(what)" : "FAIL \(what)")
69
+ XCTAssertTrue(ok, what)
70
+ }
71
+
72
+ func element(containing text: String) -> XCUIElement? {
73
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR value CONTAINS[c] %@ OR identifier == %@", text, text, text)
74
+ for query in [app.buttons, app.links, app.staticTexts, app.otherElements, app.cells, app.textFields] {
75
+ let matches = query.matching(predicate)
76
+ let count = min(matches.count, 25)
77
+ for index in 0..<count {
78
+ let candidate = matches.element(boundBy: index)
79
+ if candidate.exists && candidate.isHittable { return candidate }
80
+ }
81
+ }
82
+ let any = app.descendants(matching: .any).matching(predicate).firstMatch
83
+ return any.exists ? any : nil
84
+ }
85
+
86
+ func exact(_ label: String) -> XCUIElement? {
87
+ let predicate = NSPredicate(format: "label == %@", label)
88
+ for query in [app.buttons, app.links, app.staticTexts] {
89
+ let matches = query.matching(predicate)
90
+ for index in 0..<min(matches.count, 25) {
91
+ let candidate = matches.element(boundBy: index)
92
+ if candidate.exists && candidate.isHittable { return candidate }
93
+ }
94
+ }
95
+ return nil
96
+ }
97
+
98
+ func poll(_ seconds: Double, _ find: () -> XCUIElement?) -> XCUIElement? {
99
+ let end = Date().addingTimeInterval(seconds)
100
+ repeat {
101
+ if let found = find() { return found }
102
+ Thread.sleep(forTimeInterval: 0.5)
103
+ } while Date() < end
104
+ return nil
105
+ }
106
+
107
+ func waitFor(_ text: String, _ seconds: Double) -> XCUIElement? {
108
+ poll(seconds) { element(containing: text) }
109
+ }
110
+
111
+ /// The native tab bar's button: Craft's floating bar is not a UITabBar, and
112
+ /// XCUITest calls its buttons not hittable, so it is the button with this
113
+ /// label in the bottom fifth of the window.
114
+ func tabButton(_ name: String) -> XCUIElement? {
115
+ let height = app.windows.firstMatch.frame.height
116
+ let matches = app.buttons.matching(NSPredicate(format: "label == %@", name))
117
+ for index in 0..<min(matches.count, 10) {
118
+ let candidate = matches.element(boundBy: index)
119
+ if candidate.exists && candidate.frame.minY > height * 0.8 && candidate.frame.maxY <= height + 1 && candidate.frame.width > 0 { return candidate }
120
+ }
121
+ return nil
122
+ }
123
+
124
+ func tabBarShown() -> Bool {
125
+ let required = min(3, tabs.count)
126
+ return required > 0 && tabs.filter { tabButton($0) != nil }.count >= required
127
+ }
128
+
129
+ func split(_ arg: String) -> (String, Double) {
130
+ let parts = arg.split(separator: "|").map { $0.trimmingCharacters(in: .whitespaces) }
131
+ return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
132
+ }
133
+
134
+ /// A tap that never fails the run: an element covered a moment (the big
135
+ /// timer coming up) is tapped where it is, and the check after says.
136
+ func press(_ element: XCUIElement) {
137
+ if element.isHittable { element.tap() }
138
+ else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
139
+ }
140
+
141
+ func answerOpenPrompt() {
142
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
143
+ let open = springboard.buttons["Open"]
144
+ if open.waitForExistence(timeout: 4) { open.tap() }
145
+ }
146
+
147
+ func testScript() throws {
148
+ continueAfterFailure = true
149
+ guard let data = get("/script"), let script = String(data: data, encoding: .utf8) else {
150
+ XCTFail("no script")
151
+ return
152
+ }
153
+ for raw in script.split(separator: "\n") {
154
+ let line = raw.trimmingCharacters(in: .whitespaces)
155
+ if line.isEmpty || line.hasPrefix("#") { continue }
156
+ let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
157
+ let command = parts[0]
158
+ let arg = parts.count > 1 ? parts[1] : ""
159
+ switch command {
160
+ case "launch": app.launch()
161
+ case "activate": app.activate()
162
+ case "terminate": app.terminate()
163
+ case "home": XCUIDevice.shared.press(.home)
164
+ case "background":
165
+ XCUIDevice.shared.press(.home)
166
+ Thread.sleep(forTimeInterval: Double(arg) ?? 3)
167
+ app.activate()
168
+ case "wait": Thread.sleep(forTimeInterval: Double(arg) ?? 1)
169
+ case "net": _ = get("/\(arg)"); note("net \(arg)")
170
+ case "delay": _ = get("/delay?ms=\(arg)"); note("delay \(arg)ms")
171
+ case "exec":
172
+ let out = get("/exec?name=\(arg)", timeout: 180).flatMap { String(data: $0, encoding: .utf8) } ?? "no answer"
173
+ note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
174
+ case "openurl":
175
+ if let url = URL(string: arg) {
176
+ XCUIDevice.shared.system.open(url)
177
+ answerOpenPrompt()
178
+ note("openurl \(arg)")
179
+ }
180
+ case "shot":
181
+ let shot = XCUIScreen.main.screenshot()
182
+ post("/save?name=\(arg).png", shot.pngRepresentation)
183
+ case "tree":
184
+ post("/save?name=\(arg).txt", Data(app.debugDescription.utf8))
185
+ case "tap":
186
+ if let target = waitFor(arg, 10) { press(target); note("tap \(arg): ok") }
187
+ else { XCTFail("Element not found"); note("FAIL tap \(arg): NOT FOUND") }
188
+ case "tapif":
189
+ if let target = element(containing: arg), target.isHittable { press(target); note("tapif \(arg): tapped") }
190
+ else { note("tapif \(arg): not shown") }
191
+ case "tapexact":
192
+ if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
193
+ else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
194
+ case "tapid":
195
+ let target = app.descendants(matching: .any)[arg]
196
+ if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
197
+ else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
198
+ case "tapxy":
199
+ let xy = arg.split(separator: " ").compactMap { Double($0) }
200
+ if xy.count == 2 && xy.allSatisfy({ $0 >= 0 && $0 <= 1 }) {
201
+ app.coordinate(withNormalizedOffset: CGVector(dx: xy[0], dy: xy[1])).tap()
202
+ } else { XCTFail("tapxy needs two coordinates between 0 and 1") }
203
+ case "back":
204
+ let bar = app.navigationBars.buttons.firstMatch
205
+ if bar.exists && bar.isHittable { bar.tap(); note("back: bar button") }
206
+ else {
207
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
208
+ start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
209
+ note("back: edge swipe")
210
+ }
211
+ case "type": app.typeText(arg)
212
+ case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
213
+ case "typeid":
214
+ let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
215
+ let target = app.descendants(matching: .any)[idParts[0]]
216
+ if target.waitForExistence(timeout: 10) {
217
+ target.tap()
218
+ Thread.sleep(forTimeInterval: 0.8)
219
+ app.typeText(idParts.count > 1 ? idParts[1] : "")
220
+ note("typeid \(idParts[0]): ok")
221
+ } else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
222
+ case "field":
223
+ let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
224
+ let index = Int(fieldParts[0]) ?? 0
225
+ let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
226
+ if index >= 0 && index < fields.count {
227
+ fields[index].tap()
228
+ Thread.sleep(forTimeInterval: 0.6)
229
+ app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
230
+ note("field \(index): ok")
231
+ } else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
232
+ case "expect":
233
+ let (text, seconds) = split(arg)
234
+ check(waitFor(text, seconds) != nil, "expect \(text)")
235
+ case "expectexact":
236
+ let (text, seconds) = split(arg)
237
+ check(poll(seconds, { exact(text) }) != nil, "expectexact \(text)")
238
+ case "expectid":
239
+ let (id, seconds) = split(arg)
240
+ check(app.descendants(matching: .any)[id].waitForExistence(timeout: seconds), "expectid \(id)")
241
+ case "absentid":
242
+ check(!app.descendants(matching: .any)[arg].exists, "absentid \(arg)")
243
+ case "absent":
244
+ check(element(containing: arg) == nil, "absent \(arg)")
245
+ case "tabbar":
246
+ let want = arg != "hidden"
247
+ let end = Date().addingTimeInterval(5)
248
+ var shown = tabBarShown()
249
+ while shown != want && Date() < end { Thread.sleep(forTimeInterval: 0.5); shown = tabBarShown() }
250
+ check(shown == want, "tabbar \(arg)")
251
+ case "selected":
252
+ check(poll(5, { tabButton(arg).flatMap { $0.isSelected ? $0 : nil } }) != nil, "selected \(arg)")
253
+ case "tab":
254
+ if let button = poll(10, { tabButton(arg) }) { button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap(); note("tab \(arg): ok") }
255
+ else { XCTFail("Element not found"); note("FAIL tab \(arg): NOT FOUND") }
256
+ case "scrollto":
257
+ var found = element(containing: arg)
258
+ var swipes = 0
259
+ while (found == nil || !(found!.isHittable)) && swipes < 8 {
260
+ app.swipeUp()
261
+ swipes += 1
262
+ found = element(containing: arg)
263
+ }
264
+ check(found != nil && found!.isHittable, "scrollto \(arg)")
265
+ case "swipe":
266
+ switch arg {
267
+ case "up": app.swipeUp()
268
+ case "down": app.swipeDown()
269
+ case "left": app.swipeLeft()
270
+ default: app.swipeRight()
271
+ }
272
+ case "audit":
273
+ var issues: [String] = []
274
+ do {
275
+ try app.performAccessibilityAudit(for: [.sufficientElementDescription, .dynamicType, .contrast, .hitRegion]) { issue in
276
+ let label = issue.element?.label ?? ""
277
+ let id = issue.element?.identifier ?? ""
278
+ issues.append("\(issue.auditType.rawValue) \(issue.compactDescription) [\(label)|\(id)]")
279
+ return true
280
+ }
281
+ } catch { issues.append("audit error \(error)") }
282
+ post("/save?name=audit-\(arg).txt", Data(issues.joined(separator: "\n").utf8))
283
+ note("AUDIT \(arg): \(issues.count) issue(s)")
284
+ case "unlabelled":
285
+ let buttons = app.buttons.allElementsBoundByIndex.filter { $0.exists && $0.isHittable && $0.label.trimmingCharacters(in: .whitespaces).isEmpty }
286
+ let where_ = buttons.prefix(5).map { "\(Int($0.frame.midX)),\(Int($0.frame.midY)) id=\($0.identifier)" }.joined(separator: "; ")
287
+ check(buttons.isEmpty, "unlabelled buttons \(arg) \(buttons.count) \(where_)")
288
+ case "dismiss":
289
+ // A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
290
+ let label = arg.isEmpty ? "Not Now" : arg
291
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
292
+ let candidates = [app.buttons[label], springboard.buttons[label]]
293
+ let end = Date().addingTimeInterval(6)
294
+ var done = false
295
+ while !done && Date() < end {
296
+ if let button = candidates.first(where: { $0.exists && $0.isHittable }) { button.tap(); done = true }
297
+ else { Thread.sleep(forTimeInterval: 0.5) }
298
+ }
299
+ note("dismiss \(label): \(done ? "tapped" : "none shown")")
300
+ case "log": note(arg)
301
+ default: XCTFail("Unknown command \(command)"); note("FAIL unknown command \(command)")
302
+ }
303
+ }
304
+ }
305
+ }
@@ -0,0 +1,6 @@
1
+ import SwiftUI
2
+
3
+ @main
4
+ struct HostApp: App {
5
+ var body: some Scene { WindowGroup { Text("Craft simulator driver") } }
6
+ }
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+ let messages = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]))) as! [[String: Any]]
3
+ func report(_ message: String) {
4
+ try! (message + "\n").write(to: URL(fileURLWithPath: CommandLine.arguments[2]), atomically: true, encoding: .utf8)
5
+ }
6
+ let document = CraftNativeMutationDocument()
7
+ for message in messages {
8
+ let payload = message["payload"] as? [String: Any] ?? [:]
9
+ if message["type"] as? String == "RENDER" { document.replace(with: payload["document"] as! [String: Any]) }
10
+ if message["type"] as? String == "MUTATE" {
11
+ do { _ = try document.apply(payload) }
12
+ catch let failure as CraftNativeMutationFailure { report("\(failure.code): \(failure.message) at operation \(failure.operationIndex ?? -1)"); exit(1) }
13
+ }
14
+ }
15
+ report("Accepted all native update batches")
@@ -0,0 +1,36 @@
1
+ name: {{PROJECT_NAME}}
2
+ options:
3
+ bundleIdPrefix: {{BUNDLE_PREFIX}}
4
+ deploymentTarget:
5
+ iOS: "{{IOS_VERSION}}"
6
+ targets:
7
+ {{PROJECT_NAME}}Host:
8
+ type: application
9
+ platform: iOS
10
+ sources: [Host]
11
+ settings:
12
+ PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_PREFIX}}.host
13
+ GENERATE_INFOPLIST_FILE: YES
14
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
15
+ SWIFT_VERSION: "5.0"
16
+ CODE_SIGNING_ALLOWED: NO
17
+ {{PROJECT_NAME}}UITests:
18
+ type: bundle.ui-testing
19
+ platform: iOS
20
+ sources: [UITests]
21
+ settings:
22
+ TEST_TARGET_NAME: {{PROJECT_NAME}}Host
23
+ PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_PREFIX}}.uitests
24
+ GENERATE_INFOPLIST_FILE: YES
25
+ SWIFT_VERSION: "5.0"
26
+ CODE_SIGNING_ALLOWED: NO
27
+ dependencies:
28
+ - target: {{PROJECT_NAME}}Host
29
+ schemes:
30
+ {{PROJECT_NAME}}:
31
+ build:
32
+ targets:
33
+ {{PROJECT_NAME}}Host: all
34
+ {{PROJECT_NAME}}UITests: [test]
35
+ test:
36
+ targets: [{{PROJECT_NAME}}UITests]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.154",
3
+ "version": "0.0.155",
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>",