@invarn/cibuild 2.6.7 → 2.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/commands/android-java-version.test.js +55 -24
  3. package/dist/src/commands/android-scanner-java.test.d.ts +2 -0
  4. package/dist/src/commands/android-scanner-java.test.d.ts.map +1 -0
  5. package/dist/src/commands/android-scanner-java.test.js +130 -0
  6. package/dist/src/commands/android-scanner.d.ts +47 -22
  7. package/dist/src/commands/android-scanner.d.ts.map +1 -1
  8. package/dist/src/commands/android-scanner.js +187 -52
  9. package/dist/src/commands/ios-scanner.d.ts +42 -1
  10. package/dist/src/commands/ios-scanner.d.ts.map +1 -1
  11. package/dist/src/commands/ios-scanner.js +123 -3
  12. package/dist/src/commands/ios-scheme-ranking.test.d.ts +2 -0
  13. package/dist/src/commands/ios-scheme-ranking.test.d.ts.map +1 -0
  14. package/dist/src/commands/ios-scheme-ranking.test.js +152 -0
  15. package/dist/src/shared/detect-project.d.ts +15 -3
  16. package/dist/src/shared/detect-project.d.ts.map +1 -1
  17. package/dist/src/shared/detect-project.js +126 -15
  18. package/dist/src/shared/detect-project.test.d.ts +2 -0
  19. package/dist/src/shared/detect-project.test.d.ts.map +1 -0
  20. package/dist/src/shared/detect-project.test.js +126 -0
  21. package/dist/src/shared/gradle-settings.d.ts +32 -0
  22. package/dist/src/shared/gradle-settings.d.ts.map +1 -0
  23. package/dist/src/shared/gradle-settings.js +66 -0
  24. package/dist/src/yaml/steps/android-variant-resolution.test.d.ts +2 -0
  25. package/dist/src/yaml/steps/android-variant-resolution.test.d.ts.map +1 -0
  26. package/dist/src/yaml/steps/android-variant-resolution.test.js +117 -0
  27. package/dist/src/yaml/steps/android.d.ts +37 -0
  28. package/dist/src/yaml/steps/android.d.ts.map +1 -1
  29. package/dist/src/yaml/steps/android.js +130 -17
  30. package/dist/src/yaml/steps/base.d.ts +20 -0
  31. package/dist/src/yaml/steps/base.d.ts.map +1 -1
  32. package/dist/src/yaml/steps/base.js +24 -1
  33. package/dist/src/yaml/steps/java-home-version-spec.test.d.ts +10 -0
  34. package/dist/src/yaml/steps/java-home-version-spec.test.d.ts.map +1 -0
  35. package/dist/src/yaml/steps/java-home-version-spec.test.js +58 -0
  36. package/package.json +1 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Which scheme an iOS pipeline builds.
