Conflicts resolved for both sides: - cli/lib/plist.ts, cli/lib/zip.ts: one implementation each. plist reads XML (text or bytes, attributes tolerated) and binary plists; ads scan now reads bytes, so a binary Info.plist works too. zip gains unzip() for the Play report exports on top of zipEntries / zipRead. - apple upload waits with testflight wait's loop (waitForBuild) instead of its own copy; BUILD_DONE removed. - config, credentials (profiles, keystore, ASC_VENDOR_NUMBER, now also a profile key), command table, MCP tools, credentials template, README, CHANGELOG: union. - MCP: --write stays forbidden; the three pull tools are the listed local-file exception (LOCAL_WRITE_TOOLS), so scan --write remains unreachable. - Adapters and API coverage tables regenerated.
202 lines
17 KiB
TypeScript
202 lines
17 KiB
TypeScript
// Sales / finance reports and crash / feedback triage: parsers and summaries on sanitized report files, plus the
|
|
// commands against recorded App Store Connect, Cloud Storage and Play Developer Reporting answers.
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { deflateRawSync } from 'node:zlib';
|
|
import {
|
|
appleFinancePath, appleReportDate, appleSalesPath, parsePlayCsv, parseTsv, playReportPrefix, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, totalsByCurrency,
|
|
} from '../cli/lib/reports';
|
|
import { unzip } from '../cli/lib/zip';
|
|
import { cluster, crashSignature, feedbackItems, playIssues, playReportItems, reportsQuery } from '../cli/lib/triage';
|
|
import { run as reports } from '../cli/reports';
|
|
import { run as triage } from '../cli/triage';
|
|
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock';
|
|
|
|
afterEach(restore);
|
|
|
|
/** A ZIP with deflated entries (what Play puts in the reports bucket). */
|
|
function zip(files: Record<string, string>): Uint8Array {
|
|
const parts: Uint8Array[] = [], central: Uint8Array[] = [];
|
|
let off = 0;
|
|
for (const [name, text] of Object.entries(files)) {
|
|
const raw = new TextEncoder().encode(text), data = deflateRawSync(raw), n = new TextEncoder().encode(name);
|
|
const h = new DataView(new ArrayBuffer(30));
|
|
h.setUint32(0, 0x04034b50, true); h.setUint16(8, 8, true); h.setUint32(18, data.length, true); h.setUint32(22, raw.length, true); h.setUint16(26, n.length, true);
|
|
const c = new DataView(new ArrayBuffer(46));
|
|
c.setUint32(0, 0x02014b50, true); c.setUint16(10, 8, true); c.setUint32(20, data.length, true); c.setUint32(24, raw.length, true); c.setUint16(28, n.length, true); c.setUint32(42, off, true);
|
|
parts.push(new Uint8Array(h.buffer), n, data);
|
|
central.push(new Uint8Array(c.buffer), n);
|
|
off += 30 + n.length + data.length;
|
|
}
|
|
const cdSize = central.reduce((s, x) => s + x.length, 0);
|
|
const e = new DataView(new ArrayBuffer(22));
|
|
e.setUint32(0, 0x06054b50, true); e.setUint16(8, Object.keys(files).length, true); e.setUint16(10, Object.keys(files).length, true); e.setUint32(12, cdSize, true); e.setUint32(16, off, true);
|
|
const all = [...parts, ...central, new Uint8Array(e.buffer)];
|
|
const out = new Uint8Array(all.reduce((s, x) => s + x.length, 0));
|
|
let p = 0;
|
|
for (const x of all) { out.set(x, p); p += x.length; }
|
|
return out;
|
|
}
|
|
|
|
// Summary Sales Report 1_0 (tab-separated, header as in App Store Connect Help); values are made up
|
|
const SALES_TSV = ['Provider\tProvider Country\tSKU\tDeveloper\tTitle\tVersion\tProduct Type Identifier\tUnits\tDeveloper Proceeds\tBegin Date\tEnd Date\tCustomer Currency\tCountry Code\tCurrency of Proceeds\tApple Identifier\tCustomer Price',
|
|
'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t3\t3.49\t10/09/2026\t10/09/2026\tUSD\tUS\tUSD\t6400000301\t4.99',
|
|
'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t1\t3.49\t10/09/2026\t10/09/2026\tUSD\tCA\tUSD\t6400000301\t4.99',
|
|
'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t2\t4.12\t10/09/2026\t10/09/2026\tEUR\tDE\tEUR\t6400000301\t5.99',
|
|
'APPLE\tUS\tNOTES1\tExample\tExample Notes\t1.2.0\t1F\t40\t0\t10/09/2026\t10/09/2026\tUSD\tUS\tUSD\t6400000101\t0', ''].join('\n');
|
|
const FINANCE_TSV = ['Start Date\tEnd Date\tUPC\tISRC/ISBN\tVendor Identifier\tQuantity\tPartner Share\tExtended Partner Share\tPartner Share Currency\tSales or Return\tApple Identifier\tArtist/Show/Developer/Author\tTitle',
|
|
'09/01/2026\t09/30/2026\t\t\tcom.example.notes.pro\t10\t3.49\t34.90\tUSD\tS\t6400000301\tExample\tPro',
|
|
'09/01/2026\t09/30/2026\t\t\tcom.example.notes.pro\t-1\t3.49\t-3.49\tUSD\tR\t6400000301\tExample\tPro', '', 'Total_Rows\t2', 'Total_Amount\t31.41'].join('\n');
|
|
const EARNINGS_CSV = 'Description,Transaction Date,Transaction Time,Tax Type,Transaction Type,Refund Type,Product Title,Package ID,Product Type,SKU ID,Hardware,Buyer Country,Buyer State,Buyer Postcode,Buyer Currency,Amount (Buyer Currency),Currency Conversion Rate,Merchant Currency,Amount (Merchant Currency)\n'
|
|
+ 'GPA.0000-0000-0000-00001,"Sep 3, 2026",10:00:00 AM PDT,,Charge,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,US,CA,9xxxx,USD,4.99,1,USD,4.99\n'
|
|
+ 'GPA.0000-0000-0000-00001,"Sep 3, 2026",10:00:00 AM PDT,,Google fee,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,US,CA,9xxxx,USD,-0.75,1,USD,-0.75\n'
|
|
+ 'GPA.0000-0000-0000-00002,"Sep 4, 2026",11:00:00 AM PDT,,Charge,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,DE,,,EUR,5.49,1.08,USD,5.93\n';
|
|
const SALES_CSV = 'Order Number,Order Charged Date,Order Charged Timestamp,Financial Status,Device Model,Product Title,Package ID,Product Type,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount,City of Buyer,State of Buyer,Postcode of Buyer,Country of Buyer\n'
|
|
+ 'GPA.0000-0000-0000-00001,2026-09-03,1788000000,Charged,phone1,Pro,com.example.notes,inapp,com.example.notes.pro,USD,4.99,0.00,4.99,,CA,,US\n'
|
|
+ 'GPA.0000-0000-0000-00003,2026-09-05,1788100000,Refund,phone2,Pro,com.example.notes,inapp,com.example.notes.pro,USD,4.99,0.00,4.99,,TX,,US\n';
|
|
|
|
describe('report parsers and summaries', () => {
|
|
test('request paths follow the allowed combinations', () => {
|
|
expect(appleSalesPath('12345678', { type: 'SALES', frequency: 'DAILY', date: '2026-10-09' })).toBe('/v1/salesReports?filter%5BvendorNumber%5D=12345678&filter%5BreportType%5D=SALES&filter%5BreportSubType%5D=SUMMARY&filter%5Bfrequency%5D=DAILY&filter%5BreportDate%5D=2026-10-09&filter%5Bversion%5D=1_0');
|
|
expect(appleSalesPath('12345678', { type: 'SUBSCRIPTION', frequency: 'DAILY', date: '2026-10-09' })).toContain('filter%5Bversion%5D=1_3');
|
|
expect(() => appleSalesPath('1', { type: 'SUBSCRIPTION', frequency: 'MONTHLY', date: '2026-09' })).toThrow('DAILY only');
|
|
expect(appleFinancePath('12345678', '2026-09')).toContain('filter%5BreportType%5D=FINANCIAL&filter%5BregionCode%5D=ZZ&filter%5BreportDate%5D=2026-09');
|
|
expect(appleFinancePath('12345678', '2026-09', { type: 'FINANCE_DETAIL' })).toContain('regionCode%5D=Z1');
|
|
expect(appleReportDate('MONTHLY', undefined, new Date('2026-01-15T00:00:00Z'))).toBe('2025-12');
|
|
expect(appleReportDate('DAILY', undefined, new Date('2026-10-11T12:00:00Z'))).toBe('2026-10-09');
|
|
expect(() => appleReportDate('YEARLY', '2026-01')).toThrow('YYYY');
|
|
expect(playReportPrefix('earnings', '2026-09')).toBe('earnings/earnings_202609');
|
|
});
|
|
test('Apple sales and finance: units x proceeds per SKU and currency, totals never mix currencies', () => {
|
|
const s = summarizeAppleSales(parseTsv(SALES_TSV));
|
|
expect(s).toEqual([
|
|
{ key: 'NOTES1', title: 'Example Notes', currency: 'USD', units: 40, amount: 0, rows: 1 },
|
|
{ key: 'com.example.notes.pro', title: 'Pro', currency: 'EUR', units: 2, amount: 8.24, rows: 1 },
|
|
{ key: 'com.example.notes.pro', title: 'Pro', currency: 'USD', units: 4, amount: 13.96, rows: 2 },
|
|
]);
|
|
expect(totalsByCurrency(s)).toEqual([{ currency: 'EUR', units: 2, amount: 8.24 }, { currency: 'USD', units: 44, amount: 13.96 }]);
|
|
expect(summarizeAppleFinance(parseTsv(FINANCE_TSV))).toEqual([{ key: 'com.example.notes.pro', title: 'Pro', currency: 'USD', units: 9, amount: 31.41, rows: 2 }]);
|
|
});
|
|
test('Play earnings and estimated sales out of the zip', () => {
|
|
const files = unzip(zip({ 'PlayApps_202609.csv': EARNINGS_CSV }));
|
|
expect(files.map(f => f.name)).toEqual(['PlayApps_202609.csv']);
|
|
const e = summarizePlayEarnings(parsePlayCsv(new TextDecoder().decode(files[0]!.data)));
|
|
expect(e).toEqual([
|
|
{ key: 'com.example.notes.pro · Charge', title: 'Pro (Example Notes)', currency: 'USD', units: 2, amount: 10.92, rows: 2 },
|
|
{ key: 'com.example.notes.pro · Google fee', title: 'Pro (Example Notes)', currency: 'USD', units: 0, amount: -0.75, rows: 1 },
|
|
]);
|
|
expect(summarizePlaySales(parsePlayCsv(SALES_CSV)).map(r => [r.key, r.amount])).toEqual([['com.example.notes.pro · Charged', 4.99], ['com.example.notes.pro · Refund', 4.99]]);
|
|
expect(() => unzip(new Uint8Array(40))).toThrow('not a ZIP');
|
|
});
|
|
});
|
|
|
|
describe('reports command', () => {
|
|
test('apple-sales: --dry hides the vendor number; a run saves the TSV and the summary CSV', async () => {
|
|
ascEnv();
|
|
process.env.ASC_VENDOR_NUMBER = '12345678';
|
|
const p = project();
|
|
const calls = mockFetch([{ match: /^\/v1\/salesReports\?/, answer: new Response(Bun.gzipSync(new TextEncoder().encode(SALES_TSV)), { headers: { 'content-type': 'application/a-gzip' } }) }]);
|
|
let r = await capture(() => reports(p.ctx(['apple-sales', '--date', '2026-10-09', '--dry', '--json'])));
|
|
expect(jsonOut(r.out).request).toContain('filter[vendorNumber]=<vendor>'.replace(/[[\]]/g, m => encodeURIComponent(m)));
|
|
expect(calls).toHaveLength(0);
|
|
r = await capture(() => reports(p.ctx(['apple-sales', '--date', '2026-10-09', '--json'])));
|
|
const j = jsonOut(r.out);
|
|
expect(j.rows).toBe(4);
|
|
expect(j.totals).toEqual([{ currency: 'EUR', units: 2, amount: 8.24 }, { currency: 'USD', units: 44, amount: 13.96 }]);
|
|
expect(calls[0]!.path).toContain('filter%5BvendorNumber%5D=12345678');
|
|
expect(readFileSync(join(p.root, '.storebound/reports/apple-sales-daily-2026-10-09.tsv'), 'utf8')).toBe(SALES_TSV);
|
|
expect(readFileSync(join(p.root, '.storebound/reports/apple-sales-daily-2026-10-09.summary.csv'), 'utf8')).toStartWith('key,title,currency,units,amount,rows\nNOTES1,Example Notes,USD,40,0,1\n');
|
|
});
|
|
test('apple-finance: 404 means no report yet; missing vendor number is explained', async () => {
|
|
ascEnv();
|
|
const p = project();
|
|
await expect(capture(() => reports(p.ctx(['apple-finance', '--month', '2026-09'])))).rejects.toThrow('ASC_VENDOR_NUMBER');
|
|
mockFetch([{ match: /^\/v1\/financeReports\?/, answer: new Response('{"errors":[{"status":"404","code":"NOT_FOUND","title":"The request expected results but none were found"}]}', { status: 404 }) }]);
|
|
const r = await capture(() => reports(p.ctx(['apple-finance', '--month', '2026-09', '--vendor', '12345678'])));
|
|
expect(r.out).toContain('no report for that date');
|
|
});
|
|
test('play-earnings: lists the bucket, unzips, summarizes', async () => {
|
|
playEnv();
|
|
const p = project();
|
|
const calls = mockFetch([
|
|
{ match: /^\/storage\/v1\/b\/pubsite_prod_rev_01234567890123456789\/o\?prefix=earnings\/earnings_202609$/, answer: { kind: 'storage#objects', items: [{ kind: 'storage#object', name: 'earnings/earnings_202609_0123456789-1.zip', size: '512' }] } },
|
|
{ match: /^\/storage\/v1\/b\/pubsite_prod_rev_01234567890123456789\/o\/earnings\/earnings_202609_0123456789-1\.zip\?alt=media$/, answer: new Response(zip({ 'PlayApps_202609.csv': EARNINGS_CSV }) as Uint8Array<ArrayBuffer>) },
|
|
]);
|
|
const r = await capture(() => reports(p.ctx(['play-earnings', '--month', '2026-09', '--json'])));
|
|
expect(jsonOut(r.out)).toMatchObject({ rows: 3, totals: [{ currency: 'USD', units: 2, amount: 10.17 }] });
|
|
expect(calls).toHaveLength(2);
|
|
expect(existsSync(join(p.root, '.storebound/reports/play-earnings-2026-09.csv'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---- triage ----
|
|
|
|
const TEXT_CRASH = `Incident Identifier: 00000000-0000-0000-0000-000000000000
|
|
Hardware Model: iPhone16,2
|
|
Process: ExampleNotes [1234]
|
|
Identifier: com.example.notes
|
|
Version: 1.2.0 (50)
|
|
OS Version: iPhone OS 26.0 (23A341)
|
|
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
|
|
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000010
|
|
Crashed Thread: 3
|
|
|
|
Thread 0 name: Dispatch queue: com.apple.main-thread
|
|
Thread 0:
|
|
0 libsystem_kernel.dylib 0x00000001e0f0a1b4 mach_msg2_trap + 8
|
|
|
|
Thread 3 Crashed:
|
|
0 libswiftCore.dylib 0x0000000190a1b2c4 swift_retain + 12
|
|
1 ExampleNotes 0x0000000100f1a2b4 ListStore.sync(_:) + 220 (ListStore.swift:88)
|
|
2 ExampleNotes 0x0000000100f1a000 closure #1 in ListStore.start() + 40
|
|
|
|
Thread 4:
|
|
0 libsystem_pthread.dylib 0x00000001f0a0b0c0 start_wqthread + 0
|
|
`;
|
|
const IPS_CRASH = `{"app_name":"ExampleNotes","bundleID":"com.example.notes","app_version":"1.2.0","build_version":"50","os_version":"iPhone OS 26.0 (23A341)","name":"ExampleNotes"}
|
|
{"procName":"ExampleNotes","exception":{"type":"EXC_CRASH","signal":"SIGABRT"},"faultingThread":0,"threads":[{"triggered":true,"frames":[{"imageIndex":1,"symbol":"__pthread_kill","imageOffset":8},{"imageIndex":0,"symbol":"ListStore.save()","imageOffset":4096}]}],"usedImages":[{"name":"ExampleNotes"},{"name":"libsystem_kernel.dylib"}]}`;
|
|
|
|
describe('triage parts', () => {
|
|
test('crash signatures: first app frame of the crashed thread, text and .ips', () => {
|
|
expect(crashSignature(TEXT_CRASH)).toBe('EXC_BAD_ACCESS (SIGSEGV) @ ExampleNotes ListStore.sync(_:) + 220 (ListStore.swift:88)');
|
|
expect(crashSignature(IPS_CRASH)).toBe('EXC_CRASH SIGABRT @ ExampleNotes ListStore.save()');
|
|
expect(crashSignature('garbage')).toBe('crash');
|
|
});
|
|
test('clusters with top devices, OS versions and builds; Play reports grouped by issue', () => {
|
|
const items = feedbackItems([
|
|
{ id: 'c1', attributes: { createdDate: '2026-10-09T10:00:00Z', deviceModel: 'iPhone16,2', osVersion: '26.0', comment: 'It crashed when I synced' }, relationships: { build: { data: { id: 'b-50' } } } },
|
|
{ id: 'c2', attributes: { createdDate: '2026-10-10T10:00:00Z', deviceModel: 'iPhone16,2', osVersion: '26.0.1' }, relationships: { build: { data: { id: 'b-50' } } } },
|
|
{ id: 'c3', attributes: { createdDate: '2026-10-10T11:00:00Z', deviceModel: 'iPhone15,3', osVersion: '26.0' }, relationships: { build: { data: { id: 'b-49' } } } },
|
|
], new Map([['b-50', '50'], ['b-49', '49']]), new Map([['c1', 'A'], ['c2', 'A'], ['c3', 'B']]));
|
|
const cs = cluster(items, { comments: 2 });
|
|
expect(cs[0]).toEqual({ signature: 'A', count: 2, devices: [['iPhone16,2', 2]], os: [['26.0', 1], ['26.0.1', 1]], builds: [['50', 2]], first: '2026-10-09T10:00:00Z', last: '2026-10-10T10:00:00Z', comments: ['It crashed when I synced'] });
|
|
const issues = playIssues({ errorIssues: [{ name: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.notes.ListStore.sync', errorReportCount: '12', distinctUsers: '9' }] });
|
|
const play = playReportItems({ errorReports: [
|
|
{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', eventTime: '2026-10-09T00:00:00Z', deviceModel: { marketingName: 'Pixel 9', deviceId: { buildBrand: 'google', buildDevice: 'tokay' } }, osVersion: { apiLevel: '36' }, appVersion: { versionCode: '120' } },
|
|
{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', deviceModel: { deviceId: { buildBrand: 'samsung', buildDevice: 'dm3q' } }, osVersion: { apiLevel: '35' }, appVersion: { versionCode: '120' } },
|
|
] }, issues);
|
|
expect(cluster(play)[0]).toMatchObject({ signature: 'CRASH java.lang.IllegalStateException @ com.example.notes.ListStore.sync', count: 2, devices: [['Pixel 9', 1], ['samsung/dm3q', 1]], os: [['API 35', 1], ['API 36', 1]], builds: [['120', 2]] });
|
|
expect(reportsQuery('1111', new Date('2026-09-27T00:00:00Z'), new Date('2026-10-11T00:00:00Z'))).toContain('filter=errorIssueId+%3D+1111');
|
|
});
|
|
test('command: both stores, emails never requested, --json', async () => {
|
|
ascEnv(); playEnv();
|
|
const p = project();
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/builds\?filter\[app\]=6400000101/, answer: { data: [{ type: 'builds', id: 'b-50', attributes: { version: '50' } }], links: {} } },
|
|
{ match: /^\/v1\/apps\/6400000101\/betaFeedbackCrashSubmissions\?/, answer: { data: [{ type: 'betaFeedbackCrashSubmissions', id: 'c1', attributes: { createdDate: new Date().toISOString(), deviceModel: 'iPhone16,2', osVersion: '26.0' }, relationships: { build: { data: { type: 'builds', id: 'b-50' } } } }], links: {} } },
|
|
{ match: /^\/v1\/betaFeedbackCrashSubmissions\/c1\/crashLog$/, answer: { data: { type: 'betaCrashLogs', id: 'cl1', attributes: { logText: TEXT_CRASH } } } },
|
|
{ match: /^\/v1\/apps\/6400000101\/betaFeedbackScreenshotSubmissions\?/, answer: { data: [], links: {} } },
|
|
{ match: /^\/v1beta1\/apps\/com\.example\.notes\/errorIssues:search\?/, answer: { errorIssues: [{ name: 'apps/com.example.notes/errorIssues/1111', type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.notes.MainActivity', errorReportCount: '4', distinctUsers: '3' }] } },
|
|
{ match: /^\/v1beta1\/apps\/com\.example\.notes\/errorReports:search\?/, answer: { errorReports: [{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'ANR', deviceModel: { marketingName: 'Pixel 9' }, osVersion: { apiLevel: '36' }, appVersion: { versionCode: '120' } }] } },
|
|
]);
|
|
const r = await capture(() => triage(p.ctx(['--days', '14', '--json'])));
|
|
const j = jsonOut(r.out) as { apple: { crashes: { signature: string }[] }; play: { issues: { signature: string; reports: number }[] } };
|
|
expect(j.apple.crashes[0]!.signature).toContain('ListStore.sync');
|
|
expect(j.play.issues[0]).toMatchObject({ signature: 'ANR Input dispatching timed out @ com.example.notes.MainActivity', count: 1, reports: 4 });
|
|
const fb = calls.filter(c => c.path.includes('betaFeedback') && c.path.includes('fields'));
|
|
expect(fb).toHaveLength(2);
|
|
for (const c of fb) { expect(decodeURIComponent(c.path)).not.toContain('email'); expect(decodeURIComponent(c.path)).not.toContain('tester'); }
|
|
expect(BASE.app.contactEmail).toBe('support@example.com');
|
|
});
|
|
});
|