catalyst-core 1.0.2-canary.1 → 1.0.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 (90) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/changelog.md +34 -0
  3. package/dist/cli/README.md +119 -0
  4. package/dist/cli/appConfig.js +51 -0
  5. package/dist/cli/buildErrors.js +170 -0
  6. package/dist/cli/buildWarnings.js +28 -0
  7. package/dist/cli/devOutput.js +524 -0
  8. package/dist/cli/diagnostic.js +388 -0
  9. package/dist/cli/hints.js +69 -0
  10. package/dist/cli/package.json +3 -0
  11. package/dist/cli/spinner.js +141 -0
  12. package/dist/cli/theme.js +155 -0
  13. package/dist/errors/index.js +7 -3
  14. package/dist/logger.js +61 -6
  15. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  20. package/dist/native/androidSetup.js +46 -57
  21. package/dist/native/buildAndroid/assets.js +22 -13
  22. package/dist/native/buildAndroid/build.js +98 -26
  23. package/dist/native/buildAndroid/config.js +43 -12
  24. package/dist/native/buildAndroid/index.js +49 -191
  25. package/dist/native/buildAppAndroid.js +19 -8
  26. package/dist/native/buildAppIos.js +35 -16
  27. package/dist/native/buildIos/assets.js +1 -6
  28. package/dist/native/buildIos/build.js +66 -95
  29. package/dist/native/buildIos/config.js +26 -8
  30. package/dist/native/buildIos/index.js +143 -64
  31. package/dist/native/buildIos/plugins.js +12 -15
  32. package/dist/native/internalPluginUtils.js +0 -1
  33. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  34. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  35. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  36. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  39. package/dist/native/pluginComposerAndroid.js +2 -3
  40. package/dist/native/renameAndroidProject.js +42 -20
  41. package/dist/native/setupEmulatorIos.js +28 -49
  42. package/dist/native/setupServer.js +12 -15
  43. package/dist/native/terminalProgress.js +186 -147
  44. package/dist/native/utils.js +340 -54
  45. package/dist/otel.js +15 -9
  46. package/dist/scripts/build.js +313 -42
  47. package/dist/scripts/generateOfflineManifest.js +11 -1
  48. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  49. package/dist/scripts/preServerInit.js +32 -10
  50. package/dist/scripts/preflight.js +35 -11
  51. package/dist/scripts/scriptUtils.js +34 -0
  52. package/dist/scripts/serve.js +64 -15
  53. package/dist/scripts/start.js +58 -21
  54. package/dist/server/expressServer.js +167 -32
  55. package/dist/server/renderer/handler.jsx +87 -11
  56. package/dist/server/utils/validator.js +14 -11
  57. package/dist/vite/manifest-categorization-plugin.js +7 -5
  58. package/dist/vite/resolveDevServerConfig.js +9 -2
  59. package/dist/vite/vite.config.client.js +18 -0
  60. package/dist/vite/vite.config.js +73 -13
  61. package/dist/vite/vite.config.server.js +21 -0
  62. package/dist/web-router/components/MetaTag.jsx +15 -2
  63. package/dist/web-router/components/MetaTag.test.jsx +71 -2
  64. package/dist/webmcp/WebMcpProvider.jsx +3 -2
  65. package/dist/webmcp/useTool.js +4 -1
  66. package/mcp_v2/conversion-tasks.json +6 -5
  67. package/mcp_v2/knowledge-base.json +124 -7
  68. package/mcp_v2/test/config.test.ts +59 -0
  69. package/mcp_v2/tools/build.js +6 -6
  70. package/mcp_v2/tools/config.js +35 -18
  71. package/mcp_v2/tools/conversion.js +7 -13
  72. package/package.json +5 -5
  73. package/test/cli/buildErrors.test.ts +173 -0
  74. package/test/cli/buildWarnings.test.cjs +62 -0
  75. package/test/cli/devOutput.test.ts +549 -0
  76. package/test/cli/diagnostic.test.ts +434 -0
  77. package/test/cli/output.test.cjs +90 -0
  78. package/test/cli/spinner.test.ts +177 -0
  79. package/test/cli/theme.test.ts +163 -0
  80. package/test/native/redactSecrets.test.ts +72 -0
  81. package/test/native/utils.test.ts +532 -0
  82. package/test/scripts/build.test.ts +275 -0
  83. package/test/scripts/serve.test.ts +102 -0
  84. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  85. package/test/server/handler.test.ts +17 -0
  86. package/test/server/logger.test.ts +185 -0
  87. package/test/server/otel.test.ts +556 -0
  88. package/test/server/validator.test.ts +19 -8
  89. package/dist/scripts/checkVersion.js +0 -3
  90. package/dist/scripts/cleanCache.js +0 -30
@@ -0,0 +1,177 @@
1
+ import { createRequire } from "node:module"
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
+
4
+ import type spinnerModule from "../../src/cli/spinner.js"
5
+ import type themeModule from "../../src/cli/theme.js"
6
+
7
+ // Loaded with require, the way build.js and the native commands load them, so
8
+ // v8 coverage sees one copy of each CommonJS file.
9
+ const load = createRequire(import.meta.url)
10
+ const { Spinner, withSpinnersPaused }: typeof spinnerModule = load("../../src/cli/spinner.js")
11
+ const theme: typeof themeModule = load("../../src/cli/theme.js")
12
+
13
+ const ESC = String.fromCharCode(27)
14
+ const CLEAR_LINE = `\r${ESC}[2K`
15
+ const plain = (text: string) => text.replace(theme.ANSI, "")
16
+
17
+ // A stream that records every write, TTY or not.
18
+ function fakeStream(isTTY: boolean) {
19
+ const writes: string[] = []
20
+ const stream = {
21
+ isTTY,
22
+ write: (chunk: string) => {
23
+ writes.push(String(chunk))
24
+ return true
25
+ },
26
+ }
27
+ return { stream: stream as unknown as typeof process.stdout, writes }
28
+ }
29
+
30
+ beforeEach(() => {
31
+ vi.useFakeTimers()
32
+ // isInteractive also requires CI to be unset, and CI sets it.
33
+ vi.stubEnv("CI", "")
34
+ })
35
+
36
+ afterEach(() => {
37
+ vi.useRealTimers()
38
+ vi.unstubAllEnvs()
39
+ })
40
+
41
+ describe("Spinner in a terminal", () => {
42
+ it("animates in place and settles into a timed result line", () => {
43
+ const { stream, writes } = fakeStream(true)
44
+ const spinner = new Spinner(stream).start("Building bundles")
45
+ expect(writes).toHaveLength(1)
46
+ expect(plain(writes[0])).toBe(`${CLEAR_LINE} ◐ Building bundles`)
47
+
48
+ vi.advanceTimersByTime(120 * 4)
49
+ expect(writes.map(plain).slice(1)).toEqual(
50
+ ["◓", "◑", "◒", "◐"].map((frame) => `${CLEAR_LINE} ${frame} Building bundles`)
51
+ )
52
+
53
+ spinner.update("Building client")
54
+ expect(plain(writes.at(-1) as string)).toBe(`${CLEAR_LINE} ◐ Building client`)
55
+
56
+ vi.advanceTimersByTime(1000)
57
+ writes.length = 0
58
+ spinner.stop("done", "Built bundles")
59
+ expect(writes.map(plain)).toEqual([CLEAR_LINE, " ✓ Built bundles 1.5s\n"])
60
+
61
+ // A stopped spinner neither redraws nor settles twice.
62
+ vi.advanceTimersByTime(1000)
63
+ spinner.stop()
64
+ expect(writes).toHaveLength(2)
65
+ })
66
+
67
+ it("marks a failure and keeps the label when none is given", () => {
68
+ const { stream, writes } = fakeStream(true)
69
+ const spinner = new Spinner(stream).start("Generating manifest")
70
+ writes.length = 0
71
+ spinner.stop("fail")
72
+ expect(writes.map(plain)).toEqual([CLEAR_LINE, " ✗ Generating manifest\n"])
73
+ })
74
+
75
+ it("does not leave a second timer running when started twice", () => {
76
+ const { stream, writes } = fakeStream(true)
77
+ const spinner = new Spinner(stream).start("first")
78
+ spinner.start("second")
79
+ writes.length = 0
80
+ vi.advanceTimersByTime(120)
81
+ expect(writes.map(plain)).toEqual([`${CLEAR_LINE} ◓ second`])
82
+ spinner.stop()
83
+ })
84
+
85
+ it("erases its row while paused and redraws on resume", () => {
86
+ const { stream, writes } = fakeStream(true)
87
+ const spinner = new Spinner(stream).start("Installing")
88
+ writes.length = 0
89
+
90
+ spinner.pause()
91
+ expect(writes).toEqual([CLEAR_LINE])
92
+ vi.advanceTimersByTime(1000)
93
+ expect(writes).toHaveLength(1)
94
+ // A paused spinner can be paused again without a second erase.
95
+ spinner.pause()
96
+ expect(writes).toHaveLength(2)
97
+
98
+ spinner.resume()
99
+ expect(plain(writes.at(-1) as string)).toBe(`${CLEAR_LINE} ◐ Installing`)
100
+ // Already running: resuming again does nothing.
101
+ spinner.resume()
102
+ const count = writes.length
103
+ vi.advanceTimersByTime(120)
104
+ expect(writes).toHaveLength(count + 1)
105
+ spinner.stop()
106
+
107
+ // Stopped: pause and resume are both inert.
108
+ writes.length = 0
109
+ spinner.pause()
110
+ spinner.resume()
111
+ expect(writes).toEqual([])
112
+ })
113
+ })
114
+
115
+ describe("Spinner without a terminal", () => {
116
+ it("prints a plain start line and a plain result, with no escape codes", () => {
117
+ const { stream, writes } = fakeStream(false)
118
+ const spinner = new Spinner(stream).start("Building bundles")
119
+ spinner.update("Building client")
120
+ spinner.pause()
121
+ spinner.resume()
122
+ vi.advanceTimersByTime(2000)
123
+ spinner.stop("warn", "Built with warnings")
124
+
125
+ expect(writes.map(plain)).toEqual([" · Building bundles\n", " ▲ Built with warnings 2.0s\n"])
126
+ expect(writes.join("")).not.toContain("\r")
127
+ })
128
+
129
+ it("treats a TTY under CI as non-interactive", () => {
130
+ vi.stubEnv("CI", "true")
131
+ const { stream, writes } = fakeStream(true)
132
+ new Spinner(stream).start("Building").stop()
133
+ expect(writes.map(plain)).toEqual([" · Building\n", " ✓ Building\n"])
134
+ })
135
+
136
+ it("defaults to process.stdout", () => {
137
+ expect(new Spinner().stream).toBe(process.stdout)
138
+ })
139
+ })
140
+
141
+ describe("withSpinnersPaused", () => {
142
+ it("stands every live spinner down while fn runs and brings them back", async () => {
143
+ const { stream, writes } = fakeStream(true)
144
+ const spinner = new Spinner(stream).start("Configuring")
145
+ writes.length = 0
146
+
147
+ const result = await withSpinnersPaused(async () => {
148
+ expect(writes).toEqual([CLEAR_LINE])
149
+ return "answer"
150
+ })
151
+ expect(result).toBe("answer")
152
+ expect(plain(writes.at(-1) as string)).toBe(`${CLEAR_LINE} ◐ Configuring`)
153
+ spinner.stop()
154
+ })
155
+
156
+ it("brings them back when fn throws", async () => {
157
+ const { stream, writes } = fakeStream(true)
158
+ const spinner = new Spinner(stream).start("Configuring")
159
+ writes.length = 0
160
+
161
+ await expect(
162
+ withSpinnersPaused(() => {
163
+ throw new Error("prompt failed")
164
+ })
165
+ ).rejects.toThrow("prompt failed")
166
+ expect(plain(writes.at(-1) as string)).toBe(`${CLEAR_LINE} ◐ Configuring`)
167
+ spinner.stop()
168
+ })
169
+
170
+ it("leaves stopped spinners alone", async () => {
171
+ const { stream, writes } = fakeStream(true)
172
+ new Spinner(stream).start("Done already").stop()
173
+ writes.length = 0
174
+ await withSpinnersPaused(() => undefined)
175
+ expect(writes).toEqual([])
176
+ })
177
+ })
@@ -0,0 +1,163 @@
1
+ import os from "os"
2
+ import path from "path"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+
5
+ import { createRequire } from "module"
6
+ import pkg from "../../package.json"
7
+ import type themeModule from "../../src/cli/theme.js"
8
+
9
+ // Loaded with require, the way diagnostic.js and the native commands load it:
10
+ // v8 coverage then sees one copy of the file rather than two that it merges
11
+ // badly across test files.
12
+ const theme: typeof themeModule = createRequire(import.meta.url)("../../src/cli/theme.js")
13
+
14
+ // theme.js is the shared visual language for every CLI command. Colour is
15
+ // decided by ansis from the environment (it turns on under CI), so every
16
+ // assertion here strips ANSI codes and checks what a reader would see.
17
+ const { ANSI, GUTTER, glyph, SPINNER_FRAMES, t } = theme
18
+ const plain = (text: string) => String(text).replace(ANSI, "")
19
+
20
+ describe("theme constants", () => {
21
+ it("uses single-width glyphs and a calm four-frame spinner", () => {
22
+ for (const mark of [...Object.values(glyph), ...SPINNER_FRAMES]) {
23
+ expect([...mark]).toHaveLength(1)
24
+ }
25
+ expect(SPINNER_FRAMES).toEqual(["◐", "◓", "◑", "◒"])
26
+ expect(GUTTER).toBe(" ")
27
+ })
28
+
29
+ it("ANSI strips colour codes and nothing else", () => {
30
+ const esc = String.fromCharCode(27)
31
+ expect(`${esc}[36mcyan${esc}[39m ${esc}[1;31mbold red${esc}[0m`.replace(ANSI, "")).toBe(
32
+ "cyan bold red"
33
+ )
34
+ expect(plain(t.accent(t.bold("text")))).toBe("text")
35
+ })
36
+ })
37
+
38
+ describe("isInteractive", () => {
39
+ afterEach(() => {
40
+ vi.unstubAllEnvs()
41
+ })
42
+
43
+ it("is true only for a TTY outside CI", () => {
44
+ vi.stubEnv("CI", "")
45
+ const stream = (isTTY?: boolean) => ({ isTTY }) as unknown as typeof process.stdout
46
+ expect(theme.isInteractive(stream(true))).toBe(true)
47
+ expect(theme.isInteractive(stream(false))).toBe(false)
48
+ expect(theme.isInteractive(stream())).toBe(false)
49
+
50
+ vi.stubEnv("CI", "true")
51
+ expect(theme.isInteractive(stream(true))).toBe(false)
52
+ })
53
+ })
54
+
55
+ describe("isVerbose", () => {
56
+ it("is on for --verbose or --debug on this process", () => {
57
+ expect(theme.isVerbose(["node", "catalyst", "--verbose"], {})).toBe(true)
58
+ expect(theme.isVerbose(["node", "catalyst", "--debug"], {})).toBe(true)
59
+ })
60
+
61
+ it("is on when a parent forwarded the mode", () => {
62
+ expect(theme.isVerbose([], { CATALYST_OUTPUT_MODE: "verbose" })).toBe(true)
63
+ expect(theme.isVerbose([], { CATALYST_OUTPUT_MODE: "debug" })).toBe(true)
64
+ })
65
+
66
+ it("is off by default and for any other mode", () => {
67
+ expect(theme.isVerbose(["node", "catalyst", "build"], {})).toBe(false)
68
+ expect(theme.isVerbose([], { CATALYST_OUTPUT_MODE: "default" })).toBe(false)
69
+ })
70
+ })
71
+
72
+ describe("version and header", () => {
73
+ it("reads catalyst-core's own package version", () => {
74
+ expect(theme.version()).toBe(pkg.version)
75
+ // Cached: a second call returns the same value.
76
+ expect(theme.version()).toBe(pkg.version)
77
+ })
78
+
79
+ it("opens with title, version and subtitle over a rule", () => {
80
+ const lines = plain(theme.header("catalyst build", "production")).split("\n")
81
+ expect(lines).toEqual([
82
+ "",
83
+ ` catalyst build v${pkg.version} › production`,
84
+ ` ${"─".repeat(46)}`,
85
+ "",
86
+ ])
87
+ })
88
+
89
+ it("omits the subtitle separator when there is no subtitle", () => {
90
+ const title = plain(theme.header("catalyst serve")).split("\n")[1]
91
+ expect(title).toBe(` catalyst serve v${pkg.version}`)
92
+ })
93
+ })
94
+
95
+ describe("row", () => {
96
+ it("pads the key so values line up in one column", () => {
97
+ expect(plain(theme.row("Local", "http://localhost:3005"))).toBe(" Local http://localhost:3005")
98
+ expect(plain(theme.row("Network", "http://10.0.0.2:3005"))).toBe(" Network http://10.0.0.2:3005")
99
+ expect(plain(theme.row("a", "b", 3))).toBe(" a b")
100
+ })
101
+ })
102
+
103
+ describe("duration", () => {
104
+ it("uses ms under a second, one decimal under a minute, then m/s", () => {
105
+ expect(theme.duration(0)).toBe("0ms")
106
+ expect(theme.duration(999)).toBe("999ms")
107
+ expect(theme.duration(1000)).toBe("1.0s")
108
+ expect(theme.duration(12345)).toBe("12.3s")
109
+ expect(theme.duration(60000)).toBe("1m 0s")
110
+ expect(theme.duration(125400)).toBe("2m 5s")
111
+ })
112
+ })
113
+
114
+ describe("bytes", () => {
115
+ it("picks B, kB or MB by size", () => {
116
+ expect(theme.bytes(0)).toBe("0 B")
117
+ expect(theme.bytes(1023)).toBe("1023 B")
118
+ expect(theme.bytes(1024)).toBe("1.0 kB")
119
+ expect(theme.bytes(1536)).toBe("1.5 kB")
120
+ expect(theme.bytes(1024 * 1024)).toBe("1.00 MB")
121
+ expect(theme.bytes(5.5 * 1024 * 1024)).toBe("5.50 MB")
122
+ })
123
+ })
124
+
125
+ describe("displayPath", () => {
126
+ const root = path.join(os.tmpdir(), "catalyst-app")
127
+
128
+ it("is relative when the file is inside the app", () => {
129
+ expect(theme.displayPath(path.join(root, "src", "App.js"), root)).toBe(path.join("src", "App.js"))
130
+ })
131
+
132
+ it("uses ~ for files under home but outside the app", () => {
133
+ const file = path.join(os.homedir(), "Library", "DerivedData", "Build.log")
134
+ expect(theme.displayPath(file, root)).toBe(
135
+ `~${path.sep}${path.join("Library", "DerivedData", "Build.log")}`
136
+ )
137
+ })
138
+
139
+ it("keeps absolute paths elsewhere, and the root itself", () => {
140
+ const outside = path.join(path.parse(root).root, "opt-catalyst-elsewhere", "file.js")
141
+ expect(theme.displayPath(outside, root)).toBe(outside)
142
+ expect(theme.displayPath(root, root)).toBe(root)
143
+ })
144
+
145
+ it("passes empty values through", () => {
146
+ expect(theme.displayPath(undefined, root)).toBeUndefined()
147
+ expect(theme.displayPath("", root)).toBe("")
148
+ })
149
+ })
150
+
151
+ describe("step", () => {
152
+ it("marks state with a glyph and shows meaningful timings", () => {
153
+ expect(plain(theme.step("Bundled client", 1200))).toBe(" ✓ Bundled client 1.2s")
154
+ expect(plain(theme.step("Type check", 250, "warn"))).toBe(" ▲ Type check 250ms")
155
+ expect(plain(theme.step("Gradle build", 61000, "fail"))).toBe(" ✗ Gradle build 1m 1s")
156
+ })
157
+
158
+ it("drops timings that are noise or missing", () => {
159
+ expect(plain(theme.step("Copied assets", 40))).toBe(" ✓ Copied assets")
160
+ expect(plain(theme.step("Copied assets"))).toBe(" ✓ Copied assets")
161
+ expect(plain(theme.step("Copied assets", null))).toBe(" ✓ Copied assets")
162
+ })
163
+ })
@@ -0,0 +1,72 @@
1
+ import os from "node:os"
2
+ import path from "node:path"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+
5
+ import { expandHome, runCommand } from "../../src/native/utils.js"
6
+
7
+ // The signing flow (renameAndroidProject.js) passes keystore passwords on the
8
+ // command line, then rethrows `Failed to create signed AAB: ${error.message}`
9
+ // and prints it. execSync's error.message carries the whole command line, so
10
+ // the passwords have to be gone from the error itself, not just from the log.
11
+ const STORE = "St0re-Secret-91"
12
+ const KEY = "K3y-Secret-27"
13
+
14
+ function failSigningCommand() {
15
+ // Fails like a wrong keystore password would, echoing a password back
16
+ // without its flag on both streams.
17
+ const command = [
18
+ `echo "using ${STORE}"; echo "bad key ${KEY}" >&2; exit 1;`,
19
+ `: ./gradlew bundleRelease`,
20
+ `-Pandroid.injected.signing.store.password="${STORE}"`,
21
+ `-Pandroid.injected.signing.key.password="${KEY}"`,
22
+ `-storepass "${STORE}" -keypass ${KEY}`,
23
+ ].join(" ")
24
+ try {
25
+ runCommand(command)
26
+ } catch (error) {
27
+ return error
28
+ }
29
+ throw new Error("expected the command to fail")
30
+ }
31
+
32
+ describe("runCommand redacts signing passwords from failures", () => {
33
+ afterEach(() => vi.restoreAllMocks())
34
+
35
+ it("never surfaces a password in the thrown error or the log", () => {
36
+ const logged: string[] = []
37
+ vi.spyOn(console, "error").mockImplementation((...args) => void logged.push(args.join(" ")))
38
+
39
+ const error = failSigningCommand()
40
+ // What renameAndroidProject.js builds from it and hands to progress.fail.
41
+ const wrapped = new Error(`Failed to create signed AAB: ${error.message}`)
42
+
43
+ const surfaces = [
44
+ error.message,
45
+ error.stack,
46
+ error.stdout,
47
+ error.stderr,
48
+ String(error.cmd ?? ""),
49
+ ...(error.output || []).map(String),
50
+ wrapped.message,
51
+ ...logged,
52
+ ].join("\n")
53
+
54
+ expect(error.message).toContain("Command failed")
55
+ expect(surfaces).toContain("***")
56
+ expect(surfaces).not.toContain(STORE)
57
+ expect(surfaces).not.toContain(KEY)
58
+ })
59
+ })
60
+
61
+ describe("expandHome", () => {
62
+ it("expands a leading ~ so the SDK hint's example path works", () => {
63
+ expect(expandHome("~/Library/Android/sdk")).toBe(path.join(os.homedir(), "Library/Android/sdk"))
64
+ expect(expandHome("~")).toBe(os.homedir())
65
+ })
66
+
67
+ it("leaves other paths alone", () => {
68
+ expect(expandHome("/opt/android-sdk")).toBe("/opt/android-sdk")
69
+ expect(expandHome("relative/~/sdk")).toBe("relative/~/sdk")
70
+ expect(expandHome(undefined)).toBe(undefined)
71
+ })
72
+ })