t3-code-android-nightly/scripts/mobile-showcase.test.ts
Julius Marminge cc60753aa2
test: stop path and platform tests depending on the host OS (#9564)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-04 20:33:19 +00:00

370 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @effect-diagnostics nodeBuiltinImport:off - expectations mirror the host path joins the script under test performs.
import * as NodePath from "node:path";
import { assert, it } from "@effect/vitest";
import { PNG } from "pngjs";
import showcaseConfig, {
DEFAULT_SHOWCASE_THEME,
resolveShowcaseAndroidAbi,
SHOWCASE_THEMES,
type ShowcaseConfig,
type ShowcaseStoreAssetSpec,
} from "./mobile-showcase.config.ts";
import {
SHOWCASE_ENVIRONMENTS,
SHOWCASE_PROJECTS,
SHOWCASE_THREADS,
} from "./mobile-showcase-environment.ts";
import {
encodeAndroidPairingUrls,
normalizeStorePng,
parseShowcaseCliArgs,
parsePairingCredentialOutput,
planShowcaseCaptures,
readPngDimensions,
readPngMetadata,
resolveAndroidSdkRoot,
selectLanIpv4Address,
showcaseCaptureDirectory,
validateStoreAsset,
validateStoreAssetCount,
} from "./mobile-showcase.ts";
const appleSpec: ShowcaseStoreAssetSpec = {
store: "apple",
directory: "apple/iphone-test",
width: 1284,
height: 2778,
minimumUploadCount: 1,
maximumUploadCount: 10,
};
const googleSpec: ShowcaseStoreAssetSpec = {
store: "google-play",
directory: "google-play/phone",
width: 1080,
height: 1920,
minimumUploadCount: 2,
maximumUploadCount: 8,
maximumFileSizeBytes: 8 * 1024 * 1024,
};
const config: ShowcaseConfig = {
outputDirectory: "artifacts",
metroPort: 8199,
settleDelayMs: 1,
devices: [
{
id: "phone",
platform: "ios",
simulator: "iPhone Test",
appearance: "dark",
theme: "t3-code",
scenes: ["thread", "review"],
storeAsset: appleSpec,
},
{
id: "pixel",
platform: "android",
avd: "Pixel_Test",
appearance: "light",
theme: "t3-code",
scenes: ["thread", "terminal"],
storeAsset: googleSpec,
},
],
};
it("parses repeatable capture filters", () => {
const options = parseShowcaseCliArgs([
"--platform",
"ios",
"--device",
"phone",
"--scene",
"review",
"--appearance",
"both",
"--skip-build",
]);
assert.deepStrictEqual([...options.platforms], ["ios"]);
assert.deepStrictEqual([...options.deviceIds], ["phone"]);
assert.deepStrictEqual([...options.scenes], ["review"]);
assert.deepStrictEqual([...options.appearances], ["light", "dark"]);
assert.equal(options.skipBuild, true);
});
it("rejects unsupported system appearances", () => {
assert.throws(
() => parseShowcaseCliArgs(["--appearance", "sepia"]),
/Unsupported appearance 'sepia'/u,
);
});
it("parses repeatable and expanded theme filters", () => {
assert.deepStrictEqual(
[...parseShowcaseCliArgs(["--theme", "ocean", "--theme", "ember"]).themes],
["ocean", "ember"],
);
assert.deepStrictEqual(
[...parseShowcaseCliArgs(["--theme", "all"]).themes],
[...SHOWCASE_THEMES],
);
});
// The app normalizes an unknown id back to its default palette, so a typo here
// would otherwise produce screenshots labeled with a theme they do not show.
it("rejects unsupported themes instead of capturing the default palette", () => {
assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u);
});
it("parses validation-only mode", () => {
assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
});
it("selects an explicit CI Android ABI without changing the local default", () => {
assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a");
assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64");
assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u);
});
it("uses platform-correct default Android SDK roots", () => {
assert.equal(
resolveAndroidSdkRoot({ HOME: "/Users/showcase" }, "darwin"),
"/Users/showcase/Library/Android/sdk",
);
assert.equal(
resolveAndroidSdkRoot({ HOME: "/home/showcase" }, "linux"),
"/home/showcase/Android/Sdk",
);
assert.equal(
resolveAndroidSdkRoot(
{ HOME: "/home/showcase", ANDROID_SDK_ROOT: "/opt/android-sdk" },
"linux",
),
"/opt/android-sdk",
);
});
it("plans only scenes supported by each selected device", () => {
const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]);
const captures = planShowcaseCaptures(config, options);
assert.deepStrictEqual(
captures.map((capture) => ({
id: capture.device.id,
appearance: capture.appearance,
scenes: capture.scenes,
})),
[{ id: "pixel", appearance: "light", scenes: ["terminal"] }],
);
});
it("expands both appearances into independent upload-ready directories", () => {
const options = parseShowcaseCliArgs(["--device", "phone", "--appearance", "both"]);
const captures = planShowcaseCaptures(config, options);
assert.deepStrictEqual(
captures.map((capture) => ({
appearance: capture.appearance,
directory: showcaseCaptureDirectory("/captures", capture),
})),
[
{
appearance: "light",
directory: NodePath.join("/captures", "apple", "iphone-test", "light", "t3-code"),
},
{
appearance: "dark",
directory: NodePath.join("/captures", "apple", "iphone-test", "dark", "t3-code"),
},
],
);
});
// Every palette needs its own leaf folder: one directory holding several themes
// would mix upload slots and break the per-store screenshot count limits.
it("expands themes into independent upload-ready directories per appearance", () => {
const options = parseShowcaseCliArgs([
"--device",
"phone",
"--appearance",
"both",
"--theme",
"ocean",
"--theme",
"ember",
]);
assert.deepStrictEqual(
planShowcaseCaptures(config, options).map((capture) =>
showcaseCaptureDirectory("/captures", capture),
),
[
NodePath.join("/captures", "apple", "iphone-test", "light", "ocean"),
NodePath.join("/captures", "apple", "iphone-test", "light", "ember"),
NodePath.join("/captures", "apple", "iphone-test", "dark", "ocean"),
NodePath.join("/captures", "apple", "iphone-test", "dark", "ember"),
],
);
});
it("rejects unknown devices instead of silently capturing another target", () => {
const options = parseShowcaseCliArgs(["--device", "missing"]);
assert.throws(() => planShowcaseCaptures(config, options), /Unknown device 'missing'/u);
});
it("reads captured PNG dimensions from the IHDR header", () => {
const bytes = new Uint8Array(26);
bytes.set([137, 80, 78, 71, 13, 10, 26, 10]);
const view = new DataView(bytes.buffer);
view.setUint32(16, 1320);
view.setUint32(20, 2868);
view.setUint8(24, 8);
view.setUint8(25, 2);
assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 });
assert.deepStrictEqual(readPngMetadata(bytes), {
width: 1320,
height: 2868,
bitDepth: 8,
colorType: 2,
hasAlpha: false,
});
});
function rgbaPng(width: number, height: number): Buffer {
const png = new PNG({ width, height });
png.data.fill(255);
return PNG.sync.write(png);
}
it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => {
const normalized = normalizeStorePng(rgbaPng(2, 3));
assert.deepStrictEqual(readPngMetadata(normalized), {
width: 2,
height: 3,
bitDepth: 8,
colorType: 2,
hasAlpha: false,
});
});
it("validates exact Apple and Google Play upload assets", () => {
const apple = normalizeStorePng(rgbaPng(appleSpec.width, appleSpec.height));
const google = normalizeStorePng(rgbaPng(googleSpec.width, googleSpec.height));
assert.equal(validateStoreAsset(appleSpec, apple).width, 1284);
assert.equal(validateStoreAsset(googleSpec, google).height, 1920);
});
it("rejects wrong dimensions and alpha-bearing PNGs", () => {
const wrongSize = normalizeStorePng(rgbaPng(1242, 2688));
assert.throws(() => validateStoreAsset(appleSpec, wrongSize), /requires 1284×2778/u);
assert.throws(() => validateStoreAsset(appleSpec, rgbaPng(1284, 2778)), /without alpha/u);
});
it("enforces store screenshot count limits", () => {
assert.doesNotThrow(() => validateStoreAssetCount(googleSpec, 5, true));
assert.throws(() => validateStoreAssetCount(googleSpec, 1, true), /requires at least 2/u);
assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
});
it("defaults every device to the app's own palette", () => {
assert.equal(DEFAULT_SHOWCASE_THEME, "t3-code");
assert.equal(
showcaseConfig.devices.every((device) => device.theme === DEFAULT_SHOWCASE_THEME),
true,
);
});
it("configures every default device with an exact upload-ready store target", () => {
assert.deepStrictEqual(
showcaseConfig.devices.map((device) => [
device.id,
device.platform === "ios" ? (device.orientation ?? "portrait") : null,
device.storeAsset.directory,
device.storeAsset.width,
device.storeAsset.height,
]),
[
["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868],
["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778],
["ipad-13", "landscape", "apple/ipad-13", 2752, 2064],
["pixel", null, "google-play/phone", 1080, 1920],
["android-tablet-7", null, "google-play/tablet-7", 1080, 1920],
["android-tablet-10", null, "google-play/tablet-10", 1440, 2560],
],
);
});
it("selects a reachable LAN IPv4 address", () => {
assert.equal(
selectLanIpv4Address([
{ address: "127.0.0.1", family: "IPv4", internal: true },
{ address: "fe80::1", family: "IPv6", internal: false },
{ address: "169.254.2.4", family: "IPv4", internal: false },
{ address: "192.168.1.80", family: "IPv4", internal: false },
]),
"192.168.1.80",
);
});
it("seeds a playful multi-environment project spectrum", () => {
assert.deepStrictEqual(
SHOWCASE_PROJECTS.map((project) => project.title),
["T3 Code", "React", "Linux"],
);
assert.deepStrictEqual(
SHOWCASE_ENVIRONMENTS.map((environment) => environment.label),
["Moonbase Terminal", "Suspense Station", "Kernel Cabin"],
);
assert.equal(SHOWCASE_THREADS.length, 9);
assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3);
const snoozedThreads = SHOWCASE_THREADS.filter((thread) => "snoozeMinutes" in thread);
assert.equal(snoozedThreads.length, 2);
assert.deepStrictEqual(
snoozedThreads.map((thread) => thread.id),
["hydration-haikus", "patient-penguins"],
);
assert.equal(new Set(snoozedThreads.map((thread) => thread.snoozeMinutes)).size, 2);
for (const thread of snoozedThreads) {
assert.equal(thread.response !== null, true, `${thread.title} is not completed`);
assert.equal("state" in thread, false, `${thread.title} is blocked or working`);
assert.equal("settled" in thread, false, `${thread.title} is settled`);
assert.equal(thread.snoozeMinutes > 60, true, `${thread.title} wakes too soon`);
}
const primaryThread = SHOWCASE_THREADS.find((thread) => thread.id === "remote-command-center");
assert.equal(primaryThread !== undefined && !("snoozeMinutes" in primaryThread), true);
// Every project contributes to both the active block and the settled tail,
// so each list scope screenshots with the same two-part structure.
for (const project of SHOWCASE_PROJECTS) {
const projectThreads = SHOWCASE_THREADS.filter((thread) => thread.projectId === project.id);
assert.equal(
projectThreads.some((thread) => "settled" in thread && thread.settled),
true,
`${project.title} has no settled thread`,
);
assert.equal(
projectThreads.some((thread) => !("settled" in thread && thread.settled)),
true,
`${project.title} has no active thread`,
);
}
assert.equal(
SHOWCASE_PROJECTS.every((project) => project.favicon.includes("<svg")),
true,
);
});
it("reads multiline JSON from the pairing CLI", () => {
assert.equal(
parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'),
"PAIR-ME",
);
});
it("encodes Android pairing URLs without shell-sensitive JSON quotes", () => {
const urls = ["http://10.0.2.2:65164/#token=ONE", "http://10.0.2.2:65198/#token=TWO"];
const encoded = encodeAndroidPairingUrls(urls);
assert.equal(encoded.startsWith("json-uri:"), true);
assert.deepStrictEqual(JSON.parse(decodeURIComponent(encoded.slice("json-uri:".length))), urls);
assert.equal(encoded.includes('"'), false);
});