Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
55c6fbb
Add code coverage script
colemancda Jul 18, 2026
7ebd7b9
Wire code coverage into CI
colemancda Jul 18, 2026
c7e3fa3
Pin toolchain to Swift 6.3.3
colemancda Jul 18, 2026
a839960
Enable syscall mocking in debug builds
colemancda Jul 18, 2026
00a41f1
Add syscall mocking driver
colemancda Jul 18, 2026
13b82c7
Add mockable ioctl syscall shims
colemancda Jul 18, 2026
3bc5f12
Route inputOutput through mockable ioctl seam
colemancda Jul 18, 2026
8d8b1f0
Add unit tests for ioctl calls
colemancda Jul 18, 2026
255aade
Migrate deviceCommand to new HCICommandParameter encoding
colemancda Jul 18, 2026
be2d859
Migrate deviceRequest to new HCICommandParameter encoding
colemancda Jul 18, 2026
422083b
Encode HCI command header via append(to:)
colemancda Jul 18, 2026
4b3b072
Update L2CAP tests for new Bluetooth UUID and GATT API
colemancda Jul 18, 2026
9c3d61e
Add unit tests for value types
colemancda Jul 18, 2026
8e90d3a
Add ioctl tests for device-down, device-info, and RFCOMM
colemancda Jul 18, 2026
782e3ae
Convert RFCOMMFlag to native OptionSet
colemancda Jul 18, 2026
45c9fcc
Convert RFCOMMLinkMode to native OptionSet
colemancda Jul 18, 2026
11faf9f
Convert HCIDeviceFlag to native OptionSet
colemancda Jul 18, 2026
a930a16
Use OptionSet API for HCIDeviceOptions flags
colemancda Jul 18, 2026
ba15a78
Convert ScanOption to native OptionSet
colemancda Jul 18, 2026
9189a62
Adopt RFCOMMFlag OptionSet in RFCOMMDevice
colemancda Jul 18, 2026
926d5de
Adopt RFCOMMFlag OptionSet in RFCOMMCreateDevice
colemancda Jul 18, 2026
b7e03f9
Adopt RFCOMMFlag OptionSet in RFCOMMReleaseDevice
colemancda Jul 18, 2026
de434e0
Adopt RFCOMMLinkMode OptionSet in RFCOMMSocketOption
colemancda Jul 18, 2026
631397f
Adapt value tests to OptionSet HCIDeviceFlag
colemancda Jul 18, 2026
da97f8a
Fix RFCOMM device destination address mapping
colemancda Jul 18, 2026
6a0f682
Fix inquiry result buffer layout to match kernel ABI
colemancda Jul 18, 2026
3214ad1
Fix RFCOMM device list buffer layout to match kernel ABI
colemancda Jul 18, 2026
c42052a
Add ioctl tests for inquiry and RFCOMM device queries
colemancda Jul 18, 2026
2edd2d7
Add tests for socket options and remaining value types
colemancda Jul 18, 2026
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
32 changes: 27 additions & 5 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ jobs:

linux-swift:
name: Linux
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
container: swift:6.0.2-jammy
permissions:
contents: read
code-quality: write # required by actions/upload-code-coverage
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Swift Version
run: swift --version
- name: Build (Debug)
Expand All @@ -20,9 +23,28 @@ jobs:
- name: Test (Debug)
run: swift test --configuration debug --enable-code-coverage
- name: Archive Build artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: swiftpm-build-ubuntu-x86_64
path: .build/*/*.xctest
- name: Coverage Report
uses: maxep/spm-lcov-action@0.3.1
- name: Install coverage prerequisites
run: apt-get update && apt-get install -y python3
- name: Export coverage and enforce threshold
run: SKIP_TEST=1 Scripts/coverage.sh
- name: Upload coverage to GitHub Code Quality
uses: actions/upload-code-coverage@v1
with:
file: .build/coverage/coverage.xml
language: Swift
label: code-coverage/llvm-cov
# Code Quality was in public preview until 2026-07-20; keep this while
# the repo might not have the feature enabled, so a failed upload
# doesn't break CI.
fail-on-error: false
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: code-coverage
path: .build/coverage/
if-no-files-found: error
1 change: 1 addition & 0 deletions .swift-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
6.3.3
10 changes: 9 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ var package = Package(
package: "Socket"
),
"CBluetoothLinux"
],
swiftSettings: [
// Syscall mocking for unit tests (see Internal/Mocking.swift),
// same pattern as swift-system.
.define("ENABLE_MOCKING", .when(configuration: .debug))
]
),
.target(
Expand All @@ -70,7 +75,10 @@ var package = Package(
package: "Bluetooth"
)
],
swiftSettings: [.swiftLanguageMode(.v5)]
swiftSettings: [
.swiftLanguageMode(.v5),
.define("ENABLE_MOCKING", .when(configuration: .debug))
]
)
]
)
Expand Down
199 changes: 199 additions & 0 deletions Scripts/coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
#
# coverage.sh — run a SwiftPM test suite with code coverage, export LCOV and
# Cobertura reports, and enforce a minimum line-coverage threshold.
#
# Coverage is measured against the package's OWN sources only (everything under
# the repo's `Sources/` directory). Dependencies (checked out under `.build`),
# generated sources, and the test target are excluded, so the number reflects
# the coverage of this package's code rather than being diluted by third-party
# code that the tests happen to exercise.
#
# The Cobertura XML (coverage.xml) is the format GitHub Code Quality consumes via
# `actions/upload-code-coverage`; the LCOV report (coverage.lcov) is kept for
# other tools (Codecov, Coveralls, editors) that prefer it.
#
# Usage:
# Scripts/coverage.sh [threshold]
#
# Environment variables:
# COVERAGE_THRESHOLD Minimum line coverage percentage (default: 1).
# The suite is small today (most of this package needs
# real Bluetooth hardware); ratchet this up as tests grow.
# Overridden by the optional [threshold] argument.
# COVERAGE_SOURCE_PREFIX Absolute path prefix selecting the sources that count
# toward coverage (default: "$PWD/Sources/"). Narrow it
# to a single target, e.g. "$PWD/Sources/MyLibrary/", to
# gate on one target instead of every source file.
# COVERAGE_OUTPUT LCOV output file (default: .build/coverage/coverage.lcov).
# COBERTURA_OUTPUT Cobertura XML output file (default: .build/coverage/coverage.xml).
# SKIP_TEST If set to 1, reuse existing coverage data instead of
# re-running `swift test` (useful while iterating).

set -euo pipefail

THRESHOLD="${1:-${COVERAGE_THRESHOLD:-1}}"
SOURCE_PREFIX="${COVERAGE_SOURCE_PREFIX:-$PWD/Sources/}"
OUTPUT="${COVERAGE_OUTPUT:-.build/coverage/coverage.lcov}"
COBERTURA_OUTPUT="${COBERTURA_OUTPUT:-.build/coverage/coverage.xml}"

# Resolve the correct llvm-cov (xcrun on Apple platforms, plain llvm-cov elsewhere).
if command -v xcrun >/dev/null 2>&1; then
LLVM_COV="xcrun llvm-cov"
else
LLVM_COV="llvm-cov"
fi

# 1. Run the tests with coverage instrumentation.
if [ "${SKIP_TEST:-0}" != "1" ]; then
echo "==> Running tests with code coverage"
swift test --enable-code-coverage
fi

# 2. Locate the coverage artifacts SwiftPM produced.
CODECOV_JSON="$(swift test --enable-code-coverage --show-codecov-path)"
COV_DIR="$(dirname "$CODECOV_JSON")"
PROFDATA="$COV_DIR/default.profdata"

if [ ! -f "$CODECOV_JSON" ]; then
echo "error: coverage report not found at $CODECOV_JSON" >&2
exit 1
fi

# 3. Locate the instrumented test binary (differs by platform: on macOS it lives
# inside the .xctest bundle, on Linux the .xctest file is the binary itself).
BIN_PATH="$(swift build --show-bin-path)"
TEST_BINARY=""
for candidate in \
"$BIN_PATH"/*PackageTests.xctest/Contents/MacOS/*PackageTests \
"$BIN_PATH"/*PackageTests.xctest; do
if [ -f "$candidate" ]; then
TEST_BINARY="$candidate"
break
fi
done

# 4. Export an LCOV report (for Codecov / Coveralls / editors).
mkdir -p "$(dirname "$OUTPUT")"
if [ -n "$TEST_BINARY" ] && [ -f "$PROFDATA" ]; then
echo "==> Exporting LCOV report to $OUTPUT"
$LLVM_COV export \
-format=lcov \
-instr-profile "$PROFDATA" \
"$TEST_BINARY" \
-ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \
> "$OUTPUT"
else
echo "warning: could not locate test binary or profdata; skipping LCOV export" >&2
fi

# 5. Generate a Cobertura XML report (for GitHub Code Quality / upload-code-coverage).
echo "==> Writing Cobertura report to $COBERTURA_OUTPUT"
mkdir -p "$(dirname "$COBERTURA_OUTPUT")"
python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$PWD" "$COBERTURA_OUTPUT" <<'PY'
import json, os, sys, time
from xml.sax.saxutils import escape, quoteattr

report_path, source_prefix, repo_root, output = sys.argv[1:5]

with open(report_path) as f:
report = json.load(f)

files = []
total_covered = total_lines = 0
for file in report["data"][0]["files"]:
name = file["filename"]
if not name.startswith(source_prefix):
continue
# Per-line hit count: the greatest region count starting on each line (a line
# is covered if any region on it executed, so branch sub-regions don't hide it).
line_hits = {}
for seg in file["segments"]:
line, count, has_count = seg[0], seg[2], seg[3]
if has_count:
line_hits[line] = max(line_hits.get(line, 0), count)
covered = sum(1 for h in line_hits.values() if h > 0)
total_covered += covered
total_lines += len(line_hits)
files.append((os.path.relpath(name, repo_root), line_hits, covered, len(line_hits)))

def rate(c, t):
return c / t if t else 0.0

overall = rate(total_covered, total_lines)
timestamp = int(time.time())

out = [
'<?xml version="1.0" ?>',
'<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">',
'<coverage line-rate="%.4f" branch-rate="0" lines-covered="%d" lines-valid="%d" '
'branches-covered="0" branches-valid="0" complexity="0" version="llvm-cov" timestamp="%d">'
% (overall, total_covered, total_lines, timestamp),
" <sources>",
" <source>%s</source>" % escape(repo_root),
" </sources>",
" <packages>",
' <package name="%s" line-rate="%.4f" branch-rate="0" complexity="0">'
% (escape(os.path.basename(repo_root)), overall),
" <classes>",
]
for rel, line_hits, covered, total in sorted(files):
out.append(
' <class name=%s filename=%s line-rate="%.4f" branch-rate="0" complexity="0">'
% (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total))
)
out.append(" <methods/>")
out.append(" <lines>")
for line in sorted(line_hits):
out.append(' <line number="%d" hits="%d"/>' % (line, line_hits[line]))
out.append(" </lines>")
out.append(" </class>")
out += [" </classes>", " </package>", " </packages>", "</coverage>"]

with open(output, "w") as f:
f.write("\n".join(out) + "\n")

print(" %d/%d lines (%.2f%%)" % (total_covered, total_lines, 100 * overall))
PY

# 6. Compute line coverage for the package sources and enforce the threshold.
echo "==> Computing coverage for ${SOURCE_PREFIX}"
python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$THRESHOLD" "$PWD" <<'PY'
import json, os, sys

report_path, source_prefix, threshold, repo_root = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4]

with open(report_path) as f:
report = json.load(f)

covered = total = 0
rows = []
for file in report["data"][0]["files"]:
name = file["filename"]
if not name.startswith(source_prefix):
continue
lines = file["summary"]["lines"]
covered += lines["covered"]
total += lines["count"]
rows.append((lines["percent"], os.path.relpath(name, repo_root)))

if total == 0:
print("error: no source files matched prefix %r" % source_prefix, file=sys.stderr)
print(" (set COVERAGE_SOURCE_PREFIX to your package's Sources path)", file=sys.stderr)
sys.exit(1)

percent = 100.0 * covered / total

for pct, name in sorted(rows):
print(" %6.2f%% %s" % (pct, name))

print("-" * 48)
print("Total line coverage: %.2f%% (%d/%d lines)" % (percent, covered, total))
print("Required threshold: %.2f%%" % threshold)

if percent < threshold:
print("FAILED: coverage %.2f%% is below the %.2f%% threshold" % (percent, threshold), file=sys.stderr)
sys.exit(1)

print("PASSED: coverage meets the threshold")
PY
2 changes: 1 addition & 1 deletion Sources/BluetoothLinux/HCI/DeviceCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public extension HostController {
func deviceCommand<T: HCICommandParameter>(_ commandParameter: T) async throws {
try await socket.sendCommand(
T.command,
parameter: commandParameter.data
parameter: Data(commandParameter)
)
}
}
6 changes: 3 additions & 3 deletions Sources/BluetoothLinux/HCI/DeviceRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public extension HostController {
) async throws -> EP {

let command = CP.command
let parameterData = commandParameter.data
let parameterData = Data(commandParameter)
let responseData = try await socket.sendRequest(
command: command,
commandParameterData: parameterData,
Expand Down Expand Up @@ -81,7 +81,7 @@ public extension HostController {

let data = try await socket.sendRequest(
command: CP.command,
commandParameterData: commandParameter.data,
commandParameterData: Data(commandParameter),
eventParameterLength: 1,
timeout: timeout
)
Expand Down Expand Up @@ -127,7 +127,7 @@ public extension HostController {

let data = try await socket.sendRequest(
command: commandReturnType.command,
commandParameterData: commandParameter.data,
commandParameterData: Data(commandParameter),
eventParameterLength: commandReturnType.length + 1,
timeout: timeout
)
Expand Down
44 changes: 30 additions & 14 deletions Sources/BluetoothLinux/HCI/HCIDeviceFlag.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,40 @@
//
// HCIDeviceFlag.swift
//
//
//
// Created by Alsey Coleman Miller on 16/10/21.
//

import Bluetooth

/// HCI device flags
public enum HCIDeviceFlag: Int32, BitMaskOption, CaseIterable {

case up
case initialized
case running

case passiveScan
case interactiveScan
case authenticated
case encrypt
case inquiry

case raw
public struct HCIDeviceFlag: OptionSet, Equatable, Hashable, Sendable {

public let rawValue: UInt32

public init(rawValue: UInt32) {
self.rawValue = rawValue
}

public static let up = HCIDeviceFlag(rawValue: 1 << 0)
public static let initialized = HCIDeviceFlag(rawValue: 1 << 1)
public static let running = HCIDeviceFlag(rawValue: 1 << 2)

public static let passiveScan = HCIDeviceFlag(rawValue: 1 << 3)
public static let interactiveScan = HCIDeviceFlag(rawValue: 1 << 4)
public static let authenticated = HCIDeviceFlag(rawValue: 1 << 5)
public static let encrypt = HCIDeviceFlag(rawValue: 1 << 6)
public static let inquiry = HCIDeviceFlag(rawValue: 1 << 7)

public static let raw = HCIDeviceFlag(rawValue: 1 << 8)
}

public extension HCIDeviceFlag {

/// All defined HCI device flags.
static let all: HCIDeviceFlag = [
.up, .initialized, .running,
.passiveScan, .interactiveScan, .authenticated, .encrypt, .inquiry,
.raw
]
}
14 changes: 4 additions & 10 deletions Sources/BluetoothLinux/HCI/HCIDeviceOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,11 @@ public struct HCIDeviceOptions: RawRepresentable, Equatable, Hashable {

public extension HCIDeviceOptions {

var flags: BitMaskOptionSet<HCIDeviceFlag> {
var options = BitMaskOptionSet<HCIDeviceFlag>()
HCIDeviceFlag.allCases.forEach {
if contains($0) {
options.insert($0)
}
}
return options
var flags: HCIDeviceFlag {
HCIDeviceFlag(rawValue: rawValue).intersection(.all)
}

func contains(_ flag: HCIDeviceFlag) -> Bool {
return (self.rawValue + (UInt32(bitPattern: flag.rawValue) >> 5)) & (1 << (UInt32(bitPattern: flag.rawValue) & 31)) != 0
HCIDeviceFlag(rawValue: rawValue).isSuperset(of: flag)
}
}
2 changes: 1 addition & 1 deletion Sources/BluetoothLinux/HCI/HCIFileDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal extension Socket {
let dataLength = 1 + HCICommandHeader.length + parameterData.count
var data = Data(capacity: dataLength)
data.append(HCIPacketType.command.rawValue)
data.append(header.data)
header.append(to: &data)
if parameterData.isEmpty == false {
data.append(parameterData)
}
Expand Down
Loading
Loading