storebound/tests/workspace.test.ts
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

324 lines
21 KiB
TypeScript

// Multi-app workspace (storebound ws), credential profiles and the desktop state import / export. Sanitized fixtures:
// example.com ids, placeholder account ids, no keys.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ascKey, profileEnv, profileStatus } from '../cli/lib/credentials';
import type { StoreApp } from '../cli/lib/desktop-protocol';
import {
accountId, accountsOf, checkWorkspace, type DesktopState, desktopProjectId, desktopStateFile, fromDesktopState, loadWorkspace, mergeDesktopState, overridesOf,
resolveApps, selectApps, toDesktopState, type Workspace,
} from '../cli/lib/workspace';
import { listTools, TOOLS, toArgv } from '../cli/mcp-tools';
import { DESKTOP_TOOLS } from '../cli/serve';
import { appEnv, discoverApps, runPolicy, workspaceApps } from '../cli/workspace';
const CLI = join(import.meta.dir, '..', 'cli', 'index.ts');
const SENTINEL = 'SENTINEL-KEY-VALUE-42';
/** An inline service account (not a path), so profileEnv keeps it as it is. */
const PLAY_INLINE = JSON.stringify({ sentinel: SENTINEL });
let root = '';
let creds = '';
const cfg = (name: string, id: string) => JSON.stringify({
app: { name, privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com/', contactEmail: 'dev@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], apple: { bundleId: id }, play: { packageName: id },
});
const WS: Workspace = {
apps: ['games/*', { path: 'apps/notes', apple: 'solo-apple' }],
studios: [{ name: 'Acme Games', color: 'indigo', apps: ['games/*'], accounts: [{ store: 'apple', profile: 'acme-apple' }, { store: 'play', profile: 'acme-play' }] }],
accounts: [{ store: 'apple', profile: 'solo-apple', label: 'Solo' }],
ignore: ['games/old-*'],
overrides: { pairs: [], unpairs: [], links: [], appStudio: [] },
};
/** A clean env for CLI children: no store keys of the machine running the tests. */
function env(extra: Record<string, string> = {}): Record<string, string> {
const e: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !/^(ASC_|PLAY_SERVICE|STOREBOUND_PROFILE|REVIEW_DEMO)/.test(k)) e[k] = v;
return { ...e, STOREBOUND_CREDENTIALS_DIR: creds, NO_COLOR: '1', ...extra };
}
function cli(args: string[], cwd = root, extra: Record<string, string> = {}) {
const r = Bun.spawnSync(['bun', CLI, ...args], { cwd, env: env(extra), stdout: 'pipe', stderr: 'pipe' });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'sb-ws-'));
creds = join(root, '..', `${root.split('/').pop()}-creds`);
mkdirSync(creds, { recursive: true });
for (const [dir, name, id] of [['games/puzzle', 'Puzzle', 'com.example.puzzle'], ['games/racer', 'Racer', 'com.example.racer'], ['games/old-tetra', 'Old', 'com.example.oldtetra'], ['apps/notes', 'Notes', 'com.example.notes']] as const) {
mkdirSync(join(root, dir), { recursive: true });
writeFileSync(join(root, dir, 'storebound.config.json'), cfg(name, id));
}
mkdirSync(join(root, 'games', 'not-an-app'), { recursive: true });
writeFileSync(join(root, 'storebound.workspace.json'), JSON.stringify(WS, null, 2));
writeFileSync(join(creds, 'acme-apple.json'), JSON.stringify({ ASC_KEY_ID: SENTINEL, ASC_ISSUER_ID: SENTINEL, ASC_P8: 'keys/acme.p8' }));
writeFileSync(join(creds, 'acme-play.json'), JSON.stringify({ PLAY_SERVICE_ACCOUNT: PLAY_INLINE }));
process.env.STOREBOUND_CREDENTIALS_DIR = creds;
});
afterAll(() => { delete process.env.STOREBOUND_CREDENTIALS_DIR; rmSync(root, { recursive: true, force: true }); rmSync(creds, { recursive: true, force: true }); });
describe('workspace file', () => {
test('a valid file passes; secrets, bad profiles and duplicates are refused', () => {
expect(checkWorkspace(WS)).toEqual([]);
expect(checkWorkspace({ apps: [], accounts: [{ store: 'apple', profile: 'x', ASC_P8: 'k' }] }).join(' ')).toContain('secrets never go here');
expect(checkWorkspace({ apps: [], accounts: [{ store: 'apple', profile: '../etc' }] }).join(' ')).toContain('profile');
expect(checkWorkspace({ apps: [], accounts: [{ store: 'play', profile: 'a' }], studios: [{ name: 'S', accounts: [{ store: 'play', profile: 'a' }] }] }).join(' ')).toContain('listed twice');
expect(checkWorkspace({ apps: 'x' })).toContain('apps: a list of app folders or globs ("apps/*")');
expect(checkWorkspace({ apps: [], studios: [{ name: 'S', color: 'pink' }] }).join(' ')).toContain('color');
expect(checkWorkspace({ apps: [], concurrency: 20 })).toContain('concurrency: 1 to 8');
});
test('account ids: the desktop id when given, else stable from store + profile (16 hex)', () => {
const a = accountId({ store: 'apple', profile: 'acme-apple' });
expect(a).toMatch(/^[0-9a-f]{16}$/);
expect(accountId({ store: 'apple', profile: 'acme-apple' })).toBe(a);
expect(accountId({ store: 'play', profile: 'acme-apple' })).not.toBe(a);
expect(accountId({ store: 'play', profile: 'x', id: '1111aaaa2222bbbb' })).toBe('1111aaaa2222bbbb');
expect(accountsOf(WS).map(x => [x.profile, x.studio])).toEqual([['acme-apple', 'acme-games'], ['acme-play', 'acme-games'], ['solo-apple', null]]);
});
test('globs, ignore list and account resolution', async () => {
const l = await loadWorkspace(join(root, 'games', 'puzzle'));
expect(l.root).toBe(root);
const { apps, warnings } = resolveApps(l);
expect(apps.map(a => [a.rel, a.ignored, a.studio, a.appleProfile, a.playProfile])).toEqual([
['apps/notes', false, null, 'solo-apple', 'acme-play'], // own apple profile; the only play account
['games/old-tetra', true, 'acme-games', 'acme-apple', 'acme-play'],
['games/puzzle', false, 'acme-games', 'acme-apple', 'acme-play'],
['games/racer', false, 'acme-games', 'acme-apple', 'acme-play'],
]);
expect(warnings).toEqual([]); // games/not-an-app has no config: skipped by the glob without a warning
const o = overridesOf(l.ws, apps);
expect(o.appStudio).toContainEqual({ key: 'project:games/puzzle', studio: 'acme-games' });
expect(o.ignored).toContain('project:games/old-tetra');
expect(selectApps(apps, ['puzzle', 'games/r*']).picked.map(a => a.rel)).toEqual(['games/puzzle', 'games/racer']);
expect(selectApps(apps, ['nope']).unknown).toEqual(['nope']);
});
test('ws init discovers app folders as globs', () => {
expect(discoverApps(root)).toEqual(['apps/notes', 'games/*']);
});
});
describe('pairing (the desktop rules, shared)', () => {
test('folders take their store apps; leftover iOS + Android pair; account ignore hides store apps', async () => {
const l = await loadWorkspace(root);
const [appleAcc, playAcc] = accountsOf(l.ws);
const ios = (id: string, bundleId: string, name: string): StoreApp => ({ store: 'ios', id, bundleId, name, platforms: ['IOS'] });
const and = (pkg: string, name: string): StoreApp => ({ store: 'android', id: pkg, bundleId: pkg, name, platforms: ['ANDROID'] });
const cache = (a: { store: 'apple' | 'play'; profile: string }, apps: StoreApp[]) => {
mkdirSync(join(root, '.storebound', 'workspace'), { recursive: true });
writeFileSync(join(root, '.storebound', 'workspace', `apps-${a.store}-${a.profile}.json`), JSON.stringify({ ...a, account: '', at: '2026-10-01T00:00:00Z', apps }));
};
try {
cache(appleAcc!, [ios('6400000101', 'com.example.puzzle', 'Puzzle'), ios('6400000102', 'com.example.blocks.ios', 'Blocks')]);
cache(playAcc!, [and('com.example.puzzle', 'Puzzle'), and('com.example.blocks', 'Blocks'), and('com.example.solo', 'Solo Game')]);
const summaries = new Map([['games/puzzle', { apple: { bundleId: 'com.example.puzzle', appId: null }, play: { packageName: 'com.example.puzzle' } } as never]]);
const { apps } = workspaceApps(l, summaries);
const puzzle = apps.find(a => a.rel === 'games/puzzle')!;
expect([puzzle.unified.pairing, puzzle.unified.ios?.app.id, puzzle.unified.android?.app.id]).toEqual(['folder', '6400000101', 'com.example.puzzle']);
const blocks = apps.find(a => a.name === 'Blocks')!;
expect([blocks.virtual, blocks.unified.pairing, blocks.studio]).toEqual([true, 'close', 'acme-games']);
expect(apps.find(a => a.name === 'Solo Game')!.unified.pairing).toBe('single');
// the store-only app reads with both studio accounts' profiles; values stay in the env object
const e = appEnv(blocks.unified, accountsOf(l.ws));
expect(Object.keys(e.env).sort()).toEqual(['ASC_ISSUER_ID', 'ASC_KEY_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'STOREBOUND_PROFILE']);
expect(e.env.STOREBOUND_PROFILE).toBe('');
expect(e.env.ASC_P8).toBe(join(creds, 'keys', 'acme.p8')); // relative to the profile folder
// the console ids of the studio's accounts (not secrets) reach console fill as env
const withIds = { ...l.ws, studios: l.ws.studios!.map(s => ({ ...s, accounts: s.accounts?.map(a => (a.store === 'play' ? { ...a, developerId: '4444' } : { ...a, teamId: 'ABCDE12345' })) })) };
const e2 = appEnv(blocks.unified, accountsOf(withIds));
expect([e2.env.STOREBOUND_PLAY_DEVELOPER_ID, e2.env.STOREBOUND_APPLE_TEAM_ID]).toEqual(['4444', 'ABCDE12345']);
expect(checkWorkspace(withIds)).toEqual([]);
expect(checkWorkspace({ accounts: [{ store: 'apple', profile: 'x', developerId: '1' }] }).join()).toContain('developerId');
// an unpair keeps them apart
const split = workspaceApps({ ...l, ws: { ...l.ws, overrides: { unpairs: [{ ios: blocks.unified.ios!.key, android: blocks.unified.android!.key }] } } }, summaries);
expect(split.apps.filter(a => a.name === 'Blocks').map(a => a.unified.pairing)).toEqual(['single', 'single']);
// ignoring the play account drops its store apps
const ign = workspaceApps({ ...l, ws: { ...l.ws, ignore: [...(l.ws.ignore ?? []), 'account:acme-play'] } }, summaries);
expect(ign.apps.some(a => a.name === 'Solo Game')).toBe(false);
} finally { rmSync(join(root, '.storebound'), { recursive: true, force: true }); }
});
});
describe('credential profiles', () => {
test('file, env override, status without values, and --account precedence', () => {
const saved = { ...process.env };
try {
process.env.STOREBOUND_CREDENTIALS_DIR = creds;
expect(profileEnv('acme-play')).toEqual({ PLAY_SERVICE_ACCOUNT: PLAY_INLINE });
process.env.STOREBOUND_PROFILE_ACME_PLAY_PLAY_SERVICE_ACCOUNT = '/keys/other.json';
expect(profileEnv('acme-play').PLAY_SERVICE_ACCOUNT).toBe('/keys/other.json');
expect(profileStatus('acme-play')).toEqual({ profile: 'acme-play', source: 'env', apple: false, play: true });
expect(profileStatus('nobody')).toEqual({ profile: 'nobody', source: 'missing', apple: false, play: false });
expect(JSON.stringify(profileStatus('acme-apple'))).not.toContain(SENTINEL);
expect(() => profileEnv('../x')).toThrow('credential profile');
// env wins over everything; then a named profile (--account), then the project file
const pem = ['-----BEGIN PRIVATE KEY-----', 'abc', '-----END PRIVATE KEY-----'].join('\n');
writeFileSync(join(creds, 'pick.json'), JSON.stringify({ ASC_KEY_ID: 'PICKED', ASC_ISSUER_ID: 'iss', ASC_P8: pem }));
process.env.STOREBOUND_PROFILE = 'pick';
expect(ascKey(root).keyId).toBe('PICKED');
process.env.ASC_KEY_ID = 'FROM-ENV';
process.env.ASC_ISSUER_ID = 'iss-env';
expect(ascKey(root).keyId).toBe('FROM-ENV');
delete process.env.STOREBOUND_PROFILE;
process.env.ASC_P8 = pem;
expect(ascKey(root).keyId).toBe('FROM-ENV');
} finally {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
});
});
describe('desktop state <-> workspace', () => {
const state = JSON.parse(readFileSync(join(import.meta.dir, 'fixtures', 'workspace', 'desktop-state.json'), 'utf8')) as DesktopState;
test('import: folders, studios, accounts as profiles, overrides with folder names', () => {
const { ws, profiles } = fromDesktopState(state, '/Users/you/studio');
expect(ws.apps).toEqual([{ path: 'games/puzzle', name: 'Puzzle Quest', apple: 'acme', play: 'acme-play' }, '/Users/you/elsewhere/notes']);
expect(profiles).toEqual([{ profile: 'acme-play', store: 'play', label: 'Acme Play' }, { profile: 'acme', store: 'apple', label: 'Acme' }, { profile: 'acme-play-2', store: 'play', label: 'Acme' }]);
expect(ws.studios).toEqual([{ id: 'f00dfeedf00dfeed', name: 'Acme Games', color: 'teal', accounts: [
{ store: 'play', profile: 'acme-play', label: 'Acme Play', id: '1111aaaa2222bbbb' }, { store: 'apple', profile: 'acme', label: 'Acme', id: '3333cccc4444dddd' }] }]);
expect(ws.accounts).toEqual([{ store: 'play', profile: 'acme-play-2', label: 'Acme', id: '5555eeee6666ffff' }]);
expect(ws.ignore).toEqual(['account:acme-play-2', 'project:games/puzzle', 'app:ios:3333cccc4444dddd:6400000199']);
expect(ws.overrides?.links).toEqual([{ key: 'android:5555eeee6666ffff:com.example.notes', project: '/Users/you/elsewhere/notes' }]);
expect(ws.overrides?.appStudio).toEqual([{ key: 'project:/Users/you/elsewhere/notes', studio: 'f00dfeedf00dfeed' }]);
expect(checkWorkspace(ws)).toEqual([]);
expect(JSON.stringify(ws)).not.toMatch(/PRIVATE KEY|service_account/);
});
test('export: the desktop format (version 2), account ids kept, project ids stable per path', async () => {
const l = await loadWorkspace(root);
const { apps } = resolveApps(l);
const s = toDesktopState(l.ws, apps, '2026-10-01T00:00:00Z');
expect(s.version).toBe(2);
expect(s.projects.map(p => p.id)).toEqual(apps.map(a => desktopProjectId(a.path)));
expect(s.projects.every(p => /^[0-9a-f]{16}$/.test(p.id))).toBe(true);
expect(s.accounts.map(a => [a.label, a.kind, a.studio])).toEqual([['acme-apple', 'apple', 'acme-games'], ['acme-play', 'play', 'acme-games'], ['Solo', 'apple', null]]);
expect(s.overrides.ignored).toContain(`project:${desktopProjectId(join(root, 'games', 'old-tetra'))}`);
expect(s.overrides.appStudio).toContainEqual({ key: `project:${desktopProjectId(join(root, 'games', 'puzzle'))}`, studio: 'acme-games' });
expect(JSON.stringify(s)).not.toContain(SENTINEL);
// the desktop's state.json shape: every key its Rust State reads
expect(Object.keys(s).sort()).toEqual(['accounts', 'overrides', 'projects', 'studios', 'version']);
});
test('merge: nothing removed, existing projects keep their ids and accounts', () => {
const incoming: DesktopState = { version: 2, projects: [{ ...state.projects[0]!, id: 'ffffffffffffffff', appleAccount: null }, { id: 'eeeeeeeeeeeeeeee', path: '/Users/you/new', name: 'new', playAccount: null, appleAccount: null, added: '' }],
accounts: [state.accounts[0]!, { id: '9999999999999999', label: 'New', kind: 'apple', created: '', studio: null }], studios: [],
overrides: { pairs: [], unpairs: [], links: [], appStudio: [{ key: 'project:ffffffffffffffff', studio: 'x' }], ignored: ['project:ffffffffffffffff'] } };
const { state: m, added } = mergeDesktopState(state, incoming);
expect(added).toEqual({ projects: 1, accounts: 1, studios: 0, overrides: 1 });
expect(m.projects[0]).toEqual(state.projects[0]!);
expect(m.overrides.ignored.filter(x => x === 'project:0a1b2c3d4e5f6071')).toHaveLength(1);
expect(m.settings).toEqual(state.settings);
});
test('state file location per platform', () => {
expect(desktopStateFile({ STOREBOUND_DESKTOP_DATA: '/d' })).toBe('/d/state.json');
expect(desktopStateFile({}, 'darwin')).toContain(join('Application Support', 'dev.storebound.desktop', 'state.json'));
expect(desktopStateFile({ XDG_DATA_HOME: '/x' }, 'linux')).toBe('/x/dev.storebound.desktop/state.json');
});
});
describe('ws run policy', () => {
test('reads run, writes get --dry, owner steps never', () => {
expect(runPolicy(['validate', '--play'])).toEqual({ ok: true, write: false });
expect(runPolicy(['inspect'])).toEqual({ ok: true, write: false });
expect(runPolicy(['apple', 'testflight', 'status'])).toEqual({ ok: true, write: false });
expect(runPolicy(['play', 'listing'])).toEqual({ ok: true, write: true });
expect(runPolicy(['health', '--weekly'])).toEqual({ ok: true, write: true });
expect(runPolicy(['health'])).toEqual({ ok: true, write: false });
expect(runPolicy(['prices', 'import'])).toEqual({ ok: true, write: true });
for (const bad of [['play', 'release', 'x.aab'], ['apple', 'release', '--phased', 'complete'], ['play', 'rollout', '--complete'], ['reviews', 'send'], ['apple', 'ppo', 'start'],
['apple', 'testflight', 'distribute', '--build', '3'], ['play', 'listing', '--yes'], ['apple', 'status', '--cancel-submission', '1'], ['ws', 'list'], ['rm', '-rf'], []]) {
expect(runPolicy(bad).ok).toBe(false);
}
});
});
describe('MCP', () => {
test('workspace and release tools: read-only, in MCP, not in the desktop profile', () => {
const names = ['workspace_list', 'workspace_status', 'release_status', 'release_plan'];
for (const n of names) {
const t = TOOLS.find(x => x.name === n)!;
expect(t.write).toBe(false);
expect((listTools().find(x => x.name === n)!.annotations as { readOnlyHint: boolean }).readOnlyHint).toBe(true);
expect(DESKTOP_TOOLS.some(x => x.name === n)).toBe(false);
}
expect(toArgv(TOOLS.find(x => x.name === 'release_plan')!, { version: '2.4.0', app: 'puzzle', json: true }).argv).toEqual(['release', 'plan', '2.4.0', '--app', 'puzzle', '--json']);
expect(toArgv(TOOLS.find(x => x.name === 'workspace_status')!, { offline: true }).argv).toEqual(['ws', 'status', '--offline']);
});
});
describe('CLI end to end (offline)', () => {
test('ws list --json: apps, studios, accounts; never a key value', () => {
const r = cli(['ws', 'list', '--json'], join(root, 'games'));
expect(r.code).toBe(0);
const d = JSON.parse(r.out);
expect(d.apps.map((a: { name: string }) => a.name)).toEqual(['Notes', 'Puzzle', 'Racer']);
expect(d.accounts.map((a: { profile: string; source: string }) => [a.profile, a.source])).toEqual([['acme-apple', 'file'], ['acme-play', 'file'], ['solo-apple', 'missing']]);
expect(r.out + r.err).not.toContain(SENTINEL);
expect(cli(['ws', 'list', '--all', '--json']).out).toContain('"Old"');
});
test('ws status --offline --json and ws check', () => {
const r = cli(['ws', 'status', '--offline', '--json', '--apps', 'puzzle,racer']);
const d = JSON.parse(r.out);
expect(d.live).toBe(false);
expect(d.apps.map((a: { name: string; status: { summary: { name: string } } }) => [a.name, a.status.summary.name])).toEqual([['Puzzle', 'Puzzle'], ['Racer', 'Racer']]);
expect(d.apps[0].status.release.source).toBe('none');
const c = cli(['ws', 'check', '--json']);
expect(JSON.parse(c.out).check).toHaveLength(3);
expect(c.code).toBe(1); // the fixture apps have no listing files yet
});
test('ws run: needs --apps / --all, adds --dry to writes, refuses owner steps', () => {
expect(cli(['ws', 'run', 'inspect']).err).toContain('--apps a,b or --all');
const i = cli(['ws', 'run', 'inspect', '--all', '--json']);
expect(i.code).toBe(0);
expect(JSON.parse(i.out).apps.map((a: { data: { name: string } }) => a.data.name)).toEqual(['Notes', 'Puzzle', 'Racer']);
const w = cli(['ws', 'run', 'play', 'listing', '--apps', 'puzzle']);
expect(w.out).toContain('storebound play listing --dry');
const bad = cli(['ws', 'run', 'play', 'rollout', '--complete', '--all']);
expect(bad.code).toBe(1);
expect(bad.err).toContain('owner step');
});
test('ws desktop export / import round trip through files', () => {
const out = join(root, '.storebound', 'state.json');
mkdirSync(join(root, '.storebound'), { recursive: true });
expect(cli(['ws', 'desktop', 'export', '--out', out]).code).toBe(0);
const s = JSON.parse(readFileSync(out, 'utf8')) as DesktopState;
expect(s.projects).toHaveLength(4);
const back = join(root, '.storebound', 'ws.json');
const r = cli(['ws', 'desktop', 'import', '--state', out, '--out', back]);
expect(r.code).toBe(0);
const ws = JSON.parse(readFileSync(back, 'utf8')) as Workspace;
expect(checkWorkspace(ws)).toEqual([]);
expect(ws.apps).toHaveLength(4);
expect(ws.ignore).toContain('project:games/old-tetra');
// merge into a desktop state: dry unless --apply
const target = join(root, '.storebound', 'desktop');
mkdirSync(target, { recursive: true });
const m = cli(['ws', 'desktop', 'export', '--merge'], root, { STOREBOUND_DESKTOP_DATA: target });
expect(m.out).toContain('dry run');
expect(cli(['ws', 'desktop', 'export', '--merge', '--apply'], root, { STOREBOUND_DESKTOP_DATA: target }).code).toBe(0);
expect((JSON.parse(readFileSync(join(target, 'state.json'), 'utf8')) as DesktopState).projects).toHaveLength(4);
});
test('release status in an app folder without keys: unreadable, never "not there"', () => {
const r = cli(['release', 'status', '--json'], join(root, 'games', 'puzzle'));
expect(r.code).toBe(1);
const d = JSON.parse(r.out);
expect(d.apps[0].storeErrors.ios).toContain('ASC_KEY_ID');
expect(d.apps[0].trains).toEqual([]);
const p = cli(['release', 'plan', '1.0.0', '--no-validate', '--json'], join(root, 'games', 'puzzle'));
const plan = JSON.parse(p.out).apps[0].plan;
expect(plan.unknown.join(' ')).toContain('App Store state not read');
expect(plan.done).toBe(false);
});
});