import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs } from '../cli/lib/args'; import { loadConfig, validateConfig } from '../cli/lib/config'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows, formPairIssues, formRow, playForm, PLAY_FORM_EXPORTED } from '../cli/lib/data-safety'; import type { DataSafety } from '../cli/lib/config'; import { parseCsv } from '../cli/lib/csv'; import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money'; import { maskExpr } from '../cli/icons'; import { ffmpegQ } from '../cli/shots/jpeg'; import { type CopyFile, copyText, palette, scriptFont, validateCopy } from '../cli/shots/lib'; import { numberedImages } from '../cli/play/images'; const T = join(import.meta.dir, '..', 'templates'); const tpl = (f: string) => readFileSync(join(T, f), 'utf8'); describe('config', () => { test('template config loads and validates', async () => { const l = await loadConfig(join(T, 'storebound.config.ts')); expect(l.config.play?.packageName).toBe('com.example.game'); expect(l.resolve('store/x.md')).toBe(join(T, 'store/x.md')); }); test('reports bad fields', () => { const e = validateConfig({ app: { name: 'X', privacyPolicyUrl: 'http://insecure', supportUrl: 'https://a.b', website: 'https://a.b', contactEmail: 'nope' }, languages: [{ id: 'en' }, { id: 'en' }], play: { packageName: 'notapackage' }, products: [{ id: 'bad id!', type: 'gift', usd: 0, listings: { de: { title: '', description: 'x' } }, overrides: [{ region: 'TUR', currency: 'tl', price: 1 }] }], dataSafety: { collectsData: true, encryptedInTransit: true, partialDeletion: 'later' as never, data: { DEVICE: { shared: true, collect: [] } } }, }); const text = e.join('\n'); for (const s of ['privacyPolicyUrl', 'contactEmail', 'duplicate "en"', 'packageName', 'products[0].id', 'products[0].type', 'products[0].usd', 'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes', "dataSafety.partialDeletion: true | false | 'auto90'"]) expect(text).toContain(s); }); test('empty config', () => { expect(validateConfig(null)).toEqual(['config must be an object']); }); }); describe('copy parsing', () => { test('play template', () => { const c = parsePlayListing(tpl('listing.md'), 'Example Game'); expect(c.map(x => x.lang)).toEqual(['en-US', 'tr-TR']); expect(c[0]!.title).toBe('Example Game'); expect(c[1]!.whatsNew).toContain('İlk sürüm'); }); test('play: one section, several codes, explicit title, limits', () => { const md = '## Español\n\n### title\n```\nJuego\n```\n### short\n```\nCorto\n```\n### full\n```\nLargo\n```\n'; const c = parsePlayListing(md, 'X'); expect(c.map(x => [x.lang, x.title])).toEqual([['es-ES', 'Juego'], ['es-419', 'Juego']]); expect(() => parsePlayListing(md.replace('Corto', 'x'.repeat(81)), 'X')).toThrow('short is 81 > 80'); expect(() => parsePlayListing('## A\n\n### short\n```\na\n```\n', 'X')).toThrow('no "### ... full ..." block'); }); test('play: emoji count as one character', () => { const md = `## A\n\n### short\n\`\`\`\n${'é'.repeat(80)}\n\`\`\`\n### full\n\`\`\`\nx\n\`\`\`\n`; expect(parsePlayListing(md, 'X')[0]!.short.length).toBe(80); }); test('appstore template', () => { const c = parseAppStoreListing(tpl('appstore.md')); expect(Object.keys(c)).toEqual(['en-US', 'tr']); expect(c['en-US']!.subtitle).toBe('Short hook under the name'); }); test('appstore: missing field, keyword spaces, byte limit', () => { const sec = (kw: string) => `## de-DE\n### subtitle\n\`\`\`\nA\n\`\`\`\n### promotionalText\n\`\`\`\nB\n\`\`\`\n### keywords\n\`\`\`\n${kw}\n\`\`\`\n### description\n\`\`\`\nD\n\`\`\`\n`; expect(parseAppStoreListing(sec('a,b'))['de-DE']!.keywords).toBe('a,b'); expect(() => parseAppStoreListing(sec('a, b'))).toThrow('no spaces after commas'); expect(() => parseAppStoreListing(sec('ö'.repeat(60)))).toThrow('bytes > 100'); expect(() => parseAppStoreListing('## fr-FR\n### subtitle\n```\nx\n```\n')).toThrow('fr-FR: missing promotionalText'); }); }); describe('data safety csv', () => { const ds = { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: true, collect: ['analytics' as const, 'advertising' as const], share: ['advertising' as const] }, PSL_OTHER_PERSONAL: { shared: false, optional: true, collect: ['functionality' as const] } } }; const rows = dataSafetyRows(ds); const find = (q: string, r: string) => rows.find(x => x[0] === q && x[1] === r); test('header + overview + account creation', () => { expect(rows[0]![0]).toBe('Question ID (machine readable)'); // Play removed this question from the form; writing it is a 400 expect(rows.some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false); expect(dataSafetyRows({ ...ds, deletionRequest: true }).some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false); expect(find('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', 'PSL_ACM_NONE')![2]).toBe('TRUE'); }); test('types and purposes', () => { expect(find('PSL_DATA_TYPES_IDENTIFIERS', 'PSL_DEVICE_ID')![2]).toBe('TRUE'); expect(find('PSL_DATA_TYPES_LOCATION', 'PSL_PRECISE_LOCATION')![2]).toBe(''); const q = (t: string, s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`; expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_COLLECTION_PURPOSE'), 'PSL_ANALYTICS')![2]).toBe('TRUE'); expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_SHARING_PURPOSE'), 'PSL_ANALYTICS')![2]).toBe(''); expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_SHARING_PURPOSE'), 'PSL_ADVERTISING')![2]).toBe('TRUE'); expect(find(q('PSL_DEVICE_ID', 'PSL_DATA_USAGE_EPHEMERAL'), '')![2]).toBe('FALSE'); expect(find(q('PSL_OTHER_PERSONAL', 'DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL')![2]).toBe('TRUE'); expect(find(q('PSL_OTHER_PERSONAL', 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED')![2]).toBe(''); expect(find(q('PSL_CONTACTS', 'PSL_DATA_USAGE_EPHEMERAL'), '')![2]).toBe(''); }); test('every row has 5 cells; quoting; unknown type', () => { const csv = dataSafetyCsv(ds); expect(csv.endsWith('\n')).toBe(true); expect(csv).toContain('"Does your app collect or share any of the required user data types?"'.slice(1, -1)); for (const r of rows) expect(r.length).toBe(5); expect(() => dataSafetyRows({ ...ds, data: { PSL_NOPE: { shared: false, collect: ['analytics'] } } })).toThrow('unknown data type'); }); }); describe('data safety: conditional required rows', () => { const acc = { ...{ collectsData: true, encryptedInTransit: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account' as const] } } }; const url = 'https://example.com/delete-account'; const get = (rows: string[][], q: string) => rows.find(r => r[0] === q); test('accounts + username: the delete-account URL row carries accounts.deletionUrl, and the deletion question is yes', () => { // PSL_SUPPORT_DATA_DELETION_BY_USER is the general "Do you provide a way for users to request that their data is // deleted?": a delete-account page is such a way. Answering NO next to a delete-account URL contradicts it. const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url }); expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url); // a SINGLE_CHOICE: one row per option, TRUE on the picked one (a value with no Response ID was Play's // "Line 7: Response ID missing") expect(rows.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER').map(r => r.slice(0, 4).join(','))).toEqual([ 'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,SINGLE_CHOICE', 'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,SINGLE_CHOICE', 'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO_AUTO_DELETED,,SINGLE_CHOICE', ]); // the "Delete data URL" a yes needs: the delete-account page (it deletes the data too) unless a data-only page is set expect(get(rows, 'PSL_DATA_DELETION_URL')![2]).toBe(url); expect(dataSafetyIssues(rows)).toEqual([]); expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data'); // no delete-account URL: the explicit answer, deletionRequest or the old partialDeletion values decide const picked = (ds: object) => dataSafetyRows({ ...acc, ...ds }).filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1]); expect(picked({ dataDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']); expect(picked({ partialDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']); expect(picked({ deletionRequest: true })).toEqual(['DATA_DELETION_YES']); expect(picked({ deletionRequest: false })).toEqual(['DATA_DELETION_NO']); expect(picked({})).toEqual([]); // a delete-account URL with anything but yes is a problem before Play sees it const no = dataSafetyRows({ ...acc, dataDeletion: 'no' }, { deletionUrl: url }); expect(dataSafetyIssues(no).map(i => i.msg).join('\n')).toContain('must be DATA_DELETION_YES (is DATA_DELETION_NO): PSL_SUPPORT_DATA_DELETION_BY_USER (PSL_ACCOUNT_DELETION_URL is set'); }); test('no accounts: no deletion rows, no issues', () => { const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url }); for (const q of ['PSL_ACCOUNT_DELETION_URL', 'PSL_SUPPORT_DATA_DELETION_BY_USER', 'PSL_DATA_DELETION_URL']) expect(get(rows, q)).toBeUndefined(); expect(dataSafetyIssues(rows)).toEqual([]); expect(get(dataSafetyRows({ ...acc, accountCreation: undefined }), 'PSL_ACCOUNT_DELETION_URL')).toBeUndefined(); }); test('missing deletionUrl / partialDeletion: named with the config field to set', () => { const msgs = dataSafetyIssues(dataSafetyRows(acc)).map(i => i.msg); expect(msgs[0]).toBe('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill))'); expect(msgs[1]).toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER'); expect(msgs[1]).toContain('dataSafety.dataDeletion'); expect(dataSafetyIssues(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: undefined }, {})).map(i => i.question)).toEqual(['PSL_ACCOUNT_DELETION_URL', 'PSL_DATA_DELETION_URL']); }); test('extra replaces a generated question; refused and per-type rows', () => { const rows = dataSafetyRows({ ...acc, dataDeletion: 'yes', partialDeletionUrl: url, extra: [{ question: 'PSL_ACCOUNT_DELETION_URL', value: url }] }); expect(rows.filter(r => r[0] === 'PSL_ACCOUNT_DELETION_URL')).toHaveLength(1); expect(dataSafetyIssues(rows)).toEqual([]); const outside = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'FALSE' }] }, { deletionUrl: url }); expect(dataSafetyIssues(outside).map(i => i.msg.split(' (')[0])).toEqual(['not allowed: PSL_HAS_OUTSIDE_APP_ACCOUNTS']); // a type selected but its purpose rows emptied (e.g. a hand-edited export) const cut = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url }) .map(r => r[0] === 'PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE' ? [r[0], r[1]!, '', r[3]!, r[4]!] : r); expect(dataSafetyIssues(cut).map(i => i.question)).toEqual(['PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE']); }); test('the template passes the rules and only uses known question ids', () => { const rows = parseCsv(readFileSync(join(import.meta.dir, '..', 'templates', 'data-safety.csv'), 'utf8')); expect(dataSafetyIssues(rows)).toEqual([]); // an example with answers, not the empty form, and every pair is in Play's current form expect(rows.some(r => r[2] === 'TRUE')).toBe(true); expect(formPairIssues(rows)).toEqual([]); for (const r of rows.slice(1)) expect(r[3]).toBe(formRow(r[0]!, r[1]!)!.requirement); const known = new Set(DATA_SAFETY_RULES.map(r => r.question)); for (const r of rows.slice(1)) expect(known.has(r[0]!) || /^PSL_DATA_TYPES_|^PSL_DATA_USAGE_RESPONSES:PSL_[A-Z_]+:/.test(r[0]!)).toBe(true); }); }); describe("data safety: pairs from Play's exported form", () => { const url = 'https://example.com/delete-account'; const all = (u: Partial = {}) => Object.fromEntries(['PSL_NAME', 'PSL_EMAIL', 'PSL_PURCHASE_HISTORY', 'PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION', 'PSL_CRASH_LOGS', 'PSL_USER_INTERACTION', 'PSL_DEVICE_ID', 'PSL_PHOTOS', 'PSL_HEALTH'].map(t => [t, { shared: true, collect: ['analytics', 'functionality'], share: ['advertising'], ...u }])) as DataSafety['data']; const base: DataSafety = { collectsData: true, encryptedInTransit: true, data: {} }; const accounts = { ...base, accountCreation: ['PSL_ACM_USER_ID_PASSWORD', 'PSL_ACM_OAUTH'], data: { PSL_EMAIL: { shared: false, collect: ['account' as const] } } }; const cases: [string, DataSafety][] = [ ['no data', { collectsData: false, encryptedInTransit: false, data: {} }], ['accounts, partialDeletion true', { ...accounts, partialDeletion: true }], ['accounts, partialDeletion false', { ...accounts, partialDeletion: false }], ["accounts, partialDeletion 'auto90'", { ...accounts, partialDeletion: 'auto90' }], ['many types shared and collected', { ...base, data: all() }], ['many types, optional and ephemeral', { ...base, data: all({ shared: false, optional: true, ephemeral: true }) }], ['extra rows', { ...base, data: all(), extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_INDEPENDENTLY_VALIDATED', value: 'FALSE' }, { question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_LOGIN_THROUGH_EMPLOYMENT_OR_ENTERPRISE_ACCOUNT', value: 'TRUE' }] }], ]; test(`the form (exported ${PLAY_FORM_EXPORTED}) is Google's empty export`, () => { const f = [...playForm().values()]; expect(f.length).toBe(782); expect(formRow('PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBeUndefined(); expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER', 'DATA_DELETION_NO')!.requirement).toBe('SINGLE_CHOICE'); expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER')).toBeUndefined(); }); for (const [name, ds] of cases) test(`${name}: every pair is in the form with its Answer requirement`, () => { const rows = dataSafetyRows(ds, { deletionUrl: url }); expect(formPairIssues(rows)).toEqual([]); for (const r of rows.slice(1)) expect([r[0], r[1], r[3]]).toEqual([r[0], r[1], formRow(r[0]!, r[1]!)!.requirement]); }); test('extra rows take the requirement from the form, not a guess', () => { const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_OUTSIDE_APP_ACCOUNT_TYPE_OTHER', value: 'TRUE', label: 'mine' }] }); expect(rows.find(r => r[0] === 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY')!.slice(2, 4)).toEqual(['TRUE', 'OPTIONAL']); expect(rows.find(r => r[0] === 'PSL_OUTSIDE_APP_ACCOUNT_TYPES')!.slice(2)).toEqual(['TRUE', 'MULTIPLE_CHOICE', 'mine']); }); test('an unknown pair is reported by dataSafetyIssues (check, declarations, play data-safety --dry)', () => { const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', value: 'TRUE' }, { question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', value: 'FALSE' }] }); const msgs = dataSafetyIssues(rows).map(i => i.msg); expect(msgs).toContain(`PSL_DATA_COLLECTION_USER_REQUEST_DELETE, is not in Play's current Data safety form (cli/data/play-data-safety-form.csv, exported ${PLAY_FORM_EXPORTED}); Play answers 400`); expect(msgs.some(m => m.startsWith("PSL_SUPPORT_DATA_DELETION_BY_USER, is not in Play's current"))).toBe(true); expect(formPairIssues([['PSL_DATA_TYPES_PERSONAL', 'PSL_NOPE', 'TRUE', 'MULTIPLE_CHOICE', '']]).map(i => i.question)).toEqual(['PSL_DATA_TYPES_PERSONAL']); }); }); describe('prices', () => { test('toMoney without float noise', () => { expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 }); expect(toMoney('TRY', 129.99)).toEqual({ currencyCode: 'TRY', units: '129', nanos: 990000000 }); expect(toMoney('JPY', 300)).toEqual({ currencyCode: 'JPY', units: '300', nanos: 0 }); expect(fromMoney(toMoney('EUR', 0.29))).toBeCloseTo(0.29); expect(() => toMoney('USD', -1)).toThrow(); }); test('play region configs with overrides', () => { const conv = { a: { regionCode: 'US', price: toMoney('USD', 2.99) }, b: { regionCode: 'TR', price: toMoney('TRY', 109.99) }, c: { regionCode: 'DE', price: toMoney('EUR', 2.99) } }; const r = playRegionConfigs(conv, [{ region: 'TR', currency: 'TRY', price: 99.99 }]); expect(r.map(x => x.regionCode)).toEqual(['DE', 'TR', 'US']); expect(r.find(x => x.regionCode === 'TR')!.price).toEqual(toMoney('TRY', 99.99)); expect(r.every(x => x.availability === 'AVAILABLE')).toBe(true); expect(() => playRegionConfigs(conv, [{ region: 'XX', currency: 'USD', price: 1 }])).toThrow('region XX'); }); test('apple price matching + territories', () => { expect(samePrice('2.99', 2.99)).toBe(true); expect(samePrice('30.0', 30)).toBe(true); expect(samePrice('2.98', 2.99)).toBe(false); expect(nearestPrices([1.99, 2.49, 2.99, 3.49], 2.89, 2)).toEqual([2.99, 2.49]); expect(appleTerritory({ region: 'TR', currency: 'TRY', price: 1 })).toBe('TUR'); expect(appleTerritory({ region: 'GB', currency: 'GBP', price: 1 })).toBe('GBR'); expect(appleTerritory({ region: 'ZZ', territory: 'ZZZ', currency: 'USD', price: 1 })).toBe('ZZZ'); expect(() => appleTerritory({ region: 'ZZ', currency: 'USD', price: 1 })).toThrow('set "territory"'); expect(Object.keys(ALPHA3).length).toBeGreaterThan(150); for (const [k, v] of Object.entries(ALPHA3)) { expect(k).toMatch(/^[A-Z]{2}$/); expect(v).toMatch(/^[A-Z]{3}$/); } }); }); describe('screenshots', () => { const copy = JSON.parse(tpl('shots-copy.json')) as CopyFile; test('script fonts', () => { expect(scriptFont('en', 'Hello')).toBeNull(); expect(scriptFont('tr', 'Dokun. Eşleştir.')).toBeNull(); expect(scriptFont('ja', copyText('ja', copy.ja))!.slug).toBe('jp'); expect(scriptFont('ar', copyText('ar', copy.ar))!.slug).toBe('arabic'); expect(scriptFont('ko', '안녕')!.slug).toBe('kr'); expect(scriptFont('zh-Hans', '你好')!.slug).toBe('sc'); expect(scriptFont('zh-Hant', '你好')!.slug).toBe('tc'); expect(scriptFont('ru', 'Привет')!.slug).toBe('cyrgreek'); expect(scriptFont('hi', 'नमस्ते')!.slug).toBe('devanagari'); }); test('template copy fits the template scenes', async () => { const { config } = await loadConfig(join(T, 'storebound.config.ts')); expect(validateCopy(copy, config.shots!, config.languages.map(l => l.id))).toEqual([]); const broken = { en: { ...copy.en, play: { ...copy.en!.play!, badges: [['a'], ['b'], ['c']] } } } as CopyFile; expect(validateCopy(broken, config.shots!, ['en', 'de']).join('\n')).toMatch(/3 badges but the scene has 2 slots[\s\S]*de: no copy/); }); test('palettes', () => { expect(palette('gold').ink).toBe('#6b2c00'); expect(() => palette('nope')).toThrow('unknown palette'); }); test('jpeg quality mapping', () => { expect(ffmpegQ(100)).toBe(2); expect(ffmpegQ(92)).toBe(4); expect(ffmpegQ(0)).toBe(31); }); test('numbered images prefer jpeg', () => { expect(numberedImages('/nonexistent')).toEqual([]); }); }); describe('icons + args', () => { test('mask expressions', () => { expect(maskExpr('circle', 192)).toBe('if(lte(hypot(X-96+0.5,Y-96+0.5),96),255,0)'); expect(maskExpr('squircle', 100)).toContain('22'); }); test('args', () => { const a = parseArgs(['file.aab', '--production', 'draft', '--dry', '--only=a,b', '--internal']); expect(a._).toEqual(['file.aab']); expect(a.get('production')).toBe('draft'); expect(a.has('dry')).toBe(true); expect(a.list('only')).toEqual(['a', 'b']); expect(a.get('internal')).toBe('true'); expect(() => parseArgs(['--jobs', 'x']).num('jobs', 1)).toThrow(); }); });