Skip to content
Source
Sign inSign up

easProjectId.test.ts

260 lines10.0 KB

1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7​
8import { findEasProjectId } from "./easProjectId.ts";
9​
10const ID_A = "d763fcb8-d37c-41ea-a773-b54a0ab4a454";
11const ID_B = "54d70eaf-b0a3-477a-a9cd-2cf81a5c84c9";
12​
13const workspace = (files: Record<string, string>) =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-eas-project-" });
18 for (const [file, content] of Object.entries(files)) {
19 yield* fs.makeDirectory(path.dirname(path.join(root, file)), { recursive: true });
20 yield* fs.writeFileString(path.join(root, file), content);
21 }
22 return root;
23 });
24​
25const layer = NodeServices.layer;
26​
27it.effect("reads a literal project id from an app config without running it", () =>
28 Effect.gen(function* () {
29 const fs = yield* FileSystem.FileSystem;
30 const path = yield* Path.Path;
31 const root = yield* workspace({});
32 const marker = path.join(root, "config-ran");
33 yield* fs.makeDirectory(path.join(root, "apps/mobile"), { recursive: true });
34 yield* fs.writeFileString(
35 path.join(root, "apps/mobile/app.config.ts"),
36 `import { writeFileSync } from "node:fs";
37 writeFileSync(${JSON.stringify(marker)}, "");
38 export default { extra: { eas: { projectId: "${ID_A}" } } };`,
39 );
40 expect(yield* findEasProjectId(root)).toEqual(Option.some(ID_A));
41 expect(yield* fs.exists(marker)).toBe(false);
42 }).pipe(Effect.scoped, Effect.provide(layer)),
43);
44​
45it.effect("falls back to app.json, and does not compute a project id a config builds", () =>
46 Effect.gen(function* () {
47 const staticOnly = yield* workspace({
48 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
49 });
50 expect(yield* findEasProjectId(staticOnly)).toEqual(Option.some(ID_B));
51 const computed = yield* workspace({
52 "app.config.ts": `const parts = ${JSON.stringify(ID_A.split("-"))};
53 export default { extra: { eas: { projectId: parts.join("-") } } };`,
54 });
55 expect(yield* findEasProjectId(computed)).toEqual(Option.none());
56 }).pipe(Effect.scoped, Effect.provide(layer)),
57);
58​
59it.effect.each([
60 {
61 case: "reads the one project id a config names, whatever else it holds",
62 files: {
63 "app.config.js": `export default {
64 assetBundlePatterns: ["**/*"],
65 extra: { eas: { projectId: "${ID_A}" }, firebase: { projectId: "my-app-12345" } },
66 };
67 /** docs */`,
68 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
69 },
70 expected: Option.some(ID_A),
71 },
72 {
73 case: "gives no answer when a config names a project only some builds use",
74 files: {
75 "app.config.ts": `export default ({ config }) =>
76 process.env.APP_VARIANT === "preview"
77 ? { ...config, extra: { ...config.extra, eas: { projectId: "${ID_B}" } } }
78 : config;`,
79 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
80 },
81 expected: Option.none(),
82 },
83 {
84 case: "gives no answer when a comment keeps an old project id",
85 files: {
86 "app.config.js": `// projectId: "${ID_B}" (the old production project)
87 export default { extra: { eas: { projectId: "${ID_A}" } } };`,
88 },
89 expected: Option.none(),
90 },
91 {
92 case: "gives no answer when one app sets its project from a variable",
93 files: {
94 "apps/mobile/app.config.ts": `export default {
95 extra: { eas: { projectId: process.env.EAS_PROJECT_ID } },
96 };`,
97 "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
98 },
99 expected: Option.none(),
100 },
101 {
102 case: "gives no answer when a config changes the project id after naming it",
103 files: {
104 "app.config.js": `export default ({ config }) => {
105 config.extra = { eas: { projectId: "${ID_A}" } };
106 if (process.env.APP_ENV === "development") {
107 config.extra.eas.projectId = process.env.DEV_PROJECT_ID;
108 }
109 return config;
110 };`,
111 },
112 expected: Option.none(),
113 },
114 {
115 case: "gives no answer when a spread after the project id may replace it",
116 files: {
117 "apps/mobile/app.config.js": `import { eas } from "./app.shared.js";
118 export default { extra: { eas: { projectId: "${ID_A}", ...eas } } };`,
119 },
120 expected: Option.none(),
121 },
122 {
123 case: "gives no answer when the config is assigned over after naming the project id",
124 files: {
125 "app.config.js": `export default ({ config }) => {
126 config.extra = { eas: { projectId: "${ID_A}" } };
127 Object.assign(config.extra.eas, JSON.parse(process.env.EAS_OVERRIDES ?? "{}"));
128 return config;
129 };`,
130 },
131 expected: Option.none(),
132 },
133 {
134 case: "gives no answer when a config spreads another file over app.json's project id",
135 files: {
136 "app.config.js": `module.exports = ({ config }) => ({ ...config, ...require("./app.local.js") });`,
137 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
138 },
139 expected: Option.none(),
140 },
141 {
142 case: "gives no answer when a dynamic config spreads nothing, which may or may not use app.json",
143 files: {
144 "app.config.js": `export default { name: "Mobile", slug: "mobile" };`,
145 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
146 },
147 expected: Option.none(),
148 },
149 {
150 case: "gives no answer when a config has more extra entries than are read",
151 files: {
152 "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: { ...config.extra, ${"extra: { ...config.extra, ".repeat(40)}${"}".repeat(40)} } });`,
153 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
154 },
155 expected: Option.none(),
156 },
157 {
158 case: "reads app.json's id through extra and eas entries that spread their own",
159 files: {
160 "app.config.js": `module.exports = ({ config }) => ({
161 ...config,
162 extra: { ...config?.["extra"], eas: { ...config.extra?.eas, build: { experimental: true } }, other: 1 },
163 });`,
164 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
165 },
166 expected: Option.some(ID_A),
167 },
168 {
169 case: "reads a project id that follows spreads",
170 files: {
171 "app.config.js": `export default ({ config }) => ({
172 ...config,
173 extra: { ...config.extra, eas: { ...(config.extra?.eas ?? {}), projectId: "${ID_A}" } },
174 });`,
175 },
176 expected: Option.some(ID_A),
177 },
178 {
179 case: "gives no answer when an app.json Expo reads as JSON5 cannot be decoded here",
180 files: {
181 "apps/mobile/app.json": `{
182 // the mobile app
183 "expo": { "extra": { "eas": { "projectId": "${ID_B}" } }, },
184 }`,
185 "apps/admin/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
186 },
187 expected: Option.none(),
188 },
189 {
190 case: "reads only the first static config, like Expo",
191 files: {
192 "app.config.json": JSON.stringify({ expo: { name: "Mobile" } }),
193 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
194 },
195 expected: Option.none(),
196 },
197 {
198 case: "does not guess between an unwrapped static config and a wrapped one",
199 files: {
200 "apps/mobile/app.config.json": JSON.stringify({ extra: { eas: { projectId: ID_A } } }),
201 "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
202 },
203 expected: Option.none(),
204 },
205 {
206 case: "uses app.json when the config never mentions a project id",
207 files: {
208 "app.config.js": `export default ({ config }) => ({ ...config, name: "Mobile" });`,
209 "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
210 },
211 expected: Option.some(ID_B),
212 },
213])("$case", ({ files, expected }) =>
214 Effect.gen(function* () {
215 expect(yield* findEasProjectId(yield* workspace(files))).toEqual(expected);
216 }).pipe(Effect.scoped, Effect.provide(layer)),
217);
218​
219it.effect(
220 "gives no answer when a comment ending in a non-LF line terminator precedes the rest of a joined id",
221 () =>
222 Effect.gen(function* () {
223 // A line comment ends at any ECMAScript line terminator, not only LF.
224 const root = yield* workspace({
225 "apps/mobile/app.config.ts":
226 `export default { extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // prefix\u2028` +
227 `+ "${ID_A.slice(9)}" } } };`,
228 "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
229 });
230 expect(yield* findEasProjectId(root)).toEqual(Option.none());
231 }).pipe(Effect.scoped, Effect.provide(layer)),
232);
233​
234it.effect("gives no answer when an app's config is there but cannot be read", () =>
235 Effect.gen(function* () {
236 const fs = yield* FileSystem.FileSystem;
237 const path = yield* Path.Path;
238 for (const file of ["app.json", "app.config.json"]) {
239 const root = yield* workspace({
240 "apps/two/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
241 });
242 // A directory where the file should be: it exists but cannot be read.
243 yield* fs.makeDirectory(path.join(root, "apps/one", file), { recursive: true });
244 expect(yield* findEasProjectId(root)).toEqual(Option.none());
245 }
246 }).pipe(Effect.scoped, Effect.provide(layer)),
247);
248​
249it.effect("does not guess between apps that name different EAS projects", () =>
250 Effect.gen(function* () {
251 const root = yield* workspace({
252 "apps/one/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }),
253 "apps/two/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }),
254 });
255 expect(yield* findEasProjectId(root)).toEqual(Option.none());
256 expect(yield* findEasProjectId(yield* workspace({ "README.md": "no app" }))).toEqual(
257 Option.none(),
258 );
259 }).pipe(Effect.scoped, Effect.provide(layer)),
260);