// Dormant apps: `ignored` per entry, whole app or one store side (cli/lib/registry.ts); storebound-side only. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { activeProjects, activeStoreApps, findEntry, ignoredStoreIds, ignoredStores, isIgnored, parseRegistry, readRegistry, setIgnore, withIgnore, writeRegistry, } from '../cli/lib/registry'; import { TOOLS, toArgv } from '../cli/mcp-tools'; const reg = () => parseRegistry(JSON.stringify({ version: 1, projects: [{ id: 'aaaaaaaaaaaaaaaa', path: '/work/star-dash', name: 'Star Dash', added: '' }, { id: 'bbbbbbbbbbbbbbbb', path: '/work/puzzle', name: 'Puzzle', added: '' }], storeApps: [{ store: 'apple', appId: '6400000000', account: 'teamA', name: 'Demo App', bundleId: 'com.example.demo' }, { store: 'play', appId: 'com.example.demo', name: 'Demo Play' }], })); describe('registry ignore', () => { test('whole app, one store side, restore; only valid sides are kept', () => { let r = reg(); r = withIgnore(r, 'Star Dash', 'apple', true).registry; expect(ignoredStores(r.projects[0]!)).toEqual(['apple']); expect(isIgnored(r.projects[0]!)).toBe(false); expect(isIgnored(r.projects[0]!, 'apple')).toBe(true); expect(isIgnored(r.projects[0]!, 'play')).toBe(false); r = withIgnore(r, 'star dash', undefined, true).registry; expect(isIgnored(r.projects[0]!)).toBe(true); expect(activeProjects(r).map(p => p.name)).toEqual(['Puzzle']); expect(activeProjects(r, 'play').map(p => p.name)).toEqual(['Puzzle']); r = withIgnore(r, 'aaaaaaaaaaaaaaaa', 'play', false).registry; expect(ignoredStores(r.projects[0]!)).toEqual(['apple']); expect(activeProjects(r, 'play').map(p => p.name)).toEqual(['Star Dash', 'Puzzle']); r = withIgnore(r, 'Star Dash', undefined, false).registry; expect(r.projects[0]!.ignored).toBeUndefined(); expect(parseRegistry(JSON.stringify({ projects: [{ path: '/a', ignored: ['play', 'x', 'play', 'apple'] }] })).projects[0]!.ignored).toEqual(['apple', 'play']); }); test('a store-only app has one side: its own store; the other store is an error', () => { const r = reg(); expect(() => withIgnore(r, 'apple:6400000000', 'play', true)).toThrow('apple app only'); const o = withIgnore(r, 'apple:6400000000', 'apple', true); expect(o.changed).toBe(true); expect(activeStoreApps(o.registry).map(a => a.store)).toEqual(['play']); expect(withIgnore(o.registry, 'apple:6400000000', undefined, true).changed).toBe(false); }); test('ambiguous names and unknown keys fail loudly', () => { const r = reg(); expect(() => withIgnore(r, 'nope', undefined, true)).toThrow('not in the app list'); expect(findEntry(r, 'Demo App')?.kind).toBe('storeApp'); const dup = parseRegistry(JSON.stringify({ projects: [{ path: '/a/x', name: 'Same' }, { path: '/b/x', name: 'Same' }] })); expect(() => findEntry(dup, 'Same')).toThrow('matches 2 apps'); }); test('written to the file and read back; unrelated entries and storeApps stay', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-ign-')); const file = join(dir, 'projects.json'); writeRegistry(reg(), file); expect(setIgnore('Puzzle', 'play', true, file).changed).toBe(true); expect(setIgnore('Puzzle', 'play', true, file).changed).toBe(false); setIgnore('play:com.example.demo', undefined, true, file); const back = readRegistry(file); expect(back.projects.find(p => p.name === 'Puzzle')!.ignored).toEqual(['play']); expect(back.storeApps!.find(a => a.store === 'play')!.ignored).toEqual(['play']); expect(back.storeApps!.find(a => a.store === 'apple')!.ignored).toBeUndefined(); expect(JSON.parse(readFileSync(file, 'utf8')).projects[0].ignored).toBeUndefined(); }); test('store ids of ignored sides come from the config text, never by running it', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-ign-')); const app = join(dir, 'game'); mkdirSync(app); writeFileSync(join(app, 'storebound.config.ts'), "export default { apple: { bundleId: 'com.example.game', appId: '6400000123' }, play: { packageName: 'com.example.game.android' } };\n"); let r = parseRegistry(JSON.stringify({ projects: [{ path: app, name: 'Game' }] })); r = withIgnore(r, 'Game', 'play', true).registry; const ids = ignoredStoreIds(r); expect([...ids.play]).toEqual(['com.example.game.android']); expect(ids.apple.size).toBe(0); }); test('MCP tools: ignore and unignore build the argv, writes default to dry run', () => { const t = TOOLS.find(x => x.name === 'apps_ignore')!; expect(t.write).toBe(true); expect(toArgv(t, { app: 'Star Dash', store: 'apple' }).argv).toEqual(['apps', 'ignore', 'Star Dash', '--store', 'apple', '--dry']); expect(TOOLS.some(x => x.name === 'apps_unignore')).toBe(true); }); }); describe('watch and ws skip ignored apps', () => { test('watch targets: a fully ignored app is not watched, a half-ignored one skips that store', async () => { const { listTargets } = await import('../cli/watch'); const { parseArgs } = await import('../cli/lib/args'); const base = mkdtempSync(join(tmpdir(), 'sb-ign-watch-')); const mk = (n: string) => { const d = join(base, n); mkdirSync(d); writeFileSync(join(d, 'storebound.config.ts'), 'export default {};\n'); return d; }; const file = join(base, 'projects.json'); writeRegistry(parseRegistry(JSON.stringify({ projects: [{ path: mk('a'), name: 'A' }, { path: mk('b'), name: 'B' }, { path: mk('c'), name: 'C' }] })), file); setIgnore('B', undefined, true, file); setIgnore('C', 'play', true, file); const saved = process.env.STOREBOUND_REGISTRY; process.env.STOREBOUND_REGISTRY = file; try { const t = await listTargets(parseArgs([])); expect(t.map(x => [x.name, x.skip])).toEqual([['A', []], ['C', ['play']]]); } finally { if (saved === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = saved; } }); });