386 lines
24 KiB
TypeScript
386 lines
24 KiB
TypeScript
// storebound import fastlane: locale mapping, Ruby option lines, the plan from tests/fixtures/fastlane-sample and the
|
|
// no-overwrite writer. Fixture folders are copied into temp dirs so each case can drop parts of the setup.
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { applyPlan, report } from '../cli/import';
|
|
import { validateConfig } from '../cli/lib/config';
|
|
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
|
|
import { categoryId, classifyAppleShots, findFastlaneDirs, importedPath, normalizeKeywords, parseRubyOptions, planImport, scanFastlane, tsString } from '../cli/lib/fastlane';
|
|
import { APPLE_LOCALES, APPLE_TO_PLAY, appleLocale, appleToPlay, pairLanguages, PLAY_LOCALES, playLocale, playToApple } from '../cli/lib/store-locales';
|
|
import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
|
import { png } from './fixtures';
|
|
|
|
const SAMPLE = join(import.meta.dir, 'fixtures', 'fastlane-sample', 'fastlane');
|
|
const temps: string[] = [];
|
|
const temp = () => { const d = mkdtempSync(join(tmpdir(), 'sb-fastlane-')); temps.push(d); return d; };
|
|
afterAll(() => { for (const d of temps) rmSync(d, { recursive: true, force: true }); });
|
|
|
|
/** A project folder with a copy of the sample fastlane folder, minus the given paths (relative to fastlane/). */
|
|
function project(drop: string[] = [], sub = 'fastlane'): { root: string; fl: string } {
|
|
const root = temp();
|
|
const fl = join(root, sub);
|
|
cpSync(SAMPLE, fl, { recursive: true });
|
|
for (const p of drop) rmSync(join(fl, p), { recursive: true, force: true });
|
|
return { root, fl };
|
|
}
|
|
|
|
const plan = (root: string, dirs: string[], profile?: 'app' | 'game' | 'subscription-app') => planImport(scanFastlane(dirs), { root, profile });
|
|
const fileText = (p: ReturnType<typeof plan>, path: string) => p.files.find(f => f.path === path)?.text;
|
|
|
|
async function loadPlannedConfig(p: ReturnType<typeof plan>): Promise<unknown> {
|
|
const d = temp();
|
|
const f = join(d, 'storebound.config.ts');
|
|
writeFileSync(f, fileText(p, 'storebound.config.ts')!);
|
|
return (await import(f)).default;
|
|
}
|
|
|
|
describe('store locales', () => {
|
|
test('every pair names a code the other store has', () => {
|
|
for (const [a, p] of Object.entries(APPLE_TO_PLAY)) {
|
|
expect(APPLE_LOCALES).toContain(a);
|
|
expect(PLAY_LOCALES).toContain(p);
|
|
expect(playToApple(p)).toBe(a);
|
|
}
|
|
});
|
|
test('fastlane deliver folders -> App Store locales', () => {
|
|
expect(appleLocale('en-US')).toEqual({ code: 'en-US', known: true });
|
|
expect(appleLocale('zh-Hans')).toEqual({ code: 'zh-Hans', known: true });
|
|
expect(appleLocale('tr-TR')).toEqual({ code: 'tr', known: true, alias: 'tr-TR' });
|
|
expect(appleLocale('en')).toEqual({ code: 'en-US', known: true, alias: 'en' });
|
|
expect(appleLocale('zh-CN').code).toBe('zh-Hans');
|
|
expect(appleLocale('he-IL').code).toBe('he');
|
|
expect(appleLocale('EN-gb')).toEqual({ code: 'en-GB', known: true, alias: 'EN-gb' });
|
|
expect(appleLocale('xx-YY')).toEqual({ code: 'xx-YY', known: false });
|
|
});
|
|
test('the official lists (checked 2026-10-11): 50 App Store locales, 86 Play codes', () => {
|
|
// Apple: "Managing metadata in your app by using locale shortcodes" (App Store Connect API docs)
|
|
expect([...APPLE_LOCALES].sort()).toEqual(['ar-SA', 'bn-BD', 'ca', 'cs', 'da', 'de-DE', 'el', 'en-AU', 'en-CA', 'en-GB',
|
|
'en-US', 'es-ES', 'es-MX', 'fi', 'fr-CA', 'fr-FR', 'gu-IN', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'ja', 'kn-IN', 'ko',
|
|
'ml-IN', 'mr-IN', 'ms', 'nl-NL', 'no', 'or-IN', 'pa-IN', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl-SI', 'sv', 'ta-IN',
|
|
'te-IN', 'th', 'tr', 'uk', 'ur-PK', 'vi', 'zh-Hans', 'zh-Hant']);
|
|
// Google: Play Console Help "Translate and localize your app" > "View list of available languages"
|
|
expect(PLAY_LOCALES).toHaveLength(86);
|
|
expect(new Set(PLAY_LOCALES).size).toBe(86);
|
|
for (const c of ['bn-BD', 'gu', 'kn-IN', 'ml-IN', 'mr-IN', 'pa', 'sl', 'ta-IN', 'te-IN', 'ur', 'iw-IL', 'es-US', 'fil', 'rm', 'fa-AE'])
|
|
expect(PLAY_LOCALES).toContain(c);
|
|
for (const c of ['zu', 'he', 'or', 'or-IN', 'tr', 'ja']) expect(PLAY_LOCALES).not.toContain(c);
|
|
});
|
|
test('the 2025 App Store languages pair with Play where Play has them', () => {
|
|
const want: Record<string, string | undefined> = { 'bn-BD': 'bn-BD', 'gu-IN': 'gu', 'kn-IN': 'kn-IN', 'ml-IN': 'ml-IN',
|
|
'mr-IN': 'mr-IN', 'or-IN': undefined, 'pa-IN': 'pa', 'sl-SI': 'sl', 'ta-IN': 'ta-IN', 'te-IN': 'te-IN', 'ur-PK': 'ur' };
|
|
for (const [a, p] of Object.entries(want)) {
|
|
expect(appleToPlay(a)).toBe(p);
|
|
if (p) expect(playToApple(p)).toBe(a);
|
|
}
|
|
// every App Store locale maps to a real Play code or to none, and back again
|
|
for (const a of APPLE_LOCALES) { const p = appleToPlay(a); if (p) { expect(PLAY_LOCALES).toContain(p); expect(playToApple(p)).toBe(a); } }
|
|
// bare spellings in fastlane folders
|
|
expect(appleLocale('ta').code).toBe('ta-IN');
|
|
expect(appleLocale('sl').code).toBe('sl-SI');
|
|
expect(appleLocale('ur')).toEqual({ code: 'ur-PK', known: true, alias: 'ur' });
|
|
expect(playLocale('pa-IN')).toEqual({ code: 'pa', known: true, alias: 'pa-IN' });
|
|
expect(playLocale('ur-PK').code).toBe('ur');
|
|
expect(playLocale('bn').code).toBe('bn-BD');
|
|
expect(playLocale('zu').known).toBe(false);
|
|
});
|
|
test('every alias lands on a listed code', async () => {
|
|
const src = await Bun.file(new URL('../cli/lib/store-locales.ts', import.meta.url)).text();
|
|
for (const k of [...src.matchAll(/'([A-Za-z-]+)': '[A-Za-z0-9-]+'/g)].map(m => m[1]!)) {
|
|
const a = appleLocale(k), p = playLocale(k);
|
|
expect(a.known || p.known).toBe(true);
|
|
}
|
|
});
|
|
test('fastlane supply folders -> Play codes', () => {
|
|
expect(playLocale('tr-TR')).toEqual({ code: 'tr-TR', known: true });
|
|
expect(playLocale('iw-IL').known).toBe(true);
|
|
expect(playLocale('he-IL')).toEqual({ code: 'iw-IL', known: true, alias: 'he-IL' });
|
|
expect(playLocale('nb-NO').code).toBe('no-NO');
|
|
expect(playLocale('zh-Hans').code).toBe('zh-CN');
|
|
expect(playLocale('qq')).toEqual({ code: 'qq', known: false });
|
|
});
|
|
test('cross-store pairs', () => {
|
|
expect(appleToPlay('tr')).toBe('tr-TR');
|
|
expect(appleToPlay('ja')).toBe('ja-JP');
|
|
expect(appleToPlay('zh-Hant')).toBe('zh-TW');
|
|
expect(appleToPlay('he')).toBe('iw-IL');
|
|
expect(appleToPlay('es-MX')).toBe('es-419');
|
|
expect(appleToPlay('de-DE')).toBe('de-DE');
|
|
expect(appleToPlay('or-IN')).toBeUndefined();
|
|
expect(playToApple('ar')).toBe('ar-SA');
|
|
expect(playToApple('ms-MY')).toBe('ms');
|
|
expect(playToApple('en-IN')).toBeUndefined();
|
|
});
|
|
test('pairLanguages: one entry per language, en-US first, unique ids', () => {
|
|
const l = pairLanguages(['tr', 'en-US', 'en-GB', 'zh-Hans', 'ja'], ['en-US', 'tr-TR', 'zh-CN', 'es-419', 'iw-IL']);
|
|
expect(l).toEqual([
|
|
{ id: 'en', play: 'en-US', apple: 'en-US' },
|
|
{ id: 'en-gb', apple: 'en-GB' },
|
|
{ id: 'es', play: 'es-419' },
|
|
{ id: 'he', play: 'iw-IL' },
|
|
{ id: 'ja', apple: 'ja' },
|
|
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
|
|
{ id: 'zh', play: 'zh-CN', apple: 'zh-Hans' },
|
|
]);
|
|
expect(pairLanguages(['ms'], ['ms-MY'])).toEqual([{ id: 'ms', play: 'ms-MY', apple: 'ms' }]);
|
|
expect(pairLanguages(['pt-BR', 'pt-PT'], []).map(x => x.id)).toEqual(['pt', 'pt-pt']);
|
|
});
|
|
});
|
|
|
|
describe('fastlane option files', () => {
|
|
test('plain lines are read, Ruby logic is reported by key only', () => {
|
|
const o = parseRubyOptions([
|
|
'app_identifier("com.example.app") # comment',
|
|
"package_name 'com.example.app'",
|
|
'languages(["en-US", "de-DE"])',
|
|
'submit_for_review(false)',
|
|
'phased_release true',
|
|
'price_tier(0)',
|
|
'username(ENV["FASTLANE_USER"])',
|
|
'team_id("#{prefix}X")',
|
|
'for_platform :ios do',
|
|
' app_identifier "com.example.app.ios"',
|
|
'end',
|
|
'app_identifier("com.example.other")',
|
|
].join('\n'));
|
|
expect(o.values).toEqual({ app_identifier: 'com.example.app', package_name: 'com.example.app', languages: ['en-US', 'de-DE'], submit_for_review: false, phased_release: true, price_tier: 0 });
|
|
expect(o.byHand.map(b => `${b.key}:${b.line}`)).toEqual(['username:7', 'team_id:8', 'app_identifier:10', 'app_identifier:12']);
|
|
expect(o.byHand[0]!.why).toMatch(/environment/);
|
|
expect(JSON.stringify(o.byHand)).not.toContain('FASTLANE_USER');
|
|
});
|
|
test('categories, keywords, TS strings', () => {
|
|
expect(categoryId('MZGenre.Productivity')).toBe('PRODUCTIVITY');
|
|
expect(categoryId('Photo & Video')).toBe('PHOTO_AND_VIDEO');
|
|
expect(categoryId('GAMES')).toBe('GAMES');
|
|
expect(categoryId('Puzzle', true)).toBe('GAMES_PUZZLE');
|
|
expect(categoryId('MZGenre.Games.Puzzle'.replace('Games.', 'Games_'), true)).toBe('GAMES_PUZZLE');
|
|
expect(categoryId('Knitting')).toBeNull();
|
|
expect(normalizeKeywords('a, b ,c\nd')).toBe('a,b,c,d');
|
|
expect(tsString("it's\n\\")).toBe("'it\\'s\\n\\\\'");
|
|
});
|
|
test('deliver screenshots: largest iPhone set, iPad as tablet, Watch left out', () => {
|
|
const d = temp();
|
|
const f = (n: string, w: number, h: number) => { const p = join(d, n); writeFileSync(p, png(w, h)); return p; };
|
|
const files = [f('iPhone 16 Pro Max-01.png', 1320, 2868), f('iPhone 8 Plus-01.png', 1242, 2208), f('iPad Pro-01.png', 2048, 2732), f('Apple Watch-01.png', 416, 496), f('01_tablet_by_size.png', 1536, 2048)];
|
|
const c = classifyAppleShots(files);
|
|
expect(c.phone).toEqual([files[0]!]);
|
|
expect(c.tablet).toEqual([files[2]!]);
|
|
expect(c.skipped.sort()).toEqual([files[1]!, files[3]!, files[4]!].sort());
|
|
});
|
|
});
|
|
|
|
describe('import plan: iOS + Android (tests/fixtures/fastlane-sample)', () => {
|
|
const { root, fl } = project();
|
|
const p = plan(root, [fl]);
|
|
|
|
test('ids, languages, profile', () => {
|
|
expect(p.bundleId).toBe('com.example.notes');
|
|
expect(p.packageName).toBe('com.example.notes');
|
|
expect(p.profile).toBe('app');
|
|
expect(p.appName).toBe('Example Notes');
|
|
expect(p.languages).toEqual([
|
|
{ id: 'en', play: 'en-US', apple: 'en-US' },
|
|
{ id: 'de', play: 'de-DE', apple: 'de-DE' },
|
|
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
|
|
]);
|
|
});
|
|
|
|
test('the config is valid and holds what fastlane had', async () => {
|
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
|
expect(validateConfig(c)).toEqual([]);
|
|
expect(c.app).toMatchObject({ name: 'Example Notes', copyright: '2026 Example Studio', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com' });
|
|
expect(c.reviewAccess).toEqual({ signInRequired: true });
|
|
expect(c.play).toMatchObject({ packageName: 'com.example.notes', defaultLanguage: 'en-US', listing: 'store/listing.md', icon: 'store/icons/play-512.png', video: 'https://www.youtube.com/watch?v=EXAMPLE0001' });
|
|
expect(c.apple).toMatchObject({ bundleId: 'com.example.notes', listing: 'store/appstore.md', ipad: true, info: { categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' }, reviewNotes: 'Sign in with the demo account. Sample lists are already there.' } });
|
|
expect(c.apple.usesNonExemptEncryption).toBeUndefined(); // a compliance answer is never guessed
|
|
expect(c.shots.capture).toEqual({ source: 'folder', folder: 'store/shots/frames', shots: {} });
|
|
expect(c.shots.scenes.map((s: { id: string }) => s.id)).toEqual(['shot-01', 'shot-02', 'shot-03']);
|
|
});
|
|
|
|
test('listing files parse with the existing parsers', () => {
|
|
const play = parsePlayListing(fileText(p, 'store/listing.md')!, p.appName);
|
|
expect(play.map(x => x.lang)).toEqual(['en-US', 'de-DE', 'tr-TR']);
|
|
expect(play[0]).toMatchObject({ title: 'Example Notes', short: 'Lists and notes that stay in sync on every device', whatsNew: 'Shared lists.' }); // newest changelog
|
|
const md = fileText(p, 'store/appstore.md')!;
|
|
expect(() => parseAppStoreListing(md)).toThrow(/tr: missing promotionalText/);
|
|
const fixed = md.replace('## tr · Turkish\n', '## tr · Turkish\n\n### promotionalText\n```\nYeni\n```\n');
|
|
const apple = parseAppStoreListing(fixed);
|
|
expect(Object.keys(apple)).toEqual(['en-US', 'de-DE', 'tr']);
|
|
expect(apple['en-US']).toMatchObject({ keywords: 'todo,checklist,groceries,planner,shared,family,tasks', whatsNew: 'Shared lists and a calmer home screen.' });
|
|
expect(apple['en-US']!.name).toBeUndefined(); // same as app.name
|
|
expect(p.warnings.join('\n')).toMatch(/tr: no promotionalText/);
|
|
});
|
|
|
|
test('screenshots become folder frames; iOS wins over Play for the same language', () => {
|
|
const frames = p.files.filter(f => f.kind === 'frame').map(f => `${f.path} <- ${f.src}`);
|
|
expect(frames).toContain('store/shots/frames/en/phone/01.png <- fastlane/screenshots/en-US/iPhone 16 Pro Max-01_home.png');
|
|
expect(frames).toContain('store/shots/frames/en/tablet/01.png <- fastlane/screenshots/en-US/iPad Pro (12.9-inch) (6th generation)-01_home.png');
|
|
expect(frames).toContain('store/shots/frames/tr/phone/02.png <- fastlane/metadata/android/tr-TR/images/phoneScreenshots/2_tr-TR.png');
|
|
expect(frames.some(f => /iPhone 8 Plus|Watch/.test(f))).toBe(false);
|
|
expect(frames.filter(f => f.startsWith('store/shots/frames/en/phone/'))).toHaveLength(3);
|
|
const copy = JSON.parse(fileText(p, 'store/shots/copy.json')!);
|
|
expect(Object.keys(copy)).toEqual(['en', 'de', 'tr']);
|
|
expect(copy.en['shot-01']).toEqual({ title: ['', ''], sub: '' });
|
|
expect(p.files.find(f => f.kind === 'feature' && f.path === 'store/renders/tr/feature/1024x500.png')).toBeTruthy();
|
|
});
|
|
|
|
test('keys, team ids, the review contact and the demo account never reach a file', () => {
|
|
const all = p.files.filter(f => f.text !== undefined).map(f => f.text).join('\n');
|
|
for (const secret of ['example-not-a-password', 'demo@example.com', 'review-contact@example.com', 'developer@example.com', 'TEAMID1234', '000000000', 'play-service-account.json', 'AuthKey', 'Alex', '555 0100', 'git.example.com'])
|
|
expect(all).not.toContain(secret);
|
|
const r = report(p);
|
|
expect(r).not.toContain('example-not-a-password');
|
|
expect(r).not.toContain('review-contact@example.com');
|
|
expect(r).toContain('json_key_file (fastlane/play-service-account.json): move the Play service account key into .storebound/');
|
|
expect(r).toMatch(/demo_password\.txt, demo_user\.txt \(App Review demo account\): not copied/);
|
|
});
|
|
|
|
test('the report lists what is not mapped and where it goes', () => {
|
|
const r = report(p);
|
|
for (const s of ['Deliverfile submit_for_review = false: never automated', 'Deliverfile price_tier:', 'Deliverfile app_rating_config_path: apple.info.ageRating',
|
|
'Fastfile lanes: beta, screenshots, release, deploy', 'Fastfile capture_screenshots: `storebound shots capture --source ios-sim`', 'Matchfile',
|
|
'apple_tv_privacy_policy.txt', 'promoGraphic.png', 'wearScreenshots', 'app_identifier (line 13): inside a block', 'username (line 11): from an environment variable',
|
|
'app.contactEmail'])
|
|
expect(r).toContain(s);
|
|
});
|
|
|
|
test('a GAMES category picks the game profile unless --profile says otherwise', () => {
|
|
const g = project();
|
|
writeFileSync(join(g.fl, 'metadata', 'primary_category.txt'), 'Games\n');
|
|
writeFileSync(join(g.fl, 'metadata', 'primary_first_sub_category.txt'), 'Puzzle\n');
|
|
const gp = plan(g.root, [g.fl]);
|
|
expect(gp.profile).toBe('game');
|
|
expect(fileText(gp, 'storebound.config.ts')).toContain("categories: { primary: 'GAMES', primarySubOne: 'GAMES_PUZZLE', secondary: 'UTILITIES' }");
|
|
expect(fileText(gp, 'storebound.config.ts')).toContain("style: 'game'");
|
|
expect(plan(g.root, [g.fl], 'subscription-app').profile).toBe('subscription-app');
|
|
});
|
|
});
|
|
|
|
describe('import plan: one store, unknown locales, missing files', () => {
|
|
test('iOS only: no play section, no store/listing.md', async () => {
|
|
const { root, fl } = project(['metadata/android']);
|
|
writeFileSync(join(fl, 'Appfile'), 'app_identifier("com.example.notes")\n');
|
|
const p = plan(root, [fl]);
|
|
expect(p.packageName).toBeUndefined();
|
|
expect(p.files.map(f => f.path)).not.toContain('store/listing.md');
|
|
expect(p.languages).toEqual([{ id: 'en', apple: 'en-US' }, { id: 'de', apple: 'de-DE' }, { id: 'tr', apple: 'tr' }]);
|
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
|
expect(validateConfig(c)).toEqual([]);
|
|
expect(c.play).toBeUndefined();
|
|
expect(c.apple.bundleId).toBe('com.example.notes');
|
|
});
|
|
|
|
test('Android only: no apple section, no store/appstore.md, title from supply', async () => {
|
|
const { root, fl } = project(['metadata/en-US', 'metadata/de-DE', 'metadata/tr', 'metadata/default', 'metadata/review_information', 'metadata/copyright.txt',
|
|
'metadata/primary_category.txt', 'metadata/secondary_category.txt', 'screenshots', 'Deliverfile', 'Matchfile']);
|
|
writeFileSync(join(fl, 'Appfile'), 'json_key_file("keys/play.json")\npackage_name("com.example.notes")\n');
|
|
const p = plan(root, [fl]);
|
|
expect(p.bundleId).toBeUndefined();
|
|
expect(p.appName).toBe('Example Notes');
|
|
expect(p.files.map(f => f.path)).not.toContain('store/appstore.md');
|
|
expect(p.languages).toEqual([{ id: 'en', play: 'en-US' }, { id: 'de', play: 'de-DE' }, { id: 'tr', play: 'tr-TR' }]);
|
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
|
expect(validateConfig(c)).toEqual([]);
|
|
expect(c.apple).toBeUndefined();
|
|
expect(c.reviewAccess).toBeUndefined();
|
|
expect(p.placeholders.map(x => x.what)).toEqual(expect.arrayContaining(['app.privacyPolicyUrl', 'app.supportUrl', 'app.website', 'app.contactEmail']));
|
|
// Play phone screenshots become the phone frames, the 10" set the tablet frames
|
|
expect(p.files.filter(f => f.kind === 'frame').map(f => f.path)).toEqual(expect.arrayContaining(['store/shots/frames/en/phone/01.png', 'store/shots/frames/en/tablet/01.png']));
|
|
expect(p.files.find(f => f.path === 'store/shots/frames/en/tablet/01.png')!.src).toBe('fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png');
|
|
});
|
|
|
|
test('both stores from ios/fastlane + android/fastlane', () => {
|
|
const root = temp();
|
|
cpSync(SAMPLE, join(root, 'ios', 'fastlane'), { recursive: true });
|
|
rmSync(join(root, 'ios', 'fastlane', 'metadata', 'android'), { recursive: true });
|
|
writeFileSync(join(root, 'ios', 'fastlane', 'Appfile'), 'app_identifier("com.example.notes")\n');
|
|
mkdirSync(join(root, 'android', 'fastlane', 'metadata'), { recursive: true });
|
|
cpSync(join(SAMPLE, 'metadata', 'android'), join(root, 'android', 'fastlane', 'metadata', 'android'), { recursive: true });
|
|
writeFileSync(join(root, 'android', 'fastlane', 'Appfile'), 'package_name("com.example.notes.android")\n');
|
|
const dirs = findFastlaneDirs(root);
|
|
expect(dirs.map(d => d.slice(root.length + 1))).toEqual(['ios/fastlane', 'android/fastlane']);
|
|
const p = plan(root, dirs);
|
|
expect(p.bundleId).toBe('com.example.notes');
|
|
expect(p.packageName).toBe('com.example.notes.android');
|
|
expect(p.languages).toHaveLength(3);
|
|
expect(p.languages.every(l => l.apple && l.play)).toBe(true);
|
|
expect(p.files.map(f => f.path)).toEqual(expect.arrayContaining(['store/appstore.md', 'store/listing.md']));
|
|
});
|
|
|
|
test('unknown locales are imported as they are, with a warning', async () => {
|
|
const { root, fl } = project();
|
|
cpSync(join(fl, 'metadata', 'tr'), join(fl, 'metadata', 'xx-YY'), { recursive: true });
|
|
cpSync(join(fl, 'metadata', 'android', 'tr-TR'), join(fl, 'metadata', 'android', 'qq-QQ'), { recursive: true });
|
|
cpSync(join(fl, 'metadata', 'de-DE'), join(fl, 'metadata', 'ja-JP'), { recursive: true });
|
|
const p = plan(root, [fl]);
|
|
expect(p.warnings.join('\n')).toContain('App Store locale "xx-YY"');
|
|
expect(p.warnings.join('\n')).toContain('Play language "qq-QQ"');
|
|
expect(p.languages).toEqual(expect.arrayContaining([{ id: 'xx', apple: 'xx-YY' }, { id: 'qq', play: 'qq-QQ' }, { id: 'ja', apple: 'ja' }]));
|
|
expect(p.mapped).toContainEqual({ from: 'metadata/ja-JP', to: 'App Store locale ja' });
|
|
expect(fileText(p, 'store/appstore.md')).toContain('## ja · Japanese');
|
|
expect(validateConfig(await loadPlannedConfig(p))).toEqual([]);
|
|
});
|
|
|
|
test('missing files: empty folder fails, missing fields and ids become warnings and placeholders', async () => {
|
|
const empty = temp();
|
|
mkdirSync(join(empty, 'fastlane'));
|
|
expect(() => plan(empty, [join(empty, 'fastlane')])).toThrow(/nothing to import/);
|
|
|
|
const { root, fl } = project(['Appfile', 'Deliverfile', 'Fastfile', 'Matchfile', 'screenshots', 'metadata/default', 'metadata/de-DE/description.txt', 'metadata/android/de-DE/short_description.txt', 'metadata/android/en-US/images']);
|
|
const p = plan(root, [fl]);
|
|
expect(p.warnings.join('\n')).toMatch(/de-DE: no description in fastlane/);
|
|
expect(p.warnings.join('\n')).toMatch(/de-DE: no short_description\.txt in fastlane/);
|
|
expect(p.placeholders.map(x => x.what)).toEqual(expect.arrayContaining(['apple.bundleId', 'play.packageName', 'app.privacyPolicyUrl']));
|
|
expect(p.files.some(f => f.kind === 'frame' && f.path.startsWith('store/shots/frames/en/'))).toBe(false);
|
|
const c = await loadPlannedConfig(p) as Record<string, any>;
|
|
expect(validateConfig(c)).toEqual([]);
|
|
expect(c.apple.bundleId).toBe('com.example.app');
|
|
expect(c.play.icon).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('writing the plan', () => {
|
|
test('dry run writes nothing; a real run creates the files', () => {
|
|
const { root, fl } = project();
|
|
const p = plan(root, [fl]);
|
|
const dry = applyPlan(p, root, true);
|
|
expect(dry.created).toContain('storebound.config.ts');
|
|
expect(existsSync(join(root, 'storebound.config.ts'))).toBe(false);
|
|
expect(existsSync(join(root, 'store'))).toBe(false);
|
|
const real = applyPlan(p, root, false);
|
|
expect(real.created).toEqual(dry.created);
|
|
expect(readFileSync(join(root, 'store', 'listing.md'), 'utf8')).toBe(fileText(p, 'store/listing.md')!);
|
|
expect(readFileSync(join(root, 'store/shots/frames/en/phone/01.png'))).toEqual(readFileSync(join(fl, 'screenshots/en-US/iPhone 16 Pro Max-01_home.png')));
|
|
});
|
|
|
|
test('existing files are never overwritten: text goes to .imported, images are kept', () => {
|
|
const { root, fl } = project();
|
|
mkdirSync(join(root, 'store', 'icons'), { recursive: true });
|
|
writeFileSync(join(root, 'storebound.config.ts'), '// mine\n');
|
|
writeFileSync(join(root, 'store', 'icons', 'play-512.png'), 'mine');
|
|
const p = plan(root, [fl]);
|
|
const r = applyPlan(p, root, false);
|
|
expect(readFileSync(join(root, 'storebound.config.ts'), 'utf8')).toBe('// mine\n');
|
|
expect(readFileSync(join(root, 'storebound.config.imported.ts'), 'utf8')).toBe(fileText(p, 'storebound.config.ts')!);
|
|
expect(readFileSync(join(root, 'store', 'icons', 'play-512.png'), 'utf8')).toBe('mine');
|
|
expect(r.imported).toEqual([{ target: 'storebound.config.ts', written: 'storebound.config.imported.ts' }]);
|
|
expect(r.kept).toEqual(['store/icons/play-512.png']);
|
|
expect(importedPath('store/shots/copy.json')).toBe('store/shots/copy.imported.json');
|
|
});
|
|
});
|
|
|
|
describe('MCP tool', () => {
|
|
const t = TOOLS.find(x => x.name === 'import_fastlane')!;
|
|
test('local write, dry run by default', () => {
|
|
expect(t).toMatchObject({ write: true, network: false, command: ['import', 'fastlane'] });
|
|
expect(toArgv(t, {}).argv).toEqual(['import', 'fastlane', '--dry']);
|
|
expect(toArgv(t, { dir: 'ios/fastlane,android/fastlane', profile: 'game', dry_run: false }).argv).toEqual(['import', 'fastlane', '--dir', 'ios/fastlane,android/fastlane', '--profile', 'game']);
|
|
});
|
|
test('dir stays inside the project', () => {
|
|
expect(validateInput(t, { dir: 'fastlane' })).toEqual([]);
|
|
expect(validateInput(t, { dir: '../other/fastlane' }).length).toBe(1);
|
|
expect(validateInput(t, { dir: '/etc' }).length).toBe(1);
|
|
expect(validateInput(t, { dir: 'fastlane,/etc' }).length).toBe(1);
|
|
});
|
|
});
|