apps folders add adds the folder (template note, refusals with reasons); MCP: .mcp.json never spawns a missing plugin path (sh fallback to PATH, npx), mcp/serve skip the self-update, spawned handshake test; store build minimums in one dated table: Play target API 36 and App Store Xcode 26 / SDK 26 blocked in scan, validate, play upload/release, apple upload; pitfalls 81-83
This commit is contained in:
parent
9805582278
commit
9ab64bcbdf
16 changed files with 323 additions and 14 deletions
51
tests/mcp-spawn.test.ts
Normal file
51
tests/mcp-spawn.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// The MCP server as a client starts it: a spawned process, an `initialize` on stdin, a JSON-RPC answer as the very
|
||||
// first stdout line within 10 s (a log line on stdout closes the client's connection: CONNECTION_CLOSED).
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const INIT = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
|
||||
export function handshake(cmd: string, args: string[], env: Record<string, string> = {}, ms = 10_000): Promise<{ first: string; stderr: string }> {
|
||||
return new Promise((res, rej) => {
|
||||
const p = spawn(cmd, args, { cwd: ROOT, env: { ...process.env, STOREBOUND_NO_NETWORK: '1', ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '', err = '';
|
||||
const t = setTimeout(() => { p.kill(); rej(new Error(`no initialize answer in ${ms} ms; stdout: ${out.slice(0, 300)} stderr: ${err.slice(0, 300)}`)); }, ms);
|
||||
p.stderr.on('data', d => { err += d; });
|
||||
p.stdout.on('data', d => {
|
||||
out += d;
|
||||
const nl = out.indexOf('\n');
|
||||
if (nl >= 0) { clearTimeout(t); p.kill(); res({ first: out.slice(0, nl), stderr: err }); }
|
||||
});
|
||||
p.on('error', e => { clearTimeout(t); rej(e); });
|
||||
p.stdin.write(INIT + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
describe('mcp start-up (spawned)', () => {
|
||||
test('bun cli/index.ts mcp answers initialize first on stdout', async () => {
|
||||
const { first } = await handshake(process.execPath, [join(ROOT, 'cli/index.ts'), 'mcp']);
|
||||
const r = JSON.parse(first);
|
||||
expect(r.id).toBe(1);
|
||||
expect(r.result.serverInfo.name).toBe('storebound');
|
||||
}, 15_000);
|
||||
|
||||
test('the plugin launcher (bin/storebound) is executable, POSIX sh, and completes the handshake', async () => {
|
||||
const bin = join(ROOT, 'bin/storebound');
|
||||
expect(readFileSync(bin, 'utf8').startsWith('#!/bin/sh\n')).toBe(true);
|
||||
expect(statSync(bin).mode & 0o111).not.toBe(0);
|
||||
const { first } = await handshake(bin, ['mcp']);
|
||||
expect(JSON.parse(first).result.serverInfo.name).toBe('storebound');
|
||||
}, 15_000);
|
||||
|
||||
test('.mcp.json never spawns a path that may not exist (falls back when the plugin root is missing)', async () => {
|
||||
const m = JSON.parse(readFileSync(join(ROOT, '.mcp.json'), 'utf8')).mcpServers.storebound as { command: string; args: string[] };
|
||||
expect(m.command).toBe('sh');
|
||||
// a plugin root that is gone (an old cache folder): the script must still start a server, here from the repo's bin
|
||||
const args = m.args.map(a => a.replaceAll('${CLAUDE_PLUGIN_ROOT:-}', '/nonexistent-plugin-root'));
|
||||
const { first } = await handshake(m.command, args, { PATH: `${join(ROOT, 'bin')}:${process.env.PATH}` });
|
||||
expect(JSON.parse(first).result.serverInfo.name).toBe('storebound');
|
||||
}, 15_000);
|
||||
});
|
||||
75
tests/store-minimums.test.ts
Normal file
75
tests/store-minimums.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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 { spawnSync } from 'node:child_process';
|
||||
import { appleMinSdk, appleSdkProblem, PLAY_TARGET_SDK_TABLE, playTargetSdk, playTargetSdkProblem, targetSdkFile } from '../cli/lib/store-minimums';
|
||||
import { readRegistry } from '../cli/lib/registry';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
|
||||
describe('Google Play target API level', () => {
|
||||
test('the dated table picks the row in force', () => {
|
||||
expect(playTargetSdk('phone', '2026-08-30')!.min).toBe(35);
|
||||
expect(playTargetSdk('phone', '2026-08-31')!.min).toBe(36);
|
||||
expect(playTargetSdk('wear', '2026-10-20')!.min).toBe(35);
|
||||
expect(PLAY_TARGET_SDK_TABLE.map(r => r.since)).toEqual([...PLAY_TARGET_SDK_TABLE.map(r => r.since)].sort());
|
||||
});
|
||||
test('below the minimum: requirement, date and fix in one line', () => {
|
||||
const m = playTargetSdkProblem(35, { date: '2026-10-20', where: 'android/variables.gradle' })!;
|
||||
expect(m).toContain('targetSdkVersion 35 < 36');
|
||||
expect(m).toContain('2026-08-31');
|
||||
expect(m).toContain('set targetSdkVersion = 36');
|
||||
expect(m).toContain('android/variables.gradle');
|
||||
expect(playTargetSdkProblem(36, { date: '2026-10-20' })).toBeNull();
|
||||
expect(playTargetSdkProblem(undefined)).toBeNull();
|
||||
});
|
||||
test('the fix names the file that sets it (Capacitor variables.gradle)', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'sb-sdk-'));
|
||||
mkdirSync(join(d, 'android'));
|
||||
writeFileSync(join(d, 'android/variables.gradle'), 'ext {\n targetSdkVersion = 35\n}\n');
|
||||
expect(targetSdkFile(d)).toBe('android/variables.gradle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Store minimum Xcode / SDK', () => {
|
||||
test('table and IPA build fields', () => {
|
||||
expect(appleMinSdk('2026-05-01')).toMatchObject({ xcode: 26, sdk: 26 });
|
||||
expect(appleMinSdk('2026-04-01')).toMatchObject({ xcode: 16, sdk: 18 });
|
||||
const m = appleSdkProblem({ xcode: '1640', sdkName: 'iphoneos18.4', platformVersion: '18.4' }, '2026-10-20')!;
|
||||
expect(m).toContain('Xcode 16');
|
||||
expect(m).toContain('Xcode 26');
|
||||
expect(m).toContain('2026-04-28');
|
||||
expect(appleSdkProblem({ xcode: '2600', sdkName: 'iphoneos26.0', platformVersion: '26.0' }, '2026-10-20')).toBeNull();
|
||||
expect(appleSdkProblem({}, '2026-10-20')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apps folders add', () => {
|
||||
const cli = (cwd: string, reg: string, ...args: string[]) => spawnSync(process.execPath, [join(ROOT, 'cli/index.ts'), ...args], { cwd, encoding: 'utf8', env: { ...process.env, STOREBOUND_REGISTRY: reg, STOREBOUND_NO_UPDATE: '1' } });
|
||||
const fresh = () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'sb-folder-'));
|
||||
writeFileSync(join(d, 'storebound.config.ts'), "export default { app: { name: 'Example App' }, apple: { bundleId: 'com.example.app' } };\n");
|
||||
return { d, reg: join(d, 'reg.json') };
|
||||
};
|
||||
test('a freshly init-ed folder is added (bare form = cwd), with a template note', () => {
|
||||
const { d, reg } = fresh();
|
||||
const r = cli(d, reg, 'apps', 'folders', 'add');
|
||||
expect(r.status).toBe(0);
|
||||
expect(readRegistry(reg).projects.map(p => p.path)).toEqual([readRegistry(reg).projects[0]!.path]);
|
||||
expect(readRegistry(reg).projects[0]!.path.endsWith(d.split('/').pop()!)).toBe(true);
|
||||
expect(r.stderr + r.stdout).toContain("'Example App'");
|
||||
});
|
||||
test('with a path, like apps add', () => {
|
||||
const { d, reg } = fresh();
|
||||
expect(cli(tmpdir(), reg, 'apps', 'folders', 'add', d).status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(reg, 'utf8')).projects).toHaveLength(1);
|
||||
});
|
||||
test('no config: refused with the reason and the fix', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'sb-empty-'));
|
||||
const r = cli(d, join(d, 'reg.json'), 'apps', 'folders', 'add');
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('no storebound config');
|
||||
expect(r.stderr).toContain('storebound init');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { generate, parseSkill, readSkills } from '../tools/gen-adapters';
|
||||
import { generate, MCP_LAUNCH, parseSkill, readSkills } from '../tools/gen-adapters';
|
||||
import { scan } from '../tools/scrub';
|
||||
import { FORBIDDEN_ARGS, listTools, LOCAL_WRITE_TOOLS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||
import { handle, LEGACY_PROTOCOL as PROTOCOL } from '../cli/mcp';
|
||||
|
|
@ -38,7 +38,7 @@ describe('adapters', () => {
|
|||
const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
||||
for (const f of ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json', 'gemini-extension.json']) expect(JSON.parse(out.get(f)!).version).toBe(version);
|
||||
expect(JSON.parse(out.get('.codex-plugin/plugin.json')!).skills).toBe('./skills/');
|
||||
expect(JSON.parse(out.get('.mcp.json')!).mcpServers.storebound).toEqual({ command: '${CLAUDE_PLUGIN_ROOT}/bin/storebound', args: ['mcp'] });
|
||||
expect(JSON.parse(out.get('.mcp.json')!).mcpServers.storebound).toEqual({ command: 'sh', args: ['-c', MCP_LAUNCH] });
|
||||
expect(JSON.parse(out.get('gemini-extension.json')!).mcpServers.storebound.command).toContain('${extensionPath}');
|
||||
expect(out.get('.cursor/rules/storebound.mdc')!).toMatch(/^---\ndescription: .+\nalwaysApply: false\n---\n/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue