A modern Swift hardware-test framework inspired by OpenHTF. Build manufacturing / bring-up / bench test plans declaratively, run them as actor-isolated sessions, observe events as AsyncStream, and emit JSON / CSV records. Ships with SwiftHTFUI for drop-in SwiftUI integration.
- Declarative test plans — compose
Phases with a@resultBuilderDSL (if/for/ availability branches supported). - Composable flow nodes —
Group(nested scope withsetup/teardown, localcontinueOnFail),Subtest(isolated-failure unit,SubtestRecorddoesn't propagate toTestRecord.outcome),Checkpoint(scope merge point that scans local phase outcomes). - Startup phase (OpenHTF
test_startequivalent) — runs after plugsetUp()and beforesetupNodes. Typical use: scan barcode → writectx.serialNumber. AserialNumberResolvedevent fires so SwiftUI refreshes beforetestCompleted. - Declarative measurements + three-state outcome — pre-declare
MeasurementSpecwith chainable validators (inRange/equals/withinPercent/marginalRange/oneOf/ …); aggregation precedencefail > marginal > pass; per-phaseoutcome ∈ {pass, marginalPass, fail, error, skip, timeout}. - Multi-dimensional
SeriesMeasurement—ctx.recordSeries("iv") { rec in ... }for IV / sweep / temperature curves;each/lengthAtLeast/customvalidators; pairs with the optionalSwiftHTFChartsproduct for SwiftUI line charts. - PhaseOptions (OpenHTF parity) —
timeout/retryCount/repeatOnMeasurementFail/.stopOnMeasurementFail()/.forceRepeat(N)/.repeatOnTimeout(false). All seven runtime knobs are snapshotted intoPhaseRecord.options. - Measurement transform chain + precision + units —
.transform { ... }composes left-to-right (g(f(raw)), OpenHTFwith_transform);.precision(N)rounds with banker's rounding before validators run;.units(.volt)does dimension-aware unit checks via a built-inUnit/UnitRegistry. - Pluggable hardware (
Plug) + mock injection — register withinit()or factory, topological dependency sort,setup(resolver:)injects ready dependencies.executor.swap(RealPSU.self, with: MockPSU.self)swaps in mocks without touching phase code. - Operator interaction (
PromptPlug) + SwiftUI —await prompt.requestConfirm(..., timeout: 30) / requestText / requestChoicesuspends a phase;SwiftHTFUIshipsTestRunnerViewModel/PromptCoordinator/PromptSheetViewwith auto-dismiss on cancel / timeout. - Multi-DUT concurrency —
TestExecutorspawns multipleTestSessions; each owns independent plug instances and a per-session event stream. Strictactor+StrictConcurrency; phase code runs@MainActor. - Diagnostics suite —
PhaseDiagnoser/TestDiagnoser(Diagnosiswith severity / fault code / details) + diagnosis-driven flow:DiagnosesStore(ctx.diagnoses.has(code:)) +DiagnosisCheckpoint(action: .fail|.stop|.skipRest)(short-circuit on prior diagnosis) +BranchSequence("router", branches: [.when(.hasCode("X")) { ... }], default: { ... })(first-match branching) +DiagnoserTrigger.onlyIfDiagnosis(codes:)(OpenHTFvalidate_on=[...]). - Output sinks + history + attachments — built-in
ConsoleOutput/JSONOutput/CSVOutput/HistoryOutputCallback;InMemoryHistoryStore/JSONFileHistoryStorequery by SN / planName / outcome / time window.ctx.attach(...)(inline) andctx.attachFromFile(url)(defaults to external reference — no in-memory copy;JSONOutput.inlineAttachments = falsewrites path + size + SHA-256 instead of base64). - Event stream + Codable records —
AsyncStream<TestEvent>(testStarted/serialNumberResolved/phaseCompleted/log/testCompleted);TestRecord/PhaseRecord/Measurement/SeriesMeasurement/Diagnosis/LogEntryall round-trip JSON.
- Swift 5.9+
- macOS 12+
Add SwiftHTF to your Package.swift:
dependencies: [
.package(url: "https://github.com/LumenMarch/SwiftHTF.git", from: "0.3.0")
],
targets: [
.target(
name: "YourTarget",
dependencies: [
"SwiftHTF",
// For SwiftUI integration:
.product(name: "SwiftHTFUI", package: "SwiftHTF")
]
)
]import SwiftHTF
actor PowerSupply: PlugProtocol {
private var voltage: Double = 0
init() {}
func setOutput(_ v: Double) async { voltage = v }
func readVoltage() async -> Double { voltage + Double.random(in: -0.05...0.05) }
func setup() async throws {}
func tearDown() async { voltage = 0 }
}
@MainActor
func makePlan(config: TestConfig) -> TestPlan {
let vccLower = config.double("vcc.lower") ?? 3.0
let vccUpper = config.double("vcc.upper") ?? 3.6
return TestPlan(name: "DemoBoard") {
// Operator confirmation
Phase(name: "OperatorReady") { @MainActor ctx in
let prompt = ctx.getPlug(PromptPlug.self)
return await prompt.requestConfirm("Fixture in place?") ? .continue : .stop
}
// Nested Group + declarative measurement + diagnoser + per-phase log
Group("PowerRail") {
Phase(name: "PowerOn") { @MainActor ctx in
ctx.logInfo("Powering on at 3.3V")
await ctx.getPlug(PowerSupply.self).setOutput(3.3)
return .continue
}
Phase(
name: "VccCheck",
measurements: [
.named("vcc", unit: "V")
.inRange(vccLower, vccUpper) // hard limits
.marginalRange(3.2, 3.4) // warning band
.withinPercent(of: 3.3, percent: 10)
],
diagnosers: [
ClosureDiagnoser("vcc-overshoot") { @MainActor record, ctx in
guard let v = record.measurements["vcc"]?.value.asDouble,
v > vccUpper else { return [] }
ctx.attach("trace.log", data: Data("v=\(v)".utf8), mimeType: "text/plain")
return [Diagnosis(code: "VCC_OVERSHOOT", message: "vcc=\(v)")]
}
]
) { @MainActor ctx in
let v = await ctx.getPlug(PowerSupply.self).readVoltage()
ctx.measure("vcc", v, unit: "V")
return .continue
}
}
}
}
@MainActor
func run() async {
let cfg = TestConfig(values: [
"vcc.lower": .double(3.0), "vcc.upper": .double(3.6)
])
let executor = TestExecutor(
plan: makePlan(config: cfg),
config: cfg,
outputCallbacks: [ConsoleOutput()]
)
await executor.register(PowerSupply.self)
await executor.register(PromptPlug.self)
let record = await executor.execute(serialNumber: "SN-0001")
print("Outcome: \(record.outcome.rawValue)")
}A TestPlan is a tree of PhaseNodes — leaves are Phase, branches are Group. @TestPlanBuilder lets you mix Phase / Group / loops / conditionals naturally:
TestPlan(name: "Smoke") {
Phase(name: "Connect") { _ in .continue }
Group("RFTests", continueOnFail: true) {
for band in [.low, .mid, .high] {
Phase(name: "RF_\(band)") { _ in .continue }
}
} setup: {
Phase(name: "Cal") { _ in .continue }
} teardown: {
Phase(name: "RF_Off") { _ in .continue }
}
if config.includeBootTest {
Phase(name: "Boot") { _ in .continue }
}
}Execution semantics:
- A node sequence runs in order; on failure the local
continueOnFaildecides whether siblings continue. Groupruns strictly:setup→children→teardown.teardownalways runs, even after a setup failure.PhaseRecord.groupPathrecords the ancestor chain so UI can render hierarchy.
Each phase closure returns a PhaseResult:
| Result | Meaning |
|---|---|
.continue |
Pass; run the next phase |
.failAndContinue |
Mark phase fail; honor continueOnFail |
.retry |
Run the same phase again (up to retryCount) |
.skip |
Skip without running |
.stop |
Abort the whole test |
.failSubtest |
Mark phase fail and short-circuit enclosing Subtest (equivalent to .failAndContinue when not in a Subtest) |
Plans often need to gate the whole run on something — scan a barcode to learn the DUT's serial number, confirm a fixture is in place, or refuse to proceed if a license check fails. Put that logic in TestPlan.startup:
TestPlan(
name: "DemoBoard",
startup: Phase(name: "ScanSN") { @MainActor ctx in
let prompt = ctx.getPlug(PromptPlug.self)
guard let sn = await prompt.requestText("Scan DUT SN", timeout: 60)
else { return .stop } // operator cancelled
ctx.serialNumber = sn // back-fill record.serialNumber
return .continue
}
) {
Phase(name: "PowerOn") { _ in .continue }
Group("RFTests") { ... }
} teardown: [
Phase(name: "PowerOff") { _ in .continue }
]Lifecycle position: plug setUp() → startup → setupNodes → nodes → teardownNodes → plug tearDown().
Outcome mapping (PhaseRecord vs TestRecord):
Startup PhaseResult |
PhaseRecord.outcome |
TestRecord.outcome |
Main body runs? | Teardown runs? |
|---|---|---|---|---|
.continue |
.pass |
(unchanged) | yes | yes |
.stop |
.pass* |
.aborted |
no | yes |
.failAndContinue |
.fail |
.fail |
no | yes |
| thrown (non-whitelist) | .error |
.fail |
no | yes |
| timed out | .timeout |
.timeout |
no | yes |
runIf returns false |
(no record written) | (unchanged) | yes | yes |
* .stop is a control-flow signal, not a failure — the PhaseRecord keeps its computed outcome (typically .pass) and stopRequested = true triggers the .aborted mapping.
Other notes:
- Startup
PhaseRecordis appended torecord.phaseswithgroupPath = TestSession.startupGroupPath(["__startup__"]) so UI / sinks can tell startup apart from business phases. - Plug
tearDown()always runs (regardless of startup outcome). - A
TestEvent.serialNumberResolved(ctx.serialNumber)is broadcast immediately after startup completes (unless skipped byrunIf).SwiftHTFUI.TestRunnerViewModelalready wires this so the title refreshes the moment the operator finishes scanning, well beforetestCompleted. - Startup inherits the full
Phasefeature set:timeout,retryCount,measurements,series,diagnosers,failureExceptions,runIf,repeatOnMeasurementFail.
A Subtest is a sibling node to Phase / Group that isolates failure: any inner phase / group failure short-circuits the remaining nodes but does not propagate to TestRecord.outcome. Subtest results are emitted as SubtestRecord entries on TestRecord.subtests, with phaseIDs cross-referencing TestRecord.phases.
TestPlan(name: "Board") {
Phase(name: "Connect") { _ in .continue }
Subtest("PowerTests") {
Phase(name: "VccCheck") { _ in .continue }
Phase(name: "VddCheck") { _ in .failAndContinue } // short-circuits this Subtest
Phase(name: "VbatCheck") { _ in .continue } // not run
}
Phase(name: "Cleanup") { _ in .continue } // still runs — Subtest failure is isolated
}Semantics:
- Phase
.fail/.error/.failSubtest, or nestedGroupfailure → short-circuit remaining nodes in the Subtest. - Subtest failure does not set
TestRecord.outcome = .fail. The outer test continues; inspectrecord.subteststo aggregate. - Nested
Subtestfailures do not propagate to the enclosing Subtest either — each Subtest is its own isolation boundary. .stopstill propagates across Subtest boundaries to abort the whole test.SubtestacceptsrunIf; false →SubtestRecord.outcome = .skipand zero phases recorded.
SubtestRecord:
| Field | Meaning |
|---|---|
id |
Stable UUID across encode / decode |
name |
As declared |
outcome |
.pass / .fail / .error / .skip |
phaseIDs |
PhaseRecord.ids of phases run inside this Subtest, in order |
failureReason |
Which inner node triggered the short-circuit ("VddCheck: FAIL") |
startTime / endTime / duration |
Subtest-level timing |
Pre-declare a MeasurementSpec on the phase; harvest runs validators against ctx.measure(...) writes:
Phase(
name: "VccCheck",
measurements: [
.named("vcc", unit: "V", description: "Main rail")
.inRange(3.0, 3.6)
.marginalRange(3.1, 3.5) // outside [3.1, 3.5] → marginalPass
.withinPercent(of: 3.3, percent: 5)
]
) { @MainActor ctx in
ctx.measure("vcc", 3.07, unit: "V")
return .continue
}Aggregation precedence: fail > marginal > pass.
- Any measurement
fail→ phase.fail, record.fail. - Otherwise any marginal → phase
.marginalPass; if every phase passes and at least one is marginal → record.marginalPass. Measurement.outcome/validatorMessageswrite back toPhaseRecord.measurements[name]for output / UI to colour.
Undeclared measurements may still be written (treated as auxiliary; no aggregation effect).
Declare the trace's dimensions then incrementally append samples in the phase; harvest runs all series validators:
Phase(
name: "VRampSweep",
series: [
.named("v_ramp")
.dimension("V_set", unit: "V")
.value("V_meas", unit: "V")
.lengthAtLeast(5)
.each { sample in // closure runs per row
guard let want = sample[0].asDouble,
let got = sample[1].asDouble else { return .pass }
let err = abs(got - want)
if err > 0.2 { return .fail("err=\(err)V") }
if err > 0.1 { return .marginal("err=\(err)V") }
return .pass
}
]
) { @MainActor ctx in
let psu = ctx.getPlug(PowerSupply.self)
await ctx.recordSeries("v_ramp") { rec in
for v in stride(from: 0.0, through: 3.3, by: 0.5) {
await psu.setOutput(v)
rec.append(v, await psu.readVoltage())
}
}
return .continue
}SeriesMeasurement lives alongside single-point Measurement in PhaseRecord.traces: [String: SeriesMeasurement]; series outcomes feed the same phase aggregation, and repeatOnMeasurementFail triggers on series failure too.
Phase(
name: "VccCheck",
timeout: 5, // seconds
retryCount: 2, // retries on exception / explicit .retry
measurements: [.named("vcc").inRange(3.0, 3.6)],
series: [.named("v_ramp").dimension("V").value("I").lengthAtLeast(5)],
runIf: { @MainActor ctx in // runtime gate — false → outcome=.skip
ctx.config.bool("vcc.enabled") ?? true
},
repeatOnMeasurementFail: 3, // re-read on measurement / series failure
diagnosers: [ // run at terminal .fail / .error
ClosureDiagnoser("trace") { record, ctx in [...] }
],
failureExceptions: [DUTRefusedToBoot.self] // whitelisted → .fail; others → .error
) { ... }runIf also works on Group — when false, a synthetic outcome=.skip PhaseRecord is written and setup / children / teardown are entirely skipped.
Phase(name: "Diag") { @MainActor ctx in
ctx.attach("trace.log", data: Data("...".utf8), mimeType: "text/plain")
try ctx.attachFromFile(URL(fileURLWithPath: "/tmp/scope.png")) // mime inferred
return .continue
}PhaseRecord.attachments: [Attachment] is persisted; JSON output uses Data's default base64; Console shows 📎 name (mime, size); CSV gains an attachments_count column.
Inside a phase write logs via ctx.logXxx; entries are appended to PhaseRecord.logs in order and broadcast to the session event stream live:
Phase(name: "BringUp") { @MainActor ctx in
ctx.logInfo("Booting BSP")
do {
try await bsp.boot()
} catch {
ctx.logError("boot failed: \(error.localizedDescription)")
throw error
}
return .continue
}LogEntry { timestamp, level, message },LogLevelisdebug/info/warning/error- Each retry attempt resets the buffer; only the last attempt's logs survive in
record.logs - Logs written from a
PhaseDiagnoserare merged intorecord.logsas well
let cfg = try TestConfig.load(from: URL(fileURLWithPath: "config.json"))
let executor = TestExecutor(plan: plan, config: cfg)
// inside a phase:
let lower = ctx.config.double("vcc.lower") ?? 3.0
struct Limits: Decodable { let lower: Double; let upper: Double }
let lim = ctx.config.value("vcc", as: Limits.self)Internally [String: AnyCodableValue]; zero external dependencies; JSON top-level must be an object.
final class CorePlug: PlugProtocol { init() {} }
final class MidPlug: PlugProtocol {
init() {}
static var dependencies: [any PlugProtocol.Type] { [CorePlug.self] }
func setup(resolver: PlugResolver) async throws {
let core = await resolver.get(CorePlug.self)!
// core is already initialised
}
}PlugManager.setupAll topologically sorts plugs so dependencies set up before dependents. Cycles or missing dependencies throw PlugManagerError, which TestExecutor surfaces as record.outcome=.error.
Real plugs in production, mocks in CI — phase code stays the same:
class RealPSU: PlugProtocol {
required init() {}
func setOutput(_ v: Double) {}
func readVoltage() -> Double { /* real readout */ 3.3 }
func setup() async throws {}
func tearDown() async {}
}
final class MockPSU: RealPSU {
override func readVoltage() -> Double { 1.5 } // simulated
}
let executor = TestExecutor(plan: plan)
await executor.register(RealPSU.self)
await executor.swap(RealPSU.self, with: MockPSU.self) // swap for tests
// Phase code unchanged:
ctx.getPlug(RealPSU.self).readVoltage() // actually returns the MockPSU instanceAPI:
bind(Abstract.self, to: Concrete.self)— alias an abstract type to an already-registered concrete oneswap(A.self, with: B.self)—unregister(A) + register(B) + bind(A, to: B)in one callswap(_, with:, factory:)— supply a factory closure for the mock instance
Aliases also participate in dependency topological sort: a plug that declares dependencies = [Abstract.self] resolves to the concrete instance after the alias is in place.
Inside a phase, suspend until the operator answers (with optional per-call timeout):
Phase(name: "ScanSerial") { @MainActor ctx in
let prompt = ctx.getPlug(PromptPlug.self)
// Without timeout: wait forever
let sn = await prompt.requestText("Scan SN", placeholder: "SN-...")
ctx.serialNumber = sn
// With 30 s timeout: empty string on timeout (same as cancel)
let opOK = await prompt.requestConfirm("Fixture ready?", timeout: 30)
if !opOK { return .stop }
return .continue
}timeout: TimeInterval? = nil is available on all three high-level APIs. To distinguish operator cancel from timeout, use the lower-level request(kind:timeout:) -> PromptResponse:
let response = await prompt.request(kind: .confirm(message: "OK?"), timeout: 5)
switch response {
case .confirm(let b): ...
case .cancelled: ctx.logWarning("operator cancelled")
case .timedOut: ctx.logWarning("no response after 5 s")
case .text, .choice: break // shape mismatch
}On the UI side, SwiftHTFUI ships ready-made view models and a default sheet:
import SwiftUI
import SwiftHTF
import SwiftHTFUI
struct ContentView: View {
@StateObject private var runner: TestRunnerViewModel
@StateObject private var prompts = PromptCoordinator()
private let plug = PromptPlug()
init() {
let exec = TestExecutor(plan: makePlan())
self._runner = StateObject(wrappedValue: TestRunnerViewModel(executor: exec))
}
var body: some View {
VStack {
Button("Run") { runner.start() }
.disabled(runner.isRunning)
List(runner.phases) { phase in
Text("\(phase.name) → \(phase.outcome.rawValue)")
}
}
.task { await prompts.attach(to: plug) }
.sheet(item: $prompts.current) { req in
PromptSheetView(request: req) { resp in
prompts.resolve(req.id, response: resp)
}
}
}
}TestRunnerViewModel exposes phases / logLines / outcome / isRunning / record / serialNumber as @Published properties; it subscribes to session.events(), so multi-session mode never mixes streams.
TestExecutor is a container of plan / config / plug registrations and can spawn multiple concurrent TestSessions:
let executor = TestExecutor(plan: plan, config: cfg)
await executor.register(PowerSupply.self)
// Single DUT:
let record = await executor.execute(serialNumber: "SN-1")
// Multi-DUT in parallel:
async let s1 = executor.startSession(serialNumber: "DUT-1")
async let s2 = executor.startSession(serialNumber: "DUT-2")
let session1 = await s1
let session2 = await s2
async let r1 = session1.record()
async let r2 = session2.record()
let (rec1, rec2) = await (r1, r2)Each session owns its own plug instances (factories are reinvoked, independent setup / tearDown). executor.events() is the aggregated stream; subscribe to session.events() to discriminate per-DUT.
Persist records to disk and query past results across processes:
let store = try JSONFileHistoryStore(directory: URL(fileURLWithPath: "/var/log/htf"))
let executor = TestExecutor(
plan: plan,
outputCallbacks: [HistoryOutputCallback(store: store)] // auto-ingest each record
)
// later:
let recent = try await store.list(HistoryQuery(serialNumber: "SN-1", limit: 10))
let fails = try await store.list(HistoryQuery(outcomes: [.fail], since: Date().addingTimeInterval(-86400)))API:
save(_:)/load(id:)/list(_:)/delete(id:)/clear()HistoryQuery:serialNumber/planName/outcomes/since/until/limit/sortDescending- Built-in implementations:
InMemoryHistoryStore(actor, for tests) andJSONFileHistoryStore(actor, one JSON file per record,secondsSince1970encoding to preserve millisecond precision)
Factory continuous-test pattern: scan barcode → start a session → wait for completion → back to scan:
let loop = TestLoop(
executor: executor,
trigger: { await viewModel.waitForBarcode() }, // returns SN, nil to stop
onCompleted: { record in
try? await store.save(record)
}
)
await loop.start()
// ...
await loop.stop()states() exposes the state stream (idle / awaitingTrigger / running(sn) / stopped) with replay buffer to drive SwiftUI; completedCount reflects sessions completed.
for await event in await executor.events() {
switch event {
case .testStarted(let name, let sn): ...
case .phaseCompleted(let r): ...
case .log(let msg): ...
case .testCompleted(let r): ...
}
}session.events() carries a replay buffer — new subscribers receive every previously emitted event, so even if startSession already started the session you won't miss .testStarted.
Once any phase / test diagnoser emits a Diagnosis, it lands in the session-scoped
DiagnosesStore (ctx.diagnoses), making it queryable by subsequent phases.
Three primitives compose on top of it:
TestPlan(name: "DUT") {
Phase(name: "QuickScan", diagnosers: [FaultClassifier()]) { _ in .continue }
// Short-circuit: any LOW_VOLTAGE diagnosis aborts the whole test
DiagnosisCheckpoint("vcc-gate", code: "LOW_VOLTAGE", action: .stop)
// Fork: pick the first matching branch (else default)
BranchSequence("rework-routing", branches: [
.when(.hasCode("RF_FAIL")) {
Phase(name: "RFDeepDive") { _ in .continue }
},
.when(.hasCode("PSU_FAIL", minSeverity: .error)) {
Phase(name: "PowerRework") { _ in .continue }
},
], default: {
Phase(name: "FullSuite") { _ in .continue }
})
// Selective diagnoser: only runs if RF_FAIL is in the store
Phase(name: "RFPostMortem", diagnosers: [
ClosureDiagnoser("rf-pm",
trigger: .onlyIfDiagnosis(codes: ["RF_FAIL"])) { _, _ in
[Diagnosis(code: "RF_PM_DONE", message: "post-mortem complete")]
},
]) { _ in .continue }
}ctx.diagnoses.has(code:) / get(code:) / allfor queries inside phases.DiagnosisCheckpointactions:.fail(local short-circuit, honorscontinueOnFail),.stop(escalate toTestRecord.outcome = .aborted),.skipRest(skip remaining siblings in scope but still run teardown).BranchSequenceis first-match-wins; the unmatched markerPhaseRecordkeeps an.errorMessagetagged with which branch ran (matched branch[i]/default branch/no match (skipped)).- A phase's own diagnosis is appended to the store after its own diagnoser block
runs, so
.onlyIfDiagnosisalways sees prior phase output (matches OpenHTFvalidate_onsemantics — no self-loop).
Implement OutputCallback.save(record:) for arbitrary destinations. Built-ins:
ConsoleOutput— pretty-printed summary (with measurements, attachments, diagnoses)JSONOutput(directory:)— one ISO8601-named JSON file per recordCSVOutput(directory:)— one CSV per record, one row per phase (columns: name, outcome, duration_s, measurements_count, traces_count, attachments_count, diagnoses_count, error)HistoryOutputCallback(store:)— wraps anyHistoryStorefor automatic ingest
# Programmatic demo (auto-answers prompts, outputs to $TMPDIR/SwiftHTFDemo/)
swift run SwiftHTFDemo
# SwiftUI window (operator answers prompts, phase grid + live log)
swift run SwiftHTFSwiftUIDemoswift build
swift test # 527 testsMIT © 2026 LumenMarch