1// @effect-diagnostics nodeBuiltinImport:off - expectations mirror the host path joins the script under test performs.
2import * as NodePath from "node:path";
3import { assert, it } from "@effect/vitest";
4import { PNG } from "pngjs";
5
6import showcaseConfig, {
7 DEFAULT_SHOWCASE_THEME,
8 resolveShowcaseAndroidAbi,
9 SHOWCASE_THEMES,
10 type ShowcaseConfig,
11 type ShowcaseStoreAssetSpec,
12} from "./mobile-showcase.config.ts";
13import {
14 SHOWCASE_ENVIRONMENTS,
15 SHOWCASE_PROJECTS,
16 SHOWCASE_THREADS,
17} from "./mobile-showcase-environment.ts";
18import {
19 encodeAndroidPairingUrls,
20 normalizeStorePng,
21 parseShowcaseCliArgs,
22 parsePairingCredentialOutput,
23 planShowcaseCaptures,
24 readPngDimensions,
25 readPngMetadata,
26 resolveAndroidSdkRoot,
27 selectLanIpv4Address,
28 showcaseCaptureDirectory,
29 validateStoreAsset,
30 validateStoreAssetCount,
31} from "./mobile-showcase.ts";
32
33const appleSpec: ShowcaseStoreAssetSpec = {
34 store: "apple",
35 directory: "apple/iphone-test",
36 width: 1284,
37 height: 2778,
38 minimumUploadCount: 1,
39 maximumUploadCount: 10,
40};
41
42const googleSpec: ShowcaseStoreAssetSpec = {
43 store: "google-play",
44 directory: "google-play/phone",
45 width: 1080,
46 height: 1920,
47 minimumUploadCount: 2,
48 maximumUploadCount: 8,
49 maximumFileSizeBytes: 8 * 1024 * 1024,
50};
51
52const config: ShowcaseConfig = {
53 outputDirectory: "artifacts",
54 metroPort: 8199,
55 settleDelayMs: 1,
56 devices: [
57 {
58 id: "phone",
59 platform: "ios",
60 simulator: "iPhone Test",
61 appearance: "dark",
62 theme: "t3-code",
63 scenes: ["thread", "review"],
64 storeAsset: appleSpec,
65 },
66 {
67 id: "pixel",
68 platform: "android",
69 avd: "Pixel_Test",
70 appearance: "light",
71 theme: "t3-code",
72 scenes: ["thread", "terminal"],
73 storeAsset: googleSpec,
74 },
75 ],
76};
77
78it("parses repeatable capture filters", () => {
79 const options = parseShowcaseCliArgs([
80 "--platform",
81 "ios",
82 "--device",
83 "phone",
84 "--scene",
85 "review",
86 "--appearance",
87 "both",
88 "--skip-build",
89 ]);
90 assert.deepStrictEqual([...options.platforms], ["ios"]);
91 assert.deepStrictEqual([...options.deviceIds], ["phone"]);
92 assert.deepStrictEqual([...options.scenes], ["review"]);
93 assert.deepStrictEqual([...options.appearances], ["light", "dark"]);
94 assert.equal(options.skipBuild, true);
95});
96
97it("rejects unsupported system appearances", () => {
98 assert.throws(
99 () => parseShowcaseCliArgs(["--appearance", "sepia"]),
100 /Unsupported appearance 'sepia'/u,
101 );
102});
103
104it("parses repeatable and expanded theme filters", () => {
105 assert.deepStrictEqual(
106 [...parseShowcaseCliArgs(["--theme", "ocean", "--theme", "ember"]).themes],
107 ["ocean", "ember"],
108 );
109 assert.deepStrictEqual(
110 [...parseShowcaseCliArgs(["--theme", "all"]).themes],
111 [...SHOWCASE_THEMES],
112 );
113});
114
115// The app normalizes an unknown id back to its default palette, so a typo here
116// would otherwise produce screenshots labeled with a theme they do not show.
117it("rejects unsupported themes instead of capturing the default palette", () => {
118 assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u);
119});
120
121it("parses validation-only mode", () => {
122 assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
123});
124
125it("selects an explicit CI Android ABI without changing the local default", () => {
126 assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a");
127 assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64");
128 assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u);
129});
130
131it("uses platform-correct default Android SDK roots", () => {
132 assert.equal(
133 resolveAndroidSdkRoot({ HOME: "/Users/showcase" }, "darwin"),
134 "/Users/showcase/Library/Android/sdk",
135 );
136 assert.equal(
137 resolveAndroidSdkRoot({ HOME: "/home/showcase" }, "linux"),
138 "/home/showcase/Android/Sdk",
139 );
140 assert.equal(
141 resolveAndroidSdkRoot(
142 { HOME: "/home/showcase", ANDROID_SDK_ROOT: "/opt/android-sdk" },
143 "linux",
144 ),
145 "/opt/android-sdk",
146 );
147});
148
149it("plans only scenes supported by each selected device", () => {
150 const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]);
151 const captures = planShowcaseCaptures(config, options);
152 assert.deepStrictEqual(
153 captures.map((capture) => ({
154 id: capture.device.id,
155 appearance: capture.appearance,
156 scenes: capture.scenes,
157 })),
158 [{ id: "pixel", appearance: "light", scenes: ["terminal"] }],
159 );
160});
161
162it("expands both appearances into independent upload-ready directories", () => {
163 const options = parseShowcaseCliArgs(["--device", "phone", "--appearance", "both"]);
164 const captures = planShowcaseCaptures(config, options);
165
166 assert.deepStrictEqual(
167 captures.map((capture) => ({
168 appearance: capture.appearance,
169 directory: showcaseCaptureDirectory("/captures", capture),
170 })),
171 [
172 {
173 appearance: "light",
174 directory: NodePath.join("/captures", "apple", "iphone-test", "light", "t3-code"),
175 },
176 {
177 appearance: "dark",
178 directory: NodePath.join("/captures", "apple", "iphone-test", "dark", "t3-code"),
179 },
180 ],
181 );
182});
183
184// Every palette needs its own leaf folder: one directory holding several themes
185// would mix upload slots and break the per-store screenshot count limits.
186it("expands themes into independent upload-ready directories per appearance", () => {
187 const options = parseShowcaseCliArgs([
188 "--device",
189 "phone",
190 "--appearance",
191 "both",
192 "--theme",
193 "ocean",
194 "--theme",
195 "ember",
196 ]);
197
198 assert.deepStrictEqual(
199 planShowcaseCaptures(config, options).map((capture) =>
200 showcaseCaptureDirectory("/captures", capture),
201 ),
202 [
203 NodePath.join("/captures", "apple", "iphone-test", "light", "ocean"),
204 NodePath.join("/captures", "apple", "iphone-test", "light", "ember"),
205 NodePath.join("/captures", "apple", "iphone-test", "dark", "ocean"),
206 NodePath.join("/captures", "apple", "iphone-test", "dark", "ember"),
207 ],
208 );
209});
210
211it("rejects unknown devices instead of silently capturing another target", () => {
212 const options = parseShowcaseCliArgs(["--device", "missing"]);
213 assert.throws(() => planShowcaseCaptures(config, options), /Unknown device 'missing'/u);
214});
215
216it("reads captured PNG dimensions from the IHDR header", () => {
217 const bytes = new Uint8Array(26);
218 bytes.set([137, 80, 78, 71, 13, 10, 26, 10]);
219 const view = new DataView(bytes.buffer);
220 view.setUint32(16, 1320);
221 view.setUint32(20, 2868);
222 view.setUint8(24, 8);
223 view.setUint8(25, 2);
224 assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 });
225 assert.deepStrictEqual(readPngMetadata(bytes), {
226 width: 1320,
227 height: 2868,
228 bitDepth: 8,
229 colorType: 2,
230 hasAlpha: false,
231 });
232});
233
234function rgbaPng(width: number, height: number): Buffer {
235 const png = new PNG({ width, height });
236 png.data.fill(255);
237 return PNG.sync.write(png);
238}
239
240it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => {
241 const normalized = normalizeStorePng(rgbaPng(2, 3));
242 assert.deepStrictEqual(readPngMetadata(normalized), {
243 width: 2,
244 height: 3,
245 bitDepth: 8,
246 colorType: 2,
247 hasAlpha: false,
248 });
249});
250
251it("validates exact Apple and Google Play upload assets", () => {
252 const apple = normalizeStorePng(rgbaPng(appleSpec.width, appleSpec.height));
253 const google = normalizeStorePng(rgbaPng(googleSpec.width, googleSpec.height));
254 assert.equal(validateStoreAsset(appleSpec, apple).width, 1284);
255 assert.equal(validateStoreAsset(googleSpec, google).height, 1920);
256});
257
258it("rejects wrong dimensions and alpha-bearing PNGs", () => {
259 const wrongSize = normalizeStorePng(rgbaPng(1242, 2688));
260 assert.throws(() => validateStoreAsset(appleSpec, wrongSize), /requires 1284×2778/u);
261 assert.throws(() => validateStoreAsset(appleSpec, rgbaPng(1284, 2778)), /without alpha/u);
262});
263
264it("enforces store screenshot count limits", () => {
265 assert.doesNotThrow(() => validateStoreAssetCount(googleSpec, 5, true));
266 assert.throws(() => validateStoreAssetCount(googleSpec, 1, true), /requires at least 2/u);
267 assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
268});
269
270it("defaults every device to the app's own palette", () => {
271 assert.equal(DEFAULT_SHOWCASE_THEME, "t3-code");
272 assert.equal(
273 showcaseConfig.devices.every((device) => device.theme === DEFAULT_SHOWCASE_THEME),
274 true,
275 );
276});
277
278it("configures every default device with an exact upload-ready store target", () => {
279 assert.deepStrictEqual(
280 showcaseConfig.devices.map((device) => [
281 device.id,
282 device.platform === "ios" ? (device.orientation ?? "portrait") : null,
283 device.storeAsset.directory,
284 device.storeAsset.width,
285 device.storeAsset.height,
286 ]),
287 [
288 ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868],
289 ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778],
290 ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064],
291 ["pixel", null, "google-play/phone", 1080, 1920],
292 ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920],
293 ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560],
294 ],
295 );
296});
297
298it("selects a reachable LAN IPv4 address", () => {
299 assert.equal(
300 selectLanIpv4Address([
301 { address: "127.0.0.1", family: "IPv4", internal: true },
302 { address: "fe80::1", family: "IPv6", internal: false },
303 { address: "169.254.2.4", family: "IPv4", internal: false },
304 { address: "192.168.1.80", family: "IPv4", internal: false },
305 ]),
306 "192.168.1.80",
307 );
308});
309
310it("seeds a playful multi-environment project spectrum", () => {
311 assert.deepStrictEqual(
312 SHOWCASE_PROJECTS.map((project) => project.title),
313 ["T3 Code", "React", "Linux"],
314 );
315 assert.deepStrictEqual(
316 SHOWCASE_ENVIRONMENTS.map((environment) => environment.label),
317 ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"],
318 );
319 assert.equal(SHOWCASE_THREADS.length, 9);
320 assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3);
321 const snoozedThreads = SHOWCASE_THREADS.filter((thread) => "snoozeMinutes" in thread);
322 assert.equal(snoozedThreads.length, 2);
323 assert.deepStrictEqual(
324 snoozedThreads.map((thread) => thread.id),
325 ["hydration-haikus", "patient-penguins"],
326 );
327 assert.equal(new Set(snoozedThreads.map((thread) => thread.snoozeMinutes)).size, 2);
328 for (const thread of snoozedThreads) {
329 assert.equal(thread.response !== null, true, `${thread.title} is not completed`);
330 assert.equal("state" in thread, false, `${thread.title} is blocked or working`);
331 assert.equal("settled" in thread, false, `${thread.title} is settled`);
332 assert.equal(thread.snoozeMinutes > 60, true, `${thread.title} wakes too soon`);
333 }
334 const primaryThread = SHOWCASE_THREADS.find((thread) => thread.id === "remote-command-center");
335 assert.equal(primaryThread !== undefined && !("snoozeMinutes" in primaryThread), true);
336 // Every project contributes to both the active block and the settled tail,
337 // so each list scope screenshots with the same two-part structure.
338 for (const project of SHOWCASE_PROJECTS) {
339 const projectThreads = SHOWCASE_THREADS.filter((thread) => thread.projectId === project.id);
340 assert.equal(
341 projectThreads.some((thread) => "settled" in thread && thread.settled),
342 true,
343 `${project.title} has no settled thread`,
344 );
345 assert.equal(
346 projectThreads.some((thread) => !("settled" in thread && thread.settled)),
347 true,
348 `${project.title} has no active thread`,
349 );
350 }
351 assert.equal(
352 SHOWCASE_PROJECTS.every((project) => project.favicon.includes("<svg")),
353 true,
354 );
355});
356
357it("reads multiline JSON from the pairing CLI", () => {
358 assert.equal(
359 parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'),
360 "PAIR-ME",
361 );
362});
363
364it("encodes Android pairing URLs without shell-sensitive JSON quotes", () => {
365 const urls = ["http://10.0.2.2:65164/#token=ONE", "http://10.0.2.2:65198/#token=TWO"];
366 const encoded = encodeAndroidPairingUrls(urls);
367 assert.equal(encoded.startsWith("json-uri:"), true);
368 assert.deepStrictEqual(JSON.parse(decodeURIComponent(encoded.slice("json-uri:".length))), urls);
369 assert.equal(encoded.includes('"'), false);
370});