diff --git a/README.md b/README.md index 37a926d..ec87d72 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,21 @@ The arguments after the override command are the flags you want to `+` enable or You can run `yarn build-flags ota-override` instead of "override" to do the same but also consider the branch name in two supported CI environments: Github and Gitlab. Use the `ota.branches` array in the flags.yml to setup that matching and branch-based enablement. -Use `--skip-if-env ` passing the name of the environment variable to check. If the variable has a truthy value the CLI command will be a no-op/skipped. This can be useful in CI contexts like EAS where other processes may handle generating the runtime build flags. In the case of EAS you could use `--skip-if-env EAS_BUILD` +### Resolution model + +Both the CLI and the config plugin resolve flag values through one canonical +function with a fixed precedence (later steps win): + +1. defaults (the `value` in flags.yml) +2. branch enablement (`ota.branches`, only with `ota-override` / in CI) +3. inversions (`invertFor.bundleId` / `invertFor.platform`) +4. explicit enable (`+flag` / `EXPO_BUILD_FLAGS`) +5. explicit disable (`-flag` / `EXPO_BUILD_FLAGS`) — disable always wins + +When a flag's `invertFor` uses `bundleId`, the CLI resolves your project's Expo +config (via `expo config --json`) so it sees the same — possibly dynamically +derived — bundle identifier the config plugin sees at prebuild. This subprocess +only runs when the spec actually contains a `bundleId` matcher. ### Set Flags in CI & for Static Builds @@ -97,7 +111,33 @@ flags: - com.example.app.special ``` -With the preceding config and the expo config-plugin installed, the `featureOnForSpecificBundleId` flag is true for native builds that have the matching bundleId. This inversion only applies during a new native build with expo prebuild. To invert the flag during development you should still use the command line tooling. +With the preceding config and the expo config-plugin installed, the `featureOnForSpecificBundleId` flag is true for native builds that have the matching bundleId. + +You can also invert based on the platform being built: + +```yaml +flags: + iosOnlyFeature: + value: false + invertFor: + platform: + - ios +``` + +If both `bundleId` and `platform` are specified, the inversion fires when +**either** matches. + +When a `platform` matcher causes a flag to resolve differently across iOS and +Android, the runtime module is emitted per platform as `.ios.ts` and +`.android.ts` instead of the single `mergePath` file (the stale form +is cleaned up automatically). The babel plugin selects the correct file for the +platform being compiled; if you import the module directly at runtime, Metro's +platform resolution picks it up. When no flag diverges across platforms, a +single shared module is written as before. + +Unlike before, both the CLI and the config plugin apply `invertFor` — the CLI +resolves the Expo config when a `bundleId` matcher is present so its output +agrees with the plugin's. ## Goals @@ -105,6 +145,8 @@ With the preceding config and the expo config-plugin installed, the `featureOnFo - [x] allow for overriding a flag's value locally during development (without having to change the default value committed to source control) - [x] allow for running OTA updates with the flag on for specific CI branches - [x] allow for overriding a flag's value for any native build for one-off testing +- [x] allow flag values to resolve per-platform (iOS vs Android) via `invertFor.platform` +- [x] unify CLI and config-plugin resolution so their core flag output always agrees - [x] allow for referencing flag values in JS - [ ] allow for referencing flag values from native code on iOS or Android - [x] allow for tree-shaking of the JS bundle and dead code path elimination diff --git a/src/api/BuildFlags.ts b/src/api/BuildFlags.ts deleted file mode 100644 index 55a239d..0000000 --- a/src/api/BuildFlags.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { writeFile } from "fs/promises"; -import { FlagMap } from "./types"; -import { resolve } from "path"; -import { printAsTs } from "./tsPrinter"; -import { getCIBranch } from "./ciHelpers"; -import { hasMatch } from "./globUtil"; -import { debug } from "./debug"; - -export class BuildFlags { - flags: FlagMap; - - constructor(defaultFlags: FlagMap) { - this.flags = defaultFlags; - } - - enableBranchFlags() { - const branch = getCIBranch(); - if (!branch) { - return; - } - - const branchFlags = Object.keys(this.flags).filter((flag) => { - const ota = this.flags[flag].ota; - if ( - ota && - Array.isArray(ota.branches) && - hasMatch(branch, ota.branches) - ) { - return true; - } - }); - - if (branchFlags.length === 0) { - return; - } - - debug("enabling branch flags for branch %s: %o", branch, branchFlags); - this.enable(new Set(branchFlags)); - } - - enable(enables: Set) { - enables.forEach((enable) => { - if (!this.flags[enable]) { - throw new Error(`Flag ${enable} does not exist, could not enable`); - } - debug("resolve: enabling flag %s (was %o)", enable, this.flags[enable].value); - this.flags[enable].value = true; - }); - } - - disable(disables: Set) { - disables.forEach((disable) => { - if (!this.flags[disable]) { - throw new Error(`Flag ${disable} does not exist, could not disable`); - } - debug("resolve: disabling flag %s (was %o)", disable, this.flags[disable].value); - this.flags[disable].value = false; - }); - } - - async save(path: string) { - const resolvedValues = Object.fromEntries( - Object.entries(this.flags).map(([name, config]) => [name, config.value]) - ); - if (path.endsWith(".json")) { - const flags = JSON.stringify(this.flags, null, 2); - const dest = resolve(path); - debug("writing resolved flags to mergePath %s (json): %o", dest, resolvedValues); - await writeFile(dest, flags); - return; - } - - if (path.endsWith(".ts")) { - const ts = printAsTs(this.flags); - const dest = resolve(path); - debug("writing resolved flags to mergePath %s (ts): %o", dest, resolvedValues); - await writeFile(dest, ts); - return; - } - - throw new Error( - "Invalid file extension in flags file for mergePath: expected .json or .ts" - ); - } -} diff --git a/src/api/agreement.spec.ts b/src/api/agreement.spec.ts new file mode 100644 index 0000000..d8a3d9b --- /dev/null +++ b/src/api/agreement.spec.ts @@ -0,0 +1,76 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; +import * as fs from "fs/promises"; +import { resolve, enabledNames } from "./resolve"; +import { resolveEnabledFlagNames, resolveFlags } from "./generateOverrides"; +import { FlagMap } from "./types"; + +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), +})); + +// A spec exercising every input axis: defaults, bundleId + platform inversion. +const specYaml = ` +mergePath: "constants/buildFlags.ts" +flags: + base: + value: true + bundleScoped: + value: false + invertFor: + bundleId: + - com.my.app.apple + iosOnly: + value: false + invertFor: + platform: + - ios +`; + +const parseSpec = (): FlagMap => { + const YAML = require("yaml"); + return YAML.parse(specYaml).flags; +}; + +const expoConfig = { + ios: { bundleIdentifier: "com.my.app.apple" }, + android: { package: "com.my.app" }, +} as any; + +describe("CLI <-> config-plugin resolution agreement", () => { + beforeEach(() => { + jest.spyOn(fs, "readFile").mockImplementation((path: any) => { + if (path.endsWith("flags.yml")) { + return Promise.resolve(specYaml) as any; + } + throw new Error(`readFile: path not mocked: ${path}`); + }); + }); + + it("plugin native path agrees with the resolved FlagMap per platform", async () => { + for (const platform of ["ios", "android"] as const) { + // Plugin native side-effect path (manifest/plist) uses resolveEnabledFlagNames. + const nativeNames = await resolveEnabledFlagNames({ + expoConfig, + platform, + }); + // CLI/bundle path resolves the same FlagMap and the babel/runtime read it. + const bundleMap = await resolveFlags({ expoConfig, platform }); + + expect(nativeNames.sort()).toEqual(enabledNames(bundleMap).sort()); + } + }); + + it("produces the documented per-platform values for this spec", async () => { + const ios = resolve(parseSpec(), { expoConfig, platform: "ios" }); + const android = resolve(parseSpec(), { expoConfig, platform: "android" }); + + // base default-on everywhere; bundleScoped inverted by matching apple id on + // both platform passes (bundleId match is platform-agnostic); iosOnly only on ios. + expect(enabledNames(ios).sort()).toEqual( + ["base", "bundleScoped", "iosOnly"].sort() + ); + expect(enabledNames(android).sort()).toEqual( + ["base", "bundleScoped"].sort() + ); + }); +}); diff --git a/src/api/expoConfig.ts b/src/api/expoConfig.ts new file mode 100644 index 0000000..2d7f2ef --- /dev/null +++ b/src/api/expoConfig.ts @@ -0,0 +1,40 @@ +import type { ExpoConfig } from "@expo/config-types"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { debug } from "./debug"; + +const execFileAsync = promisify(execFile); + +let cached: ExpoConfig | null | undefined; + +/** + * Resolve the project's Expo config by shelling out to `expo config --json`. + * This evaluates dynamic config (app.config.js/ts) under the current env, so + * the CLI sees the same resolved bundle identifier the config plugin would at + * prebuild time. Result is cached for the lifetime of the process. + * + * Returns null if the Expo CLI is unavailable or the config can't be read; the + * caller should treat that as "no config-dependent inversions resolvable". + */ +export const readExpoConfig = async (): Promise => { + if (cached !== undefined) { + return cached; + } + try { + const { stdout } = await execFileAsync( + "npx", + ["expo", "config", "--json"], + { maxBuffer: 1024 * 1024 * 16 } + ); + cached = JSON.parse(stdout) as ExpoConfig; + debug( + "resolved expo config: ios.bundleIdentifier=%o android.package=%o", + cached.ios?.bundleIdentifier, + cached.android?.package + ); + } catch (e) { + debug("failed to resolve expo config via `expo config --json`: %o", e); + cached = null; + } + return cached; +}; 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..51aab48 100644 --- a/src/api/generateOverrides.ts +++ b/src/api/generateOverrides.ts @@ -1,45 +1,65 @@ -import { BuildFlags } from "./BuildFlags"; +import type { ExpoConfig } from "@expo/config-types"; import { readConfig } from "./readConfig"; +import { getCIBranch } from "./ciHelpers"; +import { resolve, enabledNames, ResolveContext } from "./resolve"; +import { saveFlags, savePlatformFlags } from "./writeFlags"; +import { FlagMap, Platform } from "./types"; -export const resolveEnabledFlagNames = async ({ - flagsToEnable, - flagsToDisable, -}: { +type OverrideOptions = { flagsToEnable?: Set; flagsToDisable?: Set; -}): Promise => { - const { flags: defaultFlags } = await readConfig(); - const flags = new BuildFlags(defaultFlags); - if (flagsToEnable) { - flags.enable(flagsToEnable); - } - if (flagsToDisable) { - flags.disable(flagsToDisable); - } - return Object.entries(flags.flags) - .filter(([_, config]) => config.value) - .map(([name]) => name); + enableBranchFlags?: boolean; + expoConfig?: ExpoConfig; }; -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); +const baseContext = ( + opts: OverrideOptions +): Omit => ({ + expoConfig: opts.expoConfig, + branch: opts.enableBranchFlags ? getCIBranch() : undefined, + enableBranchFlags: opts.enableBranchFlags, + envEnable: opts.flagsToEnable, + envDisable: opts.flagsToDisable, +}); + +/** + * Resolve the runtime flags from flags.yml and write the runtime module(s). + * Writes platform-specific files (.ios.ts / .android.ts) only when a flag + * resolves differently across platforms; otherwise a single shared module. + * Called by the CLI and the config plugin so both produce identical output. + */ +export const generateOverrides = async (opts: OverrideOptions = {}) => { + const { mergePath, flags: spec } = await readConfig(); + const ctx = baseContext(opts); + + const ios = resolve(spec, { ...ctx, platform: "ios" }); + const android = resolve(spec, { ...ctx, platform: "android" }); + + await savePlatformFlags(mergePath, ios, android); +}; + +/** Resolve and write without a platform axis (used where platform is irrelevant). */ +export const generateSharedOverrides = async (opts: OverrideOptions = {}) => { + const { mergePath, flags: spec } = await readConfig(); + const resolved = resolve(spec, baseContext(opts)); + await saveFlags(mergePath, resolved); +}; + +/** Names of flags that resolve enabled for a given platform/context. */ +export const resolveEnabledFlagNames = async ({ + platform, + ...opts +}: OverrideOptions & { platform?: Platform } = {}): Promise => { + const { flags: spec } = await readConfig(); + const resolved = resolve(spec, { ...baseContext(opts), platform }); + return enabledNames(resolved); +}; + +/** Resolve the full FlagMap for a platform (used by autolinking exclusions). */ +export const resolveFlags = async ({ + platform, + ...opts +}: OverrideOptions & { platform?: Platform } = {}): Promise => { + const { flags: spec } = await readConfig(); + return resolve(spec, { ...baseContext(opts), platform }); }; diff --git a/src/api/index.ts b/src/api/index.ts index a5c1425..8d72f3c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,13 +1,28 @@ import { generateOverrides, + generateSharedOverrides, resolveEnabledFlagNames, + resolveFlags, } from "./generateOverrides"; -import { resolveFlagsToInvert } from "./resolveFlagsToInvert"; -import { readConfig } from "./readConfig"; +import { + resolve, + enabledNames, + valuesDiffer, + hasPlatformInversions, + hasBundleIdInversions, +} from "./resolve"; +import { readConfig, resolveModuleExclusions } from "./readConfig"; export { generateOverrides, + generateSharedOverrides, resolveEnabledFlagNames, - resolveFlagsToInvert, + resolveFlags, + resolve, + enabledNames, + valuesDiffer, + hasPlatformInversions, + hasBundleIdInversions, readConfig, + resolveModuleExclusions, }; diff --git a/src/api/readConfig.spec.ts b/src/api/readConfig.spec.ts index c6fba0b..c4cc5a0 100644 --- a/src/api/readConfig.spec.ts +++ b/src/api/readConfig.spec.ts @@ -1,6 +1,7 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import * as fs from "fs/promises"; -import { readConfigModuleExclusions } from "./readConfig"; +import { resolveModuleExclusions } from "./readConfig"; +import { resolve } from "./resolve"; jest.mock("fs/promises", () => ({ readFile: jest.fn(() => Promise.resolve()), @@ -8,15 +9,20 @@ jest.mock("fs/promises", () => ({ const fsActual: any = jest.requireActual("fs/promises"); -describe("readConfigModuleExclusions", () => { +let yaml: string; + +const loadSpec = async () => { + const YAML = require("yaml"); + const config = YAML.parse(yaml); + return config.flags; +}; + +describe("resolveModuleExclusions", () => { beforeEach(async () => { - const yaml = await fsActual.readFile("src/api/fixtures/flags.yml", { + yaml = await fsActual.readFile("src/api/fixtures/flags.yml", { encoding: "utf-8", }); jest.spyOn(fs, "readFile").mockImplementation((path: any) => { - if (path.endsWith("flags.yml")) { - return yaml; - } if (path.endsWith(".git/HEAD")) { return "ref: refs/heads/feature-in-dev-build-branch"; } @@ -24,15 +30,17 @@ describe("readConfigModuleExclusions", () => { }); }); - it("should return array of strings for modules for false flags", async () => { - const exclusions = await readConfigModuleExclusions(); + it("should return modules for flags that resolve false", async () => { + const resolved = resolve(await loadSpec()); + const exclusions = await resolveModuleExclusions(resolved); expect(exclusions).toEqual(["react-native-device-info", "exclude-me"]); }); - it("should include modules for flags enabled by override", async () => { - const exclusions = await readConfigModuleExclusions([ - "featureInDevelopment", - ]); + it("should not exclude modules for flags enabled by override", async () => { + const resolved = resolve(await loadSpec(), { + envEnable: new Set(["featureInDevelopment"]), + }); + const exclusions = await resolveModuleExclusions(resolved); expect(exclusions).toEqual([]); }); }); diff --git a/src/api/readConfig.ts b/src/api/readConfig.ts index 6116776..77e3504 100644 --- a/src/api/readConfig.ts +++ b/src/api/readConfig.ts @@ -1,6 +1,6 @@ import YAML from "yaml"; import { readFile } from "fs/promises"; -import { FlagsConfig } from "./types"; +import { FlagMap, FlagsConfig } from "./types"; export const readConfig = async (): Promise => { try { @@ -45,35 +45,42 @@ const getGitBranchName = async () => { } }; -export const readConfigModuleExclusions = async ( - flagOverrides?: string[] +/** + * Compute the native modules to exclude from autolinking, given an already + * resolved FlagMap (values applied for the target platform, including any + * inversions / env overrides). A module is excluded when its owning flag + * resolves false, unless a per-module `branch` allowance matches the current + * git branch (which keeps the module linked so a build can run on that branch). + */ +export const resolveModuleExclusions = async ( + resolved: FlagMap ): Promise => { - const { flags } = await readConfig(); const branch = await getGitBranchName(); - return Object.keys(flags) - .filter((flag) => !flags[flag].value) + return Object.keys(resolved) + .filter((flag) => !resolved[flag].value) .reduce((acc, flag) => { - if (flags[flag].modules && !flagOverrides?.includes(flag)) { - return [ - ...acc, - ...flags[flag].modules - .map((mod) => { - if (typeof mod === "string") { - return mod; - } - const [[modName, modConfig]] = Object.entries(mod); - if ( - typeof modConfig === "object" && - "branch" in modConfig && - // @ts-expect-error ts inference issue - modConfig.branch !== branch - ) { - return modName; - } - }) - .filter((mod): mod is string => typeof mod === "string"), - ]; + const modules = resolved[flag].modules; + if (!modules) { + return acc; } - return acc; + return [ + ...acc, + ...modules + .map((mod) => { + if (typeof mod === "string") { + return mod; + } + const [[modName, modConfig]] = Object.entries(mod); + if ( + typeof modConfig === "object" && + "branch" in modConfig && + // @ts-expect-error ts inference issue + modConfig.branch !== branch + ) { + return modName; + } + }) + .filter((mod): mod is string => typeof mod === "string"), + ]; }, [] as string[]); }; diff --git a/src/api/resolve.spec.ts b/src/api/resolve.spec.ts new file mode 100644 index 0000000..cbe6653 --- /dev/null +++ b/src/api/resolve.spec.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "@jest/globals"; +import { + resolve, + enabledNames, + valuesDiffer, + hasPlatformInversions, + hasBundleIdInversions, +} from "./resolve"; +import { FlagMap } from "./types"; + +const spec: FlagMap = { + base: { value: false, meta: {} }, + defaultOn: { value: true, meta: {} }, + branchFlag: { value: false, meta: {}, ota: { branches: ["feature-branch"] } }, + bundleFlag: { + value: false, + meta: {}, + invertFor: { bundleId: ["com.my.app.apple", "com.my.app.android"] }, + }, + iosFlag: { value: false, meta: {}, invertFor: { platform: ["ios"] } }, +}; + +describe("resolve", () => { + it("applies defaults when no context", () => { + const r = resolve(spec); + expect(r.base.value).toBe(false); + expect(r.defaultOn.value).toBe(true); + }); + + it("does not mutate the input spec", () => { + resolve(spec, { envEnable: new Set(["base"]) }); + expect(spec.base.value).toBe(false); + }); + + it("enables branch flags only when enableBranchFlags + matching branch", () => { + expect(resolve(spec, { branch: "feature-branch" }).branchFlag.value).toBe( + false + ); + expect( + resolve(spec, { enableBranchFlags: true, branch: "other" }).branchFlag + .value + ).toBe(false); + expect( + resolve(spec, { enableBranchFlags: true, branch: "feature-branch" }) + .branchFlag.value + ).toBe(true); + }); + + it("inverts bundleId flags only on a matching resolved config", () => { + expect( + resolve(spec, { expoConfig: { ios: { bundleIdentifier: "com.my.app" } } as any }) + .bundleFlag.value + ).toBe(false); + expect( + resolve(spec, { + expoConfig: { ios: { bundleIdentifier: "com.my.app.apple" } } as any, + }).bundleFlag.value + ).toBe(true); + expect( + resolve(spec, { + expoConfig: { android: { package: "com.my.app.android" } } as any, + }).bundleFlag.value + ).toBe(true); + }); + + it("inverts platform flags only for the matching platform", () => { + expect(resolve(spec, { platform: "android" }).iosFlag.value).toBe(false); + expect(resolve(spec, { platform: "ios" }).iosFlag.value).toBe(true); + }); + + it("applies env enable then disable, with disable winning", () => { + expect(resolve(spec, { envEnable: new Set(["base"]) }).base.value).toBe( + true + ); + expect( + resolve(spec, { + envEnable: new Set(["base"]), + envDisable: new Set(["base"]), + }).base.value + ).toBe(false); + }); + + it("disable beats branch enablement and inversion", () => { + expect( + resolve(spec, { + enableBranchFlags: true, + branch: "feature-branch", + envDisable: new Set(["branchFlag"]), + }).branchFlag.value + ).toBe(false); + expect( + resolve(spec, { + platform: "ios", + envDisable: new Set(["iosFlag"]), + }).iosFlag.value + ).toBe(false); + }); + + it("throws on enabling/disabling unknown flags", () => { + expect(() => resolve(spec, { envEnable: new Set(["nope"]) })).toThrow( + /nope/ + ); + expect(() => resolve(spec, { envDisable: new Set(["nope"]) })).toThrow( + /nope/ + ); + }); +}); + +describe("helpers", () => { + it("enabledNames lists only true flags", () => { + expect(enabledNames(resolve(spec)).sort()).toEqual(["defaultOn"]); + }); + + it("valuesDiffer detects per-platform divergence", () => { + const ios = resolve(spec, { platform: "ios" }); + const android = resolve(spec, { platform: "android" }); + expect(valuesDiffer(ios, android)).toBe(true); + expect(valuesDiffer(ios, ios)).toBe(false); + }); + + it("detects matcher presence", () => { + expect(hasPlatformInversions(spec)).toBe(true); + expect(hasBundleIdInversions(spec)).toBe(true); + expect(hasPlatformInversions({ a: { value: false, meta: {} } })).toBe(false); + }); +}); diff --git a/src/api/resolve.ts b/src/api/resolve.ts new file mode 100644 index 0000000..a9ec30c --- /dev/null +++ b/src/api/resolve.ts @@ -0,0 +1,133 @@ +import type { ExpoConfig } from "@expo/config-types"; +import { FlagMap, Platform } from "./types"; +import { hasMatch } from "./globUtil"; +import { debug } from "./debug"; + +export type ResolveContext = { + /** Resolve for a single platform; undefined = platform-agnostic pass. */ + platform?: Platform; + /** Resolved Expo config (bundleIdentifier / package); enables bundleId inversion. */ + expoConfig?: ExpoConfig; + /** Branch name for ota matching; undefined = no branch enablement. */ + branch?: string; + /** Whether to apply ota.branches enablement against ctx.branch. */ + enableBranchFlags?: boolean; + /** Explicit enables (CLI +flag / EXPO_BUILD_FLAGS). */ + envEnable?: Set; + /** Explicit disables (CLI -flag / EXPO_BUILD_FLAGS). Always win. */ + envDisable?: Set; +}; + +const shouldInvert = ( + invertFor: NonNullable, + ctx: ResolveContext +): boolean => { + if (invertFor.bundleId && ctx.expoConfig) { + const bundleIds = [ + ctx.expoConfig.ios?.bundleIdentifier, + ctx.expoConfig.android?.package, + ].filter(Boolean) as string[]; + if ( + bundleIds.length && + invertFor.bundleId.some((id) => bundleIds.includes(id)) + ) { + return true; + } + } + + if (invertFor.platform && ctx.platform) { + if (invertFor.platform.includes(ctx.platform)) { + return true; + } + } + + return false; +}; + +/** + * The single canonical flag resolver. Deterministic for a given (spec, ctx). + * Returns a new FlagMap with `value` applied; the input spec is not mutated. + * + * Precedence (later steps win): + * 1. defaults (spec value) + * 2. branch enablement (ota.branches vs ctx.branch, if enableBranchFlags) + * 3. inversions (invertFor.bundleId / invertFor.platform) + * 4. explicit enable (envEnable) + * 5. explicit disable (envDisable) + */ +export const resolve = (spec: FlagMap, ctx: ResolveContext = {}): FlagMap => { + const resolved: FlagMap = {}; + + for (const [name, config] of Object.entries(spec)) { + let value = config.value; + + if ( + ctx.enableBranchFlags && + ctx.branch && + config.ota && + Array.isArray(config.ota.branches) && + hasMatch(ctx.branch, config.ota.branches) + ) { + value = true; + } + + if (config.invertFor && shouldInvert(config.invertFor, ctx)) { + value = !value; + } + + if (ctx.envEnable?.has(name)) { + value = true; + } + + if (ctx.envDisable?.has(name)) { + value = false; + } + + resolved[name] = { ...config, value }; + } + + const unknownEnable = [...(ctx.envEnable ?? [])].find((f) => !spec[f]); + if (unknownEnable) { + throw new Error(`Flag ${unknownEnable} does not exist, could not enable`); + } + const unknownDisable = [...(ctx.envDisable ?? [])].find((f) => !spec[f]); + if (unknownDisable) { + throw new Error(`Flag ${unknownDisable} does not exist, could not disable`); + } + + debug( + "resolved flags (platform=%o branch=%o) -> %o", + ctx.platform, + ctx.enableBranchFlags ? ctx.branch : undefined, + Object.fromEntries( + Object.entries(resolved).map(([n, c]) => [n, c.value]) + ) + ); + + return resolved; +}; + +/** Names of flags that resolve enabled. */ +export const enabledNames = (resolved: FlagMap): string[] => + Object.entries(resolved) + .filter(([, config]) => config.value) + .map(([name]) => name); + +/** Whether two resolved FlagMaps differ in any flag value. */ +export const valuesDiffer = (a: FlagMap, b: FlagMap): boolean => { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if (a[key]?.value !== b[key]?.value) { + return true; + } + } + return false; +}; + +/** Whether any flag in the spec uses a platform-dependent matcher. */ +export const hasPlatformInversions = (spec: FlagMap): boolean => + Object.values(spec).some((c) => c.invertFor?.platform?.length); + +/** Whether any flag in the spec uses a bundleId (resolved-config) matcher. */ +export const hasBundleIdInversions = (spec: FlagMap): boolean => + Object.values(spec).some((c) => c.invertFor?.bundleId?.length); diff --git a/src/api/resolveFlagsToInvert.spec.ts b/src/api/resolveFlagsToInvert.spec.ts deleted file mode 100644 index b2ee85a..0000000 --- a/src/api/resolveFlagsToInvert.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import * as fs from "fs/promises"; -import { resolveFlagsToInvert } from "./resolveFlagsToInvert"; - -jest.mock("fs/promises", () => ({ - readFile: jest.fn(() => Promise.resolve()), -})); - -const fsActual: any = jest.requireActual("fs/promises"); - -describe("resolveFlagsToInvert", () => { - beforeEach(async () => { - const yaml = await fsActual.readFile("src/api/fixtures/flags.yml", { - encoding: "utf-8", - }); - jest.spyOn(fs, "readFile").mockImplementation((path: any) => { - if (path.endsWith("flags.yml")) { - return yaml; - } - throw new Error(`readFile: path not mocked: ${path}`); - }); - }); - - it("should not invert with no bundle ID match", async () => { - const result = await resolveFlagsToInvert({ - ios: { bundleIdentifier: "com.my.app" }, - } as any); - - expect(result.flagsToEnable.has("invertableFeature")).toBe(false); - }); - - it("should match against iOS config bundleId", async () => { - const result = await resolveFlagsToInvert({ - ios: { bundleIdentifier: "com.my.app.apple" }, - android: { package: "com.my.app" }, - } as any); - - expect(result.flagsToEnable.has("invertableFeature")).toBe(true); - }); - - it("should match against Android config package", async () => { - const result = await resolveFlagsToInvert({ - ios: { bundleIdentifier: "com.my.app" }, - android: { package: "com.my.app.android" }, - } as any); - - expect(result.flagsToEnable.has("invertableFeature")).toBe(true); - }); -}); diff --git a/src/api/resolveFlagsToInvert.ts b/src/api/resolveFlagsToInvert.ts deleted file mode 100644 index 6922214..0000000 --- a/src/api/resolveFlagsToInvert.ts +++ /dev/null @@ -1,67 +0,0 @@ -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) => { - const { flags } = await readConfig(); - const invertable = Object.entries(flags).filter( - (tuple): tuple is InvertableFlagTuple => !!tuple[1].invertFor - ); - - const flagsToEnable = new Set(); - const flagsToDisable = new Set(); - - if (!invertable.length) { - return { flagsToEnable, flagsToDisable }; - } - - invertable.forEach(([flagName, flagConfig]) => { - const invertFor = flagConfig.invertFor; - - if (invertFor.bundleId) { - const bundleIds = [ - expoConfig.ios?.bundleIdentifier, - expoConfig.android?.package, - ].filter(Boolean); - if ( - !bundleIds.length || - !invertFor.bundleId.find((bundleId) => bundleIds.includes(bundleId)) - ) { - return; - } - } - - if (flagConfig.value) { - flagsToDisable.add(flagName); - } else { - flagsToEnable.add(flagName); - } - }); - - return { flagsToEnable, flagsToDisable }; -}; diff --git a/src/api/types.ts b/src/api/types.ts index 1cb9f6e..ecaeabb 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,5 @@ -type InvertMatchers = { bundleId?: string[] }; +export type Platform = "ios" | "android"; +type InvertMatchers = { bundleId?: string[]; platform?: Platform[] }; type OTAFilter = { branches: string[] }; type ModuleConfig = string | { branch: string }; export type FlagConfig = { diff --git a/src/api/writeFlags.ts b/src/api/writeFlags.ts new file mode 100644 index 0000000..7e3b30e --- /dev/null +++ b/src/api/writeFlags.ts @@ -0,0 +1,78 @@ +import { writeFile, unlink } from "fs/promises"; +import { existsSync } from "fs"; +import { resolve as resolvePath } from "path"; +import { FlagMap } from "./types"; +import { printAsTs } from "./tsPrinter"; +import { valuesDiffer } from "./resolve"; +import { debug } from "./debug"; + +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 writeOne = async (path: string, flags: FlagMap) => { + const dest = resolvePath(path); + if (path.endsWith(".ts")) { + await writeFile(dest, printAsTs(flags)); + return; + } + if (path.endsWith(".json")) { + await writeFile(dest, JSON.stringify(flags, null, 2)); + return; + } + throw new Error( + "Invalid file extension in flags file for mergePath: expected .json or .ts" + ); +}; + +const cleanupStale = async (paths: string[]) => { + for (const path of paths) { + const dest = resolvePath(path); + if (existsSync(dest)) { + debug("removing stale flags file %s", dest); + await unlink(dest); + } + } +}; + +/** Write a single platform-agnostic runtime module; clean up any stale platform siblings. */ +export const saveFlags = async (basePath: string, flags: FlagMap) => { + const paths = platformPaths(basePath); + await cleanupStale([paths.ios, paths.android]); + debug("writing resolved flags to %s", resolvePath(basePath)); + await writeOne(basePath, flags); +}; + +/** + * Write platform-specific modules only when the resolved values differ between + * platforms; otherwise write a single shared module. Cleans up whichever form + * is now stale so consumers never read leftover files. + */ +export const savePlatformFlags = async ( + basePath: string, + ios: FlagMap, + android: FlagMap +) => { + const paths = platformPaths(basePath); + + if (!valuesDiffer(ios, android)) { + await cleanupStale([paths.ios, paths.android]); + debug("writing shared resolved flags to %s", resolvePath(basePath)); + await writeOne(basePath, ios); + return; + } + + await cleanupStale([basePath]); + debug( + "writing platform-specific resolved flags to %s / %s", + resolvePath(paths.ios), + resolvePath(paths.android) + ); + await writeOne(paths.ios, ios); + await writeOne(paths.android, android); +}; diff --git a/src/babel-plugin/index.ts b/src/babel-plugin/index.ts index 00374e6..d04c6ae 100644 --- a/src/babel-plugin/index.ts +++ b/src/babel-plugin/index.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { declare } from "@babel/helper-plugin-utils"; import type * as BabelT from "babel__core"; import { parseTsConstantsModule } from "../api/tsParser"; @@ -26,7 +27,14 @@ export default declare((babel, options, cwd) => { ); } - const flags = parseTsConstantsModule(options.flagsModule); + // When the runtime module diverges per platform, the CLI/config-plugin emit + // `.ios.ts` / `.android.ts` instead of the single file. Pick the + // file for the platform Metro is compiling for, falling back to the single + // file when no platform-specific module exists. + const platform = babel.caller((caller: any) => caller?.platform); + const flagsModulePath = resolveFlagsModulePath(options.flagsModule, platform); + + const flags = parseTsConstantsModule(flagsModulePath); const baseModulePath = options.flagsModule .split("/") .filter((segment) => segment !== ".." && segment !== ".") @@ -70,3 +78,16 @@ export default declare((babel, options, cwd) => { } } }); + +function resolveFlagsModulePath( + flagsModule: string, + platform?: string +): string { + if (platform === "ios" || platform === "android") { + const platformModule = flagsModule.replace(/\.ts$/, `.${platform}.ts`); + if (existsSync(platformModule)) { + return platformModule; + } + } + return flagsModule; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 69c117f..83f96b6 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,8 +1,11 @@ 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 { readConfig } from "../api/readConfig"; +import { saveFlags } from "../api/writeFlags"; +import { resolve, hasBundleIdInversions } from "../api/resolve"; +import { readExpoConfig } from "../api/expoConfig"; export const shouldSkip = (envKey: string | undefined): boolean => { if (envKey && process.env[envKey] !== undefined) { @@ -75,8 +78,7 @@ const initFlagsFile = async () => { console.log(""); await writeFile("flags.yml", YAML.stringify(baseFlags, null, 2)); console.log("Wrote default flags to flags.yml in the current directory"); - const flags = new BuildFlags(baseFlags.flags); - await flags.save(baseFlags.mergePath); + await saveFlags(baseFlags.mergePath, resolve(baseFlags.flags)); if (gitignoreExists) { const gitignore = await readFile(".gitignore", { encoding: "utf-8" }); await writeFile( @@ -91,6 +93,14 @@ const initFlagsFile = async () => { ); }; +const resolveExpoConfigIfNeeded = async () => { + const { flags } = await readConfig(); + if (!hasBundleIdInversions(flags)) { + return null; + } + return readExpoConfig(); +}; + const run = async () => { const { command, flagsToDisable, flagsToEnable, skipIfEnv } = parseArgs( process.argv @@ -105,16 +115,13 @@ const run = async () => { return; } - if (command === "override") { - await generateOverrides({ flagsToEnable, flagsToDisable }); - return; - } - - if (command === "ota-override") { + if (command === "override" || command === "ota-override") { + const expoConfig = await resolveExpoConfigIfNeeded(); await generateOverrides({ flagsToEnable, flagsToDisable, - enableBranchFlags: true, + enableBranchFlags: command === "ota-override", + expoConfig: expoConfig ?? undefined, }); return; } diff --git a/src/config-plugin/index.ts b/src/config-plugin/index.ts index 3323acd..4d189d9 100644 --- a/src/config-plugin/index.ts +++ b/src/config-plugin/index.ts @@ -7,14 +7,9 @@ import { withInfoPlist, } from "@expo/config-plugins"; import { ExpoConfig } from "@expo/config-types"; -import { - generateOverrides, - resolveEnabledFlagNames, - resolveFlagsToInvert, -} from "../api"; +import { generateOverrides, resolveEnabledFlagNames } from "../api"; import pkg from "../../package.json"; import { withFlaggedAutolinking } from "./withFlaggedAutolinking"; -import { mergeSets } from "../api/mergeSets"; import { parseEnvFlags } from "./parseEnvFlags"; import { debug } from "../api/debug"; @@ -25,36 +20,16 @@ type EnvFlagSets = { type NativeFlagPluginProps = EnvFlagSets & { expoConfig: ExpoConfig }; -let cachedResolvedFlags: string[] | null = null; -const resolveAllEnabledFlags = async ({ - flagsToEnable: envEnable, - flagsToDisable: envDisable, - expoConfig, -}: NativeFlagPluginProps): Promise => { - if (cachedResolvedFlags) { - return cachedResolvedFlags; - } - let flagsToEnable = new Set(envEnable); - let flagsToDisable = new Set(envDisable); - const invertable = await resolveFlagsToInvert(expoConfig); - if (invertable.flagsToEnable.size > 0) { - flagsToEnable = mergeSets(flagsToEnable, invertable.flagsToEnable); - } - if (invertable.flagsToDisable.size > 0) { - flagsToDisable = mergeSets(flagsToDisable, invertable.flagsToDisable); - } - cachedResolvedFlags = await resolveEnabledFlagNames({ - flagsToEnable, - flagsToDisable, +const resolveEnabledFor = ( + props: NativeFlagPluginProps, + platform: "ios" | "android" +) => + resolveEnabledFlagNames({ + flagsToEnable: props.flagsToEnable, + flagsToDisable: props.flagsToDisable, + expoConfig: props.expoConfig, + platform, }); - debug( - "resolved enabled flags (enable=%o disable=%o) -> %o", - Array.from(flagsToEnable), - Array.from(flagsToDisable), - cachedResolvedFlags - ); - return cachedResolvedFlags; -}; const withAndroidBuildFlags: ConfigPlugin = ( config, @@ -70,7 +45,7 @@ const withAndroidBuildFlags: ConfigPlugin = ( throw new Error("Application node not found in AndroidManifest.xml"); } - const resolvedFlags = await resolveAllEnabledFlags(props); + const resolvedFlags = await resolveEnabledFor(props, "android"); debug( "writing EXBuildFlags to AndroidManifest.xml: %s", resolvedFlags.join(",") @@ -96,64 +71,40 @@ const withAppleBuildFlags: ConfigPlugin = ( props ) => { return withInfoPlist(config, async (config) => { - const resolvedFlags = await resolveAllEnabledFlags(props); + const resolvedFlags = await resolveEnabledFor(props, "ios"); debug("writing EXBuildFlags to Info.plist: %o", resolvedFlags); config.modResults.EXBuildFlags = resolvedFlags; return config; }); }; -type BundlePluginProps = EnvFlagSets; +type BundlePluginProps = NativeFlagPluginProps; -const createCrossPlatformMod = - ({ - config, - props, - }: { - config: ExpoConfig; - props: BundlePluginProps; - }): Mod => +const createBundleMod = + (props: BundlePluginProps): Mod => async (modConfig) => { - let flagsToEnable = new Set(props.flagsToEnable); - let flagsToDisable = new Set(props.flagsToDisable); - const invertable = await resolveFlagsToInvert(config); - if (invertable.flagsToEnable.size > 0) { - flagsToEnable = mergeSets(flagsToEnable, invertable.flagsToEnable); - } - if (invertable.flagsToDisable.size > 0) { - flagsToDisable = mergeSets(flagsToDisable, invertable.flagsToDisable); + if (bundleGenerated) { + return modConfig; } + bundleGenerated = true; await generateOverrides({ - flagsToEnable, - flagsToDisable, + flagsToEnable: props.flagsToEnable, + flagsToDisable: props.flagsToDisable, + expoConfig: props.expoConfig, }); 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 }), - ]); -}; +// generateOverrides resolves both platforms internally and writes the runtime +// module(s) in one shot, so we only need to run once. We register the mod on +// both platforms (guarded) so a single-platform prebuild (e.g. `-p android`) +// still regenerates the module. +let bundleGenerated = false; const withBundleFlags: ConfigPlugin = (config, props) => { - return withAppleBundleBuildFlags( - withAndroidBundleBuildFlags(config, props), - props + return withDangerousMod( + withDangerousMod(config, ["ios", createBundleMod(props)]), + ["android", createBundleMod(props)] ); }; @@ -161,11 +112,16 @@ type ConfigPluginProps = | { skipBundleOverride?: boolean; flaggedAutolinking?: boolean } | undefined; -type WithBuildFlagsProps = EnvFlagSets & { skipBundleOverride?: boolean }; +type WithBuildFlagsProps = NativeFlagPluginProps & { + skipBundleOverride?: boolean; +}; const withBuildFlags: ConfigPlugin = (config, props) => { - const { flagsToEnable, flagsToDisable } = props; - const nativeProps = { flagsToEnable, flagsToDisable, expoConfig: config }; + const nativeProps = { + flagsToEnable: props.flagsToEnable, + flagsToDisable: props.flagsToDisable, + expoConfig: config, + }; const nativeConfig = withAndroidBuildFlags(config, nativeProps); const mergedNativeConfig = withAppleBuildFlags(nativeConfig, nativeProps); @@ -173,7 +129,7 @@ const withBuildFlags: ConfigPlugin = (config, props) => { return mergedNativeConfig; } - return withBundleFlags(mergedNativeConfig, { flagsToEnable, flagsToDisable }); + return withBundleFlags(mergedNativeConfig, nativeProps); }; const withBuildFlagsAndLinking: ConfigPlugin = ( @@ -184,16 +140,19 @@ const withBuildFlagsAndLinking: ConfigPlugin = ( const { flagsToEnable, flagsToDisable } = parseEnvFlags(); if (props?.flaggedAutolinking) { - // Autolinking only consults the enable list: a flag forced ON at build time - // should not have its native modules excluded. Disabling a default-true flag - // via env does not currently re-introduce module exclusions for it; that - // would require resolving the merged flag state before computing exclusions. mergedConfig = withFlaggedAutolinking(mergedConfig, { - flags: Array.from(flagsToEnable), + flagsToEnable, + flagsToDisable, + expoConfig: config, }); } - return withBuildFlags(config, { ...props, flagsToEnable, flagsToDisable }); + return withBuildFlags(mergedConfig, { + ...props, + flagsToEnable, + flagsToDisable, + expoConfig: config, + }); }; export default createRunOncePlugin( diff --git a/src/config-plugin/withFlaggedAutolinking.ts b/src/config-plugin/withFlaggedAutolinking.ts index d448760..d6fdf1d 100644 --- a/src/config-plugin/withFlaggedAutolinking.ts +++ b/src/config-plugin/withFlaggedAutolinking.ts @@ -1,10 +1,19 @@ import fs from "fs"; import path from "path"; import { ConfigPlugin, withDangerousMod } from "@expo/config-plugins"; -import { readConfigModuleExclusions } from "../api/readConfig"; +import { ExpoConfig } from "@expo/config-types"; +import { resolveModuleExclusions } from "../api/readConfig"; +import { resolveFlags } from "../api/generateOverrides"; +import { Platform } from "../api/types"; import { debug } from "../api/debug"; -type Props = { flags: string[]; expoMajorVersion: number }; +type FlagSets = { + flagsToEnable: Set; + flagsToDisable: Set; + expoConfig: ExpoConfig; +}; + +type Props = FlagSets & { expoMajorVersion: number }; type Updater = (contents: string, { exclude }: { exclude: string[] }) => string; @@ -38,8 +47,9 @@ const androidRNLinkingLookup: Record = { const withFlaggedAutolinkingForApple: ConfigPlugin = ( config, - { flags, expoMajorVersion } + props ) => { + const { expoMajorVersion } = props; return withDangerousMod(config, [ "ios", async (config) => { @@ -48,7 +58,7 @@ const withFlaggedAutolinkingForApple: ConfigPlugin = ( "Podfile" ); let contents = await fs.promises.readFile(podfile, "utf8"); - const exclude = await getExclusions(flags); + const exclude = await getExclusions(props, "ios"); if (!exclude.length) { return config; } @@ -74,8 +84,9 @@ const withFlaggedAutolinkingForApple: ConfigPlugin = ( const withFlaggedAutolinkingForAndroid: ConfigPlugin = ( config, - { flags, expoMajorVersion } + props ) => { + const { expoMajorVersion } = props; return withDangerousMod(config, [ "android", async (config) => { @@ -84,7 +95,7 @@ const withFlaggedAutolinkingForAndroid: ConfigPlugin = ( "settings.gradle" ); let contents = await fs.promises.readFile(gradleSettings, "utf8"); - const exclude = await getExclusions(flags); + const exclude = await getExclusions(props, "android"); if (!exclude.length) { return config; } @@ -107,7 +118,7 @@ const withFlaggedAutolinkingForAndroid: ConfigPlugin = ( ]); }; -export const withFlaggedAutolinking: ConfigPlugin<{ flags: string[] }> = ( +export const withFlaggedAutolinking: ConfigPlugin = ( config, props ) => { @@ -259,11 +270,19 @@ export function updateGradleExpoModulesAutolinkCallForSDK54( ); } -let exclude: string[] | null = null; -async function getExclusions(flagOverrides?: string[]) { - if (Array.isArray(exclude)) { - return exclude; +const excludeCache: Partial> = {}; +async function getExclusions(props: FlagSets, platform: Platform) { + const cached = excludeCache[platform]; + if (cached) { + return cached; } - exclude = await readConfigModuleExclusions(flagOverrides); - return exclude; + const resolved = await resolveFlags({ + flagsToEnable: props.flagsToEnable, + flagsToDisable: props.flagsToDisable, + expoConfig: props.expoConfig, + platform, + }); + const result = await resolveModuleExclusions(resolved); + excludeCache[platform] = result; + return result; } diff --git a/test/run-integration.sh b/test/run-integration.sh index b38fd79..6e1c8c5 100755 --- a/test/run-integration.sh +++ b/test/run-integration.sh @@ -34,3 +34,6 @@ node ../test/test-config-plugin-android.js logMark "Running test-autolinking.js" node ../test/test-autolinking.js + +logMark "Running test-config-plugin-platform-inversion.js" +node ../test/test-config-plugin-platform-inversion.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..4c75edb --- /dev/null +++ b/test/test-config-plugin-platform-inversion.js @@ -0,0 +1,128 @@ +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!"); +}