347 lines
21 KiB
TypeScript
347 lines
21 KiB
TypeScript
// Ad-hoc distribution (apple adhoc) and Google Play internal app sharing (play share). No network: fetch is mocked,
|
|
// xcodebuild is never run. Response shapes are written from the App Store Connect API and androidpublisher v3 docs.
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { run as adhocRun } from '../cli/apple/adhoc';
|
|
import {
|
|
deviceFromApi, exportArgv, exportOptionsPlist, maskUdid, MODERN_METHOD, parseDevicesCsv, parsePlatform, planRegister, profileCreateReq,
|
|
profileCurrent, type RegisteredDevice, scrubUdids, shellLine, slotUsage, usableCertificates, validUdid,
|
|
} from '../cli/lib/adhoc';
|
|
import { parseArgs } from '../cli/lib/args';
|
|
import type { Loaded, ShipConfig } from '../cli/lib/config';
|
|
import { parseShareArtifact, SHARE_HEADERS, SHARE_LIMIT, shareProblems, shareUrl } from '../cli/lib/play-share';
|
|
import { run as shareRun } from '../cli/play/share';
|
|
|
|
const PKG = 'com.example.game';
|
|
const NEW = '00008101-000A1B2C3D4E5F60';
|
|
const OLD = 'a'.repeat(39) + 'b';
|
|
const OTHER = '00008030-0011223344556677';
|
|
const GONE = '00008110-00AABBCCDDEEFF00';
|
|
|
|
// ----- fetch / env / console doubles -----
|
|
const realFetch = globalThis.fetch;
|
|
const savedEnv = { ...process.env };
|
|
const tty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
afterEach(() => {
|
|
globalThis.fetch = realFetch;
|
|
for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'STOREBOUND_SERVE']) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; }
|
|
if (tty) Object.defineProperty(process.stdin, 'isTTY', tty); else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
|
});
|
|
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
interface Call { method: string; url: string; body?: unknown; headers: Record<string, string> }
|
|
function fake(route: (c: Call) => Response): Call[] {
|
|
const calls: Call[] = [];
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'tok', expires_in: 3600 });
|
|
const c: Call = { method: (init?.method ?? 'GET').toUpperCase(), url, body: init?.body, headers: { ...(init?.headers as Record<string, string>) } };
|
|
calls.push(c);
|
|
return route(c);
|
|
}) as typeof fetch;
|
|
return calls;
|
|
}
|
|
function ascKey(): void {
|
|
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
process.env.ASC_KEY_ID = 'test-key';
|
|
process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000';
|
|
process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
}
|
|
function playKey(): void {
|
|
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
|
|
}
|
|
function noTty(): void { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); delete process.env.STOREBOUND_SERVE; }
|
|
async function captured(fn: () => Promise<void>): Promise<string> {
|
|
const lines: string[] = [];
|
|
const o = { log: console.log, warn: console.warn, error: console.error };
|
|
console.log = console.warn = console.error = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); };
|
|
try { await fn(); } finally { Object.assign(console, o); }
|
|
return lines.join('\n');
|
|
}
|
|
function project(extra: Partial<ShipConfig> = {}, files: Record<string, string> = {}): Loaded {
|
|
const root = mkdtempSync(join(tmpdir(), 'sb-adhoc-'));
|
|
for (const [p, t] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), t); }
|
|
const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }],
|
|
apple: { bundleId: PKG, teamId: 'ABCDEFGHIJ' }, play: { packageName: PKG }, ...extra } as unknown as ShipConfig;
|
|
return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) };
|
|
}
|
|
|
|
// One page of GET /v1/devices (DevicesResponse), as documented: Device.Attributes.
|
|
const DEVICE = (id: string, udid: string, deviceClass: string, status = 'ENABLED', platform = 'IOS') =>
|
|
({ type: 'devices', id, attributes: { name: `Test ${deviceClass.toLowerCase()} ${id}`, platform, udid, deviceClass, status, model: 'iPhone 15', addedDate: '2026-01-02T00:00:00.000+0000' } });
|
|
const DEVICES = { data: [DEVICE('D1', OTHER, 'IPHONE'), DEVICE('D2', GONE, 'IPAD', 'DISABLED'), DEVICE('D3', 'c'.repeat(40), 'APPLE_TV')], links: { self: 'https://api.appstoreconnect.apple.com/v1/devices' } };
|
|
const CSV = `name,udid,platform\nNew phone,${NEW},IOS\nOld pad,${OLD},ios\nKnown phone,${OTHER.toLowerCase()},IOS\nDisabled pad,${GONE},IOS\n`;
|
|
|
|
const ALL_UDIDS = [NEW, OLD, OTHER, GONE, 'c'.repeat(40)];
|
|
const leaks = (s: string) => ALL_UDIDS.filter(u => s.toLowerCase().includes(u.toLowerCase()));
|
|
|
|
describe('UDIDs: validation and masking', () => {
|
|
test('legacy 40-hex and the 8-16 form; Mac hardware UUIDs only for MAC_OS', () => {
|
|
expect(validUdid(NEW)).toBe(true);
|
|
expect(validUdid(OLD)).toBe(true);
|
|
expect(validUdid(' ' + NEW + ' ')).toBe(true);
|
|
expect(validUdid('00008101-000A1B2C3D4E5F6')).toBe(false);
|
|
expect(validUdid('g'.repeat(40))).toBe(false);
|
|
expect(validUdid('0000-1111')).toBe(false);
|
|
const mac = '00000000-0000-0000-0000-000000000000';
|
|
expect(validUdid(mac, 'IOS')).toBe(false);
|
|
expect(validUdid(mac, 'MAC_OS')).toBe(true);
|
|
});
|
|
|
|
test('mask keeps 4 + 4; scrub masks UDID-shaped strings in any text', () => {
|
|
expect(maskUdid(NEW)).toBe('0000…5F60');
|
|
expect(maskUdid('short')).toBe('…');
|
|
const msg = `POST /v1/devices -> 409 {"detail":"A device with number '${NEW}' already exists; also ${OLD}"}`;
|
|
const s = scrubUdids(msg, [NEW]);
|
|
expect(leaks(s)).toEqual([]);
|
|
expect(s).toContain('0000…5F60');
|
|
expect(s).toContain('aaaa…aaab');
|
|
});
|
|
|
|
test('platform names', () => {
|
|
expect([parsePlatform(undefined), parsePlatform('ios'), parsePlatform('macOS'), parsePlatform('MAC_OS'), parsePlatform('tvos')]).toEqual(['IOS', 'IOS', 'MAC_OS', 'MAC_OS', null]);
|
|
});
|
|
|
|
test('CSV: rows parsed; errors name the row and never hold a full UDID', () => {
|
|
expect(parseDevicesCsv(CSV).devices.map(d => d.platform)).toEqual(['IOS', 'IOS', 'IOS', 'IOS']);
|
|
const bad = parseDevicesCsv(`name,udid,platform\nA,${NEW}x,IOS\n,${OLD},IOS\nB,${NEW},TVOS\nC,${OTHER},IOS\nD,${OTHER},IOS\n`);
|
|
expect(bad.errors).toEqual([
|
|
'row 2: "0000…F60x" is not a device UDID (40 hex digits, or 8 hex - 16 hex)',
|
|
'row 3: name is empty',
|
|
'row 4: platform must be IOS or MAC_OS',
|
|
'row 6: 0000…6677 is listed twice',
|
|
]);
|
|
expect(leaks(bad.errors.join('\n'))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('register plan', () => {
|
|
const reg = DEVICES.data.map(deviceFromApi);
|
|
test('new vs registered (case-insensitive) vs disabled', () => {
|
|
const p = planRegister(parseDevicesCsv(CSV).devices, reg);
|
|
expect(p.add.map(d => d.name)).toEqual(['New phone', 'Old pad']);
|
|
expect(p.present.map(d => d.name)).toEqual(['Known phone']);
|
|
expect(p.disabled.map(d => d.name)).toEqual(['Disabled pad']);
|
|
});
|
|
test('slots per device class count disabled devices too', () => {
|
|
expect(slotUsage(reg)).toEqual([
|
|
{ deviceClass: 'APPLE_TV', registered: 1, limit: 100 }, { deviceClass: 'IPAD', registered: 1, limit: 100 }, { deviceClass: 'IPHONE', registered: 1, limit: 100 }]);
|
|
});
|
|
});
|
|
|
|
describe('ExportOptions.plist', () => {
|
|
test('automatic signing, exact XML', () => {
|
|
expect(exportOptionsPlist({ method: 'release-testing', teamId: 'ABCDEFGHIJ', bundleId: PKG })).toBe(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
\t<key>destination</key>
|
|
\t<string>export</string>
|
|
\t<key>method</key>
|
|
\t<string>release-testing</string>
|
|
\t<key>signingStyle</key>
|
|
\t<string>automatic</string>
|
|
\t<key>teamID</key>
|
|
\t<string>ABCDEFGHIJ</string>
|
|
\t<key>thinning</key>
|
|
\t<string><none></string>
|
|
</dict>
|
|
</plist>
|
|
`);
|
|
});
|
|
|
|
test('manual signing with a profile; XML escaped', () => {
|
|
const x = exportOptionsPlist({ method: 'ad-hoc', teamId: 'ABCDEFGHIJ', bundleId: 'com.example.a&b', profile: `Ad <Hoc> "Q" & 'x'` });
|
|
expect(x).toContain('\t<key>provisioningProfiles</key>\n\t<dict>\n\t\t<key>com.example.a&b</key>\n\t\t<string>Ad <Hoc> "Q" & 'x'</string>\n\t</dict>\n');
|
|
expect(x).toContain('<key>signingStyle</key>\n\t<string>manual</string>');
|
|
expect(x).toContain('<string>ad-hoc</string>');
|
|
expect(() => exportOptionsPlist({ method: 'ad-hoc', teamId: 'ABCDEFGHIJ', profile: 'P' })).toThrow('apple.bundleId');
|
|
});
|
|
|
|
test('deprecated method names map to the current ones', () => {
|
|
expect(MODERN_METHOD).toEqual({ 'ad-hoc': 'release-testing', development: 'debugging', 'app-store': 'app-store-connect' });
|
|
});
|
|
|
|
test('xcodebuild argv and the printed line', () => {
|
|
const argv = exportArgv('build/My App.xcarchive', '/p/build/ExportOptions.plist', '/p/build/adhoc');
|
|
expect(argv).toEqual(['xcodebuild', '-exportArchive', '-archivePath', 'build/My App.xcarchive', '-exportOptionsPlist', '/p/build/ExportOptions.plist', '-exportPath', '/p/build/adhoc', '-allowProvisioningUpdates']);
|
|
expect(shellLine(argv)).toBe(`xcodebuild -exportArchive -archivePath 'build/My App.xcarchive' -exportOptionsPlist /p/build/ExportOptions.plist -exportPath /p/build/adhoc -allowProvisioningUpdates`);
|
|
});
|
|
});
|
|
|
|
describe('profile pieces', () => {
|
|
test('request shape, certificates, freshness', () => {
|
|
expect(profileCreateReq('P', 'B1', ['C1'], ['D1', 'D3'])).toEqual({ data: { type: 'profiles', attributes: { name: 'P', profileType: 'IOS_APP_ADHOC' }, relationships: {
|
|
bundleId: { data: { type: 'bundleIds', id: 'B1' } }, certificates: { data: [{ type: 'certificates', id: 'C1' }] },
|
|
devices: { data: [{ type: 'devices', id: 'D1' }, { type: 'devices', id: 'D3' }] } } } });
|
|
const certs = [
|
|
{ id: 'C1', attributes: { certificateType: 'DISTRIBUTION', expirationDate: '2027-05-01T00:00:00.000+0000' } },
|
|
{ id: 'C2', attributes: { certificateType: 'IOS_DISTRIBUTION', expirationDate: '2025-01-01T00:00:00.000+0000' } },
|
|
{ id: 'C3', attributes: { certificateType: 'DEVELOPMENT', expirationDate: '2027-05-01T00:00:00.000+0000' } }];
|
|
expect(usableCertificates(certs, new Date('2026-10-11'))).toEqual(['C1']);
|
|
expect(profileCurrent('ACTIVE', ['D1', 'D3'], ['D1'])).toEqual({ current: true, missing: 0 });
|
|
expect(profileCurrent('ACTIVE', ['D1'], ['D1', 'D4'])).toEqual({ current: false, missing: 1 });
|
|
expect(profileCurrent('INVALID', ['D1'], ['D1']).current).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('apple adhoc runs (mocked App Store Connect)', () => {
|
|
const route = (c: Call) => {
|
|
if (c.method === 'GET' && c.url.startsWith('https://api.appstoreconnect.apple.com/v1/devices')) return json(DEVICES);
|
|
if (c.method === 'POST' && c.url.endsWith('/v1/devices')) {
|
|
const b = JSON.parse(String(c.body));
|
|
if (b.data.attributes.udid === OLD) return json({ errors: [{ status: '409', code: 'ENTITY_ERROR', detail: `A device with number '${OLD}' already exists on this team.` }] }, 409);
|
|
return json({ data: { type: 'devices', id: 'D9', attributes: { ...b.data.attributes, status: 'ENABLED' } } }, 201);
|
|
}
|
|
return json({ errors: [{ status: '400' }] }, 400);
|
|
};
|
|
const files = { '.storebound/devices.csv': CSV };
|
|
|
|
test('devices --json: every UDID masked', async () => {
|
|
ascKey();
|
|
const calls = fake(route);
|
|
const out = await captured(() => adhocRun({ loaded: project(), args: parseArgs(['devices', '--platform', 'IOS', '--json']) }));
|
|
expect(calls.map(c => c.url)).toEqual(['https://api.appstoreconnect.apple.com/v1/devices?limit=200&filter[platform]=IOS']);
|
|
const j = JSON.parse(out);
|
|
expect(j.devices[0]).toEqual({ id: 'D1', name: 'Test iphone D1', platform: 'IOS', deviceClass: 'IPHONE', model: 'iPhone 15', status: 'ENABLED', udid: '0000…6677' });
|
|
expect(j.usage.length).toBe(3);
|
|
expect(leaks(out)).toEqual([]);
|
|
});
|
|
|
|
test('register --dry: reads, lists masked, sends no POST', async () => {
|
|
ascKey();
|
|
const calls = fake(route);
|
|
const out = await captured(() => adhocRun({ loaded: project({}, files), args: parseArgs(['register', '--dry']) }));
|
|
expect(calls.every(c => c.method === 'GET')).toBe(true);
|
|
expect(out).toContain('4 in the list: 2 new, 1 already registered, 1 disabled');
|
|
expect(out).toContain('IPHONE: 1 registered of 100 per membership year');
|
|
expect(out).toContain('0000…5F60');
|
|
expect(out).toContain('dry run: would register 2 device(s)');
|
|
expect(leaks(out)).toEqual([]);
|
|
});
|
|
|
|
test('register without a terminal and without --yes: no POST', async () => {
|
|
ascKey(); noTty();
|
|
const calls = fake(route);
|
|
const out = await captured(() => adhocRun({ loaded: project({}, files), args: parseArgs(['register']) }));
|
|
expect(calls.some(c => c.method !== 'GET')).toBe(false);
|
|
expect(out).toContain('re-run with --yes');
|
|
expect(leaks(out)).toEqual([]);
|
|
});
|
|
|
|
test('register --yes: one POST per new device; an API error echoing the UDID is masked', async () => {
|
|
ascKey();
|
|
const calls = fake(route);
|
|
let err = '';
|
|
const out = await captured(async () => {
|
|
try { await adhocRun({ loaded: project({}, files), args: parseArgs(['register', '--yes']) }); } catch (e) { err = (e as Error).message; }
|
|
});
|
|
const posts = calls.filter(c => c.method === 'POST');
|
|
expect(posts.map(c => JSON.parse(String(c.body)))).toEqual([
|
|
{ data: { type: 'devices', attributes: { name: 'New phone', udid: NEW, platform: 'IOS' } } },
|
|
{ data: { type: 'devices', attributes: { name: 'Old pad', udid: OLD, platform: 'IOS' } } }]);
|
|
expect(out).toContain('registered New phone (0000…5F60)');
|
|
expect(out).toContain('Old pad (aaaa…aaab) not registered');
|
|
expect(err).toBe('1 of 2 device(s) not registered');
|
|
expect(leaks(out + err)).toEqual([]);
|
|
});
|
|
|
|
test('register --udid with a bad UDID fails before any call, masked', async () => {
|
|
ascKey();
|
|
const calls = fake(route);
|
|
await expect(adhocRun({ loaded: project(), args: parseArgs(['register', '--name', 'X', '--udid', NEW + 'zz']) })).rejects.toThrow('--udid 0000…60zz: not a device UDID');
|
|
expect(calls.length).toBe(0);
|
|
});
|
|
|
|
test('profile --dry plans, profile --yes creates the IOS_APP_ADHOC profile with enabled iOS devices only', async () => {
|
|
ascKey();
|
|
const prof = (c: Call) => {
|
|
if (c.url.includes('/v1/bundleIds?')) return json({ data: [{ type: 'bundleIds', id: 'B1', attributes: { identifier: PKG, platform: 'IOS', name: 'Example' } }, { type: 'bundleIds', id: 'B2', attributes: { identifier: PKG + '.widget', platform: 'IOS' } }] });
|
|
if (c.url.includes('/v1/certificates?')) return json({ data: [{ type: 'certificates', id: 'C1', attributes: { certificateType: 'DISTRIBUTION', expirationDate: '2099-01-01T00:00:00.000+0000' } }] });
|
|
if (c.url.includes('/v1/bundleIds/B1/profiles')) return json({ data: [] });
|
|
if (c.method === 'POST' && c.url.endsWith('/v1/profiles')) return json({ data: { type: 'profiles', id: 'P1', attributes: { name: 'x', uuid: '00000000-0000-0000-0000-000000000000', profileContent: Buffer.from('profile').toString('base64') } } }, 201);
|
|
return route(c);
|
|
};
|
|
let calls = fake(prof);
|
|
const loaded = project({ apple: { bundleId: PKG, teamId: 'ABCDEFGHIJ', adhoc: { profile: 'Example AdHoc' } } } as Partial<ShipConfig>);
|
|
const dryOut = await captured(() => adhocRun({ loaded, args: parseArgs(['profile', '--dry']) }));
|
|
expect(calls.every(c => c.method === 'GET')).toBe(true);
|
|
expect(calls.map(c => c.url)).toContain('https://api.appstoreconnect.apple.com/v1/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200');
|
|
expect(dryOut).toContain('plan: create IOS_APP_ADHOC "Example AdHoc" for com.example.game: 1 devices (1 IPHONE), 1 distribution certificate(s)');
|
|
calls = fake(prof);
|
|
await captured(() => adhocRun({ loaded, args: parseArgs(['profile', '--yes']) }));
|
|
const post = calls.find(c => c.method === 'POST')!;
|
|
expect(JSON.parse(String(post.body))).toEqual(profileCreateReq('Example AdHoc', 'B1', ['C1'], ['D1']));
|
|
expect(existsSync(join(loaded.root, 'build/adhoc/Example_AdHoc.mobileprovision'))).toBe(true);
|
|
});
|
|
|
|
test('options --dry prints the plist and writes nothing; options writes it', async () => {
|
|
const loaded = project();
|
|
const out = await captured(() => adhocRun({ loaded, args: parseArgs(['options', '--dry', '--method', 'ad-hoc']) }));
|
|
expect(out).toContain('<string>ad-hoc</string>');
|
|
expect(existsSync(join(loaded.root, 'build/ExportOptions.plist'))).toBe(false);
|
|
await captured(() => adhocRun({ loaded, args: parseArgs(['options']) }));
|
|
expect(existsSync(join(loaded.root, 'build/ExportOptions.plist'))).toBe(true);
|
|
await expect(adhocRun({ loaded, args: parseArgs(['options', '--method', 'enterprise']) })).rejects.toThrow('--method');
|
|
});
|
|
|
|
test('export --dry prints the exact command; nothing written or run', async () => {
|
|
const loaded = project({}, { 'build/App.xcarchive/Info.plist': '<plist/>' });
|
|
const archive = join(loaded.root, 'build/App.xcarchive');
|
|
const out = await captured(() => adhocRun({ loaded, args: parseArgs(['export', '--archive', archive, '--dry']) }));
|
|
expect(out).toContain(shellLine(exportArgv(archive, join(loaded.root, 'build/ExportOptions.plist'), join(loaded.root, 'build/adhoc'))));
|
|
expect(out).toContain('would write build/ExportOptions.plist');
|
|
expect(existsSync(join(loaded.root, 'build/ExportOptions.plist'))).toBe(false);
|
|
expect(existsSync(join(loaded.root, 'build/adhoc'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('play internal app sharing', () => {
|
|
const ART = { downloadUrl: 'https://play.google.com/apps/test/com.example.game/placeholder', certificateFingerprint: 'AB:CD:EF', sha256: '' };
|
|
|
|
test('URL is not under applications/{packageName}; limits; response parsing', () => {
|
|
expect(shareUrl(PKG, 'bundle')).toBe('https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/internalappsharing/com.example.game/artifacts/bundle?uploadType=media');
|
|
expect(shareUrl(PKG, 'apk')).toEndWith('/internalappsharing/com.example.game/artifacts/apk?uploadType=media');
|
|
expect(SHARE_HEADERS).toEqual({ 'Content-Type': 'application/octet-stream' });
|
|
expect(SHARE_LIMIT).toEqual({ bundle: 10 * 1024 ** 3, apk: 1024 ** 3 });
|
|
expect(shareProblems('bundle', 'a.apk', 10)).toEqual(['--aab a.apk: expected an .aab file']);
|
|
expect(shareProblems('apk', 'a.apk', SHARE_LIMIT.apk + 1)[0]).toContain('above the 1024.0 MB internal app sharing limit');
|
|
expect(parseShareArtifact({ ...ART, sha256: 'ABC' })).toEqual({ ...ART, sha256: 'abc' });
|
|
expect(() => parseShareArtifact({})).toThrow('no downloadUrl');
|
|
});
|
|
|
|
test('share --dry sends nothing (not even a token request)', async () => {
|
|
let n = 0;
|
|
globalThis.fetch = (async () => { n++; return json({}); }) as unknown as typeof fetch;
|
|
const loaded = project({}, { 'app.aab': 'bundle bytes' });
|
|
const out = await captured(() => shareRun({ loaded, args: parseArgs(['--aab', join(loaded.root, 'app.aab'), '--dry', '--json']) }));
|
|
expect(n).toBe(0);
|
|
expect(JSON.parse(out)).toMatchObject({ dry: true, packageName: PKG, kind: 'bundle', bytes: 12, sha256: expect.stringMatching(/^[0-9a-f]{64}$/) });
|
|
});
|
|
|
|
test('share without --yes and no terminal: nothing sent', async () => {
|
|
noTty();
|
|
let n = 0;
|
|
globalThis.fetch = (async () => { n++; return json({}); }) as unknown as typeof fetch;
|
|
const loaded = project({}, { 'app.apk': 'apk bytes' });
|
|
await captured(() => shareRun({ loaded, args: parseArgs(['--apk', join(loaded.root, 'app.apk')]) }));
|
|
expect(n).toBe(0);
|
|
});
|
|
|
|
test('share --yes --json: one POST of the raw bytes, octet-stream, the link printed', async () => {
|
|
playKey();
|
|
const calls = fake(() => json(ART));
|
|
const loaded = project({}, { 'app.aab': 'bundle bytes' });
|
|
const out = await captured(() => shareRun({ loaded, args: parseArgs(['--aab', join(loaded.root, 'app.aab'), '--yes', '--json']) }));
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0]!.method).toBe('POST');
|
|
expect(calls[0]!.url).toBe(shareUrl(PKG, 'bundle'));
|
|
expect(calls[0]!.headers['Content-Type']).toBe('application/octet-stream');
|
|
expect(calls[0]!.headers.Authorization).toBe('Bearer tok');
|
|
expect(new TextDecoder().decode(calls[0]!.body as Uint8Array)).toBe('bundle bytes');
|
|
expect(JSON.parse(out.slice(out.indexOf('{')))).toEqual(ART);
|
|
});
|
|
});
|