catalyst-core 1.1.0 → 1.2.0-canary.2

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 (69) hide show
  1. package/bin/catalyst.js +54 -6
  2. package/changelog.md +27 -1
  3. package/dist/errors/registry.js +306 -18
  4. package/dist/errors-index.json +273 -70
  5. package/dist/native/androidProject/app/build.gradle.kts +53 -79
  6. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
  11. package/dist/native/androidSetup.js +76 -170
  12. package/dist/native/androidToolchain.js +642 -0
  13. package/dist/native/appIcon.js +100 -0
  14. package/dist/native/buildAndroid/assets.js +29 -5
  15. package/dist/native/buildAndroid/build.js +110 -223
  16. package/dist/native/buildAndroid/config.js +57 -65
  17. package/dist/native/buildAndroid/devices.js +176 -0
  18. package/dist/native/buildAndroid/index.js +271 -46
  19. package/dist/native/buildAndroid/output.js +43 -0
  20. package/dist/native/buildAndroid/signing.js +456 -0
  21. package/dist/native/buildAppAndroid.js +27 -7
  22. package/dist/native/buildAppIos.js +139 -20
  23. package/dist/native/buildIos/assets.js +76 -4
  24. package/dist/native/buildIos/build.js +31 -181
  25. package/dist/native/buildIos/config.js +126 -11
  26. package/dist/native/buildIos/index.js +354 -34
  27. package/dist/native/buildIos/localhostCert.js +87 -0
  28. package/dist/native/buildIos/output.js +28 -0
  29. package/dist/native/buildIos/signing.js +596 -0
  30. package/dist/native/credentials.js +309 -0
  31. package/dist/native/exportCredentials.js +51 -0
  32. package/dist/native/iosToolchain.js +583 -0
  33. package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
  34. package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
  35. package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
  36. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
  37. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
  38. package/dist/native/localState.js +148 -0
  39. package/dist/native/nativeConfig.js +621 -0
  40. package/dist/native/releaseOutput.js +53 -0
  41. package/dist/native/renameAndroidProject.js +154 -369
  42. package/dist/native/setupEmulatorIos.js +58 -275
  43. package/dist/native/setupServer.js +520 -124
  44. package/dist/native/utils.js +418 -115
  45. package/dist/vite/resolveDevServerConfig.js +21 -0
  46. package/mcp_v2/knowledge-base.json +13 -11
  47. package/mcp_v2/test/config.test.ts +40 -0
  48. package/mcp_v2/tools/build.js +80 -104
  49. package/mcp_v2/tools/config.js +181 -174
  50. package/package.json +1 -1
  51. package/test/cli/nativeFlags.test.ts +97 -0
  52. package/test/native/androidDevices.test.ts +230 -0
  53. package/test/native/androidElfAlignment.test.ts +148 -0
  54. package/test/native/androidSigning.test.ts +646 -0
  55. package/test/native/androidToolchain.test.ts +766 -0
  56. package/test/native/appIcon.test.ts +124 -0
  57. package/test/native/credentials.test.ts +241 -0
  58. package/test/native/interaction.test.ts +179 -0
  59. package/test/native/iosConfigPhase.test.ts +611 -0
  60. package/test/native/iosSigning.test.ts +1060 -0
  61. package/test/native/iosToolchain.test.ts +821 -0
  62. package/test/native/localState.test.ts +80 -0
  63. package/test/native/nativeConfig.test.ts +602 -0
  64. package/test/native/setupServer.test.ts +529 -0
  65. package/test/native/utils.test.ts +102 -127
  66. package/test/vite/resolveDevServerConfig.test.js +25 -0
  67. package/dist/native/androidProject/keystore/Untitled +0 -0
  68. package/dist/native/androidProject/keystore/release-key.jks +0 -0
  69. package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
@@ -0,0 +1,124 @@
1
+ import fs from "fs"
2
+ import os from "os"
3
+ import path from "path"
4
+ import { afterEach, describe, expect, it } from "vitest"
5
+
6
+ import { checkAppIcon, readPngInfo } from "../../src/native/appIcon.js"
7
+
8
+ const temps: string[] = []
9
+ afterEach(() => {
10
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
11
+ })
12
+
13
+ function chunk(type: string, data: Buffer = Buffer.alloc(0)) {
14
+ const length = Buffer.alloc(4)
15
+ length.writeUInt32BE(data.length)
16
+ // The reader never checks CRCs, so a zero CRC keeps the fixture simple.
17
+ return Buffer.concat([length, Buffer.from(type, "ascii"), data, Buffer.alloc(4)])
18
+ }
19
+
20
+ /** A structurally valid PNG header: signature, IHDR, optional chunks, IDAT, IEND. */
21
+ function png(width: number, height: number, colorType: number, extra: Buffer[] = []) {
22
+ const ihdr = Buffer.alloc(13)
23
+ ihdr.writeUInt32BE(width, 0)
24
+ ihdr.writeUInt32BE(height, 4)
25
+ ihdr[8] = 8
26
+ ihdr[9] = colorType
27
+ return Buffer.concat([
28
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
29
+ chunk("IHDR", ihdr),
30
+ ...extra,
31
+ chunk("IDAT", Buffer.from([0, 1, 2, 3])),
32
+ chunk("IEND"),
33
+ ])
34
+ }
35
+
36
+ function iconFile(buffer: Buffer) {
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-app-icon-"))
38
+ temps.push(dir)
39
+ const file = path.join(dir, "icon.png")
40
+ fs.writeFileSync(file, buffer)
41
+ return file
42
+ }
43
+
44
+ function thrown(fn: () => unknown): any {
45
+ try {
46
+ fn()
47
+ } catch (error) {
48
+ return error
49
+ }
50
+ throw new Error("expected a throw")
51
+ }
52
+
53
+ describe("readPngInfo", () => {
54
+ it("reads size and colour type from IHDR", () => {
55
+ expect(readPngInfo(png(1024, 1024, 2))).toEqual({
56
+ width: 1024,
57
+ height: 1024,
58
+ bitDepth: 8,
59
+ colorType: 2,
60
+ hasAlpha: false,
61
+ })
62
+ })
63
+
64
+ it("reports alpha for grey+alpha, RGBA and a tRNS chunk", () => {
65
+ expect(readPngInfo(png(1024, 1024, 4))?.hasAlpha).toBe(true)
66
+ expect(readPngInfo(png(1024, 1024, 6))?.hasAlpha).toBe(true)
67
+ expect(
68
+ readPngInfo(png(1024, 1024, 3, [chunk("PLTE", Buffer.alloc(6)), chunk("tRNS", Buffer.from([0]))]))
69
+ ?.hasAlpha
70
+ ).toBe(true)
71
+ })
72
+
73
+ it("ignores a tRNS-looking chunk after the image data", () => {
74
+ const buffer = png(1024, 1024, 2)
75
+ const late = Buffer.concat([
76
+ buffer.subarray(0, buffer.length - 12),
77
+ chunk("tRNS", Buffer.from([0])),
78
+ chunk("IEND"),
79
+ ])
80
+ expect(readPngInfo(late)?.hasAlpha).toBe(false)
81
+ })
82
+
83
+ it("returns null for anything that is not a PNG", () => {
84
+ expect(readPngInfo(Buffer.from("not a png at all, just some text bytes"))).toBeNull()
85
+ expect(readPngInfo(Buffer.alloc(0))).toBeNull()
86
+ })
87
+ })
88
+
89
+ describe("checkAppIcon", () => {
90
+ it("accepts a square opaque icon of 1024 or more", () => {
91
+ expect(checkAppIcon(iconFile(png(2048, 2048, 2)), { platform: "ios", release: true }).width).toBe(
92
+ 2048
93
+ )
94
+ })
95
+
96
+ it("fails a non-square or small icon with the platform's code", () => {
97
+ expect(thrown(() => checkAppIcon(iconFile(png(1024, 512, 2)), { platform: "android" })).code).toBe(
98
+ "ANDROID-010"
99
+ )
100
+ const small = thrown(() => checkAppIcon(iconFile(png(512, 512, 2)), { platform: "ios" }))
101
+ expect(small.code).toBe("IOS-005")
102
+ expect(small.message).toContain("at least 1024x1024")
103
+ expect(
104
+ thrown(() => checkAppIcon(iconFile(Buffer.from("GIF89a....")), { platform: "ios" })).code
105
+ ).toBe("IOS-005")
106
+ })
107
+
108
+ it("warns about alpha in debug and on Android release", () => {
109
+ const logs: Array<[string, string?]> = []
110
+ const log = (message: string, type?: string) => logs.push([message, type])
111
+ checkAppIcon(iconFile(png(1024, 1024, 6)), { platform: "ios", log })
112
+ checkAppIcon(iconFile(png(1024, 1024, 6)), { platform: "android", release: true, log })
113
+ expect(logs).toHaveLength(2)
114
+ expect(logs.every(([, type]) => type === "warning")).toBe(true)
115
+ })
116
+
117
+ it("fails alpha for an iOS release", () => {
118
+ const error = thrown(() =>
119
+ checkAppIcon(iconFile(png(1024, 1024, 6)), { platform: "ios", release: true })
120
+ )
121
+ expect(error.code).toBe("IOS-006")
122
+ expect(error.docUrl).toMatch(/errors\/IOS\/IOS-006\.md$/)
123
+ })
124
+ })
@@ -0,0 +1,241 @@
1
+ import { execFileSync } from "child_process"
2
+ import fs from "fs"
3
+ import os from "os"
4
+ import path from "path"
5
+ import crypto from "crypto"
6
+ import { afterEach, describe, expect, it, vi } from "vitest"
7
+ import zlib from "zlib"
8
+
9
+ import { EventEmitter } from "events"
10
+ import {
11
+ MAX_CREDENTIALS_SECRET_BYTES,
12
+ cleanupOnSignal,
13
+ credentialsEnvName,
14
+ maskSecret,
15
+ packCredentials,
16
+ resolveCredentials,
17
+ unpackCredentials,
18
+ } from "../../src/native/credentials.js"
19
+ import { runExportCredentials } from "../../src/native/exportCredentials.js"
20
+
21
+ const temps: string[] = []
22
+ function tempDir() {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-credentials-test-"))
24
+ temps.push(dir)
25
+ return dir
26
+ }
27
+ afterEach(() => {
28
+ vi.restoreAllMocks()
29
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
30
+ })
31
+
32
+ /** An app with fake (throwaway) Android credentials. */
33
+ function appWithCredentials() {
34
+ const cwd = tempDir()
35
+ const dir = path.join(cwd, "credentials", "android")
36
+ fs.mkdirSync(dir, { recursive: true })
37
+ fs.writeFileSync(path.join(dir, "upload.p12"), "fake-keystore-bytes")
38
+ fs.writeFileSync(path.join(dir, "keystore.properties"), "storePassword=not-a-real-password\n")
39
+ return cwd
40
+ }
41
+
42
+ function thrown(fn: () => unknown): any {
43
+ try {
44
+ fn()
45
+ } catch (error) {
46
+ return error
47
+ }
48
+ throw new Error("expected a throw")
49
+ }
50
+
51
+ describe("credentials", () => {
52
+ it("names one secret per platform", () => {
53
+ expect(credentialsEnvName("android")).toBe("CATALYST_ANDROID_CREDENTIALS")
54
+ expect(credentialsEnvName("ios")).toBe("CATALYST_IOS_CREDENTIALS")
55
+ })
56
+
57
+ it("round-trips the folder through the secret value", () => {
58
+ const value = packCredentials("android", { cwd: appWithCredentials() })
59
+ expect(value).toMatch(/^[A-Za-z0-9+/]+=*$/)
60
+ const ci = tempDir()
61
+ const resolved = resolveCredentials("android", {
62
+ cwd: ci,
63
+ env: { CATALYST_ANDROID_CREDENTIALS: value },
64
+ })
65
+ try {
66
+ expect(resolved.source).toBe("env")
67
+ expect(resolved.dir.startsWith(ci)).toBe(false)
68
+ expect(fs.readFileSync(path.join(resolved.dir, "upload.p12"), "utf8")).toBe("fake-keystore-bytes")
69
+ expect(fs.existsSync(path.join(ci, "credentials"))).toBe(false)
70
+ } finally {
71
+ resolved.cleanup()
72
+ }
73
+ expect(fs.existsSync(resolved.dir)).toBe(false)
74
+ })
75
+
76
+ it("removes the unpacked secret when the build is interrupted", () => {
77
+ const value = packCredentials("android", { cwd: appWithCredentials() })
78
+ const signals = new EventEmitter()
79
+ const exits: number[] = []
80
+ const resolved = resolveCredentials("android", {
81
+ cwd: tempDir(),
82
+ env: { CATALYST_ANDROID_CREDENTIALS: value },
83
+ signals,
84
+ exit: (code: number) => exits.push(code),
85
+ })
86
+ expect(signals.listenerCount("SIGINT")).toBe(1)
87
+ signals.emit("SIGINT", "SIGINT")
88
+ expect(fs.existsSync(resolved.dir)).toBe(false)
89
+ expect(exits).toEqual([130])
90
+ expect(signals.listenerCount("SIGINT") + signals.listenerCount("SIGTERM")).toBe(0)
91
+
92
+ // A normal cleanup removes the handlers as well.
93
+ const again = resolveCredentials("android", {
94
+ cwd: tempDir(),
95
+ env: { CATALYST_ANDROID_CREDENTIALS: value },
96
+ signals,
97
+ exit: (code: number) => exits.push(code),
98
+ })
99
+ again.cleanup()
100
+ expect(fs.existsSync(again.dir)).toBe(false)
101
+ expect(signals.listenerCount("SIGINT") + signals.listenerCount("SIGTERM")).toBe(0)
102
+ })
103
+
104
+ it("lets a later signal handler run its cleanup and exit", () => {
105
+ const signals = new EventEmitter()
106
+ const order: string[] = []
107
+ const exits: number[] = []
108
+ const exit = (code: number) => exits.push(code)
109
+ cleanupOnSignal(() => order.push("credentials"), { signals, exit })
110
+ cleanupOnSignal(() => order.push("keychain"), { signals, exit })
111
+ signals.emit("SIGTERM", "SIGTERM")
112
+ expect(order).toEqual(["credentials", "keychain"])
113
+ expect(exits).toEqual([143])
114
+ })
115
+
116
+ it("prefers the project folder over the secret", () => {
117
+ const cwd = appWithCredentials()
118
+ const resolved = resolveCredentials("android", {
119
+ cwd,
120
+ env: { CATALYST_ANDROID_CREDENTIALS: "garbage" },
121
+ })
122
+ expect(resolved).toMatchObject({ source: "folder", dir: path.join(cwd, "credentials", "android") })
123
+ })
124
+
125
+ it("reports none when there is neither", () => {
126
+ const cwd = tempDir()
127
+ expect(resolveCredentials("ios", { cwd, env: {} })).toMatchObject({
128
+ source: "none",
129
+ dir: path.join(cwd, "credentials", "ios"),
130
+ })
131
+ })
132
+
133
+ it("refuses a value that is not a gzipped tar (NATIVE-007)", () => {
134
+ const error = thrown(() =>
135
+ resolveCredentials("android", {
136
+ cwd: tempDir(),
137
+ env: { CATALYST_ANDROID_CREDENTIALS: "aGVsbG8=" },
138
+ })
139
+ )
140
+ expect(error.code).toBe("NATIVE-007")
141
+ expect(error.message).toContain("CATALYST_ANDROID_CREDENTIALS")
142
+ })
143
+
144
+ it("refuses gzipped data that is not a tar archive (NATIVE-007)", () => {
145
+ const value = zlib.gzipSync(Buffer.from("not a tar archive at all\n".repeat(40))).toString("base64")
146
+ const error = thrown(() =>
147
+ unpackCredentials(value, tempDir(), { envName: "CATALYST_IOS_CREDENTIALS" })
148
+ )
149
+ expect(error.code).toBe("NATIVE-007")
150
+ expect(error.message).toContain("CATALYST_IOS_CREDENTIALS could not be read as a tar archive")
151
+ })
152
+
153
+ it("names the platforms it knows", () => {
154
+ expect(() => credentialsEnvName("windows" as any)).toThrow(
155
+ 'Unknown platform "windows": expected android or ios'
156
+ )
157
+ expect(() => packCredentials("web" as any, { cwd: tempDir() })).toThrow(/Unknown platform "web"/)
158
+ })
159
+
160
+ it("refuses an archive with paths outside the folder or links", () => {
161
+ const work = tempDir()
162
+ fs.mkdirSync(path.join(work, "inner"))
163
+ fs.symlinkSync("/etc/hosts", path.join(work, "inner", "link"))
164
+ const archive = execFileSync("tar", ["-czf", "-", "-C", path.join(work, "inner"), "."])
165
+ expect(thrown(() => unpackCredentials(archive.toString("base64"), tempDir())).code).toBe("NATIVE-007")
166
+ })
167
+
168
+ it("fails to export a missing folder (NATIVE-008)", () => {
169
+ expect(thrown(() => packCredentials("ios", { cwd: tempDir() })).code).toBe("NATIVE-008")
170
+ })
171
+
172
+ it("fails to export more than 48 KB (NATIVE-009)", () => {
173
+ const cwd = tempDir()
174
+ const dir = path.join(cwd, "credentials", "ios")
175
+ fs.mkdirSync(dir, { recursive: true })
176
+ // Random bytes do not compress.
177
+ fs.writeFileSync(path.join(dir, "big.bin"), crypto.randomBytes(60 * 1024))
178
+ const error = thrown(() => packCredentials("ios", { cwd }))
179
+ expect(error.code).toBe("NATIVE-009")
180
+ expect(MAX_CREDENTIALS_SECRET_BYTES).toBe(48 * 1024)
181
+ })
182
+
183
+ it("masks secrets only under GitHub Actions", () => {
184
+ const out: string[] = []
185
+ const write = (text: string) => out.push(text)
186
+ expect(maskSecret("s3cret", { env: {}, write })).toBe(false)
187
+ expect(maskSecret("", { env: { GITHUB_ACTIONS: "true" }, write })).toBe(false)
188
+ expect(maskSecret("50%\nx", { env: { GITHUB_ACTIONS: "true" }, write })).toBe(true)
189
+ expect(out).toEqual(["::add-mask::50%25%0Ax\n"])
190
+ })
191
+
192
+ it("writes the mask to stdout by default", () => {
193
+ const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
194
+ expect(maskSecret("abc", { env: { GITHUB_ACTIONS: "true" } })).toBe(true)
195
+ expect(write).toHaveBeenCalledWith("::add-mask::abc\n")
196
+ })
197
+ })
198
+
199
+ describe("catalyst credentials:export", () => {
200
+ it("prints the value on stdout and where to paste it on stderr", () => {
201
+ const out: string[] = []
202
+ const err: string[] = []
203
+ const code = runExportCredentials({
204
+ argv: ["android"],
205
+ cwd: appWithCredentials(),
206
+ stdout: (text: string) => out.push(text),
207
+ stderr: (text: string) => err.push(text),
208
+ })
209
+ expect(code).toBe(0)
210
+ expect(out.join("").trim()).toMatch(/^[A-Za-z0-9+/]+=*$/)
211
+ expect(err.join("")).toContain("CATALYST_ANDROID_CREDENTIALS")
212
+ })
213
+
214
+ it("prints the coded error and exits 1", () => {
215
+ const err: string[] = []
216
+ const code = runExportCredentials({
217
+ argv: ["ios"],
218
+ cwd: tempDir(),
219
+ stdout: () => {},
220
+ stderr: (text: string) => err.push(text),
221
+ })
222
+ expect(code).toBe(1)
223
+ expect(err.join("")).toContain("NATIVE-008")
224
+ })
225
+
226
+ it("writes to the process streams by default", () => {
227
+ const out = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
228
+ const err = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
229
+ expect(runExportCredentials({ argv: ["android"], cwd: appWithCredentials() })).toBe(0)
230
+ expect(String(out.mock.calls[0][0]).trim()).toMatch(/^[A-Za-z0-9+/]+=*$/)
231
+ expect(String(err.mock.calls[0][0])).toContain("CATALYST_ANDROID_CREDENTIALS")
232
+ })
233
+
234
+ it("needs a platform", () => {
235
+ const err: string[] = []
236
+ expect(runExportCredentials({ argv: [], stdout: () => {}, stderr: (t: string) => err.push(t) })).toBe(
237
+ 1
238
+ )
239
+ expect(err.join("")).toContain("<android|ios>")
240
+ })
241
+ })
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import {
4
+ askConsent,
5
+ isInteractive,
6
+ needsAnswerError,
7
+ parseNativeArgs,
8
+ select,
9
+ } from "../../src/native/utils.js"
10
+
11
+ function thrown(fn: () => unknown): any {
12
+ try {
13
+ fn()
14
+ } catch (error) {
15
+ return error
16
+ }
17
+ throw new Error("expected a throw")
18
+ }
19
+
20
+ describe("parseNativeArgs", () => {
21
+ it("reads the five native flags", () => {
22
+ expect(parseNativeArgs([])).toEqual({
23
+ release: false,
24
+ device: false,
25
+ url: undefined,
26
+ yes: false,
27
+ acceptLicenses: false,
28
+ })
29
+ expect(
30
+ parseNativeArgs([
31
+ "--release",
32
+ "--device",
33
+ "--url",
34
+ "https://shop.acme.com",
35
+ "--yes",
36
+ "--accept-licenses",
37
+ ])
38
+ ).toEqual({
39
+ release: true,
40
+ device: true,
41
+ url: "https://shop.acme.com",
42
+ yes: true,
43
+ acceptLicenses: true,
44
+ })
45
+ })
46
+
47
+ it("takes --url=<u> too, and ignores other arguments", () => {
48
+ expect(parseNativeArgs(["--verbose", "--url=https://a.com"])).toMatchObject({ url: "https://a.com" })
49
+ })
50
+
51
+ it("no longer knows the removed flags", () => {
52
+ expect(parseNativeArgs(["--build-number", "7", "--no-sign"])).not.toHaveProperty("buildNumber")
53
+ expect(parseNativeArgs(["--no-sign"])).not.toHaveProperty("noSign")
54
+ })
55
+
56
+ it.each([[["--url"]], [["--url", "--release"]], [["--url="]]])(
57
+ "fails on --url without a value: %j",
58
+ (argv) => {
59
+ expect(thrown(() => parseNativeArgs(argv)).code).toBe("NATIVE-004")
60
+ }
61
+ )
62
+ })
63
+
64
+ describe("isInteractive", () => {
65
+ const tty = { isTTY: true }
66
+ const pipe = { isTTY: false }
67
+
68
+ it("needs both stdin and stdout to be terminals", () => {
69
+ expect(isInteractive({ stdin: tty, stdout: tty, env: {} })).toBe(true)
70
+ expect(isInteractive({ stdin: pipe, stdout: tty, env: {} })).toBe(false)
71
+ expect(isInteractive({ stdin: tty, stdout: pipe, env: {} })).toBe(false)
72
+ })
73
+
74
+ it("is false under CI", () => {
75
+ expect(isInteractive({ stdin: tty, stdout: tty, env: { CI: "true" } })).toBe(false)
76
+ expect(isInteractive({ stdin: tty, stdout: tty, env: { CI: "1" } })).toBe(false)
77
+ expect(isInteractive({ stdin: tty, stdout: tty, env: { CI: "false" } })).toBe(true)
78
+ expect(isInteractive({ stdin: tty, stdout: tty, env: { CI: "" } })).toBe(true)
79
+ })
80
+ })
81
+
82
+ describe("needsAnswerError", () => {
83
+ it("names the flag", () => {
84
+ const error = needsAnswerError("Install JDK 17 with Homebrew?", { flag: "--yes" })
85
+ expect(error.code).toBe("NATIVE-005")
86
+ expect(error.message).toContain("Install JDK 17 with Homebrew (pass --yes)")
87
+ expect(error.hint).toContain("--yes")
88
+ })
89
+
90
+ it("says no flag helps when only a person can answer", () => {
91
+ expect(needsAnswerError("Create an upload key").hint).toContain("not even --yes")
92
+ })
93
+ })
94
+
95
+ describe("askConsent", () => {
96
+ const never = async () => {
97
+ throw new Error("must not prompt")
98
+ }
99
+ const answer = (value: string) => async () => value
100
+
101
+ it("--yes answers installs without asking", async () => {
102
+ await expect(
103
+ askConsent("install", "Install?", { flags: { yes: true }, isTTY: false, prompt: never })
104
+ ).resolves.toBe(true)
105
+ })
106
+
107
+ it("--yes never answers licences or key creation", async () => {
108
+ const licence = await askConsent("licence", "Accept?", { flags: { yes: true }, isTTY: false }).catch(
109
+ (e) => e
110
+ )
111
+ expect(licence.code).toBe("NATIVE-005")
112
+ expect(licence.message).toContain("--accept-licenses")
113
+ const key = await askConsent("key", "Create key?", {
114
+ flags: { yes: true, acceptLicenses: true },
115
+ isTTY: false,
116
+ }).catch((e) => e)
117
+ expect(key.code).toBe("NATIVE-005")
118
+ })
119
+
120
+ it("--accept-licenses answers licences", async () => {
121
+ await expect(
122
+ askConsent("licence", "Accept?", { flags: { acceptLicenses: true }, isTTY: false, prompt: never })
123
+ ).resolves.toBe(true)
124
+ })
125
+
126
+ it("without a terminal or flag, an install fails naming --yes", async () => {
127
+ const error = await askConsent("install", "Install the SDK?", { isTTY: false }).catch((e) => e)
128
+ expect(error.code).toBe("NATIVE-005")
129
+ expect(error.message).toContain("--yes")
130
+ })
131
+
132
+ it("asks in a terminal, and key creation defaults to no", async () => {
133
+ await expect(askConsent("install", "Install?", { isTTY: true, prompt: answer("n") })).resolves.toBe(
134
+ false
135
+ )
136
+ await expect(askConsent("key", "Create?", { isTTY: true, prompt: answer("yes") })).resolves.toBe(true)
137
+ let asked = ""
138
+ await askConsent("key", "Create?", {
139
+ isTTY: true,
140
+ prompt: async (question: string, { defaultValue }: any) => {
141
+ asked = question
142
+ return defaultValue
143
+ },
144
+ })
145
+ expect(asked).toContain("[y/N]")
146
+ })
147
+ })
148
+
149
+ describe("select", () => {
150
+ const choices = [
151
+ { label: "Emulator", value: "emulator" },
152
+ { label: "Pixel 8 (USB)", value: "device" },
153
+ ]
154
+
155
+ it("returns the default without a terminal", async () => {
156
+ await expect(select("Run on", choices, { defaultValue: "emulator", isTTY: false })).resolves.toBe(
157
+ "emulator"
158
+ )
159
+ })
160
+
161
+ it("fails naming the flag without a terminal or default", async () => {
162
+ const error = await select("Run on", choices, { flag: "--device", isTTY: false }).catch((e) => e)
163
+ expect(error.code).toBe("NATIVE-005")
164
+ expect(error.message).toContain("--device")
165
+ })
166
+
167
+ it("asks by number and re-asks on a bad answer", async () => {
168
+ const answers = ["9", "2"]
169
+ const out: string[] = []
170
+ const value = await select("Run on", choices, {
171
+ isTTY: true,
172
+ defaultValue: "emulator",
173
+ prompt: async () => answers.shift() as string,
174
+ write: (text: string) => out.push(text),
175
+ })
176
+ expect(value).toBe("device")
177
+ expect(out.join("")).toContain("2. Pixel 8 (USB)")
178
+ })
179
+ })