Platforms
Mac Catalyst
Troupe runs a Mac Catalyst build of your app as a phone-sized window on your Mac and drives it from inside the app's own process. It needs no simulator, and it does less than a simulator can.
The platform's id is catalyst.
Requirements
- macOS on Apple silicon, with the user who runs Troupe logged in to the desktop: the app opens a window. macOS 27.0 is verified.
- Bun 1.4.3 or newer.
- Xcode with the Mac Catalyst SDK, only to build the app. Running a built app needs no Xcode.
- No Accessibility or Screen Recording permission.
- Memory: Troupe budgets 500 MB for a session.
Build an app for this platform
The app is a Mac Catalyst build that links and embeds TroupeCatalystHost.framework. The framework's Xcode
project, TroupeCatalystHost.xcodeproj, is in Troupe's repository under native/catalyst-host. It is not
part of the npm package: your app's Xcode project references it.
With XcodeGen:
projectReferences:
TroupeCatalystHost:
path: ../troupe/native/catalyst-host/TroupeCatalystHost.xcodeproj
targets:
MyAppCatalyst:
type: application
platform: iOS
dependencies:
- target: TroupeCatalystHost/TroupeCatalystHost
settings:
base:
SUPPORTS_MACCATALYST: YESThe host starts itself when the framework loads. It does nothing unless Troupe launched the app, so the same build runs as usual when you open it yourself.
The app must adopt the UIScene life cycle. A SwiftUI App does.
Troupe checks the bundle before it runs anything and refuses with APP_INSTALL_FAILED:
| Reason | The bundle |
|---|---|
wrong-platform | Is an iOS Simulator build, or a Mac app that is not Catalyst |
missing-scene-manifest | Has no scene manifest |
invalid-bundle | Does not embed Troupe's host |
Run tests
Name the platform, a preset and the built app in troupe.config.ts:
import { defineConfig } from '@alivelabs/troupe'
export default defineConfig({
use: { platform: 'catalyst', deviceType: 'iphone-17', app: './build/MyApp.app' },
})Then check the Mac once and run:
bunx troupe install catalyst
bunx troupe testtroupe install catalyst builds nothing. It checks that the Mac is on Apple silicon and that a desktop
session is logged in.
How it runs
A Catalyst "device" is a preset, which gives the window's size and the screenshot scale, plus a directory of
Troupe's own under $TROUPE_HOME/catalyst/devices/. The directory holds the installed copy of the app and
the home directory the app runs with, so app data stays on the device and is deleted with it.
At launch the runtime starts the app's executable as its own child process. The host inside the app listens on a Unix socket, and the runtime sends it queries and actions. One app runs per device: a launch ends the one running. If the runtime stops, the app exits by itself.
Capabilities
Catalyst input is semantic. A tap() activates the element, as assistive technology does, instead of
touching a point on the screen.
| Area | Mac Catalyst |
|---|---|
| Locators | Role, text, label, placeholder and test id, with regular expressions. No system UI |
| Test id | accessibilityIdentifier |
| Input | tap(), fill(), type() and scrolling |
| Keyboard | Text. There are no key events |
| Screenshots | Yes |
| Logs | App log and crash reports |
| OS | The Mac's own macOS. It cannot be chosen |
Limits
The runtime refuses each of these with CAPABILITY_UNSUPPORTED:
- Touch: a tap on a point, double tap, long press, swipe, drag, pinch and gestures.
- Key presses, hardware buttons, rotation and hiding the keyboard.
- Launch arguments and launch environment variables, deep links, clearing an app's data and permissions.
- Location, appearance and the clipboard.
- The live stream and screen recording. Troupe Studio shows no live picture for a Catalyst session; the app's window is on your desktop.
The test framework checks a project's options against these before any test runs. A project that sets
launchArgs, launchEnv, permissions or appIsolation: 'clear' on Catalyst is refused, with the option
and the missing capability in the message.
A suite that also runs on simulators can skip what Catalyst lacks with test.requires:
test.describe('gestures', () => {
test.requires('input.touch')
test('a long press is recognised', async ({ screen }) => {
await screen.getByTestId('gesture-area').longPress()
await expect(screen.getByTestId('last-gesture')).toHaveText('Last gesture: long press')
})
})Two more things differ from a simulator:
- A SwiftUI view backed by a UIKit view (
TextField,SecureField,List) has no test id in the tree. Find it by placeholder or role. SwiftUI's own views, such asButton,TextandToggle, keep theirs. - Traces default to
'on-first-retry'instead of'retain-on-failure', because a screenshot after every action costs more here. Settraceyourself to change it. See Traces.
Presets and OS
deviceType names a preset by id or by name:
deviceType | Name | Screen |
|---|---|---|
iphone-17 | iPhone 17 (the default) | 402 × 874 points at 3× |
iphone-17-pro-max | iPhone 17 Pro Max | 440 × 956 points at 3× |
iphone-se | iPhone SE | 375 × 667 points at 2× |
ipad-pro-11 | iPad Pro 11-inch | 834 × 1210 points at 2× |
Leave os out. The only value a spec may name is the Mac's macOS major version; anything else is
DEVICE_SPEC_INVALID.
Host settings
platforms.catalyst in $TROUPE_HOME/host.json takes these keys:
| Key | Default | What it does |
|---|---|---|
enabled | true | false switches the platform off: a request for it fails with ENVIRONMENT_PLATFORM_DISABLED |
maxDevices | 2 | The most Catalyst devices running at once |
defaultDeviceType | iphone-17 | The preset a spec gets when it names none |
Next
- iOS Simulator: the full set of gestures, system settings and the live stream
- Fixtures and options:
test.requiresand the options a platform must support - Sessions