3
+ *
4
+ * It used to be the first `.xcscheme` the filesystem listed, and that has a
5
+ * rule: a space (0x20) sorts before a period (0x2E), so
6
+ * `"<App> <Suffix>.xcscheme"` always lists before `"<App>.xcscheme"`. An app
7
+ * named after its project loses to its own extension or tvOS companion every
8
+ * time. Four of the eight iOS rows that built in the thirty-app study got a
9
+ * non-app scheme:
10
+ *
11
+ * Stay → "Stay Extension" → "There are no test bundles available to test"
12
+ * Swiftfin → "Swiftfin tvOS" → "Unable to find a destination matching … iOS Simulator"
13
+ * wikipedia → "ContinueReadingWidget" (masked by an earlier failure)
14
+ * pocketcasts → "NotificationContent" (masked by an earlier failure)
15
+ *
16
+ * The `.xcscheme` XML says what each scheme is, so the ranking reads it.
17
+ */
18
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+ import { rankSchemes } from "./ios-scanner.js";
22
+ let root;
23
+ beforeEach(() => {
24
+ root = mkdtempSync(join(tmpdir(), "cibuild-scheme-"));
25
+ });
26
+ afterEach(() => {
27
+ rmSync(root, { recursive: true, force: true });
28
+ });
29
+ /** An `.xcscheme` with the parts the ranking reads, in Xcode's own shape. */
30
+ function scheme(project, name, { runnable, testable }) {
31
+ const dir = join(root, `${project}.xcodeproj`, "xcshareddata", "xcschemes");
32
+ mkdirSync(dir, { recursive: true });
33
+ writeFileSync(join(dir, `${name}.xcscheme`), `<?xml version="1.0" encoding="UTF-8"?>
34
+ <Scheme LastUpgradeVersion = "1600" version = "1.7">
35
+ <TestAction buildConfiguration = "Debug">
36
+ <Testables>${testable
37
+ ? `
38
+ <TestableReference skipped = "NO">
39
+ <BuildableReference BuildableName = "${name}Tests.xctest" />
40
+ </TestableReference>`
41
+ : ""}
42
+ </Testables>
43
+ </TestAction>
44
+ <LaunchAction buildConfiguration = "Debug">${runnable
45
+ ? `
46
+ <BuildableProductRunnable runnableDebuggingMode = "0">
47
+ <BuildableReference
48
+ BuildableIdentifier = "primary"
49
+ BuildableName = "${runnable}"
50
+ BlueprintName = "${name}"
51
+ ReferencedContainer = "container:${project}.xcodeproj">
52
+ </BuildableReference>
53
+ </BuildableProductRunnable>`
54
+ : ""}
55
+ </LaunchAction>
56
+ </Scheme>
57
+ `);
58
+ }
59
+ /** The ranking, given the order `readdirSync` happens to produce. */
60
+ const pick = (projectPath, names) => rankSchemes(root, projectPath, names)[0];
61
+ describe("rankSchemes", () => {
62
+ // shenruisi/Stay. "Stay Extension" listed first and was written into the
63
+ // pipeline; the app is named after its project.
64
+ test("prefers the scheme named after the project", () => {
65
+ scheme("Stay", "Stay", { runnable: "Stay.app", testable: true });
66
+ scheme("Stay", "Stay Extension", { runnable: "Stay.app" });
67
+ expect(pick("Stay.xcodeproj", ["Stay Extension", "Stay"])).toBe("Stay");
68
+ });
69
+ // jellyfin/Swiftfin. Every destination "Swiftfin tvOS" offered was tvOS.
70
+ test("prefers the project's own name over a platform companion", () => {
71
+ scheme("Swiftfin", "Swiftfin", { runnable: "Swiftfin iOS.app" });
72
+ scheme("Swiftfin", "Swiftfin tvOS", { runnable: "Swiftfin tvOS.app" });
73
+ expect(pick("Swiftfin.xcodeproj", ["Swiftfin tvOS", "Swiftfin"])).toBe("Swiftfin");
74
+ });
75
+ // Finb/Bark builds from a workspace; the schemes live in the project inside.
76
+ test("matches the workspace basename too", () => {
77
+ scheme("Bark", "Bark", { runnable: "Bark.app", testable: true });
78
+ scheme("Bark", "NotificationContentExtension", { runnable: "Bark.app" });
79
+ expect(pick("Bark.xcworkspace", ["Bark", "NotificationContentExtension"])).toBe("Bark");
80
+ });
81
+ // Automattic/pocket-casts-ios: the scheme is `pocketcasts`, the project is
82
+ // `podcasts.xcodeproj`, so no name matches. What separates them is that one
83
+ // launches an app and has tests, and the others are extensions.
84
+ test("falls back to an app scheme with tests when no name matches", () => {
85
+ scheme("podcasts", "pocketcasts", { runnable: "podcasts.app", testable: true });
86
+ scheme("podcasts", "NotificationContent", { runnable: "podcasts.app" });
87
+ scheme("podcasts", "PocketCastsWatch", { runnable: "PocketCastsWatch.app" });
88
+ expect(pick("podcasts.xcodeproj", ["NotificationContent", "PocketCastsWatch", "pocketcasts"])).toBe("pocketcasts");
89
+ });
90
+ // thebaselab/codeapp: `Code App` launches `Code.app` and declares no tests
91
+ // at all; `Code UI` has two test bundles and launches `CodeUI.app`. The
92
+ // product named after the project is the app; tests are a weaker signal.
93
+ test("prefers the product named after the project over one with tests", () => {
94
+ scheme("Code", "Code App", { runnable: "Code.app" });
95
+ scheme("Code", "Code UI", { runnable: "CodeUI.app", testable: true });
96
+ scheme("Code", "extension", { runnable: "CodeUI.app" });
97
+ expect(pick("Code.xcodeproj", ["Code App", "Code UI", "extension"])).toBe("Code App");
98
+ });
99
+ // oblador/hush: `Hush iOS` and `Hush macOS`, project `Hush.xcodeproj`.
100
+ test("prefers the iOS variant over the macOS one", () => {
101
+ scheme("Hush", "Hush iOS", { runnable: "Hush.app", testable: true });
102
+ scheme("Hush", "Hush macOS", { runnable: "Hush.app" });
103
+ expect(pick("Hush.xcodeproj", ["Hush iOS", "Hush macOS"])).toBe("Hush iOS");
104
+ });
105
+ // home-assistant/iOS: App-Debug and App-Release, both runnable, both with
106
+ // tests. Debug is what the template builds.
107
+ test("prefers the debug configuration when two app schemes tie", () => {
108
+ scheme("HomeAssistant", "App-Debug", { runnable: "Home Assistant.app", testable: true });
109
+ scheme("HomeAssistant", "App-Release", { runnable: "Home Assistant.app", testable: true });
110
+ expect(pick("HomeAssistant.xcodeproj", ["App-Debug", "App-Release"])).toBe("App-Debug");
111
+ });
112
+ test("prefers the shortest name when nothing else separates them", () => {
113
+ scheme("Demo", "App", { runnable: "App.app", testable: true });
114
+ scheme("Demo", "App With A Longer Name", { runnable: "App.app", testable: true });
115
+ expect(pick("Demo.xcodeproj", ["App With A Longer Name", "App"])).toBe("App");
116
+ });
117
+ // The whole defect: a space sorts before a period, so the companion is
118
+ // always listed first. The answer must not depend on that.
119
+ test("gives the same answer whatever order the filesystem lists", () => {
120
+ scheme("Stay", "Stay", { runnable: "Stay.app", testable: true });
121
+ scheme("Stay", "Stay Extension", { runnable: "Stay.app" });
122
+ scheme("Stay", "Stay Widget", { runnable: "Stay.app" });
123
+ const orders = [
124
+ ["Stay Extension", "Stay Widget", "Stay"],
125
+ ["Stay", "Stay Extension", "Stay Widget"],
126
+ ["Stay Widget", "Stay", "Stay Extension"],
127
+ ];
128
+ for (const order of orders) {
129
+ expect([order.join(","), pick("Stay.xcodeproj", order)]).toEqual([
130
+ order.join(","),
131
+ "Stay",
132
+ ]);
133
+ }
134
+ });
135
+ test("keeps every scheme in the list, only reordered", () => {
136
+ scheme("Stay", "Stay", { runnable: "Stay.app", testable: true });
137
+ scheme("Stay", "Stay Extension", { runnable: "Stay.app" });
138
+ const ranked = rankSchemes(root, "Stay.xcodeproj", ["Stay Extension", "Stay"]);
139
+ expect([...ranked].sort()).toEqual(["Stay", "Stay Extension"]);
140
+ });
141
+ test("leaves a single scheme alone", () => {
142
+ scheme("Solo", "Solo", { runnable: "Solo.app", testable: true });
143
+ expect(pick("Solo.xcodeproj", ["Solo"])).toBe("Solo");
144
+ });
145
+ // A scheme with no shared `.xcscheme` on disk — `detectSchemes` falls back
146
+ // to the project name — must still come out first, not be ranked below a
147
+ // companion it cannot read.
148
+ test("keeps the order it was given when nothing can be read", () => {
149
+ expect(pick("Solo.xcodeproj", ["Solo", "Other"])).toBe("Solo");
150
+ });
151
+ });
152
+ //# sourceMappingURL=ios-scheme-ranking.test.js.map
@@ -1,11 +1,23 @@
1
1
  export type MobileProjectType = "android" | "ios" | "kmm";
