diff --git a/Makefile b/Makefile index 877192f..0ea8e45 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ SHELL := /bin/bash SWIFT_FLAGS ?= -.PHONY: help build test release run clean +.PHONY: help build test coverage mutation-test release run clean help: @printf "%s\n" \ "make build - build the Swift package" \ "make test - run Swift tests" \ + "make coverage - run source coverage gate" \ + "make mutation-test - run mutation smoke checks" \ "make release - build dist/icloud-cli and checksum" \ "make run ARGS=... - run the debug CLI" \ "make clean - remove SwiftPM build outputs" @@ -17,6 +19,12 @@ build: test: swift test $(SWIFT_FLAGS) +coverage: + bash scripts/ci/check-coverage.sh + +mutation-test: + bash scripts/ci/run-mutation-smoke.sh + release: swift build $(SWIFT_FLAGS) -c release mkdir -p dist diff --git a/Tests/ICloudCLICoreTests/CLIParserTests.swift b/Tests/ICloudCLICoreTests/CLIParserTests.swift index 2c48e51..e7f07c8 100644 --- a/Tests/ICloudCLICoreTests/CLIParserTests.swift +++ b/Tests/ICloudCLICoreTests/CLIParserTests.swift @@ -37,6 +37,14 @@ import Testing #expect(options.safariDirectory.path == "/tmp/safari-fixture") } +@Test func parsesHelpAndVersionShortcuts() throws { + #expect(try CLIParser().parse(arguments: ["icloud-cli"]) == .help) + #expect(try CLIParser().parse(arguments: ["icloud-cli", "-h"]) == .help) + #expect(try CLIParser().parse(arguments: ["icloud-cli", "--help"]) == .help) + #expect(try CLIParser().parse(arguments: ["icloud-cli", "-V"]) == .version) + #expect(try CLIParser().parse(arguments: ["icloud-cli", "--version"]) == .version) +} + @Test func rejectsInvalidSource() throws { #expect(throws: CLIParseError.invalidSource("icloud")) { try CLIParser().parse(arguments: ["icloud-cli", "safari", "tabs", "--source", "icloud"]) diff --git a/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift b/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift index abff4ab..12cfe52 100644 --- a/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift +++ b/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift @@ -30,6 +30,35 @@ import Testing #expect(tabs[1].url == "https://openclaw.ai/docs") } +@Test func parserKeepsHttpTabsAndFiltersNonWebUrls() { + let plist: [String: Any] = [ + "Nested": [ + "Tabs": [ + [ + "url": "http://example.org/plain-http", + "title": "Plain HTTP", + ], + [ + "TabURL": "ftp://example.org/not-web", + "TabTitle": "FTP", + ], + ], + ], + ] + + let tabs = SafariSessionPlistParser(sourceName: "fixture").parse(plist) + + #expect(tabs == [ + SafariTab( + url: "http://example.org/plain-http", + title: "Plain HTTP", + windowIndex: nil, + tabIndex: 0, + source: "fixture" + ), + ]) +} + @Test func readerLoadsCurrentSessionPlist() throws { let tempRoot = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: tempRoot) } diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index 19289d0..937f3b9 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -38,6 +38,8 @@ ci: fastChecks: - build - test + - coverage + - mutation - secrets release: enabled: false diff --git a/scripts/ci/check-coverage.sh b/scripts/ci/check-coverage.sh new file mode 100755 index 0000000..81ef41a --- /dev/null +++ b/scripts/ci/check-coverage.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +minimum_line_coverage="${COVERAGE_MIN_LINES:-68}" +module_cache="${CLANG_MODULE_CACHE_PATH:-$PWD/.build/module-cache}" +mkdir -p "$module_cache" +export CLANG_MODULE_CACHE_PATH="$module_cache" +export SWIFTPM_MODULECACHE_OVERRIDE="${SWIFTPM_MODULECACHE_OVERRIDE:-$module_cache}" + +swift_args=() +if [[ -n "${SWIFT_FLAGS:-}" ]]; then + # Intentional word splitting lets callers pass SwiftPM flags such as + # SWIFT_FLAGS="--disable-sandbox --scratch-path /tmp/icloud-cli-build". + read -r -a swift_args <<< "$SWIFT_FLAGS" +fi + +if [[ "${#swift_args[@]}" -gt 0 ]]; then + swift test "${swift_args[@]}" --enable-code-coverage + coverage_json="$(swift test "${swift_args[@]}" --show-codecov-path)" +else + swift test --enable-code-coverage + coverage_json="$(swift test --show-codecov-path)" +fi + +source_line_coverage="$( + jq -r --arg prefix "$PWD/Sources/ICloudCLICore/" ' + [.data[0].files[] + | select(.filename | startswith($prefix)) + | .summary.lines] + | {count: (map(.count) | add), covered: (map(.covered) | add)} + | (.covered * 100 / .count) + ' "$coverage_json" +)" + +printf 'Source line coverage: %.2f%% (minimum %.2f%%)\n' "$source_line_coverage" "$minimum_line_coverage" + +awk -v actual="$source_line_coverage" -v minimum="$minimum_line_coverage" ' + BEGIN { + if (actual + 0 < minimum + 0) { + exit 1 + } + } +' diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 949b3b1..1f5f576 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -6,6 +6,8 @@ bash scripts/ci/check-shell-syntax.sh bash scripts/check-detect-secrets.sh --all-files bash scripts/check-privacy-fixtures.sh swift test +bash scripts/ci/check-coverage.sh +bash scripts/ci/run-mutation-smoke.sh swift build swift build -c release swift run icloud-cli --help diff --git a/scripts/ci/run-mutation-smoke.sh b/scripts/ci/run-mutation-smoke.sh new file mode 100755 index 0000000..4cbdc71 --- /dev/null +++ b/scripts/ci/run-mutation-smoke.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +module_cache="${CLANG_MODULE_CACHE_PATH:-$PWD/.build/module-cache}" +mkdir -p "$module_cache" +export CLANG_MODULE_CACHE_PATH="$module_cache" +export SWIFTPM_MODULECACHE_OVERRIDE="${SWIFTPM_MODULECACHE_OVERRIDE:-$module_cache}" + +swift_args=() +if [[ -n "${SWIFT_FLAGS:-}" ]]; then + read -r -a swift_args <<< "$SWIFT_FLAGS" +fi + +run_swift_test() { + if [[ "${#swift_args[@]}" -gt 0 ]]; then + swift test "${swift_args[@]}" + else + swift test + fi +} + +mutation_file="" +backup_file="" + +restore_mutation() { + if [[ -n "$mutation_file" && -n "$backup_file" && -f "$backup_file" ]]; then + cp "$backup_file" "$mutation_file" + fi + if [[ -n "$backup_file" && -f "$backup_file" ]]; then + rm -f "$backup_file" + fi + mutation_file="" + backup_file="" +} + +trap restore_mutation EXIT + +run_mutant() { + local name="$1" + local file="$2" + local needle="$3" + local replacement="$4" + + mutation_file="$file" + backup_file="$(mktemp)" + cp "$file" "$backup_file" + + NEEDLE="$needle" REPLACEMENT="$replacement" perl -0pi -e ' + BEGIN { $needle = $ENV{"NEEDLE"}; $replacement = $ENV{"REPLACEMENT"}; } + $count = s/\Q$needle\E/$replacement/; + END { exit($count == 1 ? 0 : 2); } + ' "$file" + + if run_swift_test >/tmp/icloud-cli-mutant.log 2>&1; then + echo "Mutation survived: $name" >&2 + cat /tmp/icloud-cli-mutant.log >&2 + exit 1 + fi + + echo "Mutation killed: $name" + restore_mutation +} + +run_mutant \ + "drop-http-url-support" \ + "Sources/ICloudCLICore/SafariTabs.swift" \ + 'return ["http", "https"].contains(scheme)' \ + 'return ["https"].contains(scheme)' + +run_mutant \ + "drop-tab-title-text-rendering" \ + "Sources/ICloudCLICore/CommandRunner.swift" \ + 'if let title = tab.title {' \ + 'if false, let title = tab.title {' + +run_mutant \ + "wrong-missing-cloud-tabs-failure-mode" \ + "Sources/ICloudCLICore/CloudTabs.swift" \ + 'return "cloud-tabs-store-missing"' \ + 'return "cloud-tabs-store-unreadable"' + +run_mutant \ + "disable-help-shortcut" \ + "Sources/ICloudCLICore/CommandLine.swift" \ + 'if tokens.isEmpty || tokens.contains("--help") || tokens.contains("-h") {' \ + 'if tokens.isEmpty {'