// Store art pulled from the live stores (storebound art pull): the live shapes of App Store Connect screenshot sets // (imageAsset.templateUrl) and Google Play images.list, where each file lands in the store art layout, the upsert // (a re-run changes nothing, a differing local file is kept without --force), the manifest (host, never the URL), and // what check / the local renders check / the uploads make of pulled files. Every id and URL is a placeholder. import { afterEach, describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { artPull, groupSteps, run as art } from '../cli/art'; import { run as check } from '../cli/check'; import { decide, extraStep, fillTemplate, playUrl, pulledChecksum, readPulled, type RemoteArt, slotOf, sniffExt, storeEmptySets, thumbSize, } from '../cli/lib/art-pull'; import type { ShipConfig } from '../cli/lib/config'; import { rendersCheck } from '../cli/shots/check'; import { toArgv, TOOLS, LOCAL_WRITE_TOOLS } from '../cli/mcp-tools'; import { png, jpeg } from './fixtures'; import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock'; afterEach(restore); const sha = (b: Uint8Array) => createHash('sha256').update(b).digest('hex'); const md5 = (b: Uint8Array) => createHash('md5').update(b).digest('hex'); /** A PNG whose bytes differ per tag (imageInfo reads only the header). */ const pic = (w: number, h: number, tag: string): Uint8Array => { const b = png(w, h, { pad: 200 }); b.set(new TextEncoder().encode(tag), 100); return b; }; // ---- live shapes (as App Store Connect and Google Play answered, ids and paths replaced) /** GET /v1/appScreenshotSets/{id}/appScreenshots: one item. */ const ascShot = (id: string, path: string, w: number, h: number, checksum: string) => ({ type: 'appScreenshots', id, attributes: { fileSize: 900427, fileName: '01.png', sourceFileChecksum: checksum, imageAsset: { templateUrl: `https://is1-ssl.mzstatic.com/image/thumb/${path}/{w}x{h}bb.{f}`, width: w, height: h }, assetToken: `PurpleSource221/v4/00/00/00/${id}/01.png`, assetType: 'SortedD74ScreenShot', uploadOperations: [], assetDeliveryState: { errors: [], warnings: null, state: 'COMPLETE' }, }, links: { self: `https://api.appstoreconnect.apple.com/v1/appScreenshots/${id}` }, }); /** GET .../edits/{id}/listings/{lang}/{imageType}: one image. */ const playImage = (id: string, path: string, bytes: Uint8Array) => ({ id, url: `https://lh3.googleusercontent.com/${path}`, sha1: createHash('sha1').update(bytes).digest('hex'), sha256: sha(bytes), aiGeneratedState: 'aiGeneratedStateUnspecified' }); const page = (data: unknown[]) => ({ data, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 50 } } }); // what the CDNs serve (App Store: re-encoded, so its MD5 differs from the uploaded file's checksum; Play: the original) const A1 = pic(1320, 2868, 'apple-en-1'), A2 = pic(1320, 2868, 'apple-en-2'), ICON = pic(1024, 1024, 'apple-icon'); const P_ICON = pic(512, 512, 'play-icon'), P_FEAT = jpeg(1024, 500, 300), P1 = pic(1080, 1920, 'play-en-1'), P2 = pic(1080, 1920, 'play-en-2'), PDE1 = pic(1080, 1920, 'play-de-1'), PDE2 = pic(1080, 1920, 'play-de-2'); const UPLOADED_MD5 = ['0a1b2c3d4e5f60718293a4b5c6d7e8f9', '1a1b2c3d4e5f60718293a4b5c6d7e8f9']; function stores(o: { apple2?: Uint8Array; apple2Id?: string } = {}) { const P = '/androidpublisher/v3/applications/com.example.notes'; const images: Record = { 'en-US/icon': [playImage('101', 'p-icon', P_ICON)], 'en-US/featureGraphic': [playImage('102', 'p-feat', P_FEAT)], 'en-US/phoneScreenshots': [playImage('103', 'p-en-1', P1), playImage('104', 'p-en-2', P2)], 'de-DE/icon': [playImage('101', 'p-icon', P_ICON)], 'de-DE/phoneScreenshots': [playImage('105', 'p-de-1', PDE1), playImage('106', 'p-de-2', PDE2)], }; return mockFetch([ { match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: page([{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION' } }]) }, { match: /^\/v1\/appStoreVersions\/v1\/appStoreVersionLocalizations/, answer: page([ { type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' } }, { type: 'appStoreVersionLocalizations', id: 'loc-de', attributes: { locale: 'de-DE' } }, { type: 'appStoreVersionLocalizations', id: 'loc-fr', attributes: { locale: 'fr-FR' } }]) }, { match: /^\/v1\/appStoreVersionLocalizations\/loc-en\/appScreenshotSets/, answer: page([{ type: 'appScreenshotSets', id: 'set-en', attributes: { screenshotDisplayType: 'APP_IPHONE_67' } }]) }, { match: /^\/v1\/appStoreVersionLocalizations\/loc-fr\/appScreenshotSets/, answer: page([{ type: 'appScreenshotSets', id: 'set-fr', attributes: { screenshotDisplayType: 'APP_IPHONE_67' } }]) }, { match: /^\/v1\/appStoreVersionLocalizations\/loc-de\/appScreenshotSets/, answer: page([]) }, { match: /^\/v1\/appStoreVersionLocalizations\/loc-\w+\/appPreviewSets/, answer: page([]) }, { match: /^\/v1\/appScreenshotSets\/set-en\/appScreenshots/, answer: page([ascShot('s-en-1', 'Purple1/v4/en1.png', 1320, 2868, UPLOADED_MD5[0]!), (o.apple2Id ? ascShot(o.apple2Id, 'Purple1/v4/en2.png', 1320, 2868, '3a1b2c3d4e5f60718293a4b5c6d7e8f9') : ascShot('s-en-2', 'Purple1/v4/en2.png', 1320, 2868, UPLOADED_MD5[1]!))]) }, { match: /^\/v1\/appScreenshotSets\/set-fr\/appScreenshots/, answer: page([ascShot('s-fr-1', 'Purple1/v4/fr1.png', 1320, 2868, '2a1b2c3d4e5f60718293a4b5c6d7e8f9')]) }, { match: /^\/v1\/builds\?filter\[app\]=6400000101/, answer: page([{ type: 'builds', id: 'b1', attributes: { version: '42', iconAssetToken: { templateUrl: 'https://is1-ssl.mzstatic.com/image/thumb/Purple1/v4/icon.png/{w}x{h}bb.{f}', width: 1024, height: 1024 } } }]) }, // the CDNs { match: /^\/image\/thumb\/Purple1\/v4\/en1\.png\/1320x2868bb\.png$/, answer: () => new Response(Buffer.from(A1)) }, { match: /^\/image\/thumb\/Purple1\/v4\/en2\.png\/1320x2868bb\.png$/, answer: () => new Response(Buffer.from(o.apple2 ?? A2)) }, { match: /^\/image\/thumb\/Purple1\/v4\/icon\.png\/1024x1024bb\.png$/, answer: () => new Response(Buffer.from(ICON)) }, ...Object.entries({ 'p-icon': P_ICON, 'p-feat': P_FEAT, 'p-en-1': P1, 'p-en-2': P2, 'p-de-1': PDE1, 'p-de-2': PDE2 }).map(([k, b]) => ({ match: new RegExp(`^/${k}=s0$`), answer: () => new Response(Buffer.from(b)) })), // Google Play (one read-only edit, deleted) { method: 'POST', match: new RegExp(`^${P}/edits$`), answer: { id: 'e1' } }, { match: new RegExp(`^${P}/edits/e1/listings$`), answer: { listings: [{ language: 'en-US' }, { language: 'de-DE' }, { language: 'tr-TR' }] } }, { match: new RegExp(`^${P}/edits/e1/listings/[\\w-]+/\\w+$`), answer: c => ({ images: images[c.path.split('/listings/')[1]!] ?? [] }) }, { method: 'DELETE', match: new RegExp(`^${P}/edits/e1$`), answer: {} }, ]); } const CONFIG: ShipConfig = { ...BASE, icons: { composite: 'store/icon/icon.png', out: 'store/icons' } as ShipConfig['icons'] }; function setup(files: Record = {}) { ascEnv(); playEnv(); return project(CONFIG, files); } describe('where live art goes', () => { const ctx = { renders: 'store/renders', appleIcon: 'store/icons/app-store-1024.png', playIcon: 'store/icons/play-512.png', langOf: (_s: string, l: string) => ({ 'en-US': 'en', 'de-DE': 'de' } as Record)[l] ?? null }; const r = (o: Partial): RemoteArt => ({ store: 'apple', kind: 'screenshot', type: 'APP_IPHONE_67', locale: 'en-US', index: 0, url: '', thumb: '', checksum: null, id: 'x', ...o }); test('App Store display types map onto the render size folders, NN from 01', () => { expect(slotOf(r({}), ctx)!.rel).toBe('store/renders/en/ios69/01'); expect(slotOf(r({ type: 'APP_IPHONE_65', index: 9 }), ctx)!.rel).toBe('store/renders/en/ios65/10'); expect(slotOf(r({ type: 'APP_IPAD_PRO_3GEN_129' }), ctx)!.rel).toBe('store/renders/en/ipad13/01'); expect(slotOf(r({ type: 'APP_IPHONE_61' }), ctx)!.rel).toBe('store/renders/en/iphone-medium/01'); // an old display type keeps its own folder (kept, never checked or uploaded as another size) expect(slotOf(r({ type: 'APP_IPHONE_55' }), ctx)!.rel).toBe('store/renders/en/apple-app_iphone_55/01'); expect(slotOf(r({ kind: 'preview', type: 'IPHONE_67' }), ctx)).toMatchObject({ rel: 'store/renders/en/preview/iphone/01', ext: 'mp4' }); expect(slotOf(r({ locale: 'fr-FR' }), ctx)).toBeNull(); }); test('Play types, the feature graphic and the icons', () => { expect(slotOf(r({ store: 'play', type: 'phoneScreenshots', index: 1 }), ctx)!.rel).toBe('store/renders/en/play/02'); expect(slotOf(r({ store: 'play', type: 'sevenInchScreenshots' }), ctx)!.rel).toBe('store/renders/en/tab7/01'); expect(slotOf(r({ store: 'play', type: 'tenInchScreenshots' }), ctx)!.rel).toBe('store/renders/en/tab10/01'); expect(slotOf(r({ store: 'play', type: 'tvScreenshots' }), ctx)!.rel).toBe('store/renders/en/tv/01'); expect(slotOf(r({ store: 'play', type: 'wearScreenshots' }), ctx)!.rel).toBe('store/renders/en/wear/01'); expect(slotOf(r({ store: 'play', kind: 'featureGraphic', type: 'featureGraphic' }), ctx)!.rel).toBe('store/renders/en/feature/1024x500'); expect(slotOf(r({ store: 'play', kind: 'icon', type: 'icon' }), ctx)).toMatchObject({ rel: 'store/icons/play-512', ext: 'png' }); expect(slotOf(r({ kind: 'icon', type: 'icon', locale: '' }), ctx)).toMatchObject({ rel: 'store/icons/app-store-1024', ext: 'png' }); }); test('URLs: the App Store template at the original size, Play =s0; thumbnails', () => { expect(fillTemplate('https://is1-ssl.mzstatic.com/image/thumb/a/b.png/{w}x{h}bb.{f}', 1320, 2868, 'png')).toBe('https://is1-ssl.mzstatic.com/image/thumb/a/b.png/1320x2868bb.png'); expect(fillTemplate('https://is1-ssl.mzstatic.com/image/thumb/a/{w}x{h}{c}.{f}', 10, 20, 'jpg')).toBe('https://is1-ssl.mzstatic.com/image/thumb/a/10x20bb.jpg'); expect(playUrl('https://lh3.googleusercontent.com/abc', 'original')).toBe('https://lh3.googleusercontent.com/abc=s0'); expect(playUrl('https://lh3.googleusercontent.com/abc=w526', 300)).toBe('https://lh3.googleusercontent.com/abc=w300'); expect(thumbSize(1320, 2868)).toEqual([138, 300]); expect(sniffExt(png(1, 1))).toBe('png'); expect(sniffExt(jpeg(1, 1))).toBe('jpg'); }); test('store-empty sets: the required sets no file came for', () => { expect(storeEmptySets([{ id: 'en', apple: true, play: true }, { id: 'de', apple: true, play: false }], new Set(['en/ios69', 'en/play']), { apple: ['ios69'], play: ['play', 'feature'] })) .toEqual(['de/ios69', 'en/feature']); }); }); describe('the upsert decision', () => { const remote: RemoteArt = { store: 'play', kind: 'screenshot', type: 'phoneScreenshots', locale: 'en-US', index: 0, url: 'u', thumb: 't', checksum: 'S1', id: 'i1' }; const local = (sha256: string, m = 'm') => ({ rel: 'store/renders/en/play/01.png', sha256, md5: m }); const entry = (sha256: string, storeId = 'i1', storeChecksum = 'S1') => ({ store: 'play' as const, kind: 'screenshot' as const, type: 'phoneScreenshots', locale: 'en-US', host: 'h', sha256, storeChecksum, storeId, pulledAt: '' }); test('missing -> add; an unchanged pull -> same; the store changed -> update', () => { expect(decide(remote, 'store/renders/en/play/01', null, null, false, 'png')).toMatchObject({ action: 'add', rel: 'store/renders/en/play/01.png' }); expect(decide(remote, 'x', local('L'), entry('L'), false, 'png').action).toBe('same'); expect(decide({ ...remote, id: 'i2', checksum: 'S2' }, 'x', local('L'), entry('L'), false, 'png').action).toBe('update'); }); test('the local file is the store\'s (Play sha256 / App Store MD5): same, recorded', () => { expect(decide(remote, 'x', local('S1'), null, false, 'png')).toMatchObject({ action: 'same', adopt: true }); expect(decide({ ...remote, store: 'apple', checksum: 'M1' }, 'x', local('L', 'M1'), null, false, 'png')).toMatchObject({ action: 'same', adopt: true }); }); test('your own file or an edit after the pull: kept (conflict) unless --force', () => { expect(decide(remote, 'x', local('MINE'), null, false, 'png')).toMatchObject({ action: 'conflict', needsForce: true }); expect(decide(remote, 'x', local('EDITED'), entry('L'), false, 'png')).toMatchObject({ action: 'conflict', why: expect.stringContaining('edited after the pull') }); expect(decide(remote, 'x', local('MINE'), null, true, 'png')).toMatchObject({ action: 'update', needsForce: true }); expect(extraStep(local('L'), entry('L'), false).action).toBe('remove'); expect(extraStep(local('MINE'), null, false).action).toBe('conflict'); expect(extraStep(local('MINE'), null, true).action).toBe('remove'); }); test('plan rows group a folder', () => { expect(groupSteps([{ action: 'add', file: 'r/en/play/01.png', why: 'w' }, { action: 'add', file: 'r/en/play/02.png', why: 'w' }, { action: 'add', file: 'r/en/feature/1024x500.jpg', why: 'w' }])) .toEqual([['add', 'r/en/play/ 2 files (01.png .. 02.png)', 'w'], ['add', 'r/en/feature/1024x500.jpg', 'w']]); }); }); describe('art pull against recorded store shapes', () => { test('dry run: the plan, nothing written, the stores only read', async () => { const p = setup(); const calls = stores(); const r = await artPull(p.ctx([]), { write: false, force: false, quiet: true }); expect(r.summary).toMatchObject({ add: 9, same: 0, update: 0, conflict: 0, remove: 0 }); expect(r.unmapped).toEqual(['App Store fr-FR']); expect(existsSync(join(p.root, 'store/renders'))).toBe(false); expect(calls.filter(c => c.method !== 'GET').map(c => c.method).sort()).toEqual(['DELETE', 'POST']); // the Play read edit only expect(calls.some(c => /=s0$|bb\.png$/.test(c.path))).toBe(false); // nothing downloaded in the dry run }); test('write: the store art layout, pulled.json with hosts and checksums, a re-run changes nothing', async () => { const p = setup(); stores(); const r = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); expect(r.summary.add).toBe(9); expect(r.written).toBe(true); const f = (rel: string) => readFileSync(join(p.root, rel)); expect(sha(f('store/renders/en/ios69/01.png'))).toBe(sha(A1)); expect(sha(f('store/renders/en/ios69/02.png'))).toBe(sha(A2)); expect(sha(f('store/renders/en/play/02.png'))).toBe(sha(P2)); expect(sha(f('store/renders/de/play/01.png'))).toBe(sha(PDE1)); // a JPEG from Play keeps its format expect(existsSync(join(p.root, 'store/renders/en/feature/1024x500.jpg'))).toBe(true); expect(sha(f('store/icons/play-512.png'))).toBe(sha(P_ICON)); expect(sha(f('store/icons/app-store-1024.png'))).toBe(sha(ICON)); const m = readPulled(join(p.root, 'store/renders'))!; expect(m.files['en/ios69/01.png']).toMatchObject({ store: 'apple', type: 'APP_IPHONE_67', locale: 'en-US', host: 'is1-ssl.mzstatic.com', sha256: sha(A1), storeChecksum: UPLOADED_MD5[0], storeId: 's-en-1' }); expect(m.files['en/play/01.png']).toMatchObject({ store: 'play', host: 'lh3.googleusercontent.com', sha256: sha(P1), storeChecksum: sha(P1) }); expect(m.files['../icons/app-store-1024.png']).toMatchObject({ kind: 'icon', storeChecksum: md5(ICON) }); // no URL and no personal data in the manifest const raw = readFileSync(join(p.root, 'store/renders/pulled.json'), 'utf8'); expect(raw).not.toContain('https://'); expect(raw).not.toContain('@'); // the sets the stores had nothing for: de on the App Store, tr on Play, the de feature graphic expect(m.storeEmpty).toEqual(expect.arrayContaining(['de/ios69', 'de/feature', 'tr/play', 'tr/feature', 'tr/ios69'])); const before = statSync(join(p.root, 'store/renders/pulled.json')).mtimeMs; const ico = statSync(join(p.root, 'store/icons/app-store-1024.png')).mtimeMs; stores(); const again = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); expect(again.summary).toMatchObject({ add: 0, update: 0, remove: 0, conflict: 0, same: 9 }); expect(again.written).toBe(false); expect(statSync(join(p.root, 'store/renders/pulled.json')).mtimeMs).toBe(before); expect(statSync(join(p.root, 'store/icons/app-store-1024.png')).mtimeMs).toBe(ico); }); test('a differing local file is kept and listed; --force replaces it; the store changing updates an unedited pull', async () => { const mine = pic(1320, 2868, 'my-own-render'); const p = setup({ 'store/renders/en/ios69/01.png': mine, 'store/renders/en/ios69/03.png': pic(1320, 2868, 'my-third') }); stores(); const r = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); expect(r.conflicts.sort()).toEqual(['store/renders/en/ios69/01.png', 'store/renders/en/ios69/03.png']); expect(sha(readFileSync(join(p.root, 'store/renders/en/ios69/01.png')))).toBe(sha(mine)); expect(readPulled(join(p.root, 'store/renders'))!.files['en/ios69/01.png']).toBeUndefined(); const out = await capture(async () => { stores(); await art(p.ctx(['pull'])); }); expect(out.out + out.err).toContain('--force replaces them'); stores(); const forced = await artPull(p.ctx(['--write', '--force']), { write: true, force: true, quiet: true }); expect(forced.summary).toMatchObject({ update: 1, remove: 1 }); expect(sha(readFileSync(join(p.root, 'store/renders/en/ios69/01.png')))).toBe(sha(A1)); expect(existsSync(join(p.root, 'store/renders/en/ios69/03.png'))).toBe(false); // the store's 2nd screenshot changes: the unedited earlier pull is updated without --force const NEW2 = pic(1320, 2868, 'apple-en-2-new'); // an unchanged store asset (same id and checksum) is never downloaded again, even when the CDN bytes differ stores({ apple2: NEW2 }); const same = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); expect(same.summary.update).toBe(0); // a new upload on the store (new id and checksum) stores({ apple2: NEW2, apple2Id: 's-en-2b' }); const changed = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); expect(changed.summary).toMatchObject({ update: 1, conflict: 0 }); expect(sha(readFileSync(join(p.root, 'store/renders/en/ios69/02.png')))).toBe(sha(NEW2)); expect(readPulled(join(p.root, 'store/renders'))!.files['en/ios69/02.png']).toMatchObject({ storeId: 's-en-2b', storeChecksum: '3a1b2c3d4e5f60718293a4b5c6d7e8f9' }); }); test('the App Store icon is compared as a picture (Apple re-encodes the build\'s icon)', async () => { const { encodePng } = await import('./build-fixtures'); const { samePicture } = await import('../cli/art'); const draw = (x: number, y: number): [number, number, number] => [x * 8 % 256, y * 8 % 256, (x + y) * 4 % 256]; const a = encodePng(64, 64, draw, { colorType: 2 }), b = encodePng(64, 64, draw, { colorType: 2, filter: 1 }); expect(sha(a)).not.toBe(sha(b)); expect(samePicture(a, b)).toBe(true); expect(samePicture(a, encodePng(64, 64, (x, y) => [255 - x * 4, y * 2, 30], { colorType: 2 }))).toBe(false); expect(samePicture(a, jpeg(64, 64))).toBe(false); }); test('rendered Play art with JPEG copies: the .jpg (what play images uploads) is compared, not the .png', async () => { // seen live: a project with 01.png + 01.jpg per Play slot was reported as "differs" everywhere const p = setup({ 'store/renders/en/play/01.png': pic(1080, 1920, 'rendered-png'), 'store/renders/en/play/01.jpg': P1, 'store/renders/en/play/02.jpg': P2 }); stores(); const r = await artPull(p.ctx([]), { write: false, force: false, quiet: true }); expect(r.conflicts).toEqual([]); expect(r.steps.filter(s => s.file.includes('/en/play/'))).toEqual([]); }); test('uploads compare a pulled file with the store\'s own checksum; an edited file counts as yours', async () => { const p = setup(); stores(); await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); const shot = join(p.root, 'store/renders/en/ios69/01.png'); expect(pulledChecksum(shot, 'apple')).toBe(UPLOADED_MD5[0]!); expect(pulledChecksum(join(p.root, 'store/renders/en/play/01.png'), 'play')).toBe(sha(P1)); expect(pulledChecksum(shot, 'play')).toBeNull(); writeFileSync(shot, pic(1320, 2868, 'edited')); expect(pulledChecksum(shot, 'apple')).toBeNull(); }); test('the local checks: pulled files pass as the store\'s, sets the store had none for are not demanded', async () => { const p = setup(); stores(); await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); const rc = rendersCheck(CONFIG, join(p.root, 'store/renders'))!; expect(rc.apple.issues.filter(i => i.level === 'error')).toEqual([]); expect(rc.play.issues.filter(i => i.level === 'error')).toEqual([]); expect(rc.play.images).toBeGreaterThan(0); }); test('check: render sources (icon artwork, logo, captions) are notes once the art is pulled', async () => { const files = { 'store/listing.md': '', 'store/appstore.md': '' }; const p = setup(files); const before = await capture(() => check(p.ctx(['--json']))); expect(String((jsonOut(before.out).problems as string[]))).toContain('missing file: store/icon/icon.png'); stores(); await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); const after = await capture(() => check(p.ctx(['--json']))); const j = jsonOut(after.out); expect(String(j.problems)).not.toContain('store/icon/icon.png'); expect(String(j.warnings)).toContain('render source not there: store/icon/icon.png'); }); }); describe('MCP / desktop', () => { test('art_pull is a dry run unless dry_run is false; --write only then, never --force', () => { const t = TOOLS.find(x => x.name === 'art_pull')!; expect(toArgv(t, {}).argv).toEqual(['art', 'pull', '--dry']); expect(toArgv(t, { dry_run: false, store: 'play' }).argv).toEqual(['art', 'pull', '--store', 'play', '--write']); expect(LOCAL_WRITE_TOOLS.art_pull).toEqual(['--write']); expect(Object.keys(t.props ?? {})).toEqual(['store']); expect(toArgv(t, { dry_run: false }).argv).not.toContain('--force'); }); });