// 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, path: string) => p.files.find(f => f.path === path)?.text; async function loadPlannedConfig(p: ReturnType): Promise { 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 = { '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; 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; 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; 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; 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); }); });