| 1 | // @effect-diagnostics nodeBuiltinImport:off - expectations mirror the host path joins the script under test performs. |
| 2 | import * as NodePath from "node:path"; |
| 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import { PNG } from "pngjs"; |
| 5 | |
| 6 | import showcaseConfig, { |
| 7 | DEFAULT_SHOWCASE_THEME, |
| 8 | resolveShowcaseAndroidAbi, |
| 9 | SHOWCASE_THEMES, |
| 10 | type ShowcaseConfig, |
| 11 | type ShowcaseStoreAssetSpec, |
| 12 | } from "./mobile-showcase.config.ts"; |
| 13 | import { |
| 14 | SHOWCASE_ENVIRONMENTS, |
| 15 | SHOWCASE_PROJECTS, |
| 16 | SHOWCASE_THREADS, |
| 17 | } from "./mobile-showcase-environment.ts"; |
| 18 | import { |
| 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 | |
| 33 | const appleSpec: ShowcaseStoreAssetSpec = { |
| 34 | store: "apple", |
| 35 | directory: "apple/iphone-test", |
| 36 | width: 1284, |
| 37 | height: 2778, |
| 38 | minimumUploadCount: 1, |
| 39 | maximumUploadCount: 10, |
| 40 | }; |
| 41 | |
| 42 | const 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 | |
| 52 | const 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 | |
| 78 | it("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 | |
| 97 | it("rejects unsupported system appearances", () => { |
| 98 | assert.throws( |
| 99 | () => parseShowcaseCliArgs(["--appearance", "sepia"]), |
| 100 | /Unsupported appearance 'sepia'/u, |
| 101 | ); |
| 102 | }); |
| 103 | |
| 104 | it("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. |
| 117 | it("rejects unsupported themes instead of capturing the default palette", () => { |
| 118 | assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u); |
| 119 | }); |
| 120 | |
| 121 | it("parses validation-only mode", () => { |
| 122 | assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); |
| 123 | }); |
| 124 | |
| 125 | it("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 | |
| 131 | it("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 | |
| 149 | it("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 | |
| 162 | it("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. |
| 186 | it("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 | |
| 211 | it("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 | |
| 216 | it("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 | |
| 234 | function 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 | |
| 240 | it("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 | |
| 251 | it("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 | |
| 258 | it("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 | |
| 264 | it("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 | |
| 270 | it("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 | |
| 278 | it("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 | |
| 298 | it("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 | |
| 310 | it("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 | |
| 357 | it("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 | |
| 364 | it("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 | }); |