store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59
This commit is contained in:
parent
49a73c90e4
commit
00be838b76
40 changed files with 1669 additions and 58 deletions
297
tests/art-pull.test.ts
Normal file
297
tests/art-pull.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// 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('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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue