storebound/tests/art-pull.test.ts

324 lines
24 KiB
TypeScript

// 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<string, unknown[]> = {
'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<string, string | Uint8Array> = {}) {
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<string, string>)[l] ?? null };
const r = (o: Partial<RemoteArt>): 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('pulled art stays out of the app repo', () => {
const sh = (cwd: string, ...a: string[]) => Bun.spawnSync(['git', ...a], { cwd });
test('art pull --write in a repo adds /store/renders/ to the repo root .gitignore once; a subfolder app gets its path', async () => {
const p = setup({ 'app/.keep': '', '.gitignore': 'node_modules\n' });
sh(p.root, 'init', '-q');
stores();
const r = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true });
expect(r.gitignored).toBe('/store/renders/');
expect(readFileSync(join(p.root, '.gitignore'), 'utf8')).toBe('node_modules\n# storebound: store art (rendered or pulled from the stores), never committed\n/store/renders/\n');
expect(sh(p.root, 'check-ignore', '-q', 'store/renders/en/ios69/01.png').exitCode).toBe(0);
stores();
expect((await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true })).gitignored).toBeNull();
// an app folder inside a bigger repo: the path relative to the repo root
const { ignoreInRepo } = await import('../cli/lib/git-ignore');
expect(ignoreInRepo(join(p.root, 'app', 'store', 'renders'), 'x', true)).toBe('/app/store/renders/');
});
test('no repo: nothing written; the dry run writes nothing either', async () => {
const p = setup();
stores();
const dry = await artPull(p.ctx([]), { write: false, force: false, quiet: true });
expect(dry.gitignored).toBeUndefined();
stores();
expect((await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true })).gitignored).toBeNull();
expect(existsSync(join(p.root, '.gitignore'))).toBe(false);
});
});
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');
});
});