validate: macOS versions need Mac screenshots, Play reads survive an ended edit; macOS build numbers across platforms; Mac build prebuild + apple.macAppCheck; signing wording off the Mac; dry flows plan uploads
This commit is contained in:
parent
b1f1b206d3
commit
f5da7c9f7a
21 changed files with 519 additions and 57 deletions
100
tests/flow-dry.test.ts
Normal file
100
tests/flow-dry.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// storebound flow, dry runs: a step whose input (the IPA / AAB, the uploaded build) only a real earlier step makes is
|
||||
// planned, not failed; a real run is untouched. The beta template: required VERSION, GROUP empty = the config's internal group.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { type DryPending, evalIf, expand, flowsOfFile, missingParams, noteDry, planStep, plannedForDryInput, substitute, validateFlows } from '../cli/lib/flow';
|
||||
import { parseYaml } from '../cli/lib/yaml';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const CLI = join(ROOT, 'cli', 'index.ts');
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'storebound-flow-dry-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
||||
const C = { apple: true, play: true, exists: () => false };
|
||||
|
||||
const load = (name: string) => flowsOfFile(`templates/workflows/${name}.yml`, parseYaml(readFileSync(join(ROOT, 'templates', 'workflows', `${name}.yml`), 'utf8')), name).flows[0]!;
|
||||
|
||||
/** What the runner decides per step in a mode: 'ran' (storebound is called), 'planned' (dry input missing) or 'skipped'. */
|
||||
function simulate(name: string, mode: 'dry' | 'apply', vars: Record<string, string>) {
|
||||
const f = load(name);
|
||||
const pending: DryPending = { built: new Set(), uploaded: new Set() };
|
||||
const out: Record<string, { how: string; argv?: string[]; owner?: string }> = {};
|
||||
for (const s of expand(f, new Map([[f.name, f]]), 'steps').steps) {
|
||||
const p = planStep(s, 'steps', mode, vars, C);
|
||||
if (p.skip) { out[s.id] = { how: 'skipped', argv: p.argv, owner: p.owner }; continue; }
|
||||
const why = plannedForDryInput(p, mode, pending);
|
||||
if (why) { out[s.id] = { how: 'planned', argv: p.argv }; continue; }
|
||||
noteDry(p, pending);
|
||||
out[s.id] = { how: 'ran', argv: p.argv };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('dry flows plan what a dry earlier step cannot produce', () => {
|
||||
test('beta: dry build then upload (and wait, distribute) are planned, reads and checks still run', () => {
|
||||
const r = simulate('beta', 'dry', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' });
|
||||
expect(r['build']).toMatchObject({ how: 'ran', argv: ['apple', 'build', '--version', '1.2.0', '--build-number', '7', '--dry'] });
|
||||
expect(r['upload']!.how).toBe('planned');
|
||||
expect(r['wait']!.how).toBe('planned');
|
||||
expect(r['distribute-internal']!.how).toBe('planned');
|
||||
expect(r['play-build']!.how).toBe('ran');
|
||||
expect(r['play-upload']!.how).toBe('planned');
|
||||
expect(r['check']!.how).toBe('ran');
|
||||
});
|
||||
|
||||
test('a real run is untouched: upload with no IPA still runs (and fails inside the command)', () => {
|
||||
const r = simulate('beta', 'apply', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' });
|
||||
expect(r['upload']).toMatchObject({ how: 'ran', argv: ['apple', 'upload', '--no-wait', '--build', '7', '--allow-existing'] });
|
||||
expect(r['play-upload']!.how).toBe('ran');
|
||||
});
|
||||
|
||||
test('an upload of a named file, or without a dry build before it, is not planned away', () => {
|
||||
const pending: DryPending = { built: new Set(['apple']), uploaded: new Set() };
|
||||
const up = planStep({ id: 'u', run: 'apple upload build/App.ipa' }, 'steps', 'dry', {}, C);
|
||||
expect(plannedForDryInput(up, 'dry', pending)).toBeNull();
|
||||
const fresh: DryPending = { built: new Set(), uploaded: new Set() };
|
||||
expect(plannedForDryInput(planStep({ id: 'u', run: 'apple upload --no-wait' }, 'steps', 'dry', {}, C), 'dry', fresh)).toBeNull();
|
||||
});
|
||||
|
||||
test('release: the macOS pkg is tracked apart from the iOS IPA', () => {
|
||||
const r = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: 'src-tauri', PLAY_TRACK: 'beta', BUILD: '9' });
|
||||
expect(r['build']!.how).toBe('ran');
|
||||
expect(r['upload']!.how).toBe('planned');
|
||||
expect(r['mac-build']!.argv).toContain('--dry');
|
||||
expect(r['mac-upload']!.how).toBe('planned');
|
||||
expect(r['play-upload']!.argv).toEqual(['play', 'upload', '--track', 'beta', '--dry']);
|
||||
const off = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: '', PLAY_TRACK: 'internal', BUILD: '9' });
|
||||
expect(off['mac-build']!.how).toBe('skipped');
|
||||
expect(off['mac-upload']!.how).toBe('skipped');
|
||||
expect(off['promote']!.owner).toContain('internal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('beta template defaults', () => {
|
||||
test('VERSION is required, GROUP defaults to empty and falls to the internal group of the config', () => {
|
||||
const f = load('beta');
|
||||
expect(f.params['VERSION']).toBe('?');
|
||||
expect(f.params['GROUP']).toBe('');
|
||||
expect(missingParams({ ...f.params })).toEqual(['VERSION']);
|
||||
expect(missingParams({ ...f.params, VERSION: '1.0.0' })).toEqual([]);
|
||||
const steps = expand(f, new Map([[f.name, f]]), 'steps').steps;
|
||||
const by = (v: Record<string, string>) => steps.filter(s => s.id.startsWith('distribute') || s.id === 'no-group')
|
||||
.filter(s => evalIf(substitute(s.if ?? '', v), C)).map(s => s.id);
|
||||
expect(by({ GROUP: '', 'config.testflight.internal': 'Team' })).toEqual(['distribute-internal']);
|
||||
expect(by({ GROUP: 'QA', 'config.testflight.internal': 'Team' })).toEqual(['distribute']);
|
||||
expect(by({ GROUP: '', 'config.testflight.internal': '' })).toEqual(['no-group']);
|
||||
expect(validateFlows([f, load('release')])).toEqual([]);
|
||||
});
|
||||
|
||||
test('flow run beta without VERSION fails early with a clear message', () => {
|
||||
const dir = join(tmp, 'req');
|
||||
mkdirSync(join(dir, 'store', 'workflows'), { recursive: true });
|
||||
writeFileSync(join(dir, 'store', 'workflows', 'beta.yml'), readFileSync(join(ROOT, 'templates', 'workflows', 'beta.yml'), 'utf8'));
|
||||
const p = sb(dir, 'flow', 'run', 'beta');
|
||||
expect(p.exitCode).not.toBe(0);
|
||||
expect(p.stderr.toString() + p.stdout.toString()).toContain('beta needs VERSION=<value>');
|
||||
});
|
||||
});
|
||||
73
tests/next-build-platforms.test.ts
Normal file
73
tests/next-build-platforms.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// One app record, an iOS and a macOS App Store version (pitfall 79): CFBundleVersion must be unique across ALL the app's
|
||||
// builds, so `apple next-build`, `apple build` and `apple build --platform macos` (also --dry) share one computation.
|
||||
// Seen live: the macOS dry run proposed a build number an uploaded macOS build already had (it asked only for macOS
|
||||
// builds), while next-build said the right one. Against the fake App Store Connect.
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { appNextBuild, run as nextBuild } from '../cli/apple/next-build';
|
||||
import { identityLine, run as buildMas } from '../cli/apple/build-mas';
|
||||
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
|
||||
import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
|
||||
|
||||
const APP = '6400000101';
|
||||
const CONFIG: ShipConfig = { ...BASE };
|
||||
const TAURI = JSON.stringify({ productName: 'Example Notes', identifier: 'com.example.notes', version: '1.2.0' });
|
||||
let s: Stores;
|
||||
beforeAll(ascEnv);
|
||||
afterAll(restore);
|
||||
beforeEach(() => { s = installStores(); });
|
||||
afterEach(uninstallStores);
|
||||
|
||||
const build = (id: string, version: string, state = 'VALID') => s.asc.put({ type: 'builds', id, attributes: { version, processingState: state }, rels: { app: APP } });
|
||||
/** iOS builds 202610261700 / ...1730, a macOS build ...1731 and an expired one ...1734 in another state. */
|
||||
function seed(): void {
|
||||
build('b-ios-1', '202610261700');
|
||||
build('b-ios-2', '202610261730');
|
||||
build('b-mac-1', '202610261731');
|
||||
build('b-mac-2', '202610261734', 'PROCESSING');
|
||||
}
|
||||
|
||||
describe('next build number over every platform', () => {
|
||||
test('apple next-build and the macOS dry run agree and sit above the highest build', async () => {
|
||||
seed();
|
||||
const p = project(CONFIG, { 'src-tauri/tauri.conf.json': TAURI });
|
||||
const nb = await capture(() => nextBuild(p.ctx(['--json'])));
|
||||
expect(JSON.parse(nb.out)).toEqual({ next: '202610261735', builds: 4 });
|
||||
const dry = await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--dry', '--json']), 'linux'));
|
||||
expect(JSON.parse(dry.out.slice(dry.out.indexOf('{')))).toMatchObject({ platform: 'MAC_OS', build: '202610261735' });
|
||||
});
|
||||
test('no request narrows the build list to one platform', async () => {
|
||||
seed();
|
||||
const p = project(CONFIG, { 'src-tauri/tauri.conf.json': TAURI });
|
||||
await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--dry', '--json']), 'linux'));
|
||||
const reads = s.calls.filter(c => /\/v1\/builds/.test(c.path));
|
||||
expect(reads.length).toBeGreaterThan(0);
|
||||
for (const r of reads) expect(decodeURIComponent(r.path)).not.toMatch(/preReleaseVersion|platform|processingState/);
|
||||
});
|
||||
test('every page counts', async () => {
|
||||
const pages: Record<string, unknown> = {
|
||||
'/p1': { data: [{ attributes: { version: '5' } }], links: { next: '/p2' } },
|
||||
'/p2': { data: [{ attributes: { version: '41' } }], links: { next: '/p3' } },
|
||||
'/p3': { data: [{ attributes: { version: '9' } }], links: {} },
|
||||
};
|
||||
const seen: string[] = [];
|
||||
const client = { app: async () => APP, all: async (path: string) => { seen.push(path); const out: unknown[] = []; for (let u: string | undefined = '/p1'; u; u = (pages[u] as { links: { next?: string } }).links.next) out.push(...(pages[u] as { data: unknown[] }).data); return out; } };
|
||||
expect(await appNextBuild(client as never)).toEqual({ next: '42', builds: 3 });
|
||||
expect(seen[0]).toContain(`filter[app]=${APP}`);
|
||||
});
|
||||
test('an app without builds starts at 1', async () => {
|
||||
expect((await appNextBuild({ app: async () => APP, all: async () => [] } as never)).next).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity line off a Mac', () => {
|
||||
test('a non-macOS machine is called a non-build machine, not "none found"', () => {
|
||||
const line = identityLine({ platform: 'win32', appIdentity: null, installerIdentity: null, profile: null });
|
||||
expect(line).toContain('not a build machine');
|
||||
expect(line).toContain('Mac');
|
||||
expect(line).not.toContain('none found');
|
||||
});
|
||||
test('on a Mac a missing identity is still reported as missing', () => {
|
||||
expect(identityLine({ platform: 'darwin', appIdentity: null, installerIdentity: 'Mac Installer', profile: null })).toContain('app identity: (none found); installer identity: Mac Installer');
|
||||
});
|
||||
});
|
||||
52
tests/validate-macos.test.ts
Normal file
52
tests/validate-macos.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// A macOS App Store version is checked against the Mac screenshot set only (APP_DESKTOP), never the iPhone / iPad
|
||||
// sets: validate / release plan showed 2 false blockers per locale for an app whose editable version was macOS.
|
||||
// Also: a non-macOS machine without the certificates' keys must not be told to create another certificate.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { requiredDisplayTypes } from '../cli/apple/screenshots';
|
||||
import { pickCertificate, type ApiCert } from '../cli/lib/signing';
|
||||
import { appleRules, type AppleSnapshot } from '../cli/lib/validate-rules';
|
||||
|
||||
const snap = (platform: string, apple: Parameters<typeof requiredDisplayTypes>[0], shots: Record<string, number>): AppleSnapshot => ({
|
||||
version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform }, build: null, expectedLocales: ['en-US', 'de-DE'],
|
||||
locales: ['en-US', 'de-DE'].map(locale => ({ locale, name: 'n', subtitle: 's', description: 'd', keywords: 'k', screenshots: shots })),
|
||||
requiredDisplayTypes: requiredDisplayTypes(apple, platform), mac: !!apple?.mac, macVersion: platform === 'MAC_OS',
|
||||
reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 },
|
||||
} as AppleSnapshot);
|
||||
const shotBlockers = (s: AppleSnapshot) => appleRules(s).filter(x => x.level === 'blocker' && x.code.startsWith('screenshots'));
|
||||
|
||||
describe('required screenshot types per platform', () => {
|
||||
test('macOS: the Mac set with apple.mac, nothing else; apple.screenshotTypes stays the iOS list', () => {
|
||||
expect(requiredDisplayTypes({ ipad: true, mac: true }, 'MAC_OS')).toEqual(['APP_DESKTOP']);
|
||||
expect(requiredDisplayTypes({ ipad: true, mac: true, screenshotTypes: ['APP_IPHONE_67'] }, 'MAC_OS')).toEqual(['APP_DESKTOP']);
|
||||
expect(requiredDisplayTypes({ ipad: true }, 'MAC_OS')).toEqual([]);
|
||||
expect(requiredDisplayTypes({ ipad: true, mac: true, screenshotTypes: ['APP_IPHONE_67'] })).toEqual(['APP_IPHONE_67']);
|
||||
expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']);
|
||||
expect(requiredDisplayTypes({ ipad: true }, 'TV_OS')).toEqual([]);
|
||||
});
|
||||
test('a macOS version with Mac screenshots and no iPhone / iPad ones has no screenshot blocker', () => {
|
||||
expect(shotBlockers(snap('MAC_OS', { ipad: true, mac: true }, { APP_DESKTOP: 3 }))).toEqual([]);
|
||||
});
|
||||
test('a macOS version without the Mac set is missing APP_DESKTOP only', () => {
|
||||
const b = shotBlockers(snap('MAC_OS', { ipad: true, mac: true }, {}));
|
||||
expect(b.map(x => x.msg)).toEqual(['en-US: no APP_DESKTOP screenshots', 'de-DE: no APP_DESKTOP screenshots']);
|
||||
});
|
||||
test('an iOS version still needs the iPhone and iPad sets', () => {
|
||||
expect(shotBlockers(snap('IOS', { ipad: true, mac: true }, { APP_DESKTOP: 3 })).length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('certificate without a local key', () => {
|
||||
const c = (id: string): ApiCert => ({ id, attributes: { certificateType: 'DISTRIBUTION', activated: true, expirationDate: new Date(Date.now() + 400 * 864e5).toISOString() } });
|
||||
test('macOS: a new one or the .p12 import', () => {
|
||||
expect(pickCertificate([c('a')], 'DISTRIBUTION', () => false, Date.now(), 'darwin').why).toContain('a new one is needed');
|
||||
});
|
||||
test('Windows / Linux: build on the Mac, never a new certificate', () => {
|
||||
for (const p of ['win32', 'linux']) {
|
||||
const why = pickCertificate([c('a')], 'DISTRIBUTION', () => false, Date.now(), p).why;
|
||||
expect(why).toContain('none of their private keys');
|
||||
expect(why).toContain('cannot build or sign for Apple');
|
||||
expect(why).toContain('Do not create another certificate');
|
||||
expect(why).not.toContain('a new one is needed');
|
||||
}
|
||||
});
|
||||
});
|
||||
91
tests/validate-play-edit.test.ts
Normal file
91
tests/validate-play-edit.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// `storebound validate --play` read its screenshots with a bare POST /edits and a DELETE in a finally: not under the
|
||||
// machine lock and never retried. When Play (or another storebound process) ended that edit meanwhile, the next read
|
||||
// answered 400 "This Edit has been deleted" and validate printed it as a store blocker ("GET .../phoneScreenshots 400").
|
||||
// Now the reads run in play.read (one locked edit, deleted only after all reads, redone once with a new edit), and a
|
||||
// vanished edit that stays gone is a tool warning, never a blocker.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseArgs } from '../cli/lib/args';
|
||||
import type { Loaded, ShipConfig } from '../cli/lib/config';
|
||||
import { collectPlay, run } from '../cli/validate';
|
||||
import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
|
||||
|
||||
const PKG = 'com.example.game';
|
||||
const GONE = { error: { code: 400, status: 'FAILED_PRECONDITION', message: 'This Edit has been deleted.' } };
|
||||
let s: Stores;
|
||||
let savedKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedKey = process.env.PLAY_SERVICE_ACCOUNT;
|
||||
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' });
|
||||
s = installStores();
|
||||
// the fake has no countryAvailability route (the console owns it): a plain 404, which validate reads as "not readable"
|
||||
s.faults.push({ method: 'GET', match: /countryAvailability/, status: 404, when: 'before', times: 99 });
|
||||
s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Game', shortDescription: 'Short.', fullDescription: 'Full.' };
|
||||
});
|
||||
afterEach(() => {
|
||||
uninstallStores();
|
||||
if (savedKey === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = savedKey;
|
||||
});
|
||||
|
||||
function loaded(): Loaded {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sb-vpe-'));
|
||||
const config = { app: { name: 'Example Game', privacyPolicyUrl: '' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig;
|
||||
return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) };
|
||||
}
|
||||
|
||||
/** Play ends the edit under the reader: the DELETE happens on the store, the read that follows answers 400. */
|
||||
function endEditOnImageRead(times: number): { hits: () => number } {
|
||||
const inner = globalThis.fetch;
|
||||
let left = times, hits = 0;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
const m = /\/edits\/([^/]+)\/listings\/[^/]+\/phoneScreenshots$/.exec(url);
|
||||
if (m && (init?.method ?? 'GET').toUpperCase() === 'GET' && left > 0) {
|
||||
left--; hits++;
|
||||
s.play.edits.delete(m[1]!);
|
||||
return new Response(JSON.stringify(GONE), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return inner(input, init);
|
||||
}) as typeof fetch;
|
||||
return { hits: () => hits };
|
||||
}
|
||||
|
||||
describe('validate --play: the read-only edit', () => {
|
||||
test('an edit ended during the screenshot reads is reopened once; the snapshot is complete and every edit is closed', async () => {
|
||||
const fault = endEditOnImageRead(1);
|
||||
const snap = await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) });
|
||||
expect(fault.hits()).toBe(1);
|
||||
expect(snap.listings.map(l => l.language)).toEqual(['en-US']);
|
||||
expect(Object.keys(snap.listings[0]!.images)).toContain('phoneScreenshots');
|
||||
expect(s.play.edits.size).toBe(0);
|
||||
const edits = s.calls.filter(c => c.method === 'POST' && /\/edits$/.test(c.path));
|
||||
expect(edits.length).toBe(2);
|
||||
});
|
||||
|
||||
test('no image read runs after the edit was deleted', async () => {
|
||||
await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) });
|
||||
const del = s.calls.findIndex(c => c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.path));
|
||||
expect(del).toBeGreaterThan(-1);
|
||||
expect(s.calls.slice(del + 1).filter(c => /\/edits\/[^/]+\//.test(c.path))).toEqual([]);
|
||||
});
|
||||
|
||||
test('an edit that stays gone is a tool warning, never a store blocker', async () => {
|
||||
endEditOnImageRead(10);
|
||||
const out: string[] = [];
|
||||
const log = console.log;
|
||||
console.log = (...a: unknown[]) => { out.push(a.join(' ')); };
|
||||
const prev = process.exitCode;
|
||||
try { await run({ loaded: loaded(), args: parseArgs(['--play', '--json', '--no-scan', '--no-health', '--no-local']) }); } finally { console.log = log; }
|
||||
process.exitCode = prev;
|
||||
const res = JSON.parse(out.join('\n').slice(out.join('\n').indexOf('{'))) as { blockers: number; findings: { level: string; code: string; msg: string }[] };
|
||||
expect(res.findings.filter(f => f.level === 'blocker' && /Edit has been deleted|phoneScreenshots/.test(f.msg))).toEqual([]);
|
||||
expect(res.findings).toContainEqual(expect.objectContaining({ level: 'warn', code: 'edit-gone' }));
|
||||
// (the template-answers blocker of the example config is unrelated: only an api blocker would be the bug)
|
||||
expect(res.findings.filter(f => f.level === 'blocker' && f.code === 'api')).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue