324 lines
24 KiB
TypeScript
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');
|
|
});
|
|
});
|