rn-iso 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/CLAUDE.md +178 -0
- package/README.md +90 -0
- package/bin/cli.js +35 -0
- package/docs/plans/2026-04-25-rn-iso-implementation.md +2653 -0
- package/docs/specs/2026-04-25-rn-iso-design.md +282 -0
- package/package.json +20 -0
- package/skill/SKILL.md +112 -0
- package/src/commands/android.js +112 -0
- package/src/commands/device.js +43 -0
- package/src/commands/ios.js +210 -0
- package/src/commands/logs.js +28 -0
- package/src/commands/prune.js +57 -0
- package/src/commands/release.js +51 -0
- package/src/commands/reserve.js +176 -0
- package/src/commands/shutdown.js +41 -0
- package/src/commands/start.js +43 -0
- package/src/commands/status.js +60 -0
- package/src/commands/stop.js +51 -0
- package/src/commands/unreserve.js +57 -0
- package/src/config.js +221 -0
- package/src/exec.js +31 -0
- package/src/metro.js +73 -0
- package/src/ports.js +50 -0
- package/src/project.js +186 -0
- package/src/runner.js +136 -0
- package/src/sim/android.js +103 -0
- package/src/sim/ios.js +128 -0
- package/test/config.test.js +208 -0
- package/test/exec.test.js +26 -0
- package/test/fixtures/sample-bare-project/android/app/build.gradle +6 -0
- package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +10 -0
- package/test/fixtures/sample-bare-project/package.json +4 -0
- package/test/fixtures/sample-expo-project/app.json +6 -0
- package/test/fixtures/sample-expo-project/package.json +4 -0
- package/test/fixtures/sample-expo-project/src/.keep +0 -0
- package/test/metro.test.js +34 -0
- package/test/ports.test.js +76 -0
- package/test/project.test.js +109 -0
- package/test/runner.test.js +209 -0
- package/test/sim-android.test.js +140 -0
- package/test/sim-ios.test.js +168 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname, resolve } from 'path';
|
|
3
|
+
import { listAllIosSims } from './sim/ios.js';
|
|
4
|
+
|
|
5
|
+
const LOCKFILES = [
|
|
6
|
+
// Order matters: most specific / modern first. If a project has multiple
|
|
7
|
+
// lockfiles (e.g., during a migration), the first match wins.
|
|
8
|
+
{ name: 'bun.lock', pm: 'bun' },
|
|
9
|
+
{ name: 'bun.lockb', pm: 'bun' },
|
|
10
|
+
{ name: 'pnpm-lock.yaml', pm: 'pnpm' },
|
|
11
|
+
{ name: 'yarn.lock', pm: 'yarn' },
|
|
12
|
+
{ name: 'package-lock.json', pm: 'npm' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Walk up from startDir looking for a lockfile. In monorepos the lockfile
|
|
16
|
+
// lives at the workspace root, several levels above any individual package.
|
|
17
|
+
// Returns { dir, pm } or null if no lockfile is found before the filesystem
|
|
18
|
+
// root.
|
|
19
|
+
export function findLockfile(startDir) {
|
|
20
|
+
let dir = resolve(startDir);
|
|
21
|
+
while (true) {
|
|
22
|
+
for (const { name, pm } of LOCKFILES) {
|
|
23
|
+
if (existsSync(join(dir, name))) return { dir, pm };
|
|
24
|
+
}
|
|
25
|
+
const parent = dirname(dir);
|
|
26
|
+
if (parent === dir) return null;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Detect package manager from lockfiles, walking up for monorepos.
|
|
32
|
+
// Defaults to npm when no lockfile is found anywhere up the tree.
|
|
33
|
+
export function detectPackageManager(projectRoot) {
|
|
34
|
+
return findLockfile(projectRoot)?.pm || 'npm';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getProjectScript(projectRoot, name) {
|
|
38
|
+
const p = join(projectRoot, 'package.json');
|
|
39
|
+
if (!existsSync(p)) return null;
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(readFileSync(p, 'utf-8'));
|
|
42
|
+
return pkg?.scripts?.[name] || null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build a `<pm> <script> ...args` invocation. npm needs `--` to separate
|
|
49
|
+
// script-name args from script-passed args; yarn / pnpm / bun pass them
|
|
50
|
+
// directly. We always invoke through the package manager rather than running
|
|
51
|
+
// the script body so the user's pre/post hooks fire.
|
|
52
|
+
export function buildScriptCommand(packageManager, scriptName, extraArgs = []) {
|
|
53
|
+
const args = extraArgs.filter(Boolean).join(' ');
|
|
54
|
+
switch (packageManager) {
|
|
55
|
+
case 'yarn':
|
|
56
|
+
return `yarn ${scriptName}${args ? ' ' + args : ''}`;
|
|
57
|
+
case 'pnpm':
|
|
58
|
+
return `pnpm ${scriptName}${args ? ' ' + args : ''}`;
|
|
59
|
+
case 'bun':
|
|
60
|
+
return `bun run ${scriptName}${args ? ' ' + args : ''}`;
|
|
61
|
+
case 'npm':
|
|
62
|
+
default:
|
|
63
|
+
return `npm run ${scriptName}${args ? ' -- ' + args : ''}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Decide which CLI a script invokes. Affects flag names: Expo CLI takes
|
|
68
|
+
// --device <UDID>, bare RN CLI takes --udid <UDID> for iOS (--device there
|
|
69
|
+
// means physical device by name) and --deviceId for Android.
|
|
70
|
+
export function detectScriptCli(scriptBody) {
|
|
71
|
+
if (typeof scriptBody !== 'string') return 'unknown';
|
|
72
|
+
if (/\bexpo\s+(run:ios|run:android|start)\b/.test(scriptBody)) return 'expo';
|
|
73
|
+
if (/\breact-native\s+(run-ios|run-android|start)\b/.test(scriptBody)) return 'react-native';
|
|
74
|
+
return 'unknown';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// iOS run command. Prefers the project's `ios` script if present (the most
|
|
78
|
+
// reliable: respects user customization, picks the right CLI). Falls back to
|
|
79
|
+
// expo run:ios / react-native run-ios when no script exists or --no-script.
|
|
80
|
+
export function buildIosCommand({ projectRoot, packageManager, scriptName, isExpo, udid, port, useScript = true }) {
|
|
81
|
+
if (useScript && scriptName) {
|
|
82
|
+
const script = getProjectScript(projectRoot, scriptName);
|
|
83
|
+
if (script) {
|
|
84
|
+
const cli = detectScriptCli(script);
|
|
85
|
+
// Expo: --device <UDID>; RN (and unknown, common case): --udid <UDID>.
|
|
86
|
+
const deviceFlag = cli === 'expo' ? `--device ${udid}` : `--udid ${udid}`;
|
|
87
|
+
return buildScriptCommand(packageManager, scriptName, [
|
|
88
|
+
deviceFlag,
|
|
89
|
+
`--port ${port}`,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (isExpo) {
|
|
94
|
+
return `npx expo run:ios --device ${udid} --port ${port}`;
|
|
95
|
+
}
|
|
96
|
+
return `npx react-native run-ios --udid ${udid} --port ${port}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, serial, port, useScript = true }) {
|
|
100
|
+
if (useScript && scriptName) {
|
|
101
|
+
const script = getProjectScript(projectRoot, scriptName);
|
|
102
|
+
if (script) {
|
|
103
|
+
const cli = detectScriptCli(script);
|
|
104
|
+
// Expo: --device <serial>; RN: --deviceId <serial>.
|
|
105
|
+
const deviceFlag = cli === 'expo' ? `--device ${serial}` : `--deviceId ${serial}`;
|
|
106
|
+
return buildScriptCommand(packageManager, scriptName, [
|
|
107
|
+
deviceFlag,
|
|
108
|
+
`--port ${port}`,
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (isExpo) {
|
|
113
|
+
return `npx expo run:android --device ${serial} --port ${port}`;
|
|
114
|
+
}
|
|
115
|
+
return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildMetroCommand({ isExpo, port }) {
|
|
119
|
+
return isExpo
|
|
120
|
+
? `npx expo start --port ${port}`
|
|
121
|
+
: `npx react-native start --port ${port}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveSimNameByUdid(udid) {
|
|
125
|
+
const sims = listAllIosSims();
|
|
126
|
+
const target = sims.find(s => s.udid === udid);
|
|
127
|
+
if (!target) throw new Error(`Simulator UDID not found: ${udid}`);
|
|
128
|
+
const sameName = sims.filter(s => s.name === target.name);
|
|
129
|
+
if (sameName.length > 1) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Ambiguous: multiple simulators named "${target.name}" -- bare RN takes a name, not UDID. ` +
|
|
132
|
+
`Rename one in the Simulator app.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return target.name;
|
|
136
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { getExecutor } from '../exec.js';
|
|
2
|
+
|
|
3
|
+
export function parseAvdList(text) {
|
|
4
|
+
return text
|
|
5
|
+
.split('\n')
|
|
6
|
+
.map(l => l.trim())
|
|
7
|
+
.filter(l => l && !l.startsWith('INFO') && !l.startsWith('WARNING'));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseAdbDevices(text) {
|
|
11
|
+
const lines = text.split('\n').slice(1); // skip "List of devices attached"
|
|
12
|
+
const emulators = [];
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (!trimmed) continue;
|
|
16
|
+
const [serial, status] = trimmed.split(/\s+/);
|
|
17
|
+
if (status !== 'device') continue;
|
|
18
|
+
const m = serial.match(/^emulator-(\d+)$/);
|
|
19
|
+
if (m) emulators.push({ serial, consolePort: parseInt(m[1], 10) });
|
|
20
|
+
}
|
|
21
|
+
return { emulators };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listAvds() {
|
|
25
|
+
return parseAvdList(getExecutor().run('emulator -list-avds'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listAdbDevices() {
|
|
29
|
+
return parseAdbDevices(getExecutor().run('adb devices'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nextConsolePort(claimedPorts) {
|
|
33
|
+
if (claimedPorts.length === 0) return 5554;
|
|
34
|
+
const max = Math.max(...claimedPorts);
|
|
35
|
+
return max + 2; // emulator console ports are even
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
|
|
39
|
+
const avds = listAvds();
|
|
40
|
+
const adbDevices = listAdbDevices();
|
|
41
|
+
const runningPorts = new Set(adbDevices.emulators.map(e => e.consolePort));
|
|
42
|
+
|
|
43
|
+
if (existingAvd && avds.includes(existingAvd)) {
|
|
44
|
+
const port = existingConsolePort ?? nextConsolePort(claimedConsolePorts);
|
|
45
|
+
return {
|
|
46
|
+
kind: 'reuse',
|
|
47
|
+
avdName: existingAvd,
|
|
48
|
+
consolePort: port,
|
|
49
|
+
isRunning: runningPorts.has(port),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (avds.length === 0) {
|
|
54
|
+
return { kind: 'noAvd' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const claimedAvdSet = new Set(claimedAvds);
|
|
58
|
+
const candidate = avds.find(a => !claimedAvdSet.has(a));
|
|
59
|
+
if (!candidate) {
|
|
60
|
+
return { kind: 'noAvd' };
|
|
61
|
+
}
|
|
62
|
+
const consolePort = nextConsolePort(claimedConsolePorts);
|
|
63
|
+
return {
|
|
64
|
+
kind: 'allocate',
|
|
65
|
+
avdName: candidate,
|
|
66
|
+
consolePort,
|
|
67
|
+
isRunning: runningPorts.has(consolePort),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function bootAndroidEmulator(avdName, consolePort) {
|
|
72
|
+
const exec = getExecutor();
|
|
73
|
+
exec.spawn('emulator', ['-avd', avdName, '-port', String(consolePort)], {
|
|
74
|
+
detached: true,
|
|
75
|
+
stdio: 'ignore',
|
|
76
|
+
}).unref();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function waitForBoot(serial, timeoutMs = 60000) {
|
|
80
|
+
const exec = getExecutor();
|
|
81
|
+
const start = Date.now();
|
|
82
|
+
while (Date.now() - start < timeoutMs) {
|
|
83
|
+
const out = exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`);
|
|
84
|
+
if (out && out.trim() === '1') return true;
|
|
85
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function shutdownAndroidEmulator(serial) {
|
|
91
|
+
getExecutor().runQuiet(`adb -s ${serial} emu kill`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function adbReverse(serial, port) {
|
|
95
|
+
getExecutor().run(`adb -s ${serial} reverse tcp:${port} tcp:${port}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getAvdNameForSerial(serial) {
|
|
99
|
+
const out = getExecutor().runQuiet(`adb -s ${serial} emu avd name`);
|
|
100
|
+
if (!out) return null;
|
|
101
|
+
// `adb emu avd name` returns the AVD name on the first line, "OK" on the second.
|
|
102
|
+
return out.split('\n')[0].trim() || null;
|
|
103
|
+
}
|
package/src/sim/ios.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { getExecutor } from '../exec.js';
|
|
3
|
+
|
|
4
|
+
export function parseSimctlList(jsonOutput) {
|
|
5
|
+
const data = JSON.parse(jsonOutput);
|
|
6
|
+
const sims = [];
|
|
7
|
+
for (const [runtime, devices] of Object.entries(data.devices || {})) {
|
|
8
|
+
// Skip non-iOS runtimes (watchOS, tvOS, visionOS). iOS runtime IDs look
|
|
9
|
+
// like com.apple.CoreSimulator.SimRuntime.iOS-26-2 (the others have
|
|
10
|
+
// watchOS-, tvOS-, xrOS- in place of iOS-).
|
|
11
|
+
if (!/\.iOS-/.test(runtime)) continue;
|
|
12
|
+
for (const dev of devices) {
|
|
13
|
+
if (!dev.isAvailable) continue;
|
|
14
|
+
sims.push({
|
|
15
|
+
udid: dev.udid,
|
|
16
|
+
name: dev.name,
|
|
17
|
+
state: dev.state,
|
|
18
|
+
runtime,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return sims;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listAllIosSims() {
|
|
26
|
+
const out = getExecutor().run('xcrun simctl list devices --json');
|
|
27
|
+
return parseSimctlList(out);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listBootedIosSims() {
|
|
31
|
+
return listAllIosSims().filter(s => s.state === 'Booted');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function deviceFamilyRank(name) {
|
|
35
|
+
if (/^iPhone/i.test(name)) return 0;
|
|
36
|
+
if (/^iPad/i.test(name)) return 1;
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sortSims(sims, usage = {}) {
|
|
41
|
+
return [...sims].sort((a, b) => {
|
|
42
|
+
// 1. Family: iPhones before iPads before others.
|
|
43
|
+
const fa = deviceFamilyRank(a.name);
|
|
44
|
+
const fb = deviceFamilyRank(b.name);
|
|
45
|
+
if (fa !== fb) return fa - fb;
|
|
46
|
+
// 2. State: booted before shutdown (within the same family).
|
|
47
|
+
if (a.state === 'Booted' && b.state !== 'Booted') return -1;
|
|
48
|
+
if (b.state === 'Booted' && a.state !== 'Booted') return 1;
|
|
49
|
+
// 3. Usage count: descending (frequently picked sims float up).
|
|
50
|
+
const ua = usage[a.udid] || 0;
|
|
51
|
+
const ub = usage[b.udid] || 0;
|
|
52
|
+
if (ua !== ub) return ub - ua;
|
|
53
|
+
// 4. Name: stable alphabetical.
|
|
54
|
+
return a.name.localeCompare(b.name);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function selectIosDevice({ existingUdid, claimedUdids, usage = {} }) {
|
|
59
|
+
const sims = listAllIosSims();
|
|
60
|
+
const claimed = new Set(claimedUdids);
|
|
61
|
+
|
|
62
|
+
if (existingUdid) {
|
|
63
|
+
const found = sims.find(s => s.udid === existingUdid);
|
|
64
|
+
if (found) {
|
|
65
|
+
return { kind: 'reuse', udid: found.udid, state: found.state };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const unclaimed = sims.filter(s => !claimed.has(s.udid));
|
|
70
|
+
if (unclaimed.length === 0) return { kind: 'needsBoot' };
|
|
71
|
+
|
|
72
|
+
return { kind: 'allocate', candidates: sortSims(unclaimed, usage) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseRuntimeVersion(runtimeId) {
|
|
76
|
+
// e.g. com.apple.CoreSimulator.SimRuntime.iOS-26-2 -> "26.2"
|
|
77
|
+
const m = runtimeId.match(/iOS-(\d+)(?:-(\d+))?$/);
|
|
78
|
+
if (!m) return runtimeId;
|
|
79
|
+
return m[2] ? `${m[1]}.${m[2]}` : m[1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function bootIosSim(udid) {
|
|
83
|
+
const exec = getExecutor();
|
|
84
|
+
try {
|
|
85
|
+
exec.run(`xcrun simctl boot ${udid}`);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// simctl errors with "Unable to boot device in current state: Booted" if already booted.
|
|
88
|
+
if (!String(e?.message || e).includes('Booted')) throw e;
|
|
89
|
+
}
|
|
90
|
+
exec.runQuiet('open -a Simulator');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function shutdownIosSim(udid) {
|
|
94
|
+
getExecutor().runQuiet(`xcrun simctl shutdown ${udid}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function listIosDeviceTypes() {
|
|
98
|
+
const exec = getExecutor();
|
|
99
|
+
const out = exec.run('xcrun simctl list devicetypes --json');
|
|
100
|
+
const data = JSON.parse(out);
|
|
101
|
+
return (data.devicetypes || []).map(dt => ({
|
|
102
|
+
identifier: dt.identifier,
|
|
103
|
+
name: dt.name,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createIosSim(deviceTypeId, runtimeId) {
|
|
108
|
+
const suffix = randomBytes(3).toString('hex');
|
|
109
|
+
const name = `rn-iso-${suffix}`;
|
|
110
|
+
const out = getExecutor().run(`xcrun simctl create "${name}" "${deviceTypeId}" "${runtimeId}"`);
|
|
111
|
+
return out.trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function listIosRuntimes() {
|
|
115
|
+
const out = getExecutor().run('xcrun simctl list runtimes --json');
|
|
116
|
+
const data = JSON.parse(out);
|
|
117
|
+
return (data.runtimes || [])
|
|
118
|
+
.filter(r => r.isAvailable && r.platform === 'iOS')
|
|
119
|
+
.map(r => ({
|
|
120
|
+
identifier: r.identifier,
|
|
121
|
+
name: r.name,
|
|
122
|
+
version: r.version,
|
|
123
|
+
supportedDeviceTypes: (r.supportedDeviceTypes || []).map(d => ({
|
|
124
|
+
identifier: d.identifier,
|
|
125
|
+
name: d.name,
|
|
126
|
+
})),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import {
|
|
7
|
+
getConfigDir,
|
|
8
|
+
loadConfig,
|
|
9
|
+
saveConfig,
|
|
10
|
+
ensureConfig,
|
|
11
|
+
getProject,
|
|
12
|
+
upsertProject,
|
|
13
|
+
removeProject,
|
|
14
|
+
setMetro,
|
|
15
|
+
setDevice,
|
|
16
|
+
clearDevice,
|
|
17
|
+
allMetroPorts,
|
|
18
|
+
allClaimedDevices,
|
|
19
|
+
addReservation,
|
|
20
|
+
removeReservation,
|
|
21
|
+
listReservations,
|
|
22
|
+
clearAllReservations,
|
|
23
|
+
findReservations,
|
|
24
|
+
recordSimUsage,
|
|
25
|
+
getSimUsage,
|
|
26
|
+
} from '../src/config.js';
|
|
27
|
+
|
|
28
|
+
let tmpHome;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
32
|
+
process.env.RN_ISO_HOME = tmpHome;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
37
|
+
delete process.env.RN_ISO_HOME;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('getConfigDir respects RN_ISO_HOME', () => {
|
|
41
|
+
assert.equal(getConfigDir(), tmpHome);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('loadConfig returns null when no file exists', () => {
|
|
45
|
+
assert.equal(loadConfig(), null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('ensureConfig creates and returns empty config', () => {
|
|
49
|
+
const cfg = ensureConfig();
|
|
50
|
+
assert.deepEqual(cfg, { version: 1, projects: {}, reservations: { ios: [], android: [] } });
|
|
51
|
+
assert.ok(existsSync(join(tmpHome, 'config.json')));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('saveConfig + loadConfig roundtrip', () => {
|
|
55
|
+
saveConfig({ version: 1, projects: { '/foo': { metroPort: 8082, platforms: {} } } });
|
|
56
|
+
const cfg = loadConfig();
|
|
57
|
+
assert.equal(cfg.projects['/foo'].metroPort, 8082);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('upsertProject creates a new project entry with defaults', () => {
|
|
61
|
+
const proj = upsertProject('/abs/path', {
|
|
62
|
+
bundleId: 'com.foo',
|
|
63
|
+
androidPackage: 'com.foo',
|
|
64
|
+
isExpo: true,
|
|
65
|
+
});
|
|
66
|
+
assert.equal(proj.bundleId, 'com.foo');
|
|
67
|
+
assert.equal(proj.metroPort, null);
|
|
68
|
+
assert.deepEqual(proj.platforms, {});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('upsertProject preserves existing fields when called again', () => {
|
|
72
|
+
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
73
|
+
setMetro('/p', 8082, 12345);
|
|
74
|
+
upsertProject('/p', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
75
|
+
const proj = getProject('/p');
|
|
76
|
+
assert.equal(proj.bundleId, 'com.b');
|
|
77
|
+
assert.equal(proj.metroPort, 8082);
|
|
78
|
+
assert.equal(proj.metroPid, 12345);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('setDevice and clearDevice mutate platforms', () => {
|
|
82
|
+
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
83
|
+
setDevice('/p', 'ios', { deviceUdid: 'ABC' });
|
|
84
|
+
assert.equal(getProject('/p').platforms.ios.deviceUdid, 'ABC');
|
|
85
|
+
clearDevice('/p', 'ios');
|
|
86
|
+
assert.equal(getProject('/p').platforms.ios, undefined);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('allMetroPorts collects ports from all projects', () => {
|
|
90
|
+
upsertProject('/a', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
91
|
+
upsertProject('/b', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
92
|
+
setMetro('/a', 8082, null);
|
|
93
|
+
setMetro('/b', 8083, null);
|
|
94
|
+
assert.deepEqual(allMetroPorts().sort(), [8082, 8083]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('allClaimedDevices returns udids and avd names across projects', () => {
|
|
98
|
+
upsertProject('/a', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
99
|
+
upsertProject('/b', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
100
|
+
setDevice('/a', 'ios', { deviceUdid: 'UDID-1' });
|
|
101
|
+
setDevice('/b', 'android', { avdName: 'Pixel_6', consolePort: 5554 });
|
|
102
|
+
const claimed = allClaimedDevices();
|
|
103
|
+
assert.deepEqual(claimed.iosUdids, ['UDID-1']);
|
|
104
|
+
assert.deepEqual(claimed.androidAvds, ['Pixel_6']);
|
|
105
|
+
assert.deepEqual(claimed.androidConsolePorts, [5554]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('removeProject deletes entry', () => {
|
|
109
|
+
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
110
|
+
removeProject('/p');
|
|
111
|
+
assert.equal(getProject('/p'), null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('addReservation appends an iOS reservation and idempotently updates by UDID', () => {
|
|
115
|
+
addReservation('ios', { udid: 'UDID-X', label: 'agent-1' });
|
|
116
|
+
addReservation('ios', { udid: 'UDID-Y' });
|
|
117
|
+
let r = listReservations();
|
|
118
|
+
assert.equal(r.ios.length, 2);
|
|
119
|
+
// Re-add with same UDID -> updated, not duplicated
|
|
120
|
+
addReservation('ios', { udid: 'UDID-X', label: 'agent-1-renamed' });
|
|
121
|
+
r = listReservations();
|
|
122
|
+
assert.equal(r.ios.length, 2);
|
|
123
|
+
assert.equal(r.ios.find(e => e.udid === 'UDID-X').label, 'agent-1-renamed');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('addReservation works for android keyed by serial', () => {
|
|
127
|
+
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, avdName: 'Pixel_6' });
|
|
128
|
+
const r = listReservations();
|
|
129
|
+
assert.equal(r.android.length, 1);
|
|
130
|
+
assert.equal(r.android[0].avdName, 'Pixel_6');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('removeReservation drops the matching entry', () => {
|
|
134
|
+
addReservation('ios', { udid: 'UDID-1' });
|
|
135
|
+
addReservation('ios', { udid: 'UDID-2' });
|
|
136
|
+
const removed = removeReservation('ios', 'UDID-1');
|
|
137
|
+
assert.equal(removed, true);
|
|
138
|
+
assert.deepEqual(listReservations().ios.map(e => e.udid), ['UDID-2']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('allClaimedDevices includes reservations alongside project assignments', () => {
|
|
142
|
+
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
143
|
+
setDevice('/p', 'ios', { deviceUdid: 'UDID-PROJECT' });
|
|
144
|
+
addReservation('ios', { udid: 'UDID-EXTERNAL', label: 'agent-1' });
|
|
145
|
+
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, avdName: 'Pixel_6' });
|
|
146
|
+
const claimed = allClaimedDevices();
|
|
147
|
+
assert.deepEqual(claimed.iosUdids.sort(), ['UDID-EXTERNAL', 'UDID-PROJECT']);
|
|
148
|
+
assert.deepEqual(claimed.androidAvds, ['Pixel_6']);
|
|
149
|
+
assert.deepEqual(claimed.androidConsolePorts, [5554]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('clearAllReservations empties both platforms', () => {
|
|
153
|
+
addReservation('ios', { udid: 'UDID-1' });
|
|
154
|
+
addReservation('android', { serial: 'emulator-5554', consolePort: 5554 });
|
|
155
|
+
clearAllReservations();
|
|
156
|
+
const r = listReservations();
|
|
157
|
+
assert.deepEqual(r, { ios: [], android: [] });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('findReservations matches by id (UDID for iOS, serial for Android)', () => {
|
|
161
|
+
addReservation('ios', { udid: 'UDID-1', label: 'agent-2' });
|
|
162
|
+
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, label: 'agent-1' });
|
|
163
|
+
const ios = findReservations('UDID-1');
|
|
164
|
+
assert.deepEqual(ios, [{ platform: 'ios', id: 'UDID-1', label: 'agent-2' }]);
|
|
165
|
+
const android = findReservations('emulator-5554');
|
|
166
|
+
assert.deepEqual(android, [{ platform: 'android', id: 'emulator-5554', label: 'agent-1' }]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('findReservations matches by label across platforms', () => {
|
|
170
|
+
addReservation('ios', { udid: 'UDID-1', label: 'shared-label' });
|
|
171
|
+
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, label: 'shared-label' });
|
|
172
|
+
const matches = findReservations('shared-label');
|
|
173
|
+
assert.equal(matches.length, 2);
|
|
174
|
+
assert.deepEqual(matches.map(m => m.platform).sort(), ['android', 'ios']);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('findReservations respects platform filter', () => {
|
|
178
|
+
addReservation('ios', { udid: 'UDID-1', label: 'shared' });
|
|
179
|
+
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, label: 'shared' });
|
|
180
|
+
const matches = findReservations('shared', 'android');
|
|
181
|
+
assert.equal(matches.length, 1);
|
|
182
|
+
assert.equal(matches[0].platform, 'android');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('findReservations returns empty when nothing matches', () => {
|
|
186
|
+
addReservation('ios', { udid: 'UDID-1', label: 'agent-1' });
|
|
187
|
+
assert.deepEqual(findReservations('nope'), []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('recordSimUsage increments and getSimUsage reads counts', () => {
|
|
191
|
+
recordSimUsage('ios', 'UDID-A');
|
|
192
|
+
recordSimUsage('ios', 'UDID-A');
|
|
193
|
+
recordSimUsage('ios', 'UDID-B');
|
|
194
|
+
recordSimUsage('android', 'Pixel_6');
|
|
195
|
+
const usage = getSimUsage();
|
|
196
|
+
assert.equal(usage.ios['UDID-A'], 2);
|
|
197
|
+
assert.equal(usage.ios['UDID-B'], 1);
|
|
198
|
+
assert.equal(usage.android['Pixel_6'], 1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('allClaimedDevices.iosClaims labels project vs reservation sources', () => {
|
|
202
|
+
upsertProject('/Users/janic/Developer/myapp', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: true });
|
|
203
|
+
setDevice('/Users/janic/Developer/myapp', 'ios', { deviceUdid: 'UDID-PROJ' });
|
|
204
|
+
addReservation('ios', { udid: 'UDID-EXT', label: 'agent-1' });
|
|
205
|
+
const claimed = allClaimedDevices();
|
|
206
|
+
assert.deepEqual(claimed.iosClaims['UDID-PROJ'], { source: 'project', label: 'myapp' });
|
|
207
|
+
assert.deepEqual(claimed.iosClaims['UDID-EXT'], { source: 'reservation', label: 'agent-1' });
|
|
208
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { setExecutor, getExecutor, resetExecutor } from '../src/exec.js';
|
|
4
|
+
|
|
5
|
+
test('default executor runs commands and returns stdout trimmed', () => {
|
|
6
|
+
resetExecutor();
|
|
7
|
+
const out = getExecutor().run('echo hello');
|
|
8
|
+
assert.equal(out, 'hello');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('runQuiet returns null on failure', () => {
|
|
12
|
+
resetExecutor();
|
|
13
|
+
const out = getExecutor().runQuiet('false');
|
|
14
|
+
assert.equal(out, null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('setExecutor replaces the active executor', () => {
|
|
18
|
+
setExecutor({
|
|
19
|
+
run: () => 'mocked',
|
|
20
|
+
runQuiet: () => 'mocked-quiet',
|
|
21
|
+
spawn: () => ({ pid: 999 }),
|
|
22
|
+
});
|
|
23
|
+
assert.equal(getExecutor().run('anything'), 'mocked');
|
|
24
|
+
assert.equal(getExecutor().runQuiet('anything'), 'mocked-quiet');
|
|
25
|
+
resetExecutor();
|
|
26
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Minimal pbxproj fixture mimicking a hybrid project layout: main app
|
|
2
|
+
// bundle id appears in multiple build configs; an extension target adds a
|
|
3
|
+
// suffix on its own id; macros are present too.
|
|
4
|
+
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
5
|
+
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
6
|
+
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
7
|
+
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
8
|
+
PRODUCT_BUNDLE_IDENTIFIER = me.sample.WidgetExtension;
|
|
9
|
+
PRODUCT_BUNDLE_IDENTIFIER = me.sample.WidgetExtension;
|
|
10
|
+
PRODUCT_BUNDLE_IDENTIFIER = $(SOMETHING);
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resetExecutor } from '../src/exec.js';
|
|
4
|
+
import { logFileFor, projectHash, buildMetroSpawnArgs } from '../src/metro.js';
|
|
5
|
+
|
|
6
|
+
afterEach(() => resetExecutor());
|
|
7
|
+
|
|
8
|
+
test('projectHash is deterministic and short', () => {
|
|
9
|
+
const a = projectHash('/foo/bar');
|
|
10
|
+
const b = projectHash('/foo/bar');
|
|
11
|
+
const c = projectHash('/foo/baz');
|
|
12
|
+
assert.equal(a, b);
|
|
13
|
+
assert.notEqual(a, c);
|
|
14
|
+
assert.equal(a.length, 12);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('logFileFor uses RN_ISO_HOME and project hash', () => {
|
|
18
|
+
process.env.RN_ISO_HOME = '/tmp/test-rn-iso';
|
|
19
|
+
const path = logFileFor('/some/project');
|
|
20
|
+
assert.match(path, /^\/tmp\/test-rn-iso\/logs\/[0-9a-f]{12}\.log$/);
|
|
21
|
+
delete process.env.RN_ISO_HOME;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('buildMetroSpawnArgs returns correct argv for expo', () => {
|
|
25
|
+
const { cmd, args } = buildMetroSpawnArgs({ isExpo: true, port: 8083 });
|
|
26
|
+
assert.equal(cmd, 'npx');
|
|
27
|
+
assert.deepEqual(args, ['expo', 'start', '--port', '8083']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('buildMetroSpawnArgs returns correct argv for bare', () => {
|
|
31
|
+
const { cmd, args } = buildMetroSpawnArgs({ isExpo: false, port: 8083 });
|
|
32
|
+
assert.equal(cmd, 'npx');
|
|
33
|
+
assert.deepEqual(args, ['react-native', 'start', '--port', '8083']);
|
|
34
|
+
});
|