diff --git a/cli/src/__tests__/commands/xlr-compile-packages.test.ts b/cli/src/__tests__/commands/xlr-compile-packages.test.ts index 7922bbb..2d2c10f 100644 --- a/cli/src/__tests__/commands/xlr-compile-packages.test.ts +++ b/cli/src/__tests__/commands/xlr-compile-packages.test.ts @@ -1,48 +1,14 @@ import fs from "fs"; import os from "os"; import path from "path"; -import { test, expect, describe, beforeEach, afterEach, vi } from "vitest"; -import { Errors } from "@oclif/core"; +import { test, expect, describe, beforeEach, afterEach } from "vitest"; import XLRCompile from "../../commands/xlr/compile"; - -/** A plugin package with one asset, laid out the way `xlr compile` expects */ -function writeFixture(dir: string, packageJson?: Record) { - fs.mkdirSync(path.join(dir, "src"), { recursive: true }); - - if (packageJson) { - fs.writeFileSync( - path.join(dir, "package.json"), - JSON.stringify(packageJson), - ); - } - - fs.writeFileSync( - path.join(dir, "src", "index.ts"), - ` -import type { ExtendedPlayerPlugin } from "@player-ui/player"; - -export interface TestAsset { - id: string; - type: "test"; -} - -export class TestPlugin implements ExtendedPlayerPlugin<[TestAsset]> { - name = "test-plugin"; -} -`, - ); -} - -/** Silences `Errors.warn` while capturing what it was called with */ -function spyOnWarn() { - return vi.spyOn(Errors, "warn").mockImplementation(() => undefined); -} - -function readManifest(dir: string) { - return JSON.parse( - fs.readFileSync(path.join(dir, "dist", "xlr", "manifest.json"), "utf-8"), - ); -} +import { + writeFixture, + spyOnWarn, + writePlayerConfig, + readManifest, +} from "./xlr-compile-test-helpers"; describe("xlr compile package info", () => { /** An isolated root, so nothing on the ambient filesystem can be picked up */ @@ -60,6 +26,8 @@ describe("xlr compile package info", () => { delete process.env.BAZEL_STABLE_STATUS_FILE; delete process.env.BAZEL_PACKAGE; delete process.env.XLR_PACKAGE_NAME; + delete process.env.XLR_IOS_PACKAGE_NAME; + delete process.env.XLR_ANDROID_PACKAGE_NAME; delete process.env.JS_BINARY__EXECROOT; }); @@ -85,18 +53,17 @@ describe("xlr compile package info", () => { }); }); - test("records the name alone when package.json has no version", async () => { + test("omits packages and warns when package.json has no version", async () => { writeFixture(workspace, { name: "@test/plugin" }); await XLRCompile.run(["-i", "src", "-o", "dist"]); - expect(readManifest(workspace).packages).toStrictEqual({ - react: { name: "@test/plugin" }, - }); + expect(readManifest(workspace).packages).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('No "version" in'), + ); }); - // The omission must be noisy: a manifest silently losing its `packages` key is the - // failure mode this whole path exists to prevent. describe("when package.json is missing or incomplete", () => { test("omits packages and warns when there is no package.json", async () => { writeFixture(workspace); @@ -123,6 +90,108 @@ describe("xlr compile package info", () => { ); }); }); + + describe("mobile packages", () => { + test("records ios and android from config.xlr.platformPackages, keyed by package name", async () => { + writeFixture(workspace, { name: "@test/plugin", version: "2.3.4" }); + const configPath = writePlayerConfig(workspace, { + "@test/plugin": { + ios: { name: "TestPlugin", version: "3.2.1" }, + android: { name: "com.test:plugin", version: "4.0.0" }, + }, + }); + + await XLRCompile.run([ + "-i", + "src", + "-o", + "dist", + "--config", + configPath, + ]); + + expect(readManifest(workspace).packages).toStrictEqual({ + react: { name: "@test/plugin", version: "2.3.4" }, + ios: { name: "TestPlugin", version: "3.2.1" }, + android: { name: "com.test:plugin", version: "4.0.0" }, + }); + }); + + test("a plugin absent from platformPackages gets react only, no warning", async () => { + writeFixture(workspace, { name: "@test/plugin", version: "2.3.4" }); + const configPath = writePlayerConfig(workspace, { + "@some/other-plugin": { + ios: { name: "Other", version: "1.0.0" }, + }, + }); + + await XLRCompile.run([ + "-i", + "src", + "-o", + "dist", + "--config", + configPath, + ]); + + expect(readManifest(workspace).packages).toStrictEqual({ + react: { name: "@test/plugin", version: "2.3.4" }, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + test("drops a platform entry missing a version, without affecting the other platform or react", async () => { + writeFixture(workspace, { name: "@test/plugin", version: "2.3.4" }); + const configPath = writePlayerConfig(workspace, { + "@test/plugin": { + ios: { name: "TestPlugin" }, + android: { name: "com.test:plugin", version: "4.0.0" }, + }, + }); + + await XLRCompile.run([ + "-i", + "src", + "-o", + "dist", + "--config", + configPath, + ]); + + expect(readManifest(workspace).packages).toStrictEqual({ + react: { name: "@test/plugin", version: "2.3.4" }, + android: { name: "com.test:plugin", version: "4.0.0" }, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/"version".*"ios"/), + ); + }); + + test("drops a platform entry missing a name, naming the missing field", async () => { + writeFixture(workspace, { name: "@test/plugin", version: "2.3.4" }); + const configPath = writePlayerConfig(workspace, { + "@test/plugin": { + android: { version: "4.0.0" }, + }, + }); + + await XLRCompile.run([ + "-i", + "src", + "-o", + "dist", + "--config", + configPath, + ]); + + expect(readManifest(workspace).packages).toStrictEqual({ + react: { name: "@test/plugin", version: "2.3.4" }, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/"name".*"android"/), + ); + }); + }); }); describe("bazel", () => { @@ -193,9 +262,15 @@ describe("xlr compile package info", () => { }); }); - test("omits the version when not stamped", async () => { + test("omits packages and warns when not stamped", async () => { + // Bazel always provides the status file (ctx.info_file exists on every build), but + // without `--stamp` its content has no STABLE_VERSION line — the env var being unset + // entirely isn't how an unstamped build actually looks. writeFixture(path.join(workspace, pkgPath)); process.env.XLR_PACKAGE_NAME = "@test/plugin"; + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, ""); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; await XLRCompile.run([ "-i", @@ -206,12 +281,21 @@ describe("xlr compile package info", () => { expect( readManifest(path.join(workspace, pkgPath)).packages, - ).toStrictEqual({ - react: { name: "@test/plugin" }, - }); + ).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("No stamped version"), + ); }); describe("when package.json is missing or incomplete", () => { + // These exercise react's name-fallback failing; a stamp is needed so the function gets + // past the version check to attempt that fallback at all. + beforeEach(() => { + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, "STABLE_VERSION 1.1.0\n"); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; + }); + test("omits packages and warns when neither XLR_PACKAGE_NAME nor package.json is available", async () => { writeFixture(path.join(workspace, pkgPath)); @@ -245,5 +329,109 @@ describe("xlr compile package info", () => { ).toBeUndefined(); }); }); + + describe("mobile packages", () => { + test("records ios and android names from env vars, sharing the stamped version", async () => { + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_PACKAGE_NAME = "@test/plugin"; + process.env.XLR_IOS_PACKAGE_NAME = "PlayerUIReferenceAssets"; + process.env.XLR_ANDROID_PACKAGE_NAME = + "com.intuit.playerui.plugins:reference-assets"; + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, "STABLE_VERSION 1.1.0\n"); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toStrictEqual({ + react: { name: "@test/plugin", version: "1.1.0" }, + ios: { name: "PlayerUIReferenceAssets", version: "1.1.0" }, + android: { + name: "com.intuit.playerui.plugins:reference-assets", + version: "1.1.0", + }, + }); + }); + + test("omits ios when only its env var is unset, without affecting android or react", async () => { + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_PACKAGE_NAME = "@test/plugin"; + process.env.XLR_ANDROID_PACKAGE_NAME = + "com.intuit.playerui.plugins:reference-assets"; + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, "STABLE_VERSION 1.1.0\n"); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toStrictEqual({ + react: { name: "@test/plugin", version: "1.1.0" }, + android: { + name: "com.intuit.playerui.plugins:reference-assets", + version: "1.1.0", + }, + }); + }); + + test("react's absence does not suppress ios/android", async () => { + // No package.json and no XLR_PACKAGE_NAME: react's name cannot be resolved. + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_IOS_PACKAGE_NAME = "PlayerUIReferenceAssets"; + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, "STABLE_VERSION 1.1.0\n"); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toStrictEqual({ + ios: { name: "PlayerUIReferenceAssets", version: "1.1.0" }, + }); + }); + + test("omits ios/android when not stamped, even with env vars set", async () => { + // Same real-world shape as the react-only "not stamped" case above: the status file + // exists, it just has no STABLE_VERSION line. + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_PACKAGE_NAME = "@test/plugin"; + process.env.XLR_IOS_PACKAGE_NAME = "PlayerUIReferenceAssets"; + process.env.XLR_ANDROID_PACKAGE_NAME = + "com.intuit.playerui.plugins:reference-assets"; + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, ""); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toBeUndefined(); + }); + }); }); }); diff --git a/cli/src/__tests__/commands/xlr-compile-test-helpers.ts b/cli/src/__tests__/commands/xlr-compile-test-helpers.ts new file mode 100644 index 0000000..9711dff --- /dev/null +++ b/cli/src/__tests__/commands/xlr-compile-test-helpers.ts @@ -0,0 +1,58 @@ +import fs from "fs"; +import path from "path"; +import { vi } from "vitest"; +import { Errors } from "@oclif/core"; + +/** A plugin package with one asset, laid out the way `xlr compile` expects */ +export function writeFixture( + dir: string, + packageJson?: Record, +) { + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + + if (packageJson) { + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify(packageJson), + ); + } + + fs.writeFileSync( + path.join(dir, "src", "index.ts"), + ` +import type { ExtendedPlayerPlugin } from "@player-ui/player"; + +export interface TestAsset { + id: string; + type: "test"; +} + +export class TestPlugin implements ExtendedPlayerPlugin<[TestAsset]> { + name = "test-plugin"; +} +`, + ); +} + +/** Silences `Errors.warn` while capturing what it was called with */ +export function spyOnWarn() { + return vi.spyOn(Errors, "warn").mockImplementation(() => undefined); +} + +/** A player config file with `xlr.platformPackages` set inline */ +export function writePlayerConfig( + dir: string, + platformPackages: Record, +): string { + const configPath = path.join(dir, "player.config.json"); + + fs.writeFileSync(configPath, JSON.stringify({ xlr: { platformPackages } })); + + return configPath; +} + +export function readManifest(dir: string) { + return JSON.parse( + fs.readFileSync(path.join(dir, "dist", "xlr", "manifest.json"), "utf-8"), + ); +} diff --git a/cli/src/commands/xlr/compile.ts b/cli/src/commands/xlr/compile.ts index c0f9411..e214a45 100644 --- a/cli/src/commands/xlr/compile.ts +++ b/cli/src/commands/xlr/compile.ts @@ -46,10 +46,12 @@ export default class XLRCompile extends BaseCommand { const input = config.xlr?.input ?? flags.input; const output = config.xlr?.output ?? flags.output; const modeValue = config.xlr?.mode ?? flags.mode; + const platformPackages = config.xlr?.platformPackages; return { inputPath: input, outputDir: path.join(output, "xlr"), mode: modeValue === "plugin" ? Mode.PLUGIN : Mode.TYPES, + platformPackages, }; } @@ -57,12 +59,13 @@ export default class XLRCompile extends BaseCommand { /** the status code */ exitCode: number; }> { - const { inputPath, outputDir, mode } = await this.getOptions(); + const { inputPath, outputDir, mode, platformPackages } = + await this.getOptions(); const inputFiles = globby.sync([ `${inputPath}/**/*.ts`, `${inputPath}/**/*.tsx`, ]); - const packages = getPackages(); + const packages = getPackages(platformPackages); try { this.processTypes(inputFiles, outputDir, {}, mode, packages); } catch (e: any) { diff --git a/cli/src/config.ts b/cli/src/config.ts index 100b436..a1cfae4 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -1,3 +1,4 @@ +import type { PlatformPackages } from "@xlr-lib/xlr"; import type { PlayerCLIPlugin } from "./plugins"; export interface PlayerConfigFileShape { @@ -40,6 +41,16 @@ export interface PlayerConfigResolvedShape { /** When converting to XLR, what strategy to use */ mode?: "plugin" | "types"; + + /** + * A map of npm package name to its ios/android package identity (name + version). For + * non-Bazel consumers, where ios/android are published from separate repos with no build + * graph to read those identities from directly, so they're hand-maintained here instead. + */ + platformPackages?: Record< + string, + Pick + >; }; /** Flattened list of plugins */ diff --git a/cli/src/utils/xlr/packages.ts b/cli/src/utils/xlr/packages.ts index f1039f1..3665a62 100644 --- a/cli/src/utils/xlr/packages.ts +++ b/cli/src/utils/xlr/packages.ts @@ -3,33 +3,177 @@ import path from "path"; import { Errors } from "@oclif/core"; import type { PlatformPackages } from "@xlr-lib/xlr"; -/** - * Where the npm name and version of the package being compiled come from. - * - * | | name | version | - * | --- | --- | --- | - * | Bazel (stamped) | `XLR_PACKAGE_NAME`, else the package's `package.json` | the `STABLE_VERSION` stamp | - * | anywhere else | `XLR_PACKAGE_NAME`, else the package's `package.json` | the package's `package.json` | - * - * Only the version differs between the two: under Bazel the version in `package.json` is a - * placeholder that is substituted at publish time, so the stamp is the only real source. - * Elsewhere the package manager keeps `package.json` current and it is read directly. - */ +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- /** - * The directory of the package being compiled. + * The packages that provide the capabilities being compiled, keyed by platform, or undefined + * if none of them can be determined. * - * Bazel runs from the workspace root and names the package in `BAZEL_PACKAGE`; everywhere - * else the working directory is already the package. + * Bazel runs from the workspace root and names the package in `BAZEL_PACKAGE`; everywhere else + * the working directory is already the package. That's also the one signal for which path + * applies below. */ -function getPackageDir(): string { +export function getPackages( + platformPackages?: Record>, +): PlatformPackages | undefined { const bazelPackage = process.env.BAZEL_PACKAGE; - - return bazelPackage + const packageDir = bazelPackage ? path.resolve(process.cwd(), bazelPackage) : process.cwd(); + + const packages = bazelPackage + ? getBazelPackages(packageDir) + : getLocalPackages(packageDir, platformPackages); + + if (!packages) { + Errors.warn("Omitting package information from the manifest."); + } + + return packages; +} + +// --------------------------------------------------------------------------- +// Bazel: react/ios/android names come from env vars Bazel passes per +// platform (react falls back to package.json if unset); version is the one +// Bazel stamp shared by all platforms in a release. +// --------------------------------------------------------------------------- + +/** The version Bazel stamped this build with, read from the stable status file */ +function getStampedVersion(): string | undefined { + const statusFile = process.env.BAZEL_STABLE_STATUS_FILE; + + if (!statusFile) { + return undefined; + } + + // Bazel names the status file relative to the execroot (`File.path`), but the js_binary + // launcher changes directory out of the execroot into BAZEL_BINDIR before running the + // tool, so re-anchor the path before reading it. + const execroot = process.env.JS_BINARY__EXECROOT; + const resolved = execroot ? path.join(execroot, statusFile) : statusFile; + + if (!fs.existsSync(resolved)) { + return undefined; + } + + const line = fs + .readFileSync(resolved, "utf-8") + .split("\n") + .find((l) => l.startsWith("STABLE_VERSION ")); + + return line?.slice("STABLE_VERSION ".length).trim() || undefined; +} + +function getBazelPackages(packageDir: string): PlatformPackages | undefined { + const version = getStampedVersion(); + + // All three platforms share this one stamp; a build without it can't produce a complete + // entry for any of them, so bail out once instead of warning per platform below. + if (!version) { + Errors.warn( + "No stamped version; omitting package information from the manifest.", + ); + return undefined; + } + + const reactName = + process.env.XLR_PACKAGE_NAME || + getPackageJsonName(packageDir, getPackageJson(packageDir)); + const iosName = process.env.XLR_IOS_PACKAGE_NAME; + const androidName = process.env.XLR_ANDROID_PACKAGE_NAME; + + const packages: PlatformPackages = { + ...(reactName ? { react: { name: reactName, version } } : {}), + ...(iosName ? { ios: { name: iosName, version } } : {}), + ...(androidName ? { android: { name: androidName, version } } : {}), + }; + + return Object.keys(packages).length > 0 ? packages : undefined; +} + +// --------------------------------------------------------------------------- +// Non-Bazel: react from package.json. There's no build graph here to read +// ios/android from — those live in entirely separate repos with their own +// release cadence — so they come from an optional, hand-maintained map +// instead (config.xlr.platformPackages). +// --------------------------------------------------------------------------- + +function getLocalPackages( + packageDir: string, + platformPackages: + | Record> + | undefined, +): PlatformPackages | undefined { + const packageJson = getPackageJson(packageDir); + const name = getPackageJsonName(packageDir, packageJson); + + if (!name) { + return undefined; + } + + const version = getPackageJsonVersion(packageJson); + + if (!version) { + Errors.warn(`No "version" in ${path.join(packageDir, "package.json")}.`); + return undefined; + } + + const mobilePackages = platformPackages + ? getMobilePackages(platformPackages, name) + : undefined; + + return { react: { name, version }, ...mobilePackages }; +} + +/** + * The ios/android entry for `packageName` in `platformPackages`, if any. A package absent from + * the map simply has no mobile packages, silently — not an error. + */ +function getMobilePackages( + platformPackages: Record>, + packageName: string, +): Pick | undefined { + const entry = platformPackages[packageName]; + + if (!entry) { + return undefined; + } + + const packages: Pick = {}; + + (["ios", "android"] as const).forEach((platform) => { + const platformPackage = entry[platform]; + + if (!platformPackage) { + return; + } + + if (!platformPackage.name) { + Errors.warn( + `No "name" for "${platform}" of "${packageName}" in config.xlr.platformPackages; omitting it from the manifest.`, + ); + return; + } + + if (!platformPackage.version) { + Errors.warn( + `No "version" for "${platform}" of "${packageName}" in config.xlr.platformPackages; omitting it from the manifest.`, + ); + return; + } + + packages[platform] = platformPackage; + }); + + return packages; } +// --------------------------------------------------------------------------- +// Shared: package.json helpers, used by both the Bazel and non-Bazel paths above. +// --------------------------------------------------------------------------- + /** The parsed `package.json` of the package being compiled, or undefined if there isn't a readable one */ function getPackageJson( packageDir: string, @@ -76,56 +220,3 @@ function getPackageJsonVersion( return typeof version === "string" && version ? version : undefined; } - -/** The version Bazel stamped this build with, read from the stable status file */ -function getStampedVersion(): string | undefined { - const statusFile = process.env.BAZEL_STABLE_STATUS_FILE; - - if (!statusFile) { - return undefined; - } - - // Bazel names the status file relative to the execroot (`File.path`), but the js_binary - // launcher changes directory out of the execroot into BAZEL_BINDIR before running the - // tool, so re-anchor the path before reading it. - const execroot = process.env.JS_BINARY__EXECROOT; - const resolved = execroot ? path.join(execroot, statusFile) : statusFile; - - if (!fs.existsSync(resolved)) { - return undefined; - } - - const line = fs - .readFileSync(resolved, "utf-8") - .split("\n") - .find((l) => l.startsWith("STABLE_VERSION ")); - - return line?.slice("STABLE_VERSION ".length).trim() || undefined; -} - -/** - * The npm package that provides the capabilities being compiled, or undefined if its name - * cannot be determined. - */ -export function getPackages(): PlatformPackages | undefined { - const packageDir = getPackageDir(); - const packageJson = getPackageJson(packageDir); - - // Bazel only knows the package path, so it passes the npm name through the environment. - const name = - process.env.XLR_PACKAGE_NAME || getPackageJsonName(packageDir, packageJson); - - if (!name) { - Errors.warn("Omitting package information from the manifest."); - return undefined; - } - - // Only a stamped Bazel build produces a status file; otherwise `package.json` is the source. - const version = getStampedVersion() ?? getPackageJsonVersion(packageJson); - - // TODO: only `react` is generated, because XLR is compiled from TypeScript and there is no - // equivalent for iOS or Android. Native configurations will be added later. - return { - react: { name, ...(version ? { version } : {}) }, - }; -}