From 6793a6d9015e8faf3549995a4196b3e20b080848 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Mon, 27 Jul 2026 21:03:55 +0100 Subject: [PATCH] chore(tests): type-check test sources without Xcode --- CONTRIBUTING.md | 13 +++++++ Makefile | 8 +++++ scripts/typecheck-tests.sh | 74 ++++++++++++++++++++++++++++++++++++++ scripts/xctest-shim.swift | 74 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100755 scripts/typecheck-tests.sh create mode 100644 scripts/xctest-shim.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d6d00e..0195e53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,19 @@ Tests live in `Tests/StackNudgePanelCoreTests/` and cover the pure-logic surface sudo xcode-select -s /Applications/Xcode.app/Contents/Developer ``` +Without Xcode the test sources are the one part of the repo that never gets +compiled locally, so an API change that breaks a test call site stays invisible +until CI fails on a build error. To catch that without installing Xcode: + +```bash +make typecheck-tests # compile-check the test sources; no XCTest required +``` + +It compiles the tests in-module against the real panel types, swapping XCTest for +the stand-ins in `scripts/xctest-shim.swift`. It does **not** run assertions — +`make test` and CI remain the authority on whether the tests pass. Run it after +any change that tightens a signature (a new required parameter, a renamed case). + CI runs the suite on every push and PR — `swift test` is one of the required checks. ## Source layout diff --git a/Makefile b/Makefile index 65221d0..9a33df6 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ help: @echo " make reload rebuild + replace installed app + bounce the daemon" @echo " make dev watch sources; auto-reload on change (ctrl-c to stop)" @echo " make test run swift test (needs full Xcode for XCTest)" + @echo " make typecheck-tests compile-check the test sources (no Xcode needed)" @echo " make clean remove build/ and .build/" .PHONY: build @@ -45,6 +46,13 @@ test: fi @swift test +# Compile-check the XCTest sources without Xcode. Catches the breakage `make +# test` can't reach on a Command Line Tools-only machine — a production API +# change leaving a test call site uncompilable. Does not run assertions. +.PHONY: typecheck-tests +typecheck-tests: + @./scripts/typecheck-tests.sh + # One-shot dev cycle: rebuild, reinstall the app, refresh notify.sh in # ~/.stack-nudge so hook-side changes propagate, kickstart the daemon. # Build output goes to $(BUILD_LOG); on failure, last 20 lines tail to stderr. diff --git a/scripts/typecheck-tests.sh b/scripts/typecheck-tests.sh new file mode 100755 index 0000000..47c449c --- /dev/null +++ b/scripts/typecheck-tests.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Type-check the XCTest sources without Xcode. +# +# `swift test` needs the XCTest module, which only ships with full Xcode. On a +# Command Line Tools-only machine the whole test target fails to load, so the +# test sources are the one part of the repo that never gets compiled locally — +# and a production API change that breaks a test call site stays invisible until +# CI fails on a build error. +# +# This compiles the test sources in-module against the real panel types, with +# XCTest swapped for the stand-ins in scripts/xctest-shim.swift. It catches +# compile breakage in seconds. It does NOT run any assertions: `swift test` +# (locally via `make test`, or in CI) remains the authority on whether the tests +# pass. +# +# Usage: scripts/typecheck-tests.sh + +set -euo pipefail + +cd "$(dirname "$0")/.." + +SHIM="scripts/xctest-shim.swift" +TESTS_DIR="Tests/StackNudgePanelCoreTests" + +if [[ ! -f "$SHIM" ]]; then + echo "missing $SHIM" >&2 + exit 1 +fi + +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT + +# Swap the imports so the test sources compile as part of the panel module: +# - XCTest is replaced (not deleted) because it re-exports Foundation; several +# test files rely on that for Calendar/TimeZone and fail if it just goes away. +# - @testable import is dropped since everything is one module here. +checked=0 +for source in "$TESTS_DIR"/*.swift; do + sed -e 's/^import XCTest$/import AppKit\nimport SwiftUI/' \ + -e 's/^@testable import StackNudgePanelCore$//' \ + "$source" > "$stage/$(basename "$source")" + checked=$((checked + 1)) +done + +if [[ "$checked" -eq 0 ]]; then + echo "no test sources found in $TESTS_DIR" >&2 + exit 1 +fi + +# panel/main.swift owns the app entry point; it is excluded so its top-level code +# doesn't clash with the test sources being compiled alongside it. build.sh +# type-checks that file as part of the real build. +sources=() +for source in panel/*.swift shared/*.swift; do + [[ "$source" == "panel/main.swift" ]] && continue + sources+=("$source") +done + +echo "type-checking $checked test file(s) against panel/ + shared/..." + +# -suppress-warnings because panel/ carries a stack of pre-existing macOS 14 +# deprecation warnings that bury the errors this script exists to surface. +# build.sh and CI still report them. +# +# Diagnostics are captured so the staging paths can be rewritten back to the real +# test files — otherwise every error points at a temp directory that no longer +# exists by the time you read it. +if ! swiftc -typecheck -suppress-warnings \ + "${sources[@]}" "$SHIM" "$stage"/*.swift 2> "$stage/diagnostics.txt"; then + sed "s|$stage/|$TESTS_DIR/|g" "$stage/diagnostics.txt" >&2 + exit 1 +fi + +echo "OK — test sources compile (assertions not run; use 'make test' or CI for that)" diff --git a/scripts/xctest-shim.swift b/scripts/xctest-shim.swift new file mode 100644 index 0000000..e0dde60 --- /dev/null +++ b/scripts/xctest-shim.swift @@ -0,0 +1,74 @@ +// Minimal stand-ins for the XCTest API surface this suite uses, so the test +// sources can be TYPE-CHECKED without Xcode. Compiled only by +// scripts/typecheck-tests.sh — never by build.sh or Package.swift, both of which +// take their sources from panel/ and shared/ only. +// +// This does NOT run the tests: no assertion here evaluates anything. It catches +// the failure mode that is otherwise invisible on a Command Line Tools-only +// machine — a production API change (a newly required parameter, a renamed case) +// leaving the test sources uncompilable, which fails `swift test` in CI on a pure +// build error. +// +// If the suite starts using an XCTAssert variant that isn't declared here, the +// type-check fails with "cannot find 'XCTAssertSomething' in scope". Add the +// overload below rather than working around it. +import Foundation + +class XCTestCase { + // @MainActor to match real XCTest, or a @MainActor test class's override + // becomes nonisolated and can't touch its own main-actor properties. + @MainActor func setUp() {} + @MainActor func tearDown() {} + @MainActor func setUpWithError() throws {} + @MainActor func tearDownWithError() throws {} +} + +func XCTAssertEqual(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertNotEqual(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertTrue(_ e: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertFalse(_ e: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertNil(_ e: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertNotNil(_ e: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertGreaterThan(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertGreaterThanOrEqual(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertLessThan(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTAssertLessThanOrEqual(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTFail(_ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {} +func XCTUnwrap(_ e: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) throws -> T { + guard let value = try e() else { fatalError("unwrap") } + return value +} +func XCTAssertEqual(_ a: @autoclosure () throws -> T, + _ b: @autoclosure () throws -> T, + accuracy: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) {}