Compare commits

...
Author SHA1 Message Date
bugfix 77d0511681 style(ios): apply prettier formatting to TRU-92 bundle ID guard test
The new tests/unit/tru-92-ios-bundle-id-guard.spec.js was added without
running `npm run format:tests`, so the format-tests CI job failed with
`prettier --check`. The trailing-comma style in the inline PNG buffer
array and one of the trailing semicolons triggered the check. This is
a pure formatting fix produced by `prettier --write` and does not
change the test logic.
2026-08-17 16:49:33 +00:00
bugfix 096f164271 fix(ios): guard against npx cap sync clobbering iOS bundle ID (TRU-92)
The iOS Xcode project uses io.truckwash.app as the release bundle ID, but
capacitor.config.ts intentionally declares appId=io.truckwash.twa (the Android
TWA app ID) because the iOS target is a separate native app, not a
Capacitor-managed one. If anyone runs 'npx cap sync ios' against the current
config, Capacitor would overwrite the iOS bundle ID with the TWA appId and
break the production iOS install (e.g. Mads's iPhone).

Harden scripts/mobile/validate-app-store.mjs so it now reads
ios/App/App.xcodeproj/project.pbxproj and asserts that every
PRODUCT_BUNDLE_IDENTIFIER is either io.truckwash.app (release) or
io.truckwash.app.debug (debug). Anything else (most importantly the TWA
appId) fails the storefront readiness check with a clear remediation message.

Cover the new guard with a focused unit test that builds a fixture Xcode
project in a temp dir with each of the known-good, TWA-clobbered, and
unrelated-bundle-ID scenarios and asserts the validator exits with the
expected status.
2026-08-17 16:15:11 +00:00
2 changed files with 271 additions and 0 deletions
+38
View File
@@ -39,6 +39,44 @@ if (release.bundleId !== "io.truckwash.app") fail("ios/release.json bundleId mus
if (release.minimumIosVersion !== "15.0")
fail("ios/release.json minimumIosVersion must remain 15.0 unless compatibility is intentionally changed");
// Guard: the iOS Xcode project must keep its native bundle IDs. capacitor.config.ts
// intentionally uses the Android TWA appId ("io.truckwash.twa") because the iOS
// project is a separate native target, not a Capacitor-managed app. If anyone
// runs `npx cap sync ios`, Capacitor would overwrite the iOS bundle ID with the
// TWA appId and break the production iOS install (e.g. Mads's iPhone). Fail
// loudly here so that mistake is caught before any release is cut.
const xcodeProjectPath = join(root, "ios/App/App.xcodeproj/project.pbxproj");
const xcodeProject = existsSync(xcodeProjectPath) ? readFileSync(xcodeProjectPath, "utf8") : "";
if (!xcodeProject) {
fail(`Missing ${relative(root, xcodeProjectPath)}`);
} else {
const releaseMatches = [
...new Set(
[...xcodeProject.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([\w.-]+)\s*;/g)].map((m) => m[1])
),
];
if (releaseMatches.length === 0) {
fail(`Could not find any PRODUCT_BUNDLE_IDENTIFIER in ${relative(root, xcodeProjectPath)}`);
} else {
if (!releaseMatches.includes("io.truckwash.app")) {
fail(
`${relative(root, xcodeProjectPath)} must contain PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app ` +
`(release target). Found: ${releaseMatches.join(", ")}. ` +
"This usually means `npx cap sync ios` overwrote the iOS bundle ID. " +
"Do not sync the TWA appId (io.truckwash.twa) into the iOS project — the iOS target is a separate native app."
);
}
for (const id of releaseMatches) {
if (id !== "io.truckwash.app" && id !== "io.truckwash.app.debug") {
fail(
`${relative(root, xcodeProjectPath)} contains an unexpected PRODUCT_BUNDLE_IDENTIFIER: ${id}. ` +
"Only io.truckwash.app (release) and io.truckwash.app.debug (debug) are allowed."
);
}
}
}
}
const name = readText(join(metadataRoot, "name.txt"));
const subtitle = readText(join(metadataRoot, "subtitle.txt"));
const promotionalText = readText(join(metadataRoot, "promotional_text.txt"));
@@ -0,0 +1,233 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const SCRIPT = "scripts/mobile/validate-app-store.mjs";
const ABS_SCRIPT = join(process.cwd(), SCRIPT);
const temporaryRoots = [];
afterEach(() => {
while (temporaryRoots.length > 0) {
rmSync(temporaryRoots.pop(), { recursive: true, force: true });
}
});
/**
* Build a minimal fixture repository root that satisfies every check in
* `validate-app-store.mjs` EXCEPT the iOS Xcode project bundle ID, which the
* caller controls via `xcodeBundleId`.
*/
function buildFixture({ xcodeBundleId, includeDebugId = true }) {
const root = mkdtempSync(join(tmpdir(), "tru-92-ios-guard-"));
temporaryRoots.push(root);
// ios/release.json
mkdirSync(join(root, "ios"), { recursive: true });
writeFileSync(
join(root, "ios/release.json"),
JSON.stringify(
{
marketingVersion: "1.1.0",
bundleId: "io.truckwash.app",
minimumIosVersion: "15.0",
},
null,
2
) + "\n"
);
// fastlane/metadata/da and copyright.txt
const files = {
name: "Truck Wash",
subtitle: "Vask af lastbil",
promotional_text: "Hurtig vask til lastbiler i hele Danmark.",
keywords: "truck,wash,dansk",
description: "Velkommen til Truck Wash. Scan QR, find afdeling, vask dit koretoj.",
release_notes: "Mindre forbedringer og fejlrettelser.",
};
mkdirSync(join(root, "fastlane/metadata/da"), { recursive: true });
for (const [name, value] of Object.entries(files)) {
const filename = name === "promotional_text" ? "promotional_text.txt" : `${name}.txt`;
writeFileSync(join(root, "fastlane/metadata/da", filename), value);
}
const urls = {
support_url: "https://truckwash.io/support",
privacy_url: "https://truckwash.io/privacy",
marketing_url: "https://truckwash.io",
};
for (const [name, value] of Object.entries(urls)) {
writeFileSync(join(root, "fastlane/metadata/da", `${name}.txt`), value);
}
writeFileSync(join(root, "fastlane/metadata/copyright.txt"), "2026 Truck Wash ApS");
// App icons (custom hashes so they do NOT match the known Capacitor defaults)
const iconDir = join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
const splashDir = join(root, "ios/App/App/Assets.xcassets/Splash.imageset");
mkdirSync(iconDir, { recursive: true });
mkdirSync(splashDir, { recursive: true });
// Build a tiny valid PNG with deterministic bytes that won't match the
// known Capacitor defaults. The validator only checks the SHA-256.
const png = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d,
0x49,
0x48,
0x44,
0x52, // IHDR length + tag
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01, // 1x1
0x08,
0x06,
0x00,
0x00,
0x00,
0x1f,
0x15,
0xc4,
0x89, // 8-bit RGBA
0x00,
0x00,
0x00,
0x0d,
0x49,
0x44,
0x41,
0x54, // IDAT length + tag
0x78,
0x9c,
0x62,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0d,
0x0a,
0x2d,
0xb4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4e,
0x44, // IEND length + tag
0xae,
0x42,
0x60,
0x82,
]);
writeFileSync(join(iconDir, "AppIcon-512@2x.png"), png);
writeFileSync(join(splashDir, "splash-2732x2732.png"), png);
// Xcode project with a controllable bundle ID
mkdirSync(join(root, "ios/App/App.xcodeproj"), { recursive: true });
const ids = [`PRODUCT_BUNDLE_IDENTIFIER = ${xcodeBundleId};`];
if (includeDebugId) ids.push("PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app.debug;");
const xcode = [
"// !$*UTF8*$!",
"{ archive = { isa = PBXProject; buildConfigurationList = LIST; }; }",
"begin XCBuildConfiguration section",
"buildSettings = {",
...ids.map((line) => ` ${line}`),
" INFOPLIST_FILE = ios/App/App/Info.plist;",
" PRODUCT_NAME = TruckWash;",
"};",
"end XCBuildConfiguration section",
].join("\n");
writeFileSync(join(root, "ios/App/App.xcodeproj/project.pbxproj"), xcode);
return root;
}
function runValidator(fixtureRoot) {
return spawnSync("node", [ABS_SCRIPT], {
cwd: fixtureRoot,
encoding: "utf8",
timeout: 30_000,
});
}
describe("TRU-92 iOS bundle ID guard", () => {
it("passes when the iOS Xcode project uses io.truckwash.app and a debug variant", () => {
const fixtureRoot = buildFixture({ xcodeBundleId: "io.truckwash.app" });
const result = runValidator(fixtureRoot);
expect(result.status).toBe(0);
expect(result.stdout).toContain("App Store metadata is valid");
expect(result.stderr).not.toContain("PRODUCT_BUNDLE_IDENTIFIER");
});
it("fails when the iOS Xcode project bundle ID has been clobbered to the TWA appId", () => {
// Simulate what `npx cap sync ios` would do: copy the Android TWA appId
// from capacitor.config.ts into the iOS Xcode project, which would break
// Mads's iPhone install.
const fixtureRoot = buildFixture({ xcodeBundleId: "io.truckwash.twa" });
const result = runValidator(fixtureRoot);
expect(result.status).toBe(1);
expect(result.stderr).toContain("PRODUCT_BUNDLE_IDENTIFIER");
expect(result.stderr).toContain("io.truckwash.app");
expect(result.stderr).toContain("io.truckwash.twa");
// The error message lists the IDs found in the Xcode project.
expect(result.stderr).toContain("io.truckwash.app.debug");
// The fix is run from a tmpdir, so the relative path differs — accept any.
expect(result.stderr).toMatch(/project\.pbxproj/u);
});
it("fails when an unrelated bundle ID sneaks into the iOS project", () => {
const fixtureRoot = buildFixture({ xcodeBundleId: "com.example.wrong" });
const result = runValidator(fixtureRoot);
expect(result.status).toBe(1);
expect(result.stderr).toContain("com.example.wrong");
expect(result.stderr).toContain("unexpected PRODUCT_BUNDLE_IDENTIFIER");
});
it("still passes for a debug-only project (no release target) only if the only ID is the debug variant", () => {
const fixtureRoot = buildFixture({
xcodeBundleId: "io.truckwash.app.debug",
includeDebugId: false,
});
const result = runValidator(fixtureRoot);
// The release target is missing in this scenario, which is a real failure
// mode that would also break the production iOS release pipeline.
expect(result.status).toBe(1);
expect(result.stderr).toContain("io.truckwash.app");
});
});
// Sanity check: ensure the script under test actually exists in the working
// tree so this test does not silently pass when the validator is moved.
describe("validate-app-store.mjs presence", () => {
it("is wired up to a real script file", () => {
const repoRoot = process.cwd();
const scriptPath = join(repoRoot, SCRIPT);
expect(existsSync(scriptPath)).toBe(true);
// Touch the unused import to keep it (used to assert the fixture path is
// meaningful and the relative() helper resolves).
expect(relative(repoRoot, scriptPath)).toBe(SCRIPT);
});
});