mirror of
https://github.com/MobAI-App/simslim.git
synced 2026-09-27 17:02:00 +02:00
* feat: watch slims parallel-testing clones as they boot xcodebuild parallel testing creates a stock clone per worker in its own device set and deletes them per run; disable overrides do not survive simctl clone, so there is no device to pre-slim. watch polls the device sets and applies a no-reboot slim to each simulator once it boots, then leaves it alone, reusing EnableSlimNoReboot from #42. Answers the ephemeral-clone case raised in #41. * fix: watch re-slims rebooted clones and retries failed slims Review fixes on the watch scan loop: - Drop a UDID from seen when a scan finds it non-Booted. Below iOS 18.5 the no-reboot slim ends with the boot session, so a clone that booted again came back stock and was skipped for the rest of the run. - Drop a UDID from seen when its slim fails, so the next scan retries it instead of leaving that clone stock behind one stderr line. - Wait for the in-flight slim goroutines before Watch returns, so an importer holds no goroutine past the call. - Pass the slim a Reporter that prefixes each line with the UDID, so the per-device progress reaches the caller. seen is now mutated by the scan loop and by the slim goroutines, so it moves into a watcher behind a mutex. go test -race is green.
211 lines
6.0 KiB
Go
211 lines
6.0 KiB
Go
package simslim
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
bootedClone = `{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-26-1":[{"udid":"AAAA","name":"clone-1","state":"Booted","isAvailable":true,"dataPath":"/tmp/a"}]}}`
|
|
shutdownClone = `{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-26-1":[{"udid":"AAAA","name":"clone-1","state":"Shutdown","isAvailable":true,"dataPath":"/tmp/a"}]}}`
|
|
bootedAndStoppedPair = `{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-26-1":[{"udid":"AAAA","name":"clone-1","state":"Booted","isAvailable":true,"dataPath":"/tmp/a"},{"udid":"BBBB","name":"clone-2","state":"Shutdown","isAvailable":true,"dataPath":"/tmp/b"}]}}`
|
|
)
|
|
|
|
// fakeDeviceList puts a fake xcrun on PATH that serves the device list from a
|
|
// file, and returns the function that rewrites it so a test can change a
|
|
// device's state between scans.
|
|
func fakeDeviceList(t *testing.T, devices string) func(string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
statePath := filepath.Join(dir, "devices.json")
|
|
xcrunPath := filepath.Join(dir, "xcrun")
|
|
script := `#!/bin/sh
|
|
if [ "$*" = "simctl list devices -j" ]; then
|
|
cat "$SIMSLIM_FAKE_DEVICES"
|
|
exit 0
|
|
fi
|
|
exit 0
|
|
`
|
|
if err := os.WriteFile(xcrunPath, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("SIMSLIM_FAKE_DEVICES", statePath)
|
|
rewrite := func(devices string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(statePath, []byte(devices), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
rewrite(devices)
|
|
return rewrite
|
|
}
|
|
|
|
func newTestWatcher(slim slimStrategy, report Reporter) *watcher {
|
|
return &watcher{report: report, slim: slim, seen: map[string]bool{}}
|
|
}
|
|
|
|
// TestWatchSlimsEachBootedDeviceOnce verifies the scan slims a booted device
|
|
// exactly once and never touches a shutdown one, so a steady poll does not
|
|
// re-slim clones it already handled.
|
|
func TestWatchSlimsEachBootedDeviceOnce(t *testing.T) {
|
|
fakeDeviceList(t, bootedAndStoppedPair)
|
|
|
|
var mu sync.Mutex
|
|
slimmed := map[string]int{}
|
|
fake := func(_ context.Context, _, udid string, _ Profile, _ Reporter) (bool, error) {
|
|
mu.Lock()
|
|
slimmed[udid]++
|
|
mu.Unlock()
|
|
return true, nil
|
|
}
|
|
|
|
w := newTestWatcher(fake, nil)
|
|
for i := 0; i < 3; i++ { // repeated scans must not re-slim
|
|
w.scan(context.Background())
|
|
w.wg.Wait()
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if slimmed["AAAA"] != 1 {
|
|
t.Fatalf("booted device slimmed %d times, want 1", slimmed["AAAA"])
|
|
}
|
|
if slimmed["BBBB"] != 0 {
|
|
t.Fatalf("shutdown device slimmed %d times, want 0", slimmed["BBBB"])
|
|
}
|
|
}
|
|
|
|
// TestWatchReslimsDeviceAfterShutdown covers the boot session the no-reboot
|
|
// slim lives in: on iOS below 18.5 a device that shuts down comes back stock,
|
|
// so watch must slim it again instead of skipping it for the rest of the run.
|
|
func TestWatchReslimsDeviceAfterShutdown(t *testing.T) {
|
|
rewrite := fakeDeviceList(t, bootedClone)
|
|
|
|
var mu sync.Mutex
|
|
slims := 0
|
|
fake := func(_ context.Context, _, _ string, _ Profile, _ Reporter) (bool, error) {
|
|
mu.Lock()
|
|
slims++
|
|
mu.Unlock()
|
|
return true, nil
|
|
}
|
|
|
|
w := newTestWatcher(fake, nil)
|
|
for _, devices := range []string{bootedClone, shutdownClone, bootedClone} {
|
|
rewrite(devices)
|
|
w.scan(context.Background())
|
|
w.wg.Wait()
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if slims != 2 {
|
|
t.Fatalf("device slimmed %d times across a shutdown and a second boot, want 2", slims)
|
|
}
|
|
}
|
|
|
|
// TestWatchRetriesSlimAfterError verifies a failed slim is retried on the next
|
|
// scan: a transient launchctl failure must not leave that clone stock for the
|
|
// whole test run. A slim that succeeds is still never repeated.
|
|
func TestWatchRetriesSlimAfterError(t *testing.T) {
|
|
fakeDeviceList(t, bootedClone)
|
|
|
|
var mu sync.Mutex
|
|
attempts := 0
|
|
fake := func(_ context.Context, _, _ string, _ Profile, _ Reporter) (bool, error) {
|
|
mu.Lock()
|
|
attempts++
|
|
first := attempts == 1
|
|
mu.Unlock()
|
|
if first {
|
|
return false, errors.New("launchctl disable failed")
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
w := newTestWatcher(fake, nil)
|
|
for i := 0; i < 3; i++ {
|
|
w.scan(context.Background())
|
|
w.wg.Wait()
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if attempts != 2 {
|
|
t.Fatalf("slim attempted %d times, want 2 (one failure, one retry)", attempts)
|
|
}
|
|
}
|
|
|
|
// TestWatchPrefixesDeviceProgress verifies the per-device messages of the slim
|
|
// reach the caller, tagged with the UDID they came from.
|
|
func TestWatchPrefixesDeviceProgress(t *testing.T) {
|
|
fakeDeviceList(t, bootedClone)
|
|
|
|
var mu sync.Mutex
|
|
var lines []string
|
|
report := Reporter(func(msg string) {
|
|
mu.Lock()
|
|
lines = append(lines, msg)
|
|
mu.Unlock()
|
|
})
|
|
fake := func(_ context.Context, _, _ string, _ Profile, deviceReport Reporter) (bool, error) {
|
|
deviceReport.report("Leaving 3 services disabled beyond this profile.")
|
|
return true, nil
|
|
}
|
|
|
|
w := newTestWatcher(fake, report)
|
|
w.scan(context.Background())
|
|
w.wg.Wait()
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
const want = "watch: AAAA: Leaving 3 services disabled beyond this profile."
|
|
if !slices.Contains(lines, want) {
|
|
t.Fatalf("reported lines = %q, want one %q", lines, want)
|
|
}
|
|
}
|
|
|
|
// TestWatchWaitsForInFlightSlims verifies Watch does not return while a slim is
|
|
// still running, so an importer holds no goroutine it cannot see.
|
|
func TestWatchWaitsForInFlightSlims(t *testing.T) {
|
|
fakeDeviceList(t, bootedClone)
|
|
|
|
started := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
fake := func(_ context.Context, _, _ string, _ Profile, _ Reporter) (bool, error) {
|
|
started <- struct{}{}
|
|
<-release
|
|
return true, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
returned := make(chan error, 1)
|
|
go func() { returned <- watch(ctx, Profile{}, time.Millisecond, nil, fake) }()
|
|
|
|
<-started
|
|
cancel()
|
|
select {
|
|
case <-returned:
|
|
t.Fatal("watch returned while a slim was still in flight")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(release)
|
|
select {
|
|
case err := <-returned:
|
|
if err != nil {
|
|
t.Fatalf("watch() = %v, want nil", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("watch did not return after the in-flight slim finished")
|
|
}
|
|
}
|