Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions Documentation/xtool.docc/Testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Run XCTest/XCUITest on a device

Build, install, and run your package's test targets on a real device -- no Xcode required.

## Overview

`xtool test` builds every `.testTarget` in your SwiftPM package into SwiftPM's own combined test
bundle (SwiftPM doesn't support separate per-target test products), packages it into a
`Runner.app` (matching Xcode's own `<Target>-Runner.app` convention), installs it on a connected
device, and drives it via the same `testmanagerd` protocol Xcode uses -- so you get real pass/fail
results, failure screenshots, and crash logs from an actual device, from Linux.

```bash
xtool test
```

`xtool test` only ever builds and installs the test Runner -- it never builds or installs your
main app product. For XCUITest targets that drive a separate app under test, install that app
first with `xtool install` or `xtool dev`, then point `xtool test` at it:

```bash
xtool test --test-target MyAppUITests --target-bundle-id com.example.MyApp
```

## Choosing what to run

If your package has more than one `.testTarget`, `xtool test` prompts you interactively to choose
one -- only one test target actually runs per invocation, since a UI test target and a plain unit
test target typically need different session settings (notably `--target-bundle-id`) and
shouldn't run mixed together in one session anyway. Skip the prompt with `--test-target`:

```bash
xtool test --test-target MyAppTests
```

To run (or exclude) specific tests within a target, use `--only`/`--skip` with a `TestClass` or
`TestClass/testMethod` identifier. Both are repeatable:

```bash
xtool test --only LoginTests --only SettingsTests/testLogout
xtool test --skip FlakyTests
```

> Note:
>
> A single on-device session reliably filters to *one* class-level identifier at a time -- running
> two whole classes' worth of tests in one filtered session is a real (confirmed) limitation of the
> on-device XCTest runner, not something `xtool test` can work around within a single session.
> `--test-target` and multiple `--only` values are handled correctly by running one session per
> class internally and aggregating the results, but each of those sessions needs its own app
> relaunch -- so a target with many test classes will take noticeably longer than one with a
> single class.

## Reports and artifacts

Write machine-readable reports with `--junit`, `--json`, or `--html`, each taking a file path:

```bash
xtool test --junit report.xml --json report.json
```

Capture additional failure diagnostics:

- `--screenshot-on-failure` -- captures a device screenshot for every failed test case
- `--capture-syslog` -- captures the device syslog for the duration of each run
- `--capture-crash-logs` -- collects any on-device crash logs written during the run

All three write into `--report-directory` (defaults to a timestamped directory in the current
directory).

## Running against multiple devices or repeatedly

```bash
xtool test --parallel # run on every currently-connected device concurrently
xtool test --repeat 5 # run the whole session 5 times sequentially, aggregating results
```

## Other options

- `--triple` -- override the build target triple (defaults to the standard iOS device triple)
- `--session-timeout <seconds>` -- fail a run if no event arrives from the device for this long
(default 120s). This guards against a stalled-but-still-connected session hanging forever; it's
measured as an idle gap between events, not a cap on the whole run, so a long `--test-target`
sweep isn't penalized for legitimately taking a while.

> Troubleshooting:
>
> A free Apple Developer account can only have a small number of apps installed on a device at
> once. If `xtool test` fails to install the Runner with a message about the maximum number of
> installed apps, uninstall one of the listed bundle IDs with `xtool uninstall <bundle-id>` and try
> again.
1 change: 1 addition & 0 deletions Documentation/xtool.docc/xtool.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona

- <doc:Control>
- <doc:Appex>
- <doc:Testing>
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ let package = Package(
],
targets: [
.systemLibrary(name: "XADI"),
// wraps libtatsu (Apple TSS/personalization protocol client), used for
// personalized Developer Disk Image mounting on iOS 17+. Declared locally
// (rather than in xtool-core, which only vendors libimobiledevice/plist/etc.
// as of this writing) against the system-installed library, same pattern as
// XADI above. Linux-only for now: xtool-core's macOS/Windows binaryTargets
// don't yet publish a libtatsu xcframework.
.systemLibrary(name: "CTatsu"),
.target(
name: "CXKit",
dependencies: [
Expand Down Expand Up @@ -111,9 +118,15 @@ let package = Package(
"CXKit",
"XUtils",
.byName(name: "XADI", condition: .when(platforms: [.linux])),
.byName(name: "CTatsu", condition: .when(platforms: [.linux])),
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "SwiftyMobileDevice", package: "SwiftyMobileDevice"),
// for personalization calls on MobileImageMounterClient not yet
// wrapped by SwiftyMobileDevice (query nonce/identifiers/manifest,
// mount-with-options). See Installation/PersonalizedDDIMounter.swift.
.product(name: "libimobiledevice", package: "xtool-core"),
.product(name: "plist", package: "xtool-core"),
.product(name: "Zupersign", package: "zsign"),
.product(name: "SignerSupport", package: "xtool-core"),
.product(name: "ProtoCodable", package: "xtool-core"),
Expand Down Expand Up @@ -147,6 +160,9 @@ let package = Package(
name: "XToolTests",
dependencies: [
"XToolSupport",
"XKit",
"PackLib",
.product(name: "plist", package: "xtool-core"),
]
),
.target(
Expand Down
4 changes: 4 additions & 0 deletions Sources/CTatsu/module.modulemap
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module CTatsu [extern_c] {
header "shim.h"
link "tatsu"
}
2 changes: 2 additions & 0 deletions Sources/CTatsu/shim.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include <libtatsu/tatsu.h>
#include <libtatsu/tss.h>
34 changes: 34 additions & 0 deletions Sources/CXKit/include/tun_ioctl.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#ifndef XTL_TUN_IOCTL_H
#define XTL_TUN_IOCTL_H

// Non-variadic wrappers around the Linux TUN-device ioctls (`ioctl()` itself is a variadic C
// function, which Swift can't call directly). Used by `TUNDevice.swift` to create and configure
// the kernel TUN interface the iOS 17+ RSD tunnel routes traffic through -- see that file's doc
// comment for the wire-level context (CoreDeviceProxy CDTunnel handshake -> this -> RSD lookup).

/// Opens `/dev/net/tun` and creates (or attaches to) a TUN interface (`IFF_TUN | IFF_NO_PI`,
/// i.e. raw IP packets with no per-packet protocol header). The kernel auto-assigns a name
/// (`tunN`); on success it's written into `name_out`, which must have room for at least 16 bytes
/// (`IFNAMSIZ`). Returns the open file descriptor, or -1 on failure (`errno` set).
int xtl_tun_create(char * _Nonnull name_out);

/// Assigns a /`prefix_len` IPv6 address (`addr6`: 16 raw bytes, network byte order) to the named
/// interface, sets its MTU, and brings it up. Returns 0 on success, -1 on failure (`errno` set
/// and `*stage_out` identifies which step failed: 0 = opening the configuration socket,
/// 1 = resolving the interface index, 2 = assigning the address, 3 = setting the MTU,
/// 4 = reading current flags, 5 = writing flags).
///
/// Setting the MTU explicitly matters: a freshly created TUN device defaults to the kernel's
/// standard 1500-byte MTU, which can be larger than the MTU the tunnel peer actually negotiated
/// (e.g. 1280). Leaving the interface at the default risks the kernel handing back a packet
/// larger than a receive buffer sized to the *negotiated* MTU, which it silently truncates to
/// fit -- corrupting the packet stream with no error raised at all.
int xtl_tun_configure(
const char * _Nonnull name,
const unsigned char * _Nonnull addr6,
unsigned int prefix_len,
unsigned int mtu,
int * _Nonnull stage_out
);

#endif
114 changes: 114 additions & 0 deletions Sources/CXKit/tun_ioctl.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#include "tun_ioctl.h"

#if __linux__

#include <fcntl.h>
#include <linux/if.h>
#include <linux/if_tun.h>
#include <linux/sockios.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>

// Deliberately does not include <net/if.h>: it redefines `struct ifreq`/`IFF_*` and conflicts
// with <linux/if.h> if both are pulled in. <sys/socket.h> (needed for `socket()`) doesn't define
// `struct ifreq` itself, so this combination is conflict-free.

int xtl_tun_create(char *name_out) {
int fd = open("/dev/net/tun", O_RDWR);
if (fd < 0) return -1;

struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TUN | IFF_NO_PI;
if (ioctl(fd, TUNSETIFF, &ifr) < 0) {
int saved_errno = errno;
close(fd);
errno = saved_errno;
return -1;
}
memcpy(name_out, ifr.ifr_name, IFNAMSIZ);
return fd;
}

// Mirrors the kernel's `struct in6_ifreq` (from <linux/ipv6.h>, which can't be included directly
// alongside <linux/if.h> without further conflicts) -- just the fields actually needed here.
struct xtl_in6_ifreq {
unsigned char addr[16];
unsigned int prefixlen;
int ifindex;
};

int xtl_tun_configure(const char *name, const unsigned char *addr6, unsigned int prefix_len, unsigned int mtu, int *stage_out) {
int sock = socket(AF_INET6, SOCK_DGRAM, 0);
if (sock < 0) { *stage_out = 0; return -1; }

struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, name, IFNAMSIZ - 1);
if (ioctl(sock, SIOCGIFINDEX, &ifr) < 0) {
int saved_errno = errno;
*stage_out = 1;
close(sock);
errno = saved_errno;
return -1;
}
int ifindex = ifr.ifr_ifindex;

struct xtl_in6_ifreq ifr6;
memcpy(ifr6.addr, addr6, 16);
ifr6.prefixlen = prefix_len;
ifr6.ifindex = ifindex;
if (ioctl(sock, SIOCSIFADDR, &ifr6) < 0) {
int saved_errno = errno;
*stage_out = 2;
close(sock);
errno = saved_errno;
return -1;
}

memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, name, IFNAMSIZ - 1);
ifr.ifr_mtu = (int)mtu;
if (ioctl(sock, SIOCSIFMTU, &ifr) < 0) {
int saved_errno = errno;
*stage_out = 3;
close(sock);
errno = saved_errno;
return -1;
}

memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, name, IFNAMSIZ - 1);
if (ioctl(sock, SIOCGIFFLAGS, &ifr) < 0) {
int saved_errno = errno;
*stage_out = 4;
close(sock);
errno = saved_errno;
return -1;
}
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
if (ioctl(sock, SIOCSIFFLAGS, &ifr) < 0) {
int saved_errno = errno;
*stage_out = 5;
close(sock);
errno = saved_errno;
return -1;
}

close(sock);
return 0;
}

#else

int xtl_tun_create(char *name_out) { (void)name_out; return -1; }
int xtl_tun_configure(const char *name, const unsigned char *addr6, unsigned int prefix_len, unsigned int mtu, int *stage_out) {
(void)name; (void)addr6; (void)prefix_len; (void)mtu;
if (stage_out) *stage_out = -1;
return -1;
}

#endif
Loading