2
+ declare function isDirectory(path: string): boolean;
2
3
  /**
3
4
  * Detects whether the given directory is the root of an Android, iOS, or
4
5
  * KMM project. Returns the detected project type, or null if none match.
5
6
  *
6
- * KMM is checked before Android because both have Gradle files; KMM is
7
- * distinguished by the presence of an `iosApp/`, `shared/`, or
8
- * `composeApp/` directory alongside the Gradle files.
7
+ * KMM is checked before Android because both have Gradle files, and it is
8
+ * decided by what the repository *is*: a module that applies the Kotlin
9
+ * Multiplatform plugin, and an Apple consumer within one directory of the
10
+ * root. The three conventional directory names (`shared`, `composeApp`,
11
+ * `iosApp`) used to be the whole rule, and were wrong in both directions —
12
+ * `joreilly/PeopleInSpace` (`common` + `PeopleInSpaceSwiftUI/`) and
13
+ * `openMF/mifos-pay` (`cmp-shared` + `cmp-ios/`) were detected `android`,
14
+ * while an Android-only project with a module *named* `shared` was detected
15
+ * KMM and given a pipeline with an xcodebuild step aimed at nothing.
16
+ *
17
+ * Without an Apple consumer it is Android: an Android-only Kotlin
18
+ * Multiplatform library builds perfectly well with the Android template.
9
19
  */
