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 +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +4 -0
- package/dist/ios/src/index.js +249 -90
- package/dist/ios/src/testing.d.ts +71 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +305 -0
- package/dist/ios/templates/testing/HostApp.swift +6 -0
- package/dist/ios/templates/testing/MutationValidator.swift +15 -0
- package/dist/ios/templates/testing/project.yml.template +36 -0
- package/package.json +1 -1
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.
|
|
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
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/ios/src/index.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
126
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
491
|
-
const appIcon =
|
|
492
|
-
const launchBackground =
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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,
|
|
638
|
+
writeOpaqueIcon(config.appIconPath, join2(appIcon, iconFilename));
|
|
502
639
|
}
|
|
503
|
-
|
|
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
|
-
|
|
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 =
|
|
540
|
-
|
|
676
|
+
const imageset = join2(catalog, `${LAUNCH_LOGO}.imageset`);
|
|
677
|
+
rmSync2(imageset, { recursive: true, force: true });
|
|
541
678
|
if (!config.splashImagePath)
|
|
542
679
|
return;
|
|
543
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
677
|
-
|
|
678
|
-
|
|
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,
|
|
819
|
+
cpSync(sourcePath, join2(distDir, "index.html"));
|
|
683
820
|
}
|
|
684
|
-
if (!existsSync(
|
|
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(
|
|
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 =
|
|
713
|
-
|
|
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 =
|
|
716
|
-
|
|
717
|
-
const target =
|
|
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(
|
|
860
|
+
cpSync(join2(runtimeDir, present[0]), target);
|
|
724
861
|
} else {
|
|
725
|
-
await $`lipo -create ${present.map((a) =>
|
|
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(
|
|
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,
|
|
929
|
+
const dirs = [output, join2(output, "Sources"), join2(output, "Shared"), join2(output, "dist")];
|
|
756
930
|
if (options.config?.enableWatchApp)
|
|
757
|
-
dirs.push(
|
|
931
|
+
dirs.push(join2(output, "WatchApp"), join2(output, "WatchExtension"));
|
|
758
932
|
for (const dir of dirs) {
|
|
759
933
|
if (!existsSync(dir)) {
|
|
760
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
const
|
|
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
|
-
|
|
806
|
-
const projectYmlTemplate =
|
|
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
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
876
|
-
const
|
|
877
|
-
|
|
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
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1201
|
+
const projectPath = join2(output, xcodeproj);
|
|
1047
1202
|
const appName = xcodeproj.replace(".xcodeproj", "");
|
|
1048
|
-
const configPath =
|
|
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(
|
|
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 =
|
|
1062
|
-
const product =
|
|
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,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]
|