Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions scripts/typecheck-tests.sh
Original file line number Diff line number Diff line change
@@ -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)"
74 changes: 74 additions & 0 deletions scripts/xctest-shim.swift
Original file line number Diff line number Diff line change
@@ -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<T: Equatable>(_ a: @autoclosure () throws -> T,
_ b: @autoclosure () throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}
func XCTAssertNotEqual<T: Equatable>(_ 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<T>(_ e: @autoclosure () throws -> T?,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}
func XCTAssertNotNil<T>(_ e: @autoclosure () throws -> T?,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}
func XCTAssertGreaterThan<T: Comparable>(_ a: @autoclosure () throws -> T,
_ b: @autoclosure () throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}
func XCTAssertGreaterThanOrEqual<T: Comparable>(_ a: @autoclosure () throws -> T,
_ b: @autoclosure () throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}
func XCTAssertLessThan<T: Comparable>(_ a: @autoclosure () throws -> T,
_ b: @autoclosure () throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}
func XCTAssertLessThanOrEqual<T: Comparable>(_ 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<T>(_ 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<T: FloatingPoint>(_ a: @autoclosure () throws -> T,
_ b: @autoclosure () throws -> T,
accuracy: @autoclosure () throws -> T,
_ message: @autoclosure () -> String = "",
file: StaticString = #filePath, line: UInt = #line) {}