src-ios/Subtrees/<Name>/<Name>Feature and the replay runner at src-ios/Libraries/Replay, the layout this page builds. The trees this tutorial opens are written by hand with the open-source duet command-line tool (0.34.0 or later) and the published Duet packages, so you can follow every step without Modaal.What will you build?
A splash screen has one job: play an animation, then hand control to the host. The splash you write here does that with a safety net. When the splash appears it arms a timer; when the animation ends, or when the timer fires, the feature tells its host that the splash completed and by which path. Both paths notify the host every time they fire, because the host treats a repeat as a no-op, and a second.appeared is inert so the timer is armed once per mount. That is the whole behavior, and it fits in one state field, three actions and two effect payloads.
You will have at the end:
src-ios/Subtrees/Splash/SplashFeature, a Swift package with the feature’s types, its reducer, its generated coders, its environment protocol and its effect handler.- Four recordings under
parity/fixtures/:splash.ceremony-completes,splash.safety-net-fires,splash.both-paths-notify-twiceandsplash.repeat-appear-inert. SplashEnvironmentMock, a test double thattools/duet mocksgenerates from the environment protocol, and a test-clock suite that uses it.- A green
tools/duet verify: the scenario replays the four recordings against the reducer, and the safety net’s timing holds on a virtual clock. src-ios/Libraries/Replay, a replay runner theduettool drives over a protocol.
Where do you start?
Clone the duet-tutorials repository and opentutorial1-swift-start. It is a Swift project with no feature yet: an empty parity manifest, empty parity/feature-specs/, parity/fixtures/ and src-ios/Subtrees/ directories, and tools/duet, which fetches the duet command-line tool at version 0.34.0 on first use. Two more versions are pinned as you go: the package you write in step 1 depends on the Duet framework at 0.7.0, and the generator row you add in step 8 pins swift-sourcery-templates 0.10.2, the bundle that generates the test double. You need Xcode 26.6 or later on macOS; this tutorial needs no JDK and no Android SDK. Every path on this page is relative to tutorial1-swift-start. Confirm the tree is green before you edit anything:
duet verify: PASS. The finished tree is tutorial1-swift-complete in the same repository; every code block on this page that names a path is an excerpt of it. Tutorial 1 is the Kotlin version of this tutorial: it builds the same feature in Kotlin Multiplatform from tutorial1-start, and step 7 compares its recordings with the ones you write here.
The steps
Declare the feature package and its manifest row
src-ios/Subtrees/<Feature>/<Feature>Feature. Its library target holds the feature’s types, its reducer and its runtime; its test target holds the scenario and the store tests. The package depends on the Duet framework and nothing else: the library uses the Duet product, and the tests add DuetTesting. Create src-ios/Subtrees/Splash/SplashFeature/Package.swift. It starts with // swift-tools-version:5.9 and import PackageDescription, and defines one setting that both targets use, strictConcurrency, which is [.enableExperimentalFeature("StrictConcurrency")]: complete concurrency checking under the Swift 5 language mode. The package declaration follows:tools/duet verify run the tests as plain swift test on your Mac, with no simulator. Create the two directories the targets name: Sources/SplashFeature, holding a SplashFeature.swift that contains only import Duet for now, and an empty Tests/SplashFeatureTests. Then build the package:Build complete!:parity/manifest.yaml is what the duet tool reads: which file holds the feature, which types are its state, action and effect payload, which test is its scenario, and which recordings it owns. Replace the empty features: key with the feature’s row, and delete the comment lines above it that describe the empty key:swift: and no kotlin: runs the Swift lane only, rooted at the package that contains the swift: file. Run tools/duet lint. It fails, and the failure names what the next steps write, the scenario file and the four recordings:Write the state, the actions and the effect payloads
path says which of the two ways completed the splash. The state is a Codable struct; the actions, the delegate events and the effect payloads are enums that conform to CanonicalSumCodable, because the recordings are JSON and every sum type on the reducer’s boundary needs one fixed coding. Write them in SplashFeature.swift, below the import:splash.safetyNet gives the timer cancel-in-flight semantics: the kernel’s Store cancels a running effect when a new one starts under the same id, and cancels every running effect at teardown. The payload case is named notifyListener because Duet’s chain runner, which Tutorial 3 uses, decodes a delegate event from the notifyListener payload at each hop between features; the environment method that carries it out, in step 4, is notifyHost.CanonicalSumCodable comes with no coding of its own. Until the coders exist, swift build fails on each enum with type 'SplashAction' does not conform to protocol 'CanonicalSumCodable'. Generate them:swift: path and writes one extension per enum. Swift’s synthesized Codable would write .armSafetyNet(afterMillis: 3000) as {"armSafetyNet":{"afterMillis":3000}}; the recordings need {"case":"armSafetyNet","value":{"afterMillis":3000}}, an object with a case and, when the case carries data, a value, which is also the shape the Kotlin core writes. This is the effect payload’s encoder:value; a case whose single payload is unlabeled, such as .notifyListener(SplashDelegateEvent), encodes the payload bare. The file is a committed build product, and its header says how to regenerate it: tools/duet record regenerates the coders as part of each recording run, and tools/duet canonical-sum runs the regeneration alone. Build the package again; the last line starts with Build complete!.Write the reducer
inout and an action, writes the new state, and returns the effects. It never sleeps, never reads a clock and never calls the host; it only decides. The three cases below are the whole behavior: .appeared arms the net once, and each completion path asks for a notification. Add it at the end of SplashFeature.swift:.ceremonyFinished neither cancels the net nor sets a “completed” flag. If the timer fires after the animation ended, the host is notified a second time and ignores the repeat. The rule lives in one place, the host, so the reducer stays three cases long, and the recordings in step 6 pin the second notification so that a later edit cannot add a latch without failing a check. Build the package again; the last line starts with Build complete!.Add the environment and the runtime
Task.sleep, so a test can run the wait on virtual time. Create SplashEnvironment.swift beside SplashFeature.swift; like every source file in the package, it imports Duet:AsyncStream of the actions the effect produces: .armSafetyNet sleeps on the environment’s clock and then yields .safetyNetElapsed; .notifyListener calls the environment and yields nothing. When the store cancels the effect, the stream’s termination cancels the task, the sleep throws, and the stream finishes without yielding. Create SplashRuntime.swift with the handler:nanosPerMilli and makeSplashStore(environment:), a @MainActor function that wires splashReducer and the handler into a Store with SplashState() as its initial state; it is the factory a shell calls to build its store. Build the package again with (cd src-ios/Subtrees/Splash/SplashFeature && swift build); the last line starts with Build complete!.Write the feature spec
parity/feature-specs/splash.md is the one-page description of the feature in prose: identity and config, state, actions, transitions, effects, delegate events, what stays app-side, and the recordings. tools/duet verify checks that every recording listed in the manifest is named in that file in backticks: a spec that leaves out splash.repeat-appear-inert fails verify’s meta-checks with manifest fixture 'splash.repeat-appear-inert' never mentioned, and no lane runs. Copy the finished tree’s file, or write your own from its headings; its last section is the table below.tools/duet lint. It prints the same five failures as in step 1: the scenario file and the four recordings are still missing, and step 6 writes all five.Describe the behavior as a scenario and record it
Given state, When steps, and Then and ThenEffects checks. Where two endings are mutually exclusive, they are Branches over the same given, and each branch leaf becomes one recording. The splash has one given, the appeared splash, and four branches. Create SplashScenarioTests.swift in Tests/SplashFeatureTests. It imports Duet, DuetTesting, SplashFeature and XCTest, and declares the scenario:notified(_:), which returns the one .notifyListener(.completed(path:)) effect the completion branches expect, and one XCTest method, testSplashScenario, whose body is try ScenarioRunner.verifyOrRecord(splashScenario, reducer: splashReducer). Run as an ordinary test, verifyOrRecord checks the committed recordings; run by the duet tool with regeneration on, it writes them. Record it:git status parity/fixtures lists the four new files. Open splash.repeat-appear-inert.fixture.json. Its scenario.source names SplashScenarioTests.swift, the file that wrote it, and each step holds the action, the expected state and the expected effects as canonical JSON, plus the label and the scenario line it came from. This is its second step, the repeat appeared:Check the recordings
verify runs the meta-checks, then the Swift lane, which is swift test in src-ios/Subtrees/Splash/SplashFeature; it reports the Kotlin lane as skipped and prints duet verify: 4/4 fixture report(s) passed, and its last line starts with duet verify: PASS. In the lane, the scenario test runs in verify mode: it drives each branch through the reducer and compares each step’s bytes with the committed recording. record --check checks in the other direction, re-recording from the scenario and comparing the result with the files on disk, and prints duet record --check: fixtures are up to date with their scenarios. CI runs it to catch a scenario edited without re-recording.Now compare your recordings with Tutorial 1’s. The tree tutorial1-complete sits beside tutorial1-swift-start in your clone, and its four recordings were written by the Kotlin scenario. Stage your recordings so that git can show what the copy changes, then copy the Kotlin recordings over them:git diff --stat reports 4 files changed, 13 insertions(+), 13 deletions(-). Read the changes with git diff parity/fixtures: the changed lines are scenario.source, which names the Kotlin scenario, and each step’s line, which is that step’s line in the Kotlin file. The actions, the expected states and the expected effects are unchanged. Run the regeneration check again:git diff parity/fixtures now prints nothing. The Swift reducer and scenario record the same behavior as Tutorial 1’s Kotlin core.Generate the environment's test double
sourcery: CreateMock gets a <Name>Mock class, which tools/duet mocks writes from a generator row in the manifest. Add the marker to SplashEnvironment.swift, as the last line of the doc comment above the protocol:bundle pins the swift-sourcery-templates release that carries the generator engine and its templates; sources is the directory the generator reads; output is the generated file, in the test target, so no production target links the double; args are the modules the generated file imports..build/ at the tree’s root, and the output ends with SplashFeatureMocks.swift — 1 types, 57 lines. Then run the check CI runs:sources and of the generated body, and it does not run the generator. After a change to SplashEnvironment it reports Generated code is stale. and tells you to run tools/duet mocks. The generated SplashEnvironmentMock takes the clock in its initializer, init(clock:). It counts and records each notifyHost call, and a test can supply a handler for it:load() gives loadCallCount, loadArgs and loadHandler, and the read-only property clock gives clockGetCount, clockGetHandler and the stored _clock.Pin the safety net's timing on a test clock
TestClock, the clock from DuetTesting that moves only when the test advances it. SplashEnvironmentMock is the environment: it hands the handler the test clock and records every delegate event in notifyHostArgs.The suite has four tests: the net does not fire before its duration, fires after it, fires exactly once, and never fires after teardown. Every test starts from the same given, a test store that has received .appeared. Create SplashTestStoreTests.swift in Tests/SplashFeatureTests, with the same imports as the scenario file. The tests are methods of a @MainActor final class SplashTestStoreTests: XCTestCase, which builds that given in a private armedStore(_:) helper; a file-level nanos(_:) converts milliseconds to the clock’s nanoseconds. The second and the fourth tests are below.TestStore is exhaustive: finish() fails if an action arrived that the test never received, or if an effect is still running. The “exactly once” test advances the clock twice across the deadline; a second .safetyNetElapsed would be an unreceived action, and finish() would fail on it. Run the package’s tests:Executed 5 tests, with 0 failures (0 unexpected): the scenario and the four store tests. Run tools/duet verify again; its last line starts with duet verify: PASS.Add the replay runner
duet tool sends each recorded step to it and compares the bytes that come back. It checks the same recordings from outside the test process. tools/duet verify does not run it; its output names the protocol lane under not covered by verify. The package, src-ios/Libraries/Replay, has two targets. ReplayAdapter is a library that holds the registry, one entry per feature:replay-runner is the executable that serves it:Package.swift declares the two targets with the feature package’s platforms and concurrency settings. It depends on the Duet package, for the Duet and DuetReplay products, and on the feature package by path:duet tool with the replayRunner: key, above features: in the manifest:protocol-run builds the replay-runner executable in the package replayRunner: names, starts it, and sends it every step of the four recordings. The output includes 4 leaf + 0 chain fixture(s), 9 step(s) byte-gated CLI-side, and the last line starts with protocol-run: PASS. Without the replayRunner: key, it fails with no manifest package root contains Sources/replay-runner.What you now have
- One Swift package,
src-ios/Subtrees/Splash/SplashFeature, with the behavior in four source files: the types and the reducer, the generated coders, the environment protocol and the runtime. - Four recordings under
parity/fixtures/, written by the Swift scenario, whose actions, states and effects match the recordings Tutorial 1’s Kotlin core writes. - A generated
SplashEnvironmentMock, whichtools/duet mocks --checkreports current. - Five passing tests in the package: the scenario and four test-clock checks.
- A green
tools/duet verify, a greentools/duet record --check, and a replay runner that passestools/duet protocol-run. - A manifest row and a feature spec that the tool cross-checks against the recordings. On the finished tree,
tools/duet lintprintslockstep-lint: OK (1 feature(s), 4 fixture(s), ledger: 0 waiver(s) / 0 island(s), mocks: 1 generator(s)).
Exercise: break the arming guard
The recordingsplash.repeat-appear-inert pins that a second .appeared does nothing. To see it fail, remove the arming guard: in SplashFeature.swift, delete the line guard !state.isArmed else { return [] }, so that every .appeared arms a net. Run tools/duet verify. The Swift lane fails on one recording, and the report names the step, the expected effects, what the reducer emitted instead, and the scenario line whose check failed:
duet verify: FAIL. Run tools/duet protocol-run with the guard still deleted: the replay runner fails the same recording with ✗ [swift] splash.repeat-appear-inert#1: effects diverged, and the last line starts with protocol-run: FAIL. Restore the guard and run tools/duet verify again; its last line starts with duet verify: PASS.
Common questions
How does this tutorial differ from Tutorial 1?
How does this tutorial differ from Tutorial 1?
scenario.source and each step’s line. The differences are in each language’s tooling. This tutorial writes a Swift package where Tutorial 1 writes a Gradle module; tools/duet canonical-sum generates the coders where Tutorial 1 registers serializers by hand; tools/duet mocks generates the environment double where Tutorial 1 writes RecordingSplashEnvironment; the scenario test replays the recordings, so there is no separate golden test; and protocol-run builds the Swift replay runner itself, where Tutorial 1 builds the Kotlin runner with Gradle and passes it with --runner.Why is there no golden replay test?
Why is there no golden replay test?
ScenarioRunner.verifyOrRecord writes the recordings when tools/duet record runs it with regeneration on; in every other run it drives each branch through the reducer and compares each step’s bytes with the committed recording, and those comparisons are the 4/4 fixture report(s) that verify prints. A golden test replays recordings on a platform whose scenario does not write them: in Twins Tutorial 2, the Kotlin twin replays these four files in a golden test.Why does the feature package depend on the Duet framework only?
Why does the feature package depend on the Duet framework only?
tools/duet verify checks, before any lane runs, that each Swift lane root resolves only Duet family packages (duet and duet-services). A feature package that adds another dependency fails that check with host-lane check: FAIL, which names the package and the allowlist, and no lane runs. App code needs app libraries, so it goes in a sibling package beside SplashFeature: Twins Tutorial 2 adds SplashNode for the splash’s shell and view.Why is the test double generated for a protocol with two members?
Why is the test double generated for a protocol with two members?
mocks: row states it. The generator row keeps the double in step with the protocol: tools/duet mocks --check compares the hash of each input file with the one recorded in the generated file, so a change to SplashEnvironment without a regeneration fails the check with Generated code is stale. The Kotlin series writes its first Kotlin double by hand in Tutorial 1 and generates its Kotlin doubles from Tutorial 3.Where does the app come from?
Where does the app come from?
tutorial1-swift-complete, adds a Kotlin twin of the splash under src-android/ that replays the same four recordings, and mounts the feature in a SwiftUI app under src-ios/App and a Compose app under src-android/app. The iPhone app links Swift packages only.Sources and further reading
- The Duet framework repository — the Swift
Store,Effect,KernelClockandCanonicalSumCodabletypes inDuet;Scenario,ScenarioRunner,TestStoreandTestClockinDuetTesting; the replay server inDuetReplay; and the contracts for the store kernel, serialization and the replay protocol. - The duet-tools repository — the
duetcommand-line tool and the manifest grammar. - The swift-sourcery-templates repository — the bundle
tools/duet mocksdownloads: the generator engine, theMocks.swifttemplatetemplate and the fingerprint check. - Sourcery — the Swift code generator the bundle runs.
- The duet-tutorials repository —
tutorial1-swift-startandtutorial1-swift-complete, andscripts/run-tree.sh, the per-tree checks CI runs on them. - SE-0295: Codable synthesis for enums with associated values — the synthesized enum coding that the generated coders replace with the canonical
caseandvalueshape. - The Duet glossary — feature, reducer, scenario, behavior recording, metadata-only and the checks.