@specific.dev/spectest 0.78.0 → 0.79.1

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.
@@ -0,0 +1,227 @@
1
+ // What `expo()` builds from a real app directory: which package manager it
2
+ // installs with, which Node it builds on, and the order of the Dockerfile
3
+ // steps. The order is what makes a source edit cheap, so it is the part a
4
+ // test has to hold: an install that moves below the source COPY runs again
5
+ // on every edit, and nothing about the environment looks wrong when it does.
6
+
7
+ import { afterEach, afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { expo, nodeMajorFromRange } from "./expo.js";
13
+ import type { ServiceConfig } from "../index.js";
14
+
15
+ let root = "";
16
+ const prevWorkspace = process.env.SPECTEST_WORKSPACE;
17
+
18
+ beforeAll(() => {
19
+ root = mkdtempSync(join(tmpdir(), "expo-app-"));
20
+ process.env.SPECTEST_WORKSPACE = root;
21
+ });
22
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
23
+ afterAll(() => {
24
+ if (prevWorkspace === undefined) delete process.env.SPECTEST_WORKSPACE;
25
+ else process.env.SPECTEST_WORKSPACE = prevWorkspace;
26
+ rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ /** Write an app into the workspace. `files` is path → content. */
30
+ function app(files: Record<string, string>, dir = "."): void {
31
+ for (const [rel, content] of Object.entries(files)) {
32
+ const abs = join(root, dir, rel);
33
+ mkdirSync(join(abs, ".."), { recursive: true });
34
+ writeFileSync(abs, content);
35
+ }
36
+ }
37
+
38
+ const pkg = (extra: Record<string, unknown> = {}): string =>
39
+ JSON.stringify({ name: "app", dependencies: { expo: "51.0.0" }, ...extra });
40
+
41
+ /** The generated Dockerfile of a service. */
42
+ function dockerfile(svc: ServiceConfig): string {
43
+ const image = svc.image as { content?: string };
44
+ if (image.content === undefined) throw new Error("service does not build a generated Dockerfile");
45
+ return image.content;
46
+ }
47
+
48
+ /** Line index of the first line that holds `needle`. */
49
+ function lineOf(text: string, needle: string): number {
50
+ const i = text.split("\n").findIndex((l) => l.includes(needle));
51
+ if (i < 0) throw new Error(`no line with ${needle} in:\n${text}`);
52
+ return i;
53
+ }
54
+
55
+ describe("expo() Dockerfile caching", () => {
56
+ test("installs from the manifests before it copies the source", () => {
57
+ app({ "package.json": pkg(), "package-lock.json": "{}", "App.js": "x" });
58
+ const df = dockerfile(expo());
59
+
60
+ // The manifests, the install, then everything else. An edit to App.js
61
+ // invalidates only the last two steps.
62
+ expect(lineOf(df, "COPY package.json package-lock.json ./")).toBeLessThan(lineOf(df, "npm ci"));
63
+ expect(lineOf(df, "npm ci")).toBeLessThan(lineOf(df, "COPY . ."));
64
+ expect(lineOf(df, "COPY . .")).toBeLessThan(lineOf(df, "expo export"));
65
+ });
66
+
67
+ test("keeps the package store and the Metro cache across builds", () => {
68
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
69
+ const df = dockerfile(expo());
70
+
71
+ expect(df).toContain("RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci");
72
+ expect(df).toContain("--mount=type=cache,target=/tmp/metro-cache");
73
+ });
74
+
75
+ test("bakes EXPO_PUBLIC_* after the source, so an edit to one only exports again", () => {
76
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
77
+ const df = dockerfile(expo({ env: { EXPO_PUBLIC_API: "https://api.test", OTHER: "no" } }));
78
+
79
+ expect(lineOf(df, "npm ci")).toBeLessThan(lineOf(df, "ENV EXPO_PUBLIC_API"));
80
+ expect(lineOf(df, "ENV EXPO_PUBLIC_API")).toBeLessThan(lineOf(df, "expo export"));
81
+ // Only the public ones are baked; the rest are container env.
82
+ expect(df).not.toContain("OTHER");
83
+ });
84
+
85
+ test("copies patches, because the install applies them", () => {
86
+ app({
87
+ "package.json": pkg(),
88
+ "package-lock.json": "{}",
89
+ "patches/expo+51.0.0.patch": "diff",
90
+ });
91
+ expect(dockerfile(expo())).toContain("COPY package.json package-lock.json patches ./");
92
+ });
93
+
94
+ test("holds the host's node_modules and the old export out of the context", () => {
95
+ app({ "package.json": pkg() });
96
+ const image = expo().image as { context?: string; exclude?: readonly string[] };
97
+ expect(image.context).toBe(".");
98
+ expect(image.exclude).toContain("node_modules");
99
+ expect(image.exclude).toContain("dist");
100
+ });
101
+ });
102
+
103
+ describe("expo() package manager", () => {
104
+ test("npm installs from the lockfile, and without one it resolves", () => {
105
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
106
+ expect(dockerfile(expo())).toContain("npm ci");
107
+
108
+ rmSync(join(root, "package-lock.json"));
109
+ expect(dockerfile(expo())).toContain("npm install");
110
+ });
111
+
112
+ test("pnpm comes from its lockfile, with Corepack and its own store", () => {
113
+ app({ "package.json": pkg(), "pnpm-lock.yaml": "lockfileVersion: 9" });
114
+ const df = dockerfile(expo());
115
+
116
+ expect(df).toContain("RUN corepack enable pnpm");
117
+ expect(df).toContain("pnpm install --store-dir /pnpm-store --frozen-lockfile");
118
+ expect(df).toContain("pnpm exec expo export");
119
+ });
120
+
121
+ test("yarn 1 uses the image's own yarn; yarn 2+ is a Corepack release", () => {
122
+ app({ "package.json": pkg(), "yarn.lock": "" });
123
+ const classic = dockerfile(expo());
124
+ expect(classic).toContain("yarn install --cache-folder /yarn-cache --frozen-lockfile");
125
+ expect(classic).not.toContain("corepack");
126
+
127
+ app({ "package.json": pkg({ packageManager: "yarn@4.5.0" }), ".yarnrc.yml": "nodeLinker: node-modules" });
128
+ const berry = dockerfile(expo());
129
+ expect(berry).toContain("corepack enable yarn");
130
+ expect(berry).toContain("yarn install --immutable");
131
+ expect(berry).toContain("ENV YARN_ENABLE_GLOBAL_CACHE=1");
132
+ });
133
+
134
+ test("bun is installed into the Node image", () => {
135
+ app({ "package.json": pkg(), "bun.lock": "" });
136
+ const df = dockerfile(expo());
137
+ expect(df).toContain("RUN npm install -g bun");
138
+ expect(df).toContain("bun install --frozen-lockfile");
139
+ expect(df).toContain("bunx expo export");
140
+ });
141
+
142
+ test("the packageManager field wins over a stray lockfile", () => {
143
+ app({ "package.json": pkg({ packageManager: "pnpm@9.1.0" }), "package-lock.json": "{}" });
144
+ expect(dockerfile(expo())).toContain("pnpm install");
145
+ });
146
+
147
+ test("the option wins over both", () => {
148
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
149
+ expect(dockerfile(expo({ packageManager: "yarn" }))).toContain("yarn install");
150
+ });
151
+ });
152
+
153
+ describe("expo() Node version", () => {
154
+ test("takes the version the app declares", () => {
155
+ app({ "package.json": pkg({ engines: { node: ">=22" } }) });
156
+ expect(dockerfile(expo())).toContain("FROM node:22-bookworm-slim");
157
+
158
+ app({ "package.json": pkg(), ".nvmrc": "v20.11.1\n" });
159
+ expect(dockerfile(expo())).toContain("FROM node:20-bookworm-slim");
160
+ });
161
+
162
+ test("builds on 22 when the app declares nothing readable", () => {
163
+ app({ "package.json": pkg(), ".nvmrc": "lts/iron\n" });
164
+ expect(dockerfile(expo())).toContain("FROM node:22-bookworm-slim");
165
+ });
166
+
167
+ test("the option wins", () => {
168
+ app({ "package.json": pkg({ engines: { node: ">=22" } }) });
169
+ expect(dockerfile(expo({ nodeVersion: "20-alpine" }))).toContain("FROM node:20-alpine");
170
+ });
171
+
172
+ test("a range names what to build on, not what to avoid", () => {
173
+ // An upper bound says which version must NOT run, so it is not a choice.
174
+ expect(nodeMajorFromRange(">=18 <21")).toBe(18);
175
+ expect(nodeMajorFromRange("18 || 20 || 22")).toBe(22);
176
+ expect(nodeMajorFromRange("^20.9.0")).toBe(20);
177
+ // One version is one token — its first number is the major.
178
+ expect(nodeMajorFromRange("18.20.4")).toBe(18);
179
+ expect(nodeMajorFromRange("22.x")).toBe(22);
180
+ expect(nodeMajorFromRange("lts/*")).toBeNull();
181
+ });
182
+ });
183
+
184
+ describe("expo() with your own Dockerfile", () => {
185
+ test("builds the repo file from the app dir, and serves the documented path", () => {
186
+ app({ "package.json": pkg(), "Dockerfile": "FROM node:22\n" }, "mobile");
187
+ const svc = expo({ appDir: "mobile", dockerfile: "mobile/Dockerfile" });
188
+
189
+ expect(svc.image).toEqual({ type: "dockerfile", path: "mobile/Dockerfile", context: "mobile" });
190
+ expect(svc.command).toContain("/app/dist");
191
+ });
192
+
193
+ test("staticDir says where that build wrote the export", () => {
194
+ app({ "package.json": pkg(), "Dockerfile": "FROM nginx\n" });
195
+ const svc = expo({ dockerfile: "Dockerfile", staticDir: "/srv/web" });
196
+ expect(svc.command).toBe("node /spectest-expo-serve.mjs /srv/web 8081");
197
+ });
198
+
199
+ test("refuses a staticDir that depends on the image's WORKDIR", () => {
200
+ app({ "package.json": pkg(), "Dockerfile": "FROM nginx\n" });
201
+ expect(() => expo({ dockerfile: "Dockerfile", staticDir: "web" })).toThrow(/absolute path/);
202
+ });
203
+
204
+ test("refuses the options that describe the generated Dockerfile", () => {
205
+ app({ "package.json": pkg(), "Dockerfile": "FROM node:22\n" });
206
+ expect(() => expo({ dockerfile: "Dockerfile", nodeVersion: "22-alpine" })).toThrow(/nodeVersion/);
207
+ expect(() => expo({ dockerfile: "Dockerfile", env: { EXPO_PUBLIC_API: "x" } })).toThrow(/EXPO_PUBLIC/);
208
+ // `env` that Expo does not inline is the container's, and stays allowed.
209
+ expect(expo({ dockerfile: "Dockerfile", env: { API: "x" } }).env).toEqual({ API: "x" });
210
+ });
211
+
212
+ test("the static server stops on an image with no export in it", () => {
213
+ app({ "package.json": pkg() });
214
+ const server = expo().files!.find((f) => f.path.endsWith(".mjs"))!.content;
215
+ expect(server).toContain("holds no index.html");
216
+ expect(server).toContain("process.exit(1)");
217
+ });
218
+ });
219
+
220
+ describe("expo() app directory", () => {
221
+ test("names the directory when there is no app in it", () => {
222
+ app({ "package.json": pkg() });
223
+ expect(() => expo({ appDir: "mobile" })).toThrow(/appDir "mobile" is not in the project/);
224
+ app({ "readme.md": "x" }, "mobile");
225
+ expect(() => expo({ appDir: "mobile" })).toThrow(/no package.json in mobile/);
226
+ });
227
+ });