10
20
  export declare function detectMobileProjectRoot(dir: string): MobileProjectType | null;
21
+ /** Kept for callers that only ask whether a path is a directory. */
22
+ export { isDirectory };
11
23
  //# sourceMappingURL=detect-project.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../../src/shared/detect-project.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,CAAC;AAE1D;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CA0C7E"}
1
+ {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../../src/shared/detect-project.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,CAAC;AAoB1D,iBAAS,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAM1C;AA+ED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAiC7E;AAED,oEAAoE;AACpE,OAAO,EAAE,WAAW,EAAE,CAAC"}
@@ -1,12 +1,131 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { declaresKotlinMultiplatformPlugin, parseSettingsModules, } from "./gradle-settings.js";
4
+ /** Directories that hold a copy of a project rather than the source of it. */
5
+ const NON_SOURCE_DIRS = new Set([
6
+ ".git",
7
+ ".gradle",
8
+ ".idea",
9
+ "build",
10
+ "DerivedData",
11
+ "Pods",
12
+ "node_modules",
13
+ ]);
14
+ /**
15
+ * The module names the multiplatform wizards write. Tried first because
16
+ * they are right most of the time and cost two `existsSync` calls — but they
17
+ * are a fast path through the rule below, not the rule.
18
+ */
19
+ const CONVENTIONAL_MULTIPLATFORM_MODULES = ["shared", "composeApp", "common"];
20
+ function isDirectory(path) {
21
+ try {
22
+ return statSync(path).isDirectory();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function readOrNull(path) {
29
+ try {
30
+ return readFileSync(path, "utf-8");
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** True when this directory holds something an Apple build reads. */
37
+ function holdsAppleConsumer(dir) {
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(dir);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ if (entries.some((e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace"))) {
46
+ return true;
47
+ }
48
+ if (entries.includes("Podfile"))
49
+ return true;
50
+ // An XcodeGen spec generates the project during the build, so the absence
51
+ // of a `.xcodeproj` says nothing about whether there is an iOS app.
52
+ if (entries.includes("project.yml")) {
53
+ const content = readOrNull(resolve(dir, "project.yml"));
54
+ return content !== null && /^\s*targets\s*:/m.test(content);
55
+ }
56
+ return false;
57
+ }
58
+ /**
59
+ * An Apple consumer at the root or one directory below it.
60
+ *
61
+ * One level, because that is where every KMM repository in the thirty-app
62
+ * study put its iOS app — under names as varied as `iosApp`,
63
+ * `PeopleInSpaceSwiftUI`, `cmp-ios` and `cmp-iosApp` — and because looking
64
+ * deeper starts finding sample apps inside library modules.
65
+ */
66
+ function hasAppleConsumerWithinOneLevel(root) {
67
+ if (holdsAppleConsumer(root))
68
+ return true;
69
+ let entries;
70
+ try {
71
+ entries = readdirSync(root, { withFileTypes: true });
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ for (const entry of entries) {
77
+ if (!entry.isDirectory())
78
+ continue;
79
+ if (NON_SOURCE_DIRS.has(entry.name) || entry.name.startsWith("."))
80
+ continue;
81
+ if (entry.name.endsWith(".xcodeproj") || entry.name.endsWith(".xcworkspace"))
82
+ continue;
83
+ if (holdsAppleConsumer(resolve(root, entry.name)))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+ function moduleAppliesMultiplatformPlugin(root, module) {
89
+ for (const name of ["build.gradle.kts", "build.gradle"]) {
90
+ const content = readOrNull(resolve(root, module, name));
91
+ if (content !== null && declaresKotlinMultiplatformPlugin(content))
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+ /** True when any module this build includes applies the Kotlin MP plugin. */
97
+ function hasMultiplatformModule(root) {
98
+ for (const module of CONVENTIONAL_MULTIPLATFORM_MODULES) {
99
+ if (moduleAppliesMultiplatformPlugin(root, module))
100
+ return true;
101
+ }
102
+ for (const name of ["settings.gradle", "settings.gradle.kts"]) {
103
+ const content = readOrNull(resolve(root, name));
104
+ if (content === null)
105
+ continue;
106
+ for (const module of parseSettingsModules(content)) {
107
+ if (moduleAppliesMultiplatformPlugin(root, module))
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
3
113
  /**
4
114
  * Detects whether the given directory is the root of an Android, iOS, or
5
115
  * KMM project. Returns the detected project type, or null if none match.
6
116
  *
7
- * KMM is checked before Android because both have Gradle files; KMM is
8
- * distinguished by the presence of an `iosApp/`, `shared/`, or
9
- * `composeApp/` directory alongside the Gradle files.
117
+ * KMM is checked before Android because both have Gradle files, and it is
118
+ * decided by what the repository *is*: a module that applies the Kotlin
119
+ * Multiplatform plugin, and an Apple consumer within one directory of the
120
+ * root. The three conventional directory names (`shared`, `composeApp`,
121
+ * `iosApp`) used to be the whole rule, and were wrong in both directions —
122
+ * `joreilly/PeopleInSpace` (`common` + `PeopleInSpaceSwiftUI/`) and
123
+ * `openMF/mifos-pay` (`cmp-shared` + `cmp-ios/`) were detected `android`,
124
+ * while an Android-only project with a module *named* `shared` was detected
125
+ * KMM and given a pipeline with an xcodebuild step aimed at nothing.
126
+ *
127
+ * Without an Apple consumer it is Android: an Android-only Kotlin
128
+ * Multiplatform library builds perfectly well with the Android template.
10
129
  */
11
130
  export function detectMobileProjectRoot(dir) {
12
131
  const androidIndicators = [
@@ -18,17 +137,7 @@ export function detectMobileProjectRoot(dir) {
18
137
  ];
19
138
  const hasAndroidIndicator = androidIndicators.some((f) => existsSync(resolve(dir, f)));
20
139
  if (hasAndroidIndicator) {
21
- const kmmIndicators = ["iosApp", "shared", "composeApp"];
22
- const hasKmmIndicator = kmmIndicators.some((d) => {
23
- const fullPath = resolve(dir, d);
24
- try {
25
- return existsSync(fullPath) && statSync(fullPath).isDirectory();
26
- }
27
- catch {
28
- return false;
29
- }
30
- });
31
- if (hasKmmIndicator)
140
+ if (hasAppleConsumerWithinOneLevel(dir) && hasMultiplatformModule(dir))
32
141
  return "kmm";
33
142
  return "android";
34
143
  }
@@ -50,4 +159,6 @@ export function detectMobileProjectRoot(dir) {
50
159
  }
51
160
  return null;
52
161
  }
162
+ /** Kept for callers that only ask whether a path is a directory. */
163
+ export { isDirectory };
53
164
  //# sourceMappingURL=detect-project.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=detect-project.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect-project.test.d.ts","sourceRoot":"","sources":["../../../src/shared/detect-project.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What makes a Gradle repository a Kotlin Multiplatform one.
3
+ *
4
+ * The old rule was three directory names — `iosApp`, `shared`, `composeApp`
5
+ * — and it was wrong in both directions. Two of the ten KMM apps in the
6
+ * thirty-app study use none of those names and were detected `android`
7
+ * (`joreilly/PeopleInSpace` keeps its iOS app in `PeopleInSpaceSwiftUI/` and
8
+ * its shared code in `common`; `openMF/mifos-pay` uses `cmp-ios` and
9
+ * `cmp-shared`). In the other direction, an Android-only project with a
10
+ * `shared` module was called KMM and handed a pipeline that builds an iOS app
11
+ * it does not have.
12
+ */
13
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { detectMobileProjectRoot } from "./detect-project.js";
17
+ let root;
18
+ beforeEach(() => {
19
+ root = mkdtempSync(join(tmpdir(), "cibuild-detect-"));
20
+ });
21
+ afterEach(() => {
22
+ rmSync(root, { recursive: true, force: true });
23
+ });
24
+ function write(relative, content) {
25
+ const path = join(root, relative);
26
+ mkdirSync(join(path, ".."), { recursive: true });
27
+ writeFileSync(path, content);
28
+ }
29
+ function settings(...modules) {
30
+ write("settings.gradle.kts", `include(${modules.map((m) => `":${m}"`).join(", ")})\n`);
31
+ }
32
+ const KMP = 'plugins { kotlin("multiplatform") }\n';
33
+ const ANDROID_APP = 'plugins { id("com.android.application") }\n';
34
+ const ANDROID_LIB = 'plugins { id("com.android.library") }\n';
35
+ /** An Xcode project is a directory, not a file. */
36
+ function xcodeProject(dir, name) {
37
+ mkdirSync(join(root, dir, `${name}.xcodeproj`), { recursive: true });
38
+ writeFileSync(join(root, dir, `${name}.xcodeproj`, "project.pbxproj"), "// stub\n");
39
+ }
40
+ describe("detectMobileProjectRoot", () => {
41
+ describe("Kotlin Multiplatform", () => {
42
+ test("a multiplatform module and an iOS app one level down is KMM", () => {
43
+ settings("common", "app");
44
+ write("common/build.gradle.kts", KMP);
45
+ write("app/build.gradle.kts", ANDROID_APP);
46
+ xcodeProject("PeopleInSpaceSwiftUI", "PeopleInSpaceSwiftUI");
47
+ expect(detectMobileProjectRoot(root)).toBe("kmm");
48
+ });
49
+ test("a CocoaPods Podfile is an Apple consumer too", () => {
50
+ settings("cmp-shared", "cmp-android");
51
+ write("cmp-shared/build.gradle.kts", KMP);
52
+ write("cmp-android/build.gradle.kts", ANDROID_APP);
53
+ write("cmp-ios/Podfile", "pod 'cmp_shared', :path => '../cmp-shared'\n");
54
+ expect(detectMobileProjectRoot(root)).toBe("kmm");
55
+ });
56
+ test("an XcodeGen spec is an Apple consumer, project or no project", () => {
57
+ settings("shared", "androidApp");
58
+ write("shared/build.gradle.kts", KMP);
59
+ write("androidApp/build.gradle.kts", ANDROID_APP);
60
+ write("iosApp/project.yml", "name: FeedFlow\ntargets:\n FeedFlow:\n");
61
+ expect(detectMobileProjectRoot(root)).toBe("kmm");
62
+ });
63
+ test("the conventional names still work, when they are true", () => {
64
+ settings("shared", "androidApp");
65
+ write("shared/build.gradle.kts", KMP);
66
+ write("androidApp/build.gradle.kts", ANDROID_APP);
67
+ xcodeProject("iosApp", "iosApp");
68
+ expect(detectMobileProjectRoot(root)).toBe("kmm");
69
+ });
70
+ test("a nested multiplatform module counts", () => {
71
+ settings("core:domain", "app");
72
+ write("core/domain/build.gradle.kts", KMP);
73
+ write("app/build.gradle.kts", ANDROID_APP);
74
+ xcodeProject("iosApp", "iosApp");
75
+ expect(detectMobileProjectRoot(root)).toBe("kmm");
76
+ });
77
+ });
78
+ describe("Android", () => {
79
+ // The correction the closed study asked for (C3): a repository with
80
+ // multiplatform modules and no Apple side builds with the Android
81
+ // template, and a KMM pipeline would aim an xcodebuild step at nothing.
82
+ test("multiplatform modules with no Apple consumer is Android", () => {
83
+ settings("shared", "app");
84
+ write("shared/build.gradle.kts", 'plugins { kotlin("multiplatform") }\nkotlin { androidTarget()\n jvm() }\n');
85
+ write("app/build.gradle.kts", ANDROID_APP);
86
+ expect(detectMobileProjectRoot(root)).toBe("android");
87
+ });
88
+ // The false positive the three directory names caused.
89
+ test("a module merely named shared is not multiplatform", () => {
90
+ settings("shared", "app");
91
+ write("shared/build.gradle.kts", ANDROID_LIB);
92
+ write("app/build.gradle.kts", ANDROID_APP);
93
+ xcodeProject("iosApp", "iosApp");
94
+ expect(detectMobileProjectRoot(root)).toBe("android");
95
+ });
96
+ test("a plain Android repository is unchanged", () => {
97
+ settings("app");
98
+ write("app/build.gradle.kts", ANDROID_APP);
99
+ write("gradlew", "#!/bin/sh\n");
100
+ expect(detectMobileProjectRoot(root)).toBe("android");
101
+ });
102
+ // Build output and dependency caches hold copies of Xcode projects.
103
+ test("an Xcode project in build output is not an Apple consumer", () => {
104
+ settings("shared", "app");
105
+ write("shared/build.gradle.kts", KMP);
106
+ write("app/build.gradle.kts", ANDROID_APP);
107
+ xcodeProject("build", "Stale");
108
+ xcodeProject("Pods", "Pods");
109
+ expect(detectMobileProjectRoot(root)).toBe("android");
110
+ });
111
+ });
112
+ describe("iOS and nothing", () => {
113
+ test("an Xcode project with no Gradle is iOS", () => {
114
+ xcodeProject(".", "App");
115
+ expect(detectMobileProjectRoot(root)).toBe("ios");
116
+ });
117
+ test("a Podfile with no Gradle is iOS", () => {
118
+ write("Podfile", "target 'App' do\nend\n");
119
+ expect(detectMobileProjectRoot(root)).toBe("ios");
120
+ });
121
+ test("an empty directory is nothing", () => {
122
+ expect(detectMobileProjectRoot(root)).toBeNull();
123
+ });
124
+ });
125
+ });
126
+ //# sourceMappingURL=detect-project.test.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Reading a Gradle settings file, once.
3
+ *
4
+ * There were two parsers for the same sentence and they disagreed. The
5
+ * scanner's matched only the first argument of an `include`, so
6
+ * `include(":android", ":common", ":viewmodels")` — one call, eight modules
7
+ * in `SimonSchubert/LinuxCommandLibrary` — was read as one module, and the
8
+ * seven others were never opened. That is how a project declaring
9
+ * `jvmToolchain(21)` was scanned as Java 17 and died at configuration.
10
+ */
11
+ /**
12
+ * The module paths an `include` names, as directories.
13
+ *
14
+ * Gradle writes project paths colon-separated (`:core:network`) and the
15
+ * directory is the same path with slashes. Every argument of every `include`
16
+ * counts, in both the Groovy (`include ':a', ':b'`) and Kotlin
17
+ * (`include(":a", ":b")`) forms, and a leading colon is not part of the name.
18
+ */
19
+ export declare function parseSettingsModules(content: string): string[];
20
+ /**
21
+ * True when a build file applies the Kotlin Multiplatform plugin, in any of
22
+ * the forms a Gradle build uses for it — including a project-local
23
+ * convention plugin whose own id ends in `multiplatform`.
24
+ */
25
+ export declare function declaresKotlinMultiplatformPlugin(content: string): boolean;
26
+ /**
27
+ * Every plugin identifier a build file declares: `id("x")`, `id 'x'`,
28
+ * `apply plugin: 'x'` / `apply(plugin = "x")`, and the version-catalog alias
29
+ * `alias(libs.plugins.x.y)`.
30
+ */
31
+ export declare function declaredPluginIds(content: string): string[];
32
+ //# sourceMappingURL=gradle-settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gradle-settings.d.ts","sourceRoot":"","sources":["../../../src/shared/gradle-settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAY9D;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAO1E;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAW3D"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reading a Gradle settings file, once.
3
+ *
4
+ * There were two parsers for the same sentence and they disagreed. The
5
+ * scanner's matched only the first argument of an `include`, so
6
+ * `include(":android", ":common", ":viewmodels")` — one call, eight modules
7
+ * in `SimonSchubert/LinuxCommandLibrary` — was read as one module, and the
8
+ * seven others were never opened. That is how a project declaring
9
+ * `jvmToolchain(21)` was scanned as Java 17 and died at configuration.
10
+ */
11
+ /**
12
+ * The module paths an `include` names, as directories.
13
+ *
14
+ * Gradle writes project paths colon-separated (`:core:network`) and the
15
+ * directory is the same path with slashes. Every argument of every `include`
16
+ * counts, in both the Groovy (`include ':a', ':b'`) and Kotlin
17
+ * (`include(":a", ":b")`) forms, and a leading colon is not part of the name.
18
+ */
19
+ export function parseSettingsModules(content) {
20
+ if (!content || typeof content !== "string")
21
+ return [];
22
+ const modules = [];
23
+ const re = /include\s*[(]?\s*((?:['"][^'"]+['"]\s*,?\s*)+)/g;
24
+ let match;
25
+ while ((match = re.exec(content)) !== null) {
26
+ for (const quoted of match[1].matchAll(/['"]([^'"]+)['"]/g)) {
27
+ const name = quoted[1].replace(/^:/, "").replace(/:/g, "/");
28
+ if (name && !modules.includes(name))
29
+ modules.push(name);
30
+ }
31
+ }
32
+ return modules;
33
+ }
34
+ /**
35
+ * True when a build file applies the Kotlin Multiplatform plugin, in any of
36
+ * the forms a Gradle build uses for it — including a project-local
37
+ * convention plugin whose own id ends in `multiplatform`.
38
+ */
39
+ export function declaresKotlinMultiplatformPlugin(content) {
40
+ if (!content || typeof content !== "string")
41
+ return false;
42
+ if (/org\.jetbrains\.kotlin\.multiplatform/.test(content))
43
+ return true;
44
+ if (/kotlin\s*\(\s*["']multiplatform["']\s*\)/.test(content))
45
+ return true;
46
+ return declaredPluginIds(content).some((id) => /kotlin[._-]?multiplatform$/i.test(id));
47
+ }
48
+ /**
49
+ * Every plugin identifier a build file declares: `id("x")`, `id 'x'`,
50
+ * `apply plugin: 'x'` / `apply(plugin = "x")`, and the version-catalog alias
51
+ * `alias(libs.plugins.x.y)`.
52
+ */
53
+ export function declaredPluginIds(content) {
54
+ if (!content || typeof content !== "string")
55
+ return [];
56
+ const ids = [];
57
+ for (const m of content.matchAll(/\bid\s*(?:\(\s*)?["']([^"']+)["']/g))
58
+ ids.push(m[1]);
59
+ for (const m of content.matchAll(/\bapply\s*(?:plugin\s*[:=]|\(\s*plugin\s*=)\s*["']([^"']+)["']/g)) {
60
+ ids.push(m[1]);
61
+ }
62
+ for (const m of content.matchAll(/\balias\s*\(\s*([A-Za-z0-9_.]+)\s*\)/g))
63
+ ids.push(m[1]);
64
+ return ids;
65
+ }
66
+ //# sourceMappingURL=gradle-settings.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=android-variant-resolution.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"android-variant-resolution.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/android-variant-resolution.test.ts"],"names":[],"mappings":""}