From 16dd4ac00d08bb7c9e058186aa290ae9220649ae Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 23 Mar 2026 20:38:14 -0400 Subject: [PATCH] platform inversion & refactors --- package.json | 4 +- src/api/BuildFlags.ts | 61 +++++++- src/api/fixtures/flags.yml | 12 ++ src/api/generateOverrides.ts | 88 ++++++++++++ src/api/index.ts | 2 + src/api/resolveFlagsToInvert.spec.ts | 58 ++++++++ src/api/resolveFlagsToInvert.ts | 48 +++---- src/api/tsParser.ts | 39 +----- src/api/tsPrinter.ts | 49 +------ src/api/types.ts | 2 +- src/babel-plugin/index.ts | 25 +++- src/cli/main.ts | 29 +++- src/config-plugin/index.ts | 86 +++++------- test/run-integration.sh | 3 + test/test-config-plugin-platform-inversion.js | 130 ++++++++++++++++++ 15 files changed, 467 insertions(+), 169 deletions(-) create mode 100644 test/test-config-plugin-platform-inversion.js diff --git a/package.json b/package.json index 5af333f..ca10e80 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,7 @@ "@babel/helper-plugin-utils": "^7.24.8", "yaml": "^2.5.1" }, - "peerDependencies": { - "typescript": "*" - }, + "peerDependencies": {}, "scripts": { "build": "tsc", "test": "npm run test:unit && EXPO_SDK_TARGET=51 ./test/run-integration.sh", diff --git a/src/api/BuildFlags.ts b/src/api/BuildFlags.ts index 0687806..2fabda5 100644 --- a/src/api/BuildFlags.ts +++ b/src/api/BuildFlags.ts @@ -1,10 +1,38 @@ -import { writeFile } from "fs/promises"; +import { writeFile, unlink } from "fs/promises"; +import { existsSync } from "fs"; import { FlagMap } from "./types"; import { resolve } from "path"; import { printAsTs } from "./tsPrinter"; import { getCIBranch } from "./ciHelpers"; import { hasMatch } from "./globUtil"; +export const platformPaths = (basePath: string) => { + const ext = basePath.endsWith(".ts") ? ".ts" : ".json"; + const stem = basePath.slice(0, -ext.length); + return { + ios: `${stem}.ios${ext}`, + android: `${stem}.android${ext}`, + }; +}; + +const cleanupStaleFiles = async ( + basePath: string, + mode: "single" | "platform" +) => { + const paths = platformPaths(basePath); + const toDelete = + mode === "single" + ? [paths.ios, paths.android] + : [basePath]; + + for (const p of toDelete) { + const resolved = resolve(p); + if (existsSync(resolved)) { + await unlink(resolved); + } + } +}; + export class BuildFlags { flags: FlagMap; @@ -55,6 +83,8 @@ export class BuildFlags { } async save(path: string) { + await cleanupStaleFiles(path, "single"); + if (path.endsWith(".json")) { const flags = JSON.stringify(this.flags, null, 2); await writeFile(resolve(path), flags); @@ -71,4 +101,33 @@ export class BuildFlags { "Invalid file extension in flags file for mergePath: expected .json or .ts" ); } + + async savePlatformSpecific( + basePath: string, + iosFlags: FlagMap, + androidFlags: FlagMap + ) { + await cleanupStaleFiles(basePath, "platform"); + + const paths = platformPaths(basePath); + + if (basePath.endsWith(".ts")) { + await writeFile(resolve(paths.ios), printAsTs(iosFlags)); + await writeFile(resolve(paths.android), printAsTs(androidFlags)); + return; + } + + if (basePath.endsWith(".json")) { + await writeFile(resolve(paths.ios), JSON.stringify(iosFlags, null, 2)); + await writeFile( + resolve(paths.android), + JSON.stringify(androidFlags, null, 2) + ); + return; + } + + throw new Error( + "Invalid file extension in flags file for mergePath: expected .json or .ts" + ); + } } diff --git a/src/api/fixtures/flags.yml b/src/api/fixtures/flags.yml index 98a7d89..1337154 100644 --- a/src/api/fixtures/flags.yml +++ b/src/api/fixtures/flags.yml @@ -22,3 +22,15 @@ flags: bundleId: - com.my.app.apple - com.my.app.android + platformInvertableFeature: + value: false + invertFor: + platform: + - ios + combinedInvertableFeature: + value: false + invertFor: + bundleId: + - com.my.app.special + platform: + - android diff --git a/src/api/generateOverrides.ts b/src/api/generateOverrides.ts index 0d62eae..a761b9d 100644 --- a/src/api/generateOverrides.ts +++ b/src/api/generateOverrides.ts @@ -1,5 +1,8 @@ +import type { ExpoConfig } from "@expo/config-types"; import { BuildFlags } from "./BuildFlags"; import { readConfig } from "./readConfig"; +import { resolveFlagsToInvert } from "./resolveFlagsToInvert"; +import { FlagMap } from "./types"; export const resolveEnabledFlagNames = async ({ flagsToEnable, @@ -21,6 +24,10 @@ export const resolveEnabledFlagNames = async ({ .map(([name]) => name); }; +/** + * Apply explicit flag overrides and write a single runtime module. + * Used by the CLI when the user provides +flag / -flag arguments. + */ export const generateOverrides = async ({ flagsToEnable, flagsToDisable, @@ -43,3 +50,84 @@ export const generateOverrides = async ({ } await flags.save(mergePath); }; + +const hasPlatformInversions = (flags: FlagMap) => + Object.values(flags).some( + (config) => config.invertFor?.platform?.length + ); + +const resolveForPlatform = async ( + defaultFlags: FlagMap, + platform: "ios" | "android", + expoConfig?: ExpoConfig +): Promise => { + const flags = new BuildFlags(structuredClone(defaultFlags)); + const invertable = await resolveFlagsToInvert(expoConfig, platform); + if (invertable.flagsToEnable.size > 0) { + flags.enable(invertable.flagsToEnable); + } + if (invertable.flagsToDisable.size > 0) { + flags.disable(invertable.flagsToDisable); + } + return flags; +}; + +const flagMapsEqual = (a: FlagMap, b: FlagMap) => { + const keysA = Object.keys(a).sort(); + const keysB = Object.keys(b).sort(); + if (keysA.length !== keysB.length) return false; + return keysA.every( + (key, i) => key === keysB[i] && a[key].value === b[key].value + ); +}; + +/** + * Resolve the source-of-truth flags from flags.yml, including platform + * inversions and bundleId inversions. Writes platform-specific files + * (.ios.ts / .android.ts) when flags differ between platforms, otherwise + * writes a single file. Used by the CLI with no override args and by the + * config plugin. + */ +export const generateSourceOfTruth = async ({ + expoConfig, + enableBranchFlags, + envFlagsToEnable, +}: { + expoConfig?: ExpoConfig; + enableBranchFlags?: boolean; + envFlagsToEnable?: Set; +} = {}) => { + const { mergePath, flags: defaultFlags } = await readConfig(); + + const iosFlags = await resolveForPlatform(defaultFlags, "ios", expoConfig); + const androidFlags = await resolveForPlatform( + defaultFlags, + "android", + expoConfig + ); + + if (enableBranchFlags) { + iosFlags.enableBranchFlags(); + androidFlags.enableBranchFlags(); + } + + if (envFlagsToEnable && envFlagsToEnable.size > 0) { + iosFlags.enable(envFlagsToEnable); + androidFlags.enable(envFlagsToEnable); + } + + if ( + hasPlatformInversions(defaultFlags) && + !flagMapsEqual(iosFlags.flags, androidFlags.flags) + ) { + await iosFlags.savePlatformSpecific( + mergePath, + iosFlags.flags, + androidFlags.flags + ); + return; + } + + // No platform differences — write single file (use ios, they're equal) + await iosFlags.save(mergePath); +}; diff --git a/src/api/index.ts b/src/api/index.ts index a5c1425..d5dc2be 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,5 +1,6 @@ import { generateOverrides, + generateSourceOfTruth, resolveEnabledFlagNames, } from "./generateOverrides"; import { resolveFlagsToInvert } from "./resolveFlagsToInvert"; @@ -7,6 +8,7 @@ import { readConfig } from "./readConfig"; export { generateOverrides, + generateSourceOfTruth, resolveEnabledFlagNames, resolveFlagsToInvert, readConfig, diff --git a/src/api/resolveFlagsToInvert.spec.ts b/src/api/resolveFlagsToInvert.spec.ts index b2ee85a..165bd87 100644 --- a/src/api/resolveFlagsToInvert.spec.ts +++ b/src/api/resolveFlagsToInvert.spec.ts @@ -46,4 +46,62 @@ describe("resolveFlagsToInvert", () => { expect(result.flagsToEnable.has("invertableFeature")).toBe(true); }); + + describe("platform inversion", () => { + it("should invert when platform matches", async () => { + const result = await resolveFlagsToInvert(undefined, "ios"); + + expect(result.flagsToEnable.has("platformInvertableFeature")).toBe(true); + }); + + it("should not invert when platform does not match", async () => { + const result = await resolveFlagsToInvert(undefined, "android"); + + expect(result.flagsToEnable.has("platformInvertableFeature")).toBe(false); + }); + + it("should not invert when no platform is provided", async () => { + const result = await resolveFlagsToInvert(); + + expect(result.flagsToEnable.has("platformInvertableFeature")).toBe(false); + }); + }); + + describe("OR composition (bundleId + platform)", () => { + it("should invert when bundleId matches but platform does not", async () => { + const result = await resolveFlagsToInvert( + { ios: { bundleIdentifier: "com.my.app.special" } } as any, + "ios" + ); + + expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(true); + }); + + it("should invert when platform matches but bundleId does not", async () => { + const result = await resolveFlagsToInvert( + { ios: { bundleIdentifier: "com.other.app" } } as any, + "android" + ); + + expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(true); + }); + + it("should invert when both bundleId and platform match", async () => { + const result = await resolveFlagsToInvert( + { android: { package: "com.my.app.special" } } as any, + "android" + ); + + expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(true); + }); + + it("should not invert when neither bundleId nor platform matches", async () => { + const result = await resolveFlagsToInvert( + { ios: { bundleIdentifier: "com.other.app" } } as any, + "ios" + ); + + expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(false); + }); + }); }); diff --git a/src/api/resolveFlagsToInvert.ts b/src/api/resolveFlagsToInvert.ts index 6922214..68576cd 100644 --- a/src/api/resolveFlagsToInvert.ts +++ b/src/api/resolveFlagsToInvert.ts @@ -1,33 +1,12 @@ import type { ExpoConfig } from "@expo/config-types"; -import { BuildFlags } from "./BuildFlags"; import { readConfig } from "./readConfig"; import { InvertableFlagTuple } from "./types"; -export const generateOverrides = async ({ - flagsToEnable, - flagsToDisable, - enableBranchFlags, -}: { - flagsToEnable?: Set; - flagsToDisable?: Set; - enableBranchFlags?: boolean; -}) => { - const { mergePath, flags: defaultFlags } = await readConfig(); - const flags = new BuildFlags(defaultFlags); - if (enableBranchFlags) { - flags.enableBranchFlags(); - } - if (flagsToEnable) { - flags.enable(flagsToEnable); - } - if (flagsToDisable) { - flags.disable(flagsToDisable); - } - await flags.save(mergePath); -}; - -export const resolveFlagsToInvert = async (expoConfig: ExpoConfig) => { +export const resolveFlagsToInvert = async ( + expoConfig?: ExpoConfig, + platform?: "ios" | "android" +) => { const { flags } = await readConfig(); const invertable = Object.entries(flags).filter( (tuple): tuple is InvertableFlagTuple => !!tuple[1].invertFor @@ -42,20 +21,31 @@ export const resolveFlagsToInvert = async (expoConfig: ExpoConfig) => { invertable.forEach(([flagName, flagConfig]) => { const invertFor = flagConfig.invertFor; + let shouldInvert = false; - if (invertFor.bundleId) { + if (invertFor.bundleId && expoConfig) { const bundleIds = [ expoConfig.ios?.bundleIdentifier, expoConfig.android?.package, ].filter(Boolean); if ( - !bundleIds.length || - !invertFor.bundleId.find((bundleId) => bundleIds.includes(bundleId)) + bundleIds.length && + invertFor.bundleId.some((id) => bundleIds.includes(id)) ) { - return; + shouldInvert = true; } } + if (invertFor.platform && platform) { + if (invertFor.platform.includes(platform)) { + shouldInvert = true; + } + } + + if (!shouldInvert) { + return; + } + if (flagConfig.value) { flagsToDisable.add(flagName); } else { diff --git a/src/api/tsParser.ts b/src/api/tsParser.ts index 7e98d8a..a12d93f 100644 --- a/src/api/tsParser.ts +++ b/src/api/tsParser.ts @@ -1,43 +1,12 @@ import { readFileSync } from "node:fs"; -import ts from "typescript"; export const parseTsConstantsModule = (sourcePath: string) => { const source = readFileSync(sourcePath, "utf-8"); - const sourceFile = ts.createSourceFile( - "temp.ts", - source, - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.TS - ); - const flags: Record = {}; - - const firstStatement = sourceFile.statements[0]; - if (!ts.isVariableStatement(firstStatement)) { - throw new Error("Expected first statement to be a variable statement"); + const pattern = /(\w+)\s*:\s*(true|false)/g; + let match; + while ((match = pattern.exec(source)) !== null) { + flags[match[1]] = match[2] === "true"; } - - const firstDeclaration = firstStatement.declarationList.declarations[0]; - - if ( - (ts.isIdentifier(firstDeclaration.name) && - firstDeclaration.name.escapedText !== "BuildFlags") || - !firstDeclaration.initializer || - !ts.isObjectLiteralExpression(firstDeclaration.initializer) - ) { - throw new Error( - "Expected an exported object literal flags mapping named 'BuildFlags'" - ); - } - - firstDeclaration.initializer.properties.forEach((property) => { - if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) { - const name = property.name.escapedText as string; - const value = ts.SyntaxKind.TrueKeyword === property.initializer.kind; - flags[name] = value; - } - }); - return flags; }; diff --git a/src/api/tsPrinter.ts b/src/api/tsPrinter.ts index 6148e0a..15f569c 100644 --- a/src/api/tsPrinter.ts +++ b/src/api/tsPrinter.ts @@ -1,48 +1,9 @@ -import ts from "typescript"; import { FlagMap } from "./types"; export const printAsTs = (flags: FlagMap) => { - const nodes = ts.factory.createNodeArray([ - ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - "BuildFlags", - undefined, - undefined, - ts.factory.createObjectLiteralExpression( - Object.keys(flags) - .sort() - .map((key) => - ts.factory.createPropertyAssignment( - key, - flags[key].value - ? ts.factory.createTrue() - : ts.factory.createFalse() - ) - ), - true - ) - ), - ], - ts.NodeFlags.Const - ) - ), - ]); - - return print(nodes); + const entries = Object.keys(flags) + .sort() + .map((key) => ` ${key}: ${flags[key].value}`) + .join(",\n"); + return `export const BuildFlags = {\n${entries}\n};\n`; }; - -function print(nodes: ts.NodeArray) { - const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); - const resultFile = ts.createSourceFile( - "temp.ts", - "", - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.TSX - ); - - return printer.printList(ts.ListFormat.MultiLine, nodes, resultFile); -} diff --git a/src/api/types.ts b/src/api/types.ts index 1cb9f6e..a32f1ff 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,4 @@ -type InvertMatchers = { bundleId?: string[] }; +type InvertMatchers = { bundleId?: string[]; platform?: ("ios" | "android")[] }; type OTAFilter = { branches: string[] }; type ModuleConfig = string | { branch: string }; export type FlagConfig = { diff --git a/src/babel-plugin/index.ts b/src/babel-plugin/index.ts index 00374e6..2f5e8a3 100644 --- a/src/babel-plugin/index.ts +++ b/src/babel-plugin/index.ts @@ -1,6 +1,22 @@ import { declare } from "@babel/helper-plugin-utils"; +import { existsSync } from "fs"; import type * as BabelT from "babel__core"; import { parseTsConstantsModule } from "../api/tsParser"; +import { platformPaths } from "../api/BuildFlags"; + +const resolveFlagsModule = ( + flagsModule: string, + platform?: string +): string => { + if (platform) { + const paths = platformPaths(flagsModule); + const platformPath = platform === "ios" ? paths.ios : paths.android; + if (existsSync(platformPath)) { + return platformPath; + } + } + return flagsModule; +}; export default declare((babel, options, cwd) => { if (process.env.NODE_ENV === "test") { @@ -26,7 +42,14 @@ export default declare((babel, options, cwd) => { ); } - const flags = parseTsConstantsModule(options.flagsModule); + let callerPlatform: string | undefined; + babel.caller?.((caller: any) => { + callerPlatform = caller?.platform; + return ""; + }); + + const resolvedModule = resolveFlagsModule(options.flagsModule, callerPlatform); + const flags = parseTsConstantsModule(resolvedModule); const baseModulePath = options.flagsModule .split("/") .filter((segment) => segment !== ".." && segment !== ".") diff --git a/src/cli/main.ts b/src/cli/main.ts index 69c117f..f563dec 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -2,7 +2,10 @@ import YAML from "yaml"; import { existsSync } from "fs"; import { readFile, writeFile } from "fs/promises"; import { BuildFlags } from "../api/BuildFlags"; -import { generateOverrides } from "../api/generateOverrides"; +import { + generateOverrides, + generateSourceOfTruth, +} from "../api/generateOverrides"; export const shouldSkip = (envKey: string | undefined): boolean => { if (envKey && process.env[envKey] !== undefined) { @@ -106,16 +109,28 @@ const run = async () => { } if (command === "override") { - await generateOverrides({ flagsToEnable, flagsToDisable }); + const hasExplicitOverrides = + flagsToEnable.size > 0 || flagsToDisable.size > 0; + if (hasExplicitOverrides) { + await generateOverrides({ flagsToEnable, flagsToDisable }); + } else { + await generateSourceOfTruth(); + } return; } if (command === "ota-override") { - await generateOverrides({ - flagsToEnable, - flagsToDisable, - enableBranchFlags: true, - }); + const hasExplicitOverrides = + flagsToEnable.size > 0 || flagsToDisable.size > 0; + if (hasExplicitOverrides) { + await generateOverrides({ + flagsToEnable, + flagsToDisable, + enableBranchFlags: true, + }); + } else { + await generateSourceOfTruth({ enableBranchFlags: true }); + } return; } diff --git a/src/config-plugin/index.ts b/src/config-plugin/index.ts index 20eec1b..bdab6cb 100644 --- a/src/config-plugin/index.ts +++ b/src/config-plugin/index.ts @@ -8,7 +8,7 @@ import { } from "@expo/config-plugins"; import { ExpoConfig } from "@expo/config-types"; import { - generateOverrides, + generateSourceOfTruth, resolveEnabledFlagNames, resolveFlagsToInvert, } from "../api"; @@ -18,24 +18,25 @@ import { mergeSets } from "../api/mergeSets"; type NativeFlagPluginProps = { envFlags: string[]; expoConfig: ExpoConfig }; -let cachedResolvedFlags: string[] | null = null; +const cachedResolvedFlags: Record = {}; const resolveAllEnabledFlags = async ( envFlags: string[], - expoConfig: ExpoConfig + expoConfig: ExpoConfig, + platform: "ios" | "android" ): Promise => { - if (cachedResolvedFlags) { - return cachedResolvedFlags; + if (cachedResolvedFlags[platform]) { + return cachedResolvedFlags[platform]; } let flagsToEnable = new Set(envFlags); - const invertable = await resolveFlagsToInvert(expoConfig); + const invertable = await resolveFlagsToInvert(expoConfig, platform); if (invertable.flagsToEnable.size > 0) { flagsToEnable = mergeSets(flagsToEnable, invertable.flagsToEnable); } - cachedResolvedFlags = await resolveEnabledFlagNames({ + cachedResolvedFlags[platform] = await resolveEnabledFlagNames({ flagsToEnable, flagsToDisable: invertable.flagsToDisable, }); - return cachedResolvedFlags; + return cachedResolvedFlags[platform]; }; const withAndroidBuildFlags: ConfigPlugin = ( @@ -54,7 +55,8 @@ const withAndroidBuildFlags: ConfigPlugin = ( const resolvedFlags = await resolveAllEnabledFlags( props.envFlags, - props.expoConfig + props.expoConfig, + "android" ); const meta = mainApplication["meta-data"]; @@ -79,61 +81,49 @@ const withAppleBuildFlags: ConfigPlugin = ( return withInfoPlist(config, async (config) => { const resolvedFlags = await resolveAllEnabledFlags( props.envFlags, - props.expoConfig + props.expoConfig, + "ios" ); config.modResults.EXBuildFlags = resolvedFlags; return config; }); }; -type BundlePluginProps = { flags: string[] }; +type BundlePluginProps = { flags: string[]; expoConfig: ExpoConfig }; -const createCrossPlatformMod = +let bundleFlagsGenerated = false; + +const createBundleFlagsMod = ({ - config, props, }: { - config: ExpoConfig; props: BundlePluginProps; }): Mod => async (modConfig) => { - const { flags } = props; - let flagsToEnable = new Set(flags); - const invertable = await resolveFlagsToInvert(config); - if (invertable.flagsToEnable.size > 0) { - flagsToEnable = mergeSets(flagsToEnable, invertable.flagsToEnable); + if (bundleFlagsGenerated) { + return modConfig; } - await generateOverrides({ - flagsToEnable, - flagsToDisable: invertable.flagsToDisable, + bundleFlagsGenerated = true; + + const envFlagsToEnable = new Set(props.flags); + + await generateSourceOfTruth({ + expoConfig: props.expoConfig, + envFlagsToEnable: envFlagsToEnable.size > 0 ? envFlagsToEnable : undefined, }); return modConfig; }; -const withAndroidBundleBuildFlags: ConfigPlugin = ( - config, - props -) => { - return withDangerousMod(config, [ - "android", - createCrossPlatformMod({ config, props }), - ]); -}; - -const withAppleBundleBuildFlags: ConfigPlugin = ( - config, - props -) => { - return withDangerousMod(config, [ - "ios", - createCrossPlatformMod({ config, props }), - ]); -}; - -const withBundleFlags: ConfigPlugin<{ flags: string[] }> = (config, props) => { - return withAppleBundleBuildFlags( - withAndroidBundleBuildFlags(config, props), - props +const withBundleFlags: ConfigPlugin = (config, props) => { + return withDangerousMod( + withDangerousMod(config, [ + "android", + createBundleFlagsMod({ props }), + ]), + [ + "ios", + createBundleFlagsMod({ props }), + ] ); }; @@ -168,7 +158,7 @@ const withBuildFlags: ConfigPlugin = (config, props) => { return mergedNativeConfig; } - return withBundleFlags(mergedNativeConfig, { flags }); + return withBundleFlags(mergedNativeConfig, { flags, expoConfig: config }); }; const withBuildFlagsAndLinking: ConfigPlugin = ( @@ -182,7 +172,7 @@ const withBuildFlagsAndLinking: ConfigPlugin = ( mergedConfig = withFlaggedAutolinking(mergedConfig, { flags }); } - return withBuildFlags(config, { ...props, flags }); + return withBuildFlags(mergedConfig, { ...props, flags }); }; export default createRunOncePlugin( diff --git a/test/run-integration.sh b/test/run-integration.sh index b38fd79..cea8a57 100755 --- a/test/run-integration.sh +++ b/test/run-integration.sh @@ -32,5 +32,8 @@ node ../test/test-config-plugin.js logMark "Running test-config-plugin-android.js" node ../test/test-config-plugin-android.js +logMark "Running test-config-plugin-platform-inversion.js" +node ../test/test-config-plugin-platform-inversion.js + logMark "Running test-autolinking.js" node ../test/test-autolinking.js diff --git a/test/test-config-plugin-platform-inversion.js b/test/test-config-plugin-platform-inversion.js new file mode 100644 index 0000000..35e212a --- /dev/null +++ b/test/test-config-plugin-platform-inversion.js @@ -0,0 +1,130 @@ +const fs = require("fs"); +const cp = require("child_process"); +const yaml = require("yaml"); + +// When a flag has invertFor.platform: [ios], and no EXPO_BUILD_FLAGS are set, +// the config plugin should produce platform-specific runtime files and +// per-platform native manifests. + +const expectedIosModule = ` +export const BuildFlags = { + bundleIdScopedFeature: true, + iosOnlyFeature: true, + newFeature: true, + publishedFeatured: true, + secretAndroidFeature: false, + secretFeature: false +}; +`; + +const expectedAndroidModule = ` +export const BuildFlags = { + bundleIdScopedFeature: true, + iosOnlyFeature: false, + newFeature: true, + publishedFeatured: true, + secretAndroidFeature: false, + secretFeature: false +}; +`; + +addPlatformScopedFlag(); +runPrebuild(); +assertPlatformSpecificFiles(); +assertAndroidManifest(); +assertInfoPlist(); + +function addPlatformScopedFlag() { + const flagsYmlString = fs.readFileSync("flags.yml", { encoding: "utf-8" }); + const flagConfig = yaml.parse(flagsYmlString); + flagConfig.flags.iosOnlyFeature = { + value: false, + invertFor: { + platform: ["ios"], + }, + }; + fs.writeFileSync("flags.yml", yaml.stringify(flagConfig)); +} + +function runPrebuild() { + // Clean stale runtime files before prebuild + for (const f of [ + "constants/buildFlags.ts", + "constants/buildFlags.ios.ts", + "constants/buildFlags.android.ts", + ]) { + if (fs.existsSync(f)) fs.unlinkSync(f); + } + + cp.execSync("./node_modules/.bin/expo prebuild --no-install --clean", { + env: { + ...process.env, + CI: 1, + // No EXPO_BUILD_FLAGS — source of truth comes from flags.yml + }, + }); +} + +function assertPlatformSpecificFiles() { + // Single file should NOT exist + if (fs.existsSync("constants/buildFlags.ts")) { + throw new Error( + "Expected single buildFlags.ts to NOT exist when platform files are generated" + ); + } + + const iosContents = fs.readFileSync("constants/buildFlags.ios.ts", "utf8"); + if (iosContents.trim() !== expectedIosModule.trim()) { + console.log( + "iOS received:\n\n", + `>${iosContents.trim()}<`, + "\n\nexpected:\n\n", + `>${expectedIosModule.trim()}<` + ); + throw new Error("iOS buildFlags module does not match expected"); + } + + const androidContents = fs.readFileSync( + "constants/buildFlags.android.ts", + "utf8" + ); + if (androidContents.trim() !== expectedAndroidModule.trim()) { + console.log( + "Android received:\n\n", + `>${androidContents.trim()}<`, + "\n\nexpected:\n\n", + `>${expectedAndroidModule.trim()}<` + ); + throw new Error("Android buildFlags module does not match expected"); + } + + console.log( + "Assertion passed: Platform-specific runtime files generated correctly!" + ); +} + +function assertAndroidManifest() { + const fileContents = fs.readFileSync( + "android/app/src/main/AndroidManifest.xml", + "utf8" + ); + // iosOnlyFeature should NOT be in Android manifest + if (fileContents.includes("iosOnlyFeature")) { + throw new Error( + "Expected AndroidManifest.xml to NOT contain iosOnlyFeature" + ); + } + + console.log( + "Assertion passed: AndroidManifest.xml does not contain iOS-only flag!" + ); +} + +function assertInfoPlist() { + const fileContents = fs.readFileSync("ios/example/Info.plist", "utf8"); + if (!fileContents.includes("iosOnlyFeature")) { + throw new Error("Expected Info.plist to contain iosOnlyFeature"); + } + + console.log("Assertion passed: Info.plist contains iOS-only flag!"); +}