Skip to content

Commit a28d3b6

Browse files
committed
Stabilize simulator integration runtime selection
1 parent 0e46794 commit a28d3b6

5 files changed

Lines changed: 220 additions & 167 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ env:
1616
jobs:
1717
rust:
1818
name: Rust lint and unit tests
19-
runs-on: macos-latest
19+
runs-on: macos-15
20+
env:
21+
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
2022

2123
steps:
2224
- uses: actions/checkout@v4
@@ -114,7 +116,9 @@ jobs:
114116

115117
build-artifacts:
116118
name: Build integration artifacts
117-
runs-on: macos-latest
119+
runs-on: macos-15
120+
env:
121+
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
118122

119123
steps:
120124
- uses: actions/checkout@v4
@@ -169,7 +173,9 @@ jobs:
169173
170174
integration-cli:
171175
name: CLI simulator integration
172-
runs-on: macos-latest
176+
runs-on: macos-15
177+
env:
178+
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
173179
needs:
174180
- rust
175181
- client
@@ -199,7 +205,9 @@ jobs:
199205

200206
integration-js-api:
201207
name: JS API simulator integration
202-
runs-on: macos-latest
208+
runs-on: macos-15
209+
env:
210+
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
203211
needs:
204212
- rust
205213
- client

docs/guide/testing.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,19 @@ Useful environment variables:
7272
| `SIMDECK_INTEGRATION_SHOW_SIMULATOR=1` | Open Simulator.app during the run. |
7373
| `SIMDECK_INTEGRATION_KEEP_SIMULATOR=1` | Leave the temporary simulator after exit. |
7474
| `SIMDECK_INTEGRATION_SIMCTL_TIMEOUT_MS` | Override the cold CoreSimulator command timeout. |
75+
| `SIMDECK_INTEGRATION_IOS_RUNTIME` | Force a runtime by version, name, or identifier. |
76+
| `SIMDECK_INTEGRATION_DEVICE_TYPE` | Force an iPhone device type by name or identifier. |
7577

7678
The integration suite is separate from `npm run test` because it boots and drives a real iOS simulator.
7779
The UIKit fixture app is cached under `.cache/simdeck/fixture` using a hash
7880
of its generated source, plist, simulator SDK, Clang version, and host
7981
architecture.
8082

83+
By default, the integration runner selects the newest available iOS runtime that
84+
does not exceed the active `iphonesimulator` SDK version, falling back to the
85+
same major version when needed. This keeps CI off newer installed runtimes that
86+
do not match the selected Xcode toolchain.
87+
8188
## Stress and Leak Checks
8289

8390
Use the stress runner against an already-running daemon when you want to shake out

scripts/integration/cli.mjs

Lines changed: 7 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import http from "node:http";
55
import os from "node:os";
66
import path from "node:path";
77
import { buildCachedFixtureApp } from "./fixture.mjs";
8+
import { selectIntegrationSimulator } from "./simulator-selection.mjs";
89

910
const root = path.resolve(new URL("../..", import.meta.url).pathname);
1011
const simdeck = path.join(root, "build", "simdeck");
@@ -73,8 +74,11 @@ async function main() {
7374
throw new Error(`Missing ${simdeck}. Run npm run build:cli first.`);
7475
}
7576

76-
const runtime = latestAvailableIosRuntime();
77-
const deviceType = preferredIphoneDeviceType(runtime);
77+
const { runtime, deviceType, sdkVersion } = selectIntegrationSimulator({
78+
runJson,
79+
runText,
80+
timeoutMs: coreSimulatorCommandTimeoutMs,
81+
});
7882
const simulatorName = `SimDeck CLI Integration ${Date.now()}`;
7983
simulatorUDID = await measuredStep(
8084
"simctl create simulator",
@@ -90,7 +94,7 @@ async function main() {
9094
);
9195

9296
console.log(
93-
`created ${simulatorUDID} (${deviceType.name}, ${runtime.version})`,
97+
`created ${simulatorUDID} (${deviceType.name}, ${runtime.version}; iphonesimulator SDK ${sdkVersion})`,
9498
);
9599
startServer();
96100
await measuredStep("server health", () => waitForHealth(), {
@@ -608,86 +612,6 @@ async function runRestControls() {
608612
);
609613
}
610614

611-
function latestAvailableIosRuntime() {
612-
const payload = runJson("xcrun", ["simctl", "list", "runtimes", "-j"], {
613-
timeoutMs: coreSimulatorCommandTimeoutMs,
614-
});
615-
const runtimes = payload.runtimes
616-
.filter(
617-
(runtime) => runtime.isAvailable && runtime.identifier?.includes("iOS"),
618-
)
619-
.sort(compareRuntimeVersions);
620-
const runtime = runtimes.at(-1);
621-
if (!runtime) {
622-
throw new Error("No available iOS simulator runtime found.");
623-
}
624-
return runtime;
625-
}
626-
627-
function preferredIphoneDeviceType(runtime) {
628-
const runtimeSupported = Array.isArray(runtime.supportedDeviceTypes)
629-
? runtime.supportedDeviceTypes
630-
: [];
631-
const allDeviceTypes = runJson(
632-
"xcrun",
633-
["simctl", "list", "devicetypes", "-j"],
634-
{ timeoutMs: coreSimulatorCommandTimeoutMs },
635-
).devicetypes;
636-
const supported =
637-
runtimeSupported.length > 0
638-
? runtimeSupported
639-
: allDeviceTypes.filter(
640-
(device) =>
641-
device.productFamily === "iPhone" ||
642-
device.identifier?.includes("iPhone"),
643-
);
644-
const iphones = supported.filter(
645-
(device) =>
646-
device.productFamily === "iPhone" ||
647-
device.identifier?.includes("iPhone"),
648-
);
649-
const preferred = [
650-
"iPhone 17",
651-
"iPhone 16",
652-
"iPhone 15",
653-
"iPhone 14",
654-
"iPhone 13",
655-
];
656-
for (const name of preferred) {
657-
const match = iphones.find((device) => device.name === name);
658-
if (match) {
659-
return match;
660-
}
661-
}
662-
const fallback = iphones[0];
663-
if (!fallback) {
664-
throw new Error(
665-
`Runtime ${runtime.identifier} does not support an iPhone device.`,
666-
);
667-
}
668-
return fallback;
669-
}
670-
671-
function compareRuntimeVersions(left, right) {
672-
const leftParts = String(left.version ?? "0")
673-
.split(".")
674-
.map(Number);
675-
const rightParts = String(right.version ?? "0")
676-
.split(".")
677-
.map(Number);
678-
for (
679-
let index = 0;
680-
index < Math.max(leftParts.length, rightParts.length);
681-
index += 1
682-
) {
683-
const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
684-
if (delta !== 0) {
685-
return delta;
686-
}
687-
}
688-
return String(left.identifier).localeCompare(String(right.identifier));
689-
}
690-
691615
function buildFixtureApp() {
692616
return buildCachedFixtureApp({
693617
root,

scripts/integration/js-api.mjs

Lines changed: 7 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import os from "node:os";
55
import path from "node:path";
66
import { connect } from "simdeck/test";
77
import { buildCachedFixtureApp } from "./fixture.mjs";
8+
import { selectIntegrationSimulator } from "./simulator-selection.mjs";
89

910
const root = path.resolve(new URL("../..", import.meta.url).pathname);
1011
const simdeck = path.join(root, "build", "simdeck");
@@ -68,8 +69,11 @@ async function main() {
6869
);
6970
}
7071

71-
const runtime = latestAvailableIosRuntime();
72-
const deviceType = preferredIphoneDeviceType(runtime);
72+
const { runtime, deviceType, sdkVersion } = selectIntegrationSimulator({
73+
runJson,
74+
runText,
75+
timeoutMs: coreSimulatorCommandTimeoutMs,
76+
});
7377
const simulatorName = `SimDeck JS API Integration ${Date.now()}`;
7478
simulatorUDID = await measuredStep(
7579
"simctl create simulator",
@@ -84,7 +88,7 @@ async function main() {
8488
{ phase: phaseSetup },
8589
);
8690
console.log(
87-
`created ${simulatorUDID} (${deviceType.name}, ${runtime.version})`,
91+
`created ${simulatorUDID} (${deviceType.name}, ${runtime.version}; iphonesimulator SDK ${sdkVersion})`,
8892
);
8993

9094
await measuredStep(
@@ -326,83 +330,6 @@ async function expectElementContains(selector, text, options = {}) {
326330
);
327331
}
328332

329-
function latestAvailableIosRuntime() {
330-
const payload = runJson("xcrun", ["simctl", "list", "runtimes", "-j"], {
331-
timeoutMs: coreSimulatorCommandTimeoutMs,
332-
});
333-
const runtimes = payload.runtimes
334-
.filter(
335-
(runtime) => runtime.isAvailable && runtime.identifier?.includes("iOS"),
336-
)
337-
.sort(compareRuntimeVersions);
338-
const runtime = runtimes.at(-1);
339-
if (!runtime) {
340-
throw new Error("No available iOS simulator runtime found.");
341-
}
342-
return runtime;
343-
}
344-
345-
function preferredIphoneDeviceType(runtime) {
346-
const runtimeSupported = Array.isArray(runtime.supportedDeviceTypes)
347-
? runtime.supportedDeviceTypes
348-
: [];
349-
const allDeviceTypes = runJson(
350-
"xcrun",
351-
["simctl", "list", "devicetypes", "-j"],
352-
{ timeoutMs: coreSimulatorCommandTimeoutMs },
353-
).devicetypes;
354-
const supported =
355-
runtimeSupported.length > 0
356-
? runtimeSupported
357-
: allDeviceTypes.filter(
358-
(device) =>
359-
device.productFamily === "iPhone" ||
360-
device.identifier?.includes("iPhone"),
361-
);
362-
const iphones = supported.filter(
363-
(device) =>
364-
device.productFamily === "iPhone" ||
365-
device.identifier?.includes("iPhone"),
366-
);
367-
for (const name of [
368-
"iPhone 17",
369-
"iPhone 16",
370-
"iPhone 15",
371-
"iPhone 14",
372-
"iPhone 13",
373-
]) {
374-
const match = iphones.find((device) => device.name === name);
375-
if (match) {
376-
return match;
377-
}
378-
}
379-
const fallback = iphones[0];
380-
if (!fallback) {
381-
throw new Error(
382-
`Runtime ${runtime.identifier} does not support an iPhone device.`,
383-
);
384-
}
385-
return fallback;
386-
}
387-
388-
function compareRuntimeVersions(left, right) {
389-
const leftParts = String(left.version ?? "0")
390-
.split(".")
391-
.map(Number);
392-
const rightParts = String(right.version ?? "0")
393-
.split(".")
394-
.map(Number);
395-
for (
396-
let index = 0;
397-
index < Math.max(leftParts.length, rightParts.length);
398-
index += 1
399-
) {
400-
const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
401-
if (delta !== 0) return delta;
402-
}
403-
return String(left.identifier).localeCompare(String(right.identifier));
404-
}
405-
406333
function buildFixtureApp() {
407334
return buildCachedFixtureApp({
408335
root,

0 commit comments

Comments
 (0)