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.
- package/bin/catalyst.js +54 -6
- package/changelog.md +27 -1
- package/dist/errors/registry.js +306 -18
- package/dist/errors-index.json +273 -70
- package/dist/native/androidProject/app/build.gradle.kts +53 -79
- package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
- package/dist/native/androidSetup.js +76 -170
- package/dist/native/androidToolchain.js +642 -0
- package/dist/native/appIcon.js +100 -0
- package/dist/native/buildAndroid/assets.js +29 -5
- package/dist/native/buildAndroid/build.js +110 -223
- package/dist/native/buildAndroid/config.js +57 -65
- package/dist/native/buildAndroid/devices.js +176 -0
- package/dist/native/buildAndroid/index.js +271 -46
- package/dist/native/buildAndroid/output.js +43 -0
- package/dist/native/buildAndroid/signing.js +456 -0
- package/dist/native/buildAppAndroid.js +27 -7
- package/dist/native/buildAppIos.js +139 -20
- package/dist/native/buildIos/assets.js +76 -4
- package/dist/native/buildIos/build.js +31 -181
- package/dist/native/buildIos/config.js +126 -11
- package/dist/native/buildIos/index.js +354 -34
- package/dist/native/buildIos/localhostCert.js +87 -0
- package/dist/native/buildIos/output.js +28 -0
- package/dist/native/buildIos/signing.js +596 -0
- package/dist/native/credentials.js +309 -0
- package/dist/native/exportCredentials.js +51 -0
- package/dist/native/iosToolchain.js +583 -0
- package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
- package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
- package/dist/native/localState.js +148 -0
- package/dist/native/nativeConfig.js +621 -0
- package/dist/native/releaseOutput.js +53 -0
- package/dist/native/renameAndroidProject.js +154 -369
- package/dist/native/setupEmulatorIos.js +58 -275
- package/dist/native/setupServer.js +520 -124
- package/dist/native/utils.js +418 -115
- package/dist/vite/resolveDevServerConfig.js +21 -0
- package/mcp_v2/knowledge-base.json +13 -11
- package/mcp_v2/test/config.test.ts +40 -0
- package/mcp_v2/tools/build.js +80 -104
- package/mcp_v2/tools/config.js +181 -174
- package/package.json +1 -1
- package/test/cli/nativeFlags.test.ts +97 -0
- package/test/native/androidDevices.test.ts +230 -0
- package/test/native/androidElfAlignment.test.ts +148 -0
- package/test/native/androidSigning.test.ts +646 -0
- package/test/native/androidToolchain.test.ts +766 -0
- package/test/native/appIcon.test.ts +124 -0
- package/test/native/credentials.test.ts +241 -0
- package/test/native/interaction.test.ts +179 -0
- package/test/native/iosConfigPhase.test.ts +611 -0
- package/test/native/iosSigning.test.ts +1060 -0
- package/test/native/iosToolchain.test.ts +821 -0
- package/test/native/localState.test.ts +80 -0
- package/test/native/nativeConfig.test.ts +602 -0
- package/test/native/setupServer.test.ts +529 -0
- package/test/native/utils.test.ts +102 -127
- package/test/vite/resolveDevServerConfig.test.js +25 -0
- package/dist/native/androidProject/keystore/Untitled +0 -0
- package/dist/native/androidProject/keystore/release-key.jks +0 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
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 {
|
|
7
|
+
ensureLocalStateDir,
|
|
8
|
+
getLastTarget,
|
|
9
|
+
localStatePath,
|
|
10
|
+
readLocalState,
|
|
11
|
+
saveLastTarget,
|
|
12
|
+
writeLocalState,
|
|
13
|
+
} from "../../src/native/localState.js"
|
|
14
|
+
|
|
15
|
+
const temps: string[] = []
|
|
16
|
+
function tempDir() {
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-local-state-"))
|
|
18
|
+
temps.push(dir)
|
|
19
|
+
return dir
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of temps.splice(0)) {
|
|
23
|
+
fs.chmodSync(dir, 0o755)
|
|
24
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("local state", () => {
|
|
29
|
+
it("is empty when the file is missing or broken", () => {
|
|
30
|
+
const cwd = tempDir()
|
|
31
|
+
expect(readLocalState({ cwd })).toEqual({})
|
|
32
|
+
fs.mkdirSync(path.join(cwd, ".catalyst"))
|
|
33
|
+
fs.writeFileSync(localStatePath(cwd), "{not json")
|
|
34
|
+
expect(readLocalState({ cwd })).toEqual({})
|
|
35
|
+
fs.writeFileSync(localStatePath(cwd), "[1]")
|
|
36
|
+
expect(readLocalState({ cwd })).toEqual({})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("remembers the last target per platform and keeps the others", () => {
|
|
40
|
+
const cwd = tempDir()
|
|
41
|
+
expect(getLastTarget("android", { cwd })).toBeNull()
|
|
42
|
+
expect(saveLastTarget("android", { target: "device", serial: "R58M" }, { cwd })).toBe(true)
|
|
43
|
+
expect(saveLastTarget("ios", { target: "simulator", udid: "ABC", name: "iPhone 17" }, { cwd })).toBe(
|
|
44
|
+
true
|
|
45
|
+
)
|
|
46
|
+
expect(getLastTarget("android", { cwd })).toEqual({ target: "device", serial: "R58M" })
|
|
47
|
+
expect(saveLastTarget("android", { target: "emulator", avd: "Pixel_8" }, { cwd })).toBe(true)
|
|
48
|
+
expect(readLocalState({ cwd })).toEqual({
|
|
49
|
+
android: { target: "emulator", avd: "Pixel_8" },
|
|
50
|
+
ios: { target: "simulator", udid: "ABC", name: "iPhone 17" },
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("keeps the folder out of git on its own", () => {
|
|
55
|
+
const cwd = tempDir()
|
|
56
|
+
writeLocalState({ migrations: { lanAddresses: "now" } }, { cwd })
|
|
57
|
+
expect(fs.readFileSync(path.join(cwd, ".catalyst", ".gitignore"), "utf8")).toBe("*\n")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("keeps a .gitignore the folder already has", () => {
|
|
61
|
+
const cwd = tempDir()
|
|
62
|
+
fs.mkdirSync(path.join(cwd, ".catalyst"))
|
|
63
|
+
fs.writeFileSync(path.join(cwd, ".catalyst", ".gitignore"), "*\n!keep.txt\n")
|
|
64
|
+
expect(writeLocalState({ a: 1 }, { cwd })).toBe(true)
|
|
65
|
+
expect(fs.readFileSync(path.join(cwd, ".catalyst", ".gitignore"), "utf8")).toBe("*\n!keep.txt\n")
|
|
66
|
+
expect(ensureLocalStateDir(cwd)).toBe(path.join(cwd, ".catalyst"))
|
|
67
|
+
expect(readLocalState({ cwd })).toEqual({ a: 1 })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("never throws when it cannot write", () => {
|
|
71
|
+
const cwd = tempDir()
|
|
72
|
+
fs.chmodSync(cwd, 0o500)
|
|
73
|
+
expect(writeLocalState({ a: 1 }, { cwd })).toBe(false)
|
|
74
|
+
expect(saveLastTarget("android", { target: "emulator" }, { cwd })).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("rejects a target without a kind", () => {
|
|
78
|
+
expect(saveLastTarget("android", { serial: "x" } as any, { cwd: tempDir() })).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import { createRequire } from "module"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
6
|
+
|
|
7
|
+
import { ERROR_DEFINITIONS } from "../../src/errors/registry.js"
|
|
8
|
+
import {
|
|
9
|
+
BUILD_NUMBER_EPOCH_MS,
|
|
10
|
+
MAX_BUILD_NUMBER,
|
|
11
|
+
assertReleaseHost,
|
|
12
|
+
computeBuildNumber,
|
|
13
|
+
computeStartUrl,
|
|
14
|
+
isPlaceholderAppId,
|
|
15
|
+
parseAppUrl,
|
|
16
|
+
privateHostReason,
|
|
17
|
+
describePrivateHost,
|
|
18
|
+
resolveAppVersion,
|
|
19
|
+
resolveNativeConfig,
|
|
20
|
+
} from "../../src/native/nativeConfig.js"
|
|
21
|
+
import { NATIVE_ERRORS } from "../../src/native/utils.js"
|
|
22
|
+
|
|
23
|
+
// buildIos/config.js is plain CJS with no ESM-syntax imports, so it loads with require.
|
|
24
|
+
const require = createRequire(import.meta.url)
|
|
25
|
+
const createIosConfigPhase = require("../../src/native/buildIos/config.js")
|
|
26
|
+
|
|
27
|
+
const temps: string[] = []
|
|
28
|
+
function tempDir() {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-native-config-"))
|
|
30
|
+
temps.push(dir)
|
|
31
|
+
return dir
|
|
32
|
+
}
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const NO_LOCAL = new Set<string>()
|
|
38
|
+
const NOW = BUILD_NUMBER_EPOCH_MS + 1_000_000 * 60_000
|
|
39
|
+
|
|
40
|
+
function thrown(fn: () => unknown): any {
|
|
41
|
+
try {
|
|
42
|
+
fn()
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return error
|
|
45
|
+
}
|
|
46
|
+
throw new Error("expected a throw")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve with fixed time, no local addresses and a temp cwd. */
|
|
50
|
+
function resolve(platform: "android" | "ios", config: any, options: Record<string, any> = {}) {
|
|
51
|
+
const cwd = options.cwd || tempDir()
|
|
52
|
+
const resolved = resolveNativeConfig(platform, {
|
|
53
|
+
config,
|
|
54
|
+
cwd,
|
|
55
|
+
env: {},
|
|
56
|
+
now: NOW,
|
|
57
|
+
localHosts: NO_LOCAL,
|
|
58
|
+
...options,
|
|
59
|
+
})
|
|
60
|
+
resolved.cleanup()
|
|
61
|
+
return resolved
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("parseAppUrl", () => {
|
|
65
|
+
it("defaults the port from the scheme", () => {
|
|
66
|
+
expect(parseAppUrl("https://shop.acme.com")).toEqual({
|
|
67
|
+
scheme: "https",
|
|
68
|
+
host: "shop.acme.com",
|
|
69
|
+
port: 443,
|
|
70
|
+
origin: "https://shop.acme.com",
|
|
71
|
+
})
|
|
72
|
+
expect(parseAppUrl("http://shop.acme.com").port).toBe(80)
|
|
73
|
+
expect(parseAppUrl("http://shop.acme.com:8080/")).toMatchObject({
|
|
74
|
+
port: 8080,
|
|
75
|
+
origin: "http://shop.acme.com:8080",
|
|
76
|
+
})
|
|
77
|
+
expect(parseAppUrl("https://shop.acme.com:443").port).toBe(443)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it.each([
|
|
81
|
+
["https://shop.acme.com:8443", "port other than 443"],
|
|
82
|
+
["https://shop.acme.com/app", "origin only"],
|
|
83
|
+
["https://shop.acme.com/?a=1", "origin only"],
|
|
84
|
+
["ftp://shop.acme.com", "https://"],
|
|
85
|
+
["shop.acme.com", "not a URL"],
|
|
86
|
+
["https://user:pw@shop.acme.com", "user name"],
|
|
87
|
+
["", "empty"],
|
|
88
|
+
])("rejects %j (NATIVE-004)", (value, why) => {
|
|
89
|
+
const error = thrown(() => parseAppUrl(value))
|
|
90
|
+
expect(error.code).toBe("NATIVE-004")
|
|
91
|
+
expect(error.message).toContain(why)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("isPlaceholderAppId", () => {
|
|
96
|
+
it.each([
|
|
97
|
+
"com.example.myshop",
|
|
98
|
+
"io.yourname.androidproject",
|
|
99
|
+
"com.catalyst.framework.app",
|
|
100
|
+
"com.debug.webview",
|
|
101
|
+
"",
|
|
102
|
+
])("%j is a placeholder", (id) => expect(isPlaceholderAppId(id)).toBe(true))
|
|
103
|
+
it("accepts a real id", () => expect(isPlaceholderAppId("com.acme.shop")).toBe(false))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("privateHostReason", () => {
|
|
107
|
+
it.each([
|
|
108
|
+
["localhost", "this machine"],
|
|
109
|
+
["app.localhost", "this machine"],
|
|
110
|
+
["192.168.1.5", "a private LAN address"],
|
|
111
|
+
["10.0.0.1", "a private LAN address"],
|
|
112
|
+
["172.20.0.3", "a private LAN address"],
|
|
113
|
+
["169.254.10.1", "a link-local address"],
|
|
114
|
+
["0.0.0.0", "not a routable address"],
|
|
115
|
+
["127.0.0.1", "this machine's loopback address"],
|
|
116
|
+
["mac.local", "a .local name that only the local network resolves"],
|
|
117
|
+
["::1", "this machine's loopback address"],
|
|
118
|
+
["::", "not a routable address"],
|
|
119
|
+
["fe80::1", "a link-local address"],
|
|
120
|
+
["fd12::1", "a private LAN address"],
|
|
121
|
+
["::ffff:192.168.1.5", "a private LAN address"],
|
|
122
|
+
["example.com", "a placeholder domain reserved for examples"],
|
|
123
|
+
["www.example.com", "a placeholder domain reserved for examples"],
|
|
124
|
+
["shop.test", "a placeholder domain reserved for examples"],
|
|
125
|
+
])("%s is %s", (host, reason) => {
|
|
126
|
+
expect(privateHostReason(host, { localHosts: NO_LOCAL })).toBe(reason)
|
|
127
|
+
expect(describePrivateHost(host, reason)).toBe(`${host} is ${reason}`)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("describes an empty host without a dangling subject", () => {
|
|
131
|
+
expect(describePrivateHost("", privateHostReason("", { localHosts: NO_LOCAL }))).toBe(
|
|
132
|
+
"the host is empty"
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("accepts public hosts", () => {
|
|
137
|
+
expect(privateHostReason("shop.acme.com", { localHosts: NO_LOCAL })).toBeNull()
|
|
138
|
+
expect(privateHostReason("8.8.8.8", { localHosts: NO_LOCAL })).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("refuses this machine's own address", () => {
|
|
142
|
+
expect(privateHostReason("100.64.1.5", { localHosts: new Set(["100.64.1.5"]) })).toBe(
|
|
143
|
+
"this machine's own address"
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe("assertReleaseHost", () => {
|
|
149
|
+
it("throws NATIVE-001 for a private host unless allowed", () => {
|
|
150
|
+
const wv = { LOCAL_IP: "192.168.1.20", port: "3005" }
|
|
151
|
+
const error = thrown(() => assertReleaseHost("android", wv, { localHosts: NO_LOCAL }))
|
|
152
|
+
expect(error.code).toBe("NATIVE-001")
|
|
153
|
+
expect(error.message).toBe(
|
|
154
|
+
"Release build points at a local or private host: http://192.168.1.20:3005/ (192.168.1.20 is a private LAN address)"
|
|
155
|
+
)
|
|
156
|
+
expect(
|
|
157
|
+
thrown(() =>
|
|
158
|
+
assertReleaseHost("ios", { LOCAL_IP: "localhost", port: "3005" }, { localHosts: NO_LOCAL })
|
|
159
|
+
).message
|
|
160
|
+
).toBe(
|
|
161
|
+
"Release build points at a local or private host: http://localhost:3005/ (localhost is this machine)"
|
|
162
|
+
)
|
|
163
|
+
const logs: string[] = []
|
|
164
|
+
const start = assertReleaseHost("android", wv, {
|
|
165
|
+
allowPrivateHost: true,
|
|
166
|
+
localHosts: NO_LOCAL,
|
|
167
|
+
log: (message: string) => logs.push(message),
|
|
168
|
+
})
|
|
169
|
+
expect(start.url).toBe("http://192.168.1.20:3005/")
|
|
170
|
+
expect(logs.join("\n")).toContain("allowPrivateHost")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("builds the start URL the way each shell does", () => {
|
|
174
|
+
expect(computeStartUrl("ios", { LOCAL_IP: "a.com", useHttps: true, port: 3005 }).url).toBe(
|
|
175
|
+
"https://a.com:443/"
|
|
176
|
+
)
|
|
177
|
+
expect(computeStartUrl("android", {}, { release: true }).host).toBe("127.0.0.1")
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("computeBuildNumber", () => {
|
|
182
|
+
it("is whole minutes since 2024-01-01T00:00:00Z", () => {
|
|
183
|
+
expect(BUILD_NUMBER_EPOCH_MS).toBe(Date.parse("2024-01-01T00:00:00Z"))
|
|
184
|
+
expect(computeBuildNumber({ env: {}, now: BUILD_NUMBER_EPOCH_MS + 90 * 60_000 + 59_999 })).toEqual({
|
|
185
|
+
buildNumber: 90,
|
|
186
|
+
buildNumberSource: "clock",
|
|
187
|
+
})
|
|
188
|
+
// Never 0, even at the epoch.
|
|
189
|
+
expect(computeBuildNumber({ env: {}, now: BUILD_NUMBER_EPOCH_MS }).buildNumber).toBe(1)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("takes CATALYST_BUILD_NUMBER over the clock", () => {
|
|
193
|
+
expect(computeBuildNumber({ env: { CATALYST_BUILD_NUMBER: " 2026102701 " }, now: NOW })).toEqual({
|
|
194
|
+
buildNumber: 2026102701,
|
|
195
|
+
buildNumberSource: "CATALYST_BUILD_NUMBER",
|
|
196
|
+
})
|
|
197
|
+
expect(computeBuildNumber({ env: { CATALYST_BUILD_NUMBER: "" }, now: NOW }).buildNumberSource).toBe(
|
|
198
|
+
"clock"
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it.each(["0", "-1", "1.5", "abc", String(MAX_BUILD_NUMBER + 1)])("rejects %s (NATIVE-002)", (value) => {
|
|
203
|
+
expect(thrown(() => computeBuildNumber({ env: { CATALYST_BUILD_NUMBER: value } })).code).toBe(
|
|
204
|
+
"NATIVE-002"
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("resolveAppVersion reads the version from package.json", () => {
|
|
209
|
+
const cwd = tempDir()
|
|
210
|
+
fs.writeFileSync(path.join(cwd, "package.json"), JSON.stringify({ version: "1.4.0" }))
|
|
211
|
+
expect(resolveAppVersion({ cwd, env: {}, now: NOW })).toEqual({
|
|
212
|
+
versionName: "1.4.0",
|
|
213
|
+
buildNumber: 1_000_000,
|
|
214
|
+
buildNumberSource: "clock",
|
|
215
|
+
})
|
|
216
|
+
expect(resolveAppVersion({ cwd: tempDir(), env: {}, now: NOW }).versionName).toBeUndefined()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe("resolveNativeConfig: release", () => {
|
|
221
|
+
const newShape = {
|
|
222
|
+
NODE_SERVER_PORT: 3005,
|
|
223
|
+
WEBVIEW_CONFIG: { appId: "com.acme.shop", appName: "Acme Shop", url: "https://shop.acme.com" },
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
it("turns url, appId and appName into the legacy shape the shells read", () => {
|
|
227
|
+
const resolved = resolve("android", newShape, { release: true })
|
|
228
|
+
expect(resolved.release).toBe(true)
|
|
229
|
+
expect(resolved.startUrl).toBe("https://shop.acme.com:443/")
|
|
230
|
+
expect(resolved.appId).toBe("com.acme.shop")
|
|
231
|
+
expect(resolved.appName).toBe("Acme Shop")
|
|
232
|
+
expect(resolved.WEBVIEW_CONFIG).toMatchObject({
|
|
233
|
+
LOCAL_IP: "shop.acme.com",
|
|
234
|
+
port: "443",
|
|
235
|
+
useHttps: true,
|
|
236
|
+
android: { packageName: "com.acme.shop", appName: "Acme Shop", buildType: "release" },
|
|
237
|
+
ios: { appBundleId: "com.acme.shop", bundleIdentifier: "com.acme.shop", appName: "Acme Shop" },
|
|
238
|
+
})
|
|
239
|
+
for (const key of ["url", "appId", "appName", "allowPrivateHost", "release"]) {
|
|
240
|
+
expect(resolved.WEBVIEW_CONFIG).not.toHaveProperty(key)
|
|
241
|
+
}
|
|
242
|
+
expect(resolved.version.buildNumber).toBe(1_000_000)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("lets --url override WEBVIEW_CONFIG.url", () => {
|
|
246
|
+
const resolved = resolve("ios", newShape, { release: true, url: "https://staging.acme.com" })
|
|
247
|
+
expect(resolved.WEBVIEW_CONFIG.LOCAL_IP).toBe("staging.acme.com")
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("keeps the legacy LOCAL_IP, port and useHttps when url is absent", () => {
|
|
251
|
+
const resolved = resolve(
|
|
252
|
+
"android",
|
|
253
|
+
{
|
|
254
|
+
WEBVIEW_CONFIG: {
|
|
255
|
+
LOCAL_IP: "shop.acme.com",
|
|
256
|
+
port: 443,
|
|
257
|
+
useHttps: true,
|
|
258
|
+
android: { packageName: "com.acme.shop" },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{ release: true }
|
|
262
|
+
)
|
|
263
|
+
expect(resolved.startUrl).toBe("https://shop.acme.com:443/")
|
|
264
|
+
expect(resolved.appId).toBe("com.acme.shop")
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("prefers appId over the platform keys", () => {
|
|
268
|
+
const resolved = resolve(
|
|
269
|
+
"ios",
|
|
270
|
+
{ WEBVIEW_CONFIG: { ...newShape.WEBVIEW_CONFIG, ios: { appBundleId: "com.old.id" } } },
|
|
271
|
+
{ release: true }
|
|
272
|
+
)
|
|
273
|
+
expect(resolved.appId).toBe("com.acme.shop")
|
|
274
|
+
expect(resolved.WEBVIEW_CONFIG.ios.appBundleId).toBe("com.acme.shop")
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("reads the iOS id from bundleIdentifier, then appBundleId, and ships that one everywhere", () => {
|
|
278
|
+
const wv = { url: "https://shop.acme.com" }
|
|
279
|
+
// Shared.xcconfig has always set PRODUCT_BUNDLE_IDENTIFIER from
|
|
280
|
+
// bundleIdentifier first; the guard checks that id, and simctl uses it too.
|
|
281
|
+
const both = resolve(
|
|
282
|
+
"ios",
|
|
283
|
+
{ WEBVIEW_CONFIG: { ...wv, ios: { appBundleId: "com.a.b", bundleIdentifier: "com.c.d" } } },
|
|
284
|
+
{ release: true }
|
|
285
|
+
)
|
|
286
|
+
expect(both.appId).toBe("com.c.d")
|
|
287
|
+
expect(both.WEBVIEW_CONFIG.ios).toMatchObject({ appBundleId: "com.c.d", bundleIdentifier: "com.c.d" })
|
|
288
|
+
expect(
|
|
289
|
+
resolve("ios", { WEBVIEW_CONFIG: { ...wv, ios: { appBundleId: "com.a.b" } } }, { release: true })
|
|
290
|
+
.appId
|
|
291
|
+
).toBe("com.a.b")
|
|
292
|
+
// A placeholder in the id that ships is caught even when the other key is real.
|
|
293
|
+
const shipsPlaceholder = {
|
|
294
|
+
WEBVIEW_CONFIG: {
|
|
295
|
+
...wv,
|
|
296
|
+
ios: { appBundleId: "com.acme.shop", bundleIdentifier: "com.example.shop" },
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
expect(thrown(() => resolve("ios", shipsPlaceholder, { release: true })).code).toBe("NATIVE-003")
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it("refuses an app id that is not valid for the platform (NATIVE-010)", () => {
|
|
303
|
+
const wv = (appId: string) => ({ WEBVIEW_CONFIG: { appId, url: "https://shop.acme.com" } })
|
|
304
|
+
for (const bad of [
|
|
305
|
+
"shop",
|
|
306
|
+
"com..shop",
|
|
307
|
+
"com.acme.1shop",
|
|
308
|
+
"com.acme.my-shop",
|
|
309
|
+
"com.new.shop",
|
|
310
|
+
'com.acme.x"$(id)',
|
|
311
|
+
]) {
|
|
312
|
+
const error = thrown(() => resolve("android", wv(bad)))
|
|
313
|
+
expect(error.code, bad).toBe("NATIVE-010")
|
|
314
|
+
}
|
|
315
|
+
for (const bad of ["shop", "com.acme.my_shop", "com.acme.sh op"]) {
|
|
316
|
+
expect(thrown(() => resolve("ios", wv(bad))).code, bad).toBe("NATIVE-010")
|
|
317
|
+
}
|
|
318
|
+
// Valid per platform: Java allows _, Apple allows - and a leading digit.
|
|
319
|
+
expect(resolve("android", wv("com.acme.my_shop2")).appId).toBe("com.acme.my_shop2")
|
|
320
|
+
expect(resolve("ios", wv("com.1mg.my-shop")).appId).toBe("com.1mg.my-shop")
|
|
321
|
+
// Shipped per-platform keys are checked the same way.
|
|
322
|
+
const legacy = { WEBVIEW_CONFIG: { android: { packageName: "com.acme.class" } } }
|
|
323
|
+
expect(thrown(() => resolve("android", legacy)).code).toBe("NATIVE-010")
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it("keeps the shipped ios.version for iOS, with a deprecation warning", () => {
|
|
327
|
+
const cwd = tempDir()
|
|
328
|
+
fs.writeFileSync(path.join(cwd, "package.json"), JSON.stringify({ version: "1.0.0" }))
|
|
329
|
+
const config = {
|
|
330
|
+
WEBVIEW_CONFIG: {
|
|
331
|
+
...newShape.WEBVIEW_CONFIG,
|
|
332
|
+
ios: { version: { major: 3, minor: 4, buildNumber: 87 } },
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
const logs: string[] = []
|
|
336
|
+
const resolved = resolve("ios", config, { release: true, cwd, log: (m: string) => logs.push(m) })
|
|
337
|
+
expect(resolved.version).toEqual({
|
|
338
|
+
versionName: "3.4.0",
|
|
339
|
+
buildNumber: 87,
|
|
340
|
+
buildNumberSource: "ios.version",
|
|
341
|
+
})
|
|
342
|
+
expect(logs.join("\n")).toMatch(/ios\.version is deprecated.*package\.json.*CATALYST_BUILD_NUMBER/)
|
|
343
|
+
|
|
344
|
+
// CATALYST_BUILD_NUMBER still wins; no buildNumber means the clock.
|
|
345
|
+
expect(
|
|
346
|
+
resolve("ios", config, { release: true, cwd, env: { CATALYST_BUILD_NUMBER: "90" } }).version
|
|
347
|
+
).toMatchObject({ versionName: "3.4.0", buildNumber: 90, buildNumberSource: "CATALYST_BUILD_NUMBER" })
|
|
348
|
+
const noBuild = { WEBVIEW_CONFIG: { ...newShape.WEBVIEW_CONFIG, ios: { version: { major: 2 } } } }
|
|
349
|
+
expect(resolve("ios", noBuild, { release: true, cwd }).version).toMatchObject({
|
|
350
|
+
versionName: "2.0.0",
|
|
351
|
+
buildNumber: 1_000_000,
|
|
352
|
+
buildNumberSource: "clock",
|
|
353
|
+
})
|
|
354
|
+
// Android never had a version object: package.json applies.
|
|
355
|
+
expect(resolve("android", config, { release: true, cwd }).version.versionName).toBe("1.0.0")
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it("refuses a placeholder or missing app id (NATIVE-003)", () => {
|
|
359
|
+
const placeholder = { WEBVIEW_CONFIG: { appId: "com.example.myshop", url: "https://shop.acme.com" } }
|
|
360
|
+
expect(thrown(() => resolve("android", placeholder, { release: true })).code).toBe("NATIVE-003")
|
|
361
|
+
const missing = { WEBVIEW_CONFIG: { url: "https://shop.acme.com" } }
|
|
362
|
+
const error = thrown(() => resolve("ios", missing, { release: true }))
|
|
363
|
+
expect(error.code).toBe("NATIVE-003")
|
|
364
|
+
expect(error.message).toContain("com.catalyst.framework.app")
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it("refuses a private or placeholder host (NATIVE-001), and allowPrivateHost downgrades it", () => {
|
|
368
|
+
const lan = { WEBVIEW_CONFIG: { appId: "com.acme.shop", url: "http://192.168.1.4:3005" } }
|
|
369
|
+
expect(thrown(() => resolve("android", lan, { release: true })).code).toBe("NATIVE-001")
|
|
370
|
+
const placeholder = { WEBVIEW_CONFIG: { appId: "com.acme.shop", url: "https://www.example.com" } }
|
|
371
|
+
expect(thrown(() => resolve("android", placeholder, { release: true })).code).toBe("NATIVE-001")
|
|
372
|
+
const allowed = resolve(
|
|
373
|
+
"android",
|
|
374
|
+
{ WEBVIEW_CONFIG: { ...lan.WEBVIEW_CONFIG, allowPrivateHost: true } },
|
|
375
|
+
{ release: true }
|
|
376
|
+
)
|
|
377
|
+
expect(allowed.startUrl).toBe("http://192.168.1.4:3005/")
|
|
378
|
+
expect(allowed.WEBVIEW_CONFIG).not.toHaveProperty("allowPrivateHost")
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("refuses a release with no production URL at all", () => {
|
|
382
|
+
expect(
|
|
383
|
+
thrown(() =>
|
|
384
|
+
resolve("android", { WEBVIEW_CONFIG: { appId: "com.acme.shop" } }, { release: true })
|
|
385
|
+
).code
|
|
386
|
+
).toBe("NATIVE-001")
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it("treats buildType release as a release, with a deprecation warning", () => {
|
|
390
|
+
const logs: string[] = []
|
|
391
|
+
const resolved = resolve(
|
|
392
|
+
"android",
|
|
393
|
+
{ WEBVIEW_CONFIG: { ...newShape.WEBVIEW_CONFIG, android: { buildType: "release" } } },
|
|
394
|
+
{ log: (message: string) => logs.push(message) }
|
|
395
|
+
)
|
|
396
|
+
expect(resolved.release).toBe(true)
|
|
397
|
+
expect(logs.join("\n")).toMatch(/deprecated.*--release/)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it("writes the resolved config to a private temp file", () => {
|
|
401
|
+
const resolved = resolveNativeConfig("android", {
|
|
402
|
+
config: newShape,
|
|
403
|
+
cwd: tempDir(),
|
|
404
|
+
env: {},
|
|
405
|
+
now: NOW,
|
|
406
|
+
localHosts: NO_LOCAL,
|
|
407
|
+
release: true,
|
|
408
|
+
})
|
|
409
|
+
try {
|
|
410
|
+
// One descriptor for the mode and the content.
|
|
411
|
+
const fd = fs.openSync(resolved.configPath, "r")
|
|
412
|
+
try {
|
|
413
|
+
expect(fs.fstatSync(fd).mode & 0o777).toBe(0o600)
|
|
414
|
+
expect(JSON.parse(fs.readFileSync(fd, "utf8")).WEBVIEW_CONFIG.LOCAL_IP).toBe("shop.acme.com")
|
|
415
|
+
} finally {
|
|
416
|
+
fs.closeSync(fd)
|
|
417
|
+
}
|
|
418
|
+
} finally {
|
|
419
|
+
resolved.cleanup()
|
|
420
|
+
}
|
|
421
|
+
expect(() => fs.openSync(resolved.configPath, "r")).toThrow(
|
|
422
|
+
expect.objectContaining({ code: "ENOENT" })
|
|
423
|
+
)
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
describe("resolveNativeConfig: debug", () => {
|
|
428
|
+
const config = {
|
|
429
|
+
NODE_SERVER_PORT: 3006,
|
|
430
|
+
WEBVIEW_CONFIG: {
|
|
431
|
+
appId: "com.example.myshop",
|
|
432
|
+
url: "https://shop.acme.com",
|
|
433
|
+
apiBaseUrl: "https://shop.acme.com/api/",
|
|
434
|
+
accessControl: { enabled: true, allowedUrls: ["https://shop.acme.com/*"] },
|
|
435
|
+
android: {},
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
it("loads the dev server on the caller's host and ignores url", () => {
|
|
440
|
+
const resolved = resolve("android", config)
|
|
441
|
+
expect(resolved.release).toBe(false)
|
|
442
|
+
expect(resolved.usesDevServer).toBe(true)
|
|
443
|
+
expect(resolved.devOrigin).toBe("http://localhost:3006")
|
|
444
|
+
expect(resolved.startUrl).toBe("http://localhost:3006/")
|
|
445
|
+
expect(resolved.WEBVIEW_CONFIG).toMatchObject({
|
|
446
|
+
LOCAL_IP: "localhost",
|
|
447
|
+
port: "3006",
|
|
448
|
+
useHttps: false,
|
|
449
|
+
})
|
|
450
|
+
expect(resolved.WEBVIEW_CONFIG.android.buildType).toBe("debug")
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it("does not refuse a placeholder app id in debug", () => {
|
|
454
|
+
expect(resolve("ios", config).appId).toBe("com.example.myshop")
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it("adds the dev origin to allowedUrls and remaps apiBaseUrl in the resolved config only", () => {
|
|
458
|
+
const resolved = resolve("ios", config, { devHost: "jane-mac.local" })
|
|
459
|
+
expect(resolved.WEBVIEW_CONFIG.accessControl.allowedUrls).toEqual([
|
|
460
|
+
"https://shop.acme.com/*",
|
|
461
|
+
"http://jane-mac.local:3006",
|
|
462
|
+
"http://jane-mac.local:3006/*",
|
|
463
|
+
])
|
|
464
|
+
expect(resolved.WEBVIEW_CONFIG.apiBaseUrl).toBe("http://jane-mac.local:3006/api/")
|
|
465
|
+
expect(config.WEBVIEW_CONFIG.accessControl.allowedUrls).toEqual(["https://shop.acme.com/*"])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it("keeps a comma-separated allowedUrls string a string", () => {
|
|
469
|
+
const resolved = resolve("android", {
|
|
470
|
+
WEBVIEW_CONFIG: {
|
|
471
|
+
port: "3005",
|
|
472
|
+
accessControl: { enabled: true, allowedUrls: "https://a.com/*, https://b.com/*" },
|
|
473
|
+
},
|
|
474
|
+
})
|
|
475
|
+
expect(resolved.WEBVIEW_CONFIG.accessControl.allowedUrls).toBe(
|
|
476
|
+
"https://a.com/*,https://b.com/*,http://localhost:3005,http://localhost:3005/*"
|
|
477
|
+
)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it("points a localhost apiBaseUrl at a device host", () => {
|
|
481
|
+
const resolved = resolve(
|
|
482
|
+
"ios",
|
|
483
|
+
{ WEBVIEW_CONFIG: { port: "3005", apiBaseUrl: "http://localhost:8080/api" } },
|
|
484
|
+
{ devHost: "jane-mac.local" }
|
|
485
|
+
)
|
|
486
|
+
expect(resolved.WEBVIEW_CONFIG.apiBaseUrl).toBe("http://jane-mac.local:8080/api")
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it("leaves an apiBaseUrl that is not a URL, or on another host, as written", () => {
|
|
490
|
+
const notUrl = resolve(
|
|
491
|
+
"ios",
|
|
492
|
+
{ WEBVIEW_CONFIG: { port: "3005", apiBaseUrl: "/api/v1" } },
|
|
493
|
+
{ devHost: "jane-mac.local" }
|
|
494
|
+
)
|
|
495
|
+
expect(notUrl.WEBVIEW_CONFIG.apiBaseUrl).toBe("/api/v1")
|
|
496
|
+
const elsewhere = resolve(
|
|
497
|
+
"ios",
|
|
498
|
+
{ WEBVIEW_CONFIG: { port: "3005", apiBaseUrl: "https://api.partner.com/v2" } },
|
|
499
|
+
{ devHost: "jane-mac.local" }
|
|
500
|
+
)
|
|
501
|
+
expect(elsewhere.WEBVIEW_CONFIG.apiBaseUrl).toBe("https://api.partner.com/v2")
|
|
502
|
+
// localhost stays localhost for a target that shares the machine's network.
|
|
503
|
+
const local = resolve("android", {
|
|
504
|
+
WEBVIEW_CONFIG: { port: "3005", apiBaseUrl: "http://localhost:8080/api" },
|
|
505
|
+
})
|
|
506
|
+
expect(local.WEBVIEW_CONFIG.apiBaseUrl).toBe("http://localhost:8080/api")
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it("passes an allowedUrls of another shape through unchanged", () => {
|
|
510
|
+
const resolved = resolve("android", {
|
|
511
|
+
WEBVIEW_CONFIG: { port: "3005", accessControl: { enabled: false, allowedUrls: null } },
|
|
512
|
+
})
|
|
513
|
+
expect(resolved.WEBVIEW_CONFIG.accessControl).toEqual({ enabled: false, allowedUrls: null })
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("keeps a legacy public LOCAL_IP with a warning and no dev server", () => {
|
|
517
|
+
const logs: string[] = []
|
|
518
|
+
const resolved = resolve(
|
|
519
|
+
"android",
|
|
520
|
+
{ WEBVIEW_CONFIG: { LOCAL_IP: "shop.acme.com", port: "443", useHttps: true } },
|
|
521
|
+
{ log: (message: string) => logs.push(message) }
|
|
522
|
+
)
|
|
523
|
+
expect(resolved.usesDevServer).toBe(false)
|
|
524
|
+
expect(resolved.WEBVIEW_CONFIG.LOCAL_IP).toBe("shop.acme.com")
|
|
525
|
+
expect(logs.join("\n")).toContain("WEBVIEW_CONFIG.url")
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it("replaces a LAN LOCAL_IP saved by an older setup with the dev host", () => {
|
|
529
|
+
const resolved = resolve("android", { WEBVIEW_CONFIG: { LOCAL_IP: "192.168.1.20", port: "3005" } })
|
|
530
|
+
expect(resolved.WEBVIEW_CONFIG.LOCAL_IP).toBe("localhost")
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it("fills platform blocks that are missing", () => {
|
|
534
|
+
const resolved = resolve("ios", { WEBVIEW_CONFIG: { appId: "com.acme.shop" } })
|
|
535
|
+
expect(resolved.WEBVIEW_CONFIG.ios).toMatchObject({
|
|
536
|
+
appBundleId: "com.acme.shop",
|
|
537
|
+
buildType: "debug",
|
|
538
|
+
})
|
|
539
|
+
expect(resolved.WEBVIEW_CONFIG.android).toMatchObject({ packageName: "com.acme.shop" })
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
describe("NATIVE_ERRORS", () => {
|
|
544
|
+
it("matches the registry for every copied code", () => {
|
|
545
|
+
for (const [code, entry] of Object.entries(NATIVE_ERRORS)) {
|
|
546
|
+
const def = (ERROR_DEFINITIONS as Record<string, any>)[code]
|
|
547
|
+
expect(def, code).toBeDefined()
|
|
548
|
+
expect(entry.message, code).toBe(def.defaultMessage)
|
|
549
|
+
expect(entry.hint, code).toBe(def.suggestedAction)
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it("copies every NATIVE code", () => {
|
|
554
|
+
const nativeCodes = Object.keys(ERROR_DEFINITIONS).filter((code) => code.startsWith("NATIVE-"))
|
|
555
|
+
expect(nativeCodes.length).toBeGreaterThan(0)
|
|
556
|
+
for (const code of nativeCodes) expect(NATIVE_ERRORS, code).toHaveProperty(code)
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
describe("iOS ConfigConstants", () => {
|
|
561
|
+
it("never emits the resolver-only keys (url, appId, appName, allowPrivateHost)", async () => {
|
|
562
|
+
const resolved = resolve(
|
|
563
|
+
"ios",
|
|
564
|
+
{
|
|
565
|
+
WEBVIEW_CONFIG: {
|
|
566
|
+
url: "https://shop.acme.com",
|
|
567
|
+
appId: "com.acme.shop",
|
|
568
|
+
appName: "Acme Shop",
|
|
569
|
+
allowPrivateHost: false,
|
|
570
|
+
ios: {},
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
{ release: true }
|
|
574
|
+
)
|
|
575
|
+
const projectDir = tempDir()
|
|
576
|
+
const progress = { start() {}, complete() {}, fail() {}, log() {} }
|
|
577
|
+
const { generateConfigConstants } = createIosConfigPhase({
|
|
578
|
+
WEBVIEW_CONFIG: resolved.WEBVIEW_CONFIG,
|
|
579
|
+
iosConfig: resolved.WEBVIEW_CONFIG.ios,
|
|
580
|
+
PROJECT_DIR: projectDir,
|
|
581
|
+
PUBLIC_PATH: tempDir(),
|
|
582
|
+
url: "https://shop.acme.com",
|
|
583
|
+
progress,
|
|
584
|
+
})
|
|
585
|
+
await generateConfigConstants()
|
|
586
|
+
const swift = fs.readFileSync(
|
|
587
|
+
path.join(
|
|
588
|
+
projectDir,
|
|
589
|
+
"CoreLogic",
|
|
590
|
+
"Sources",
|
|
591
|
+
"CatalystCoreLogic",
|
|
592
|
+
"Constants",
|
|
593
|
+
"ConfigConstants.swift"
|
|
594
|
+
),
|
|
595
|
+
"utf8"
|
|
596
|
+
)
|
|
597
|
+
// One url: the start URL the build passes in, not a second from config.
|
|
598
|
+
expect(swift.match(/static let url\b/g)).toHaveLength(1)
|
|
599
|
+
expect(swift).not.toMatch(/static let (appId|allowPrivateHost)\b/)
|
|
600
|
+
expect(swift).toContain('public static let LOCAL_IP = "shop.acme.com"')
|
|
601
|
+
})
|
|
602
|
+
})
|