diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea38232..cc199ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# GitHub-hosted macOS runner (public-ready). Keep linux/windows smokes separate. +# GitHub-hosted macOS + Linux matrix. Either platform failure fails this workflow. on: push: @@ -17,15 +17,41 @@ permissions: jobs: build-test-smoke: - runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + swift_setup: false + configuration: release + aps_bin: .build/release/aps + - os: ubuntu-latest + swift_setup: true + configuration: debug + aps_bin: .build/debug/aps + runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: - uses: actions/checkout@v4 + + - name: Install Swift + if: matrix.swift_setup + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.0" + - name: Swift version run: swift --version - - name: Test (release) - run: ./Scripts/test.sh -c release + + - name: Test + run: ./Scripts/test.sh -c ${{ matrix.configuration }} + + - name: Test parallel (isolation regression) + run: ./Scripts/test-parallel.sh + env: + APS_TEST_WORKERS: "4" + - name: Smoke CLI env: - APS_BIN: .build/release/aps + APS_BIN: ${{ matrix.aps_bin }} run: ./Scripts/smoke.sh diff --git a/.github/workflows/linux-smoke.yml b/.github/workflows/linux-smoke.yml index f1f9f1e..9425ef7 100644 --- a/.github/workflows/linux-smoke.yml +++ b/.github/workflows/linux-smoke.yml @@ -1,7 +1,8 @@ name: Linux smoke -# GitHub-hosted Linux build + smoke for the 0.x agent-ready milestone. -# Complements the private-repo macOS self-hosted CI/Trust workflows. +# Linux coverage now lives in the CI matrix (ci.yml). This workflow remains as a +# lightweight duplicate so existing required checks named "Linux smoke" keep +# green while branch protection is updated to the CI matrix cells. on: push: @@ -36,6 +37,9 @@ jobs: # Observation toolchain bug on 6.0.x. run: ./Scripts/test.sh + - name: Test parallel (isolation regression) + run: ./Scripts/test-parallel.sh + - name: Smoke CLI env: APS_BIN: .build/debug/aps diff --git a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/approvals.json b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/approvals.json index 554d74a..fa61bac 100644 --- a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/approvals.json +++ b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/approvals.json @@ -41,7 +41,207 @@ "timestamp": 1784764274, "digest": "a46335e0569c96b5bcbb7dced5a4c5586372a67e39b770e78d51ee76f9b4f189", "note": "Verified concurrent FileState and Slice writes with smoke and fledge gate" + }, + { + "gate": "acceptance", + "actor": "Codex", + "timestamp": 1784767876, + "digest": "e5f5694b768a0174aab17d0deebde732e96b53b86ff2877a6efdbe604105f0ad", + "note": "Acceptance after rebased branch verification." } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations", + "actor": "codex", + "reason": "Re-verify accepted change after rebasing PR onto current main and updating covered testing specs.", + "timestamp": 1784766523, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "Codex", + "timestamp": 1784764274, + "digest": "a46335e0569c96b5bcbb7dced5a4c5586372a67e39b770e78d51ee76f9b4f189", + "note": "Verified concurrent FileState and Slice writes with smoke and fledge gate" + }, + "prior_verification": { + "timestamp": 1784764267, + "commit": "6a40258d80047be13132e68b0597ff3bfe9b52b7", + "contract_digest": "d0845b60a9c43a12bdb1f6588e678b92d18f05ef83644edcc009ea40a5b58720", + "workspace_digest": "2e8333769275a87ea608c90e963f6312a9b4ab23155f7c3c5ea784c98ecfa259", + "acceptance_input_digest": "c638f9bf3b8762c8c0b4b6c21e63eb6ecd4ef315d98a8217becb9144b7e49e45", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "f9c5f154ca02b24d3dbcbe27f638b9c2a2656aaddf58291d3f6a6a37298968a4", + "entry_digest": "423f74daedf58367dd90a7b6169a7cbe676a581c31c134428d92c3134b938bc1", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "ea93b7ee69bbd829015e5ed2980f6889332db914a36d5074e6ca64159eaac3ad", + "entry_digest": "42bc00afd55e8963205030f0651e720ced04c3ece87b0e24968c44daa60e8e82", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/DynamicKeyStorage.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "83d1001c687c8d87978e377a973e5f306b4bce876c990bd6cae9ec76361435be", + "entry_digest": "18980a16da10134ffb2226baba26375f21f3b6761d13337db1004ecf18062ae6", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/SchemaFileLock.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "944429324e43897d2404675f3e767b8c0c30122964572e457e175c3408942104", + "entry_digest": "d75c086410c8c8649f6ef1e10c1f2b19eb140786a502f84e3120be0890f01192", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "543158d8ef7553c81015fddebd19f9bdbd3039b769f97682dd514124316e2986", + "entry_digest": "63d59cf0372bfaffd8c83492562711219500721ccab5f7979e0712161ea80d53", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "a34659e33913de1f023f5496e840deb31214b5de95c13dc5ed6605789cae8682", + "entry_digest": "af1dd90a0c2b304b2dae25bfcc685db76efbbbda9bb67d7bea3ac764ba433b17", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6b45cae19244f96b97854129adc5794b76623aa0547b5b5ba27fd20e522948f", + "entry_digest": "3d95dbf704bd5d40be4f8f4c95897cf6e5549152b4cdaab0adc43328febf8b90", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8cb54f4892cbaf772a325831685da8d2ee9821ecb53c269c555c26ff66c47b1c", + "entry_digest": "a489d70664df850e07b811552a5b40ebfc5e7330ea5df82494fd8bafe7b83320", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "83d2620192191bceccaa4a8758b8c7120300db800d7595309ddbc96ec6591525", + "entry_digest": "16ee871a59dd92753ba0d3be7f10aff13bd6a0d622abfada0d67073e6ccf0c90", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "bfba02c52a61ef945db92d21bb85ca057b01be0070ee41becada1435d8752e38", + "entry_digest": "f302745cc3e91377476c7a084e135b1059e4ab970a55e3213ec2969cdcf7322d", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "9148fb7ca2a5c078d57b477c4b84625dbe9e6a19c4d764854f3232e8ab48595b", + "entry_digest": "052b5cac0066e6399cd129acf2f35f018337005a79425a0e6e7e64e08c725e1e", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1b37b782eb02875ed5044b933e00a9b27fc7c721f6fe60055faadfd4c81b4c60", + "entry_digest": "9be552e6e1a5df22eef12906fde78a289500d26689c0096edafc91075b32eb2a", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f38de1c661bb60fdaee4616bfbbba73a388e4f08fbc3641eacb1762577a45e77", + "entry_digest": "5b599611b22a63e5e6746e42c0bbf56f017213c486e3442e797b012c528c6f74", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0f02b782887467b358568bf22c2d68f39c40a00d56143bd6c64abdf8198cf433", + "entry_digest": "8f828f3625d4f24c3c71852bd28f76aac97cd31ace2a18d1a901a3d34642de8d", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "fadd1cb6cc4e989a09f748be4eef6f5c1b1b204e882e6bad785618787b096d00", + "entry_digest": "a83c4ac9a4e126d5cc2ee2c4449a548cb8ea15794a1ec8c37bf455f7c1280e07", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-025", + "REQ-state-store-019" + ] + }, + "stale_acceptance_input_digest": "c638f9bf3b8762c8c0b4b6c21e63eb6ecd4ef315d98a8217becb9144b7e49e45", + "current_acceptance_input_digest": "581db891b8bb35709a6c7012356d0c3cb12c926ec74c3ac1140320f0443070bf" + } + ] } diff --git a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/state.json b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/state.json index f9664b5..301c47a 100644 --- a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/state.json +++ b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "6a40258d80047be13132e68b0597ff3bfe9b52b7", "created_at": 1784763748, - "updated_at": 1784764274, + "updated_at": 1784767876, "affected_specs": [ "state-store", "aps-cli" diff --git a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification-attempts.json b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification-attempts.json index 323ea13..8c410d5 100644 --- a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification-attempts.json +++ b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification-attempts.json @@ -103,6 +103,42 @@ "REQ-aps-cli-025", "REQ-state-store-019" ] + }, + { + "timestamp": 1784767112, + "commit": "e601af3dd0ac0552766a8c98f061308a31b61075", + "contract_digest": "d0845b60a9c43a12bdb1f6588e678b92d18f05ef83644edcc009ea40a5b58720", + "workspace_digest": "372b56f7730cfe3c512902828b84742b222e671e92116377173c500de9fd9826", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-025", + "REQ-state-store-019" + ] + }, + { + "timestamp": 1784767865, + "commit": "427f42e57bd11a2b92b6245abbcc347549cacec6", + "contract_digest": "d0845b60a9c43a12bdb1f6588e678b92d18f05ef83644edcc009ea40a5b58720", + "workspace_digest": "372b56f7730cfe3c512902828b84742b222e671e92116377173c500de9fd9826", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-025", + "REQ-state-store-019" + ] } ] } diff --git a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification.json b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification.json index 42a9c09..8ed5dac 100644 --- a/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification.json +++ b/.specsync/changes/CHG-0041-serialize-cross-process-filestate-and-slice-profile-read-modify-write-operations/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1784764267, - "commit": "6a40258d80047be13132e68b0597ff3bfe9b52b7", + "timestamp": 1784767865, + "commit": "427f42e57bd11a2b92b6245abbcc347549cacec6", "contract_digest": "d0845b60a9c43a12bdb1f6588e678b92d18f05ef83644edcc009ea40a5b58720", - "workspace_digest": "2e8333769275a87ea608c90e963f6312a9b4ab23155f7c3c5ea784c98ecfa259", - "acceptance_input_digest": "c638f9bf3b8762c8c0b4b6c21e63eb6ecd4ef315d98a8217becb9144b7e49e45", + "workspace_digest": "372b56f7730cfe3c512902828b84742b222e671e92116377173c500de9fd9826", + "acceptance_input_digest": "581db891b8bb35709a6c7012356d0c3cb12c926ec74c3ac1140320f0443070bf", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -101,8 +101,8 @@ "path": "specs/aps-cli/testing.md", "kind": "file", "mode": 33188, - "payload_digest": "bfba02c52a61ef945db92d21bb85ca057b01be0070ee41becada1435d8752e38", - "entry_digest": "f302745cc3e91377476c7a084e135b1059e4ab970a55e3213ec2969cdcf7322d", + "payload_digest": "e67750e6f7395b76924cbc0855203f13e3c9f94b17e6683df75efd29fad1ac18", + "entry_digest": "84df77693472fc697971f28150ceb74299d8a30803bf059a92cf9dd974b1da4c", "owners": [ "aps-cli" ] @@ -151,8 +151,8 @@ "path": "specs/state-store/testing.md", "kind": "file", "mode": 33188, - "payload_digest": "fadd1cb6cc4e989a09f748be4eef6f5c1b1b204e882e6bad785618787b096d00", - "entry_digest": "a83c4ac9a4e126d5cc2ee2c4449a548cb8ea15794a1ec8c37bf455f7c1280e07", + "payload_digest": "4251736322194cd40b5c0f059e454853e8982ee5de85924ee76dde01b2c9907d", + "entry_digest": "e38d9ef8f8ab5b0445dc4a4cd35430d88319eb1a35a064c3b9e301cb9b4ee802", "owners": [ "state-store" ] diff --git a/Scripts/test-parallel.sh b/Scripts/test-parallel.sh new file mode 100755 index 0000000..7fea9a0 --- /dev/null +++ b/Scripts/test-parallel.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run the unit suite under SwiftPM parallel workers. +# Proves APSTests isolation (per-case FileState path, hermetic UserDefaults, +# Application resets, DynamicKeyStorage memory clear, and the setUp/tearDown +# gate) prevents cross-talk when workers interleave cases. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +workers="${APS_TEST_WORKERS:-4}" + +echo "test-parallel: swift test --parallel --num-workers ${workers}" +if [[ "$(uname -s)" == "Linux" ]]; then + # libswiftObservation.so has an undefined swift::threading::fatal ref on + # Linux 6.0.x toolchains; same allowance as linux-smoke / CI Linux cells. + swift test --parallel --num-workers "${workers}" -Xlinker --allow-shlib-undefined +else + swift test --parallel --num-workers "${workers}" +fi diff --git a/Tests/apsTests/APSTests.swift b/Tests/apsTests/APSTests.swift index 9223cb9..1e18c9a 100644 --- a/Tests/apsTests/APSTests.swift +++ b/Tests/apsTests/APSTests.swift @@ -40,19 +40,63 @@ private func setProcessEnv(_ key: String, _ value: String?) { } final class APSTests: XCTestCase { + /// Held between setUp and tearDown so `--parallel` cannot interleave cases. + private var holdsIsolationGate = false + private var fileStatePath: String? + private var userDefaultsOverride: Application.DependencyOverride? + private var hermeticDefaults: InMemoryUserDefaults? + override func setUp() async throws { try await super.setUp() - await MainActor.run { - Application.logging(isEnabled: false) + // Serialize Application singleton access across parallel workers. + await TestIsolationGate.shared.acquire() + holdsIsolationGate = true + + // Build scoped resources off the MainActor so we do not capture `self` + // inside a main-actor closure (Swift 6 isolation). + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("aps-tests-\(UUID().uuidString)", isDirectory: true) + .path + fileStatePath = path + try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + + let defaults = InMemoryUserDefaults() + hermeticDefaults = defaults + + // Secret passphrase env is process-global; start each case clean. + setProcessEnv("APS_SECRET_PASSPHRASE", nil) + setProcessEnv("APS_SECRET_USE_PASSPHRASE", nil) - // Isolate FileState under a unique temp directory for this test run. - let path = FileManager.default.temporaryDirectory - .appendingPathComponent("aps-tests-\(UUID().uuidString)", isDirectory: true) - .path + // Drop any leftover standard-domain flag from prior non-hermetic runs so + // hermetic assertions are meaningful on developer machines / CI caches. + UserDefaults.standard.removeObject(forKey: "App/aps.flag") + UserDefaults.standard.synchronize() + + let override = await MainActor.run { () -> Application.DependencyOverride in + Application.logging(isEnabled: false) FileManager.defaultFileStatePath = path - try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + let token = Application.override(\.userDefaults, with: defaults) + + DynamicKeyStorage.resetProcessMemory() + + Application.reset(\.counter) + Application.reset(\.message) + Application.reset(storedState: \.flag) + Application.reset(fileState: \.note) + Application.reset(fileState: \.profile) + Application.dependency(\.stats).reset() + return token + } + userDefaultsOverride = override + } + + override func tearDown() async throws { + let path = fileStatePath + + await MainActor.run { + DynamicKeyStorage.resetProcessMemory() Application.reset(\.counter) Application.reset(\.message) Application.reset(storedState: \.flag) @@ -60,6 +104,22 @@ final class APSTests: XCTestCase { Application.reset(fileState: \.profile) Application.dependency(\.stats).reset() } + + if let path { + try? FileManager.default.removeItem(atPath: path) + } + fileStatePath = nil + hermeticDefaults = nil + + await userDefaultsOverride?.cancel() + userDefaultsOverride = nil + + if holdsIsolationGate { + await TestIsolationGate.shared.release() + holdsIsolationGate = false + } + + try await super.tearDown() } func testParseBool() { @@ -681,6 +741,41 @@ final class APSTests: XCTestCase { XCTAssertEqual(StateStore().get(.flag), "false") } + @MainActor + func testUserDefaultsStandardIsHermetic() async throws { + // StoredState must use the per-test InMemoryUserDefaults override, not + // UserDefaults.standard (App/aps.flag is the AppState StoredState key). + let store = StateStore() + try store.set(.flag, value: "true") + XCTAssertEqual(store.get(.flag), "true") + + XCTAssertNil( + UserDefaults.standard.object(forKey: "App/aps.flag"), + "flag round-trip must not pollute UserDefaults.standard" + ) + XCTAssertTrue( + hermeticDefaults?.keys.contains("App/aps.flag") == true, + "expected App/aps.flag in the hermetic suite, got \(hermeticDefaults?.keys ?? [])" + ) + + store.reset(.flag) + XCTAssertEqual(store.get(.flag), "false") + XCTAssertNil(UserDefaults.standard.object(forKey: "App/aps.flag")) + } + + @MainActor + func testIsolationStartsWithCleanDemoState() async throws { + let store = StateStore() + XCTAssertEqual(store.get(.counter), "0") + XCTAssertEqual(store.get(.message), "") + XCTAssertEqual(store.get(.flag), "false") + XCTAssertEqual(store.statsSnapshot().mutationCount, 0) + XCTAssertTrue( + FileManager.defaultFileStatePath.contains("aps-tests-"), + "setUp must inject a temp FileState path" + ) + } + @MainActor func testProcessLocalStateKeysDoNotClaimCrossProcessPersistence() async throws { // Document the contract: State keys are process-local. A fresh Application diff --git a/Tests/apsTests/TestIsolation.swift b/Tests/apsTests/TestIsolation.swift new file mode 100644 index 0000000..ffec1fc --- /dev/null +++ b/Tests/apsTests/TestIsolation.swift @@ -0,0 +1,67 @@ +import AppState +import Foundation + +/// Serializes Application-touching XCTest cases under `swift test --parallel`. +/// +/// AppState `Application` is a process singleton (`FileManager.defaultFileStatePath`, +/// StoredState / State / Dependency cache). Concurrent cases in one process race on +/// that singleton; acquire in `setUp` and release in `tearDown` keeps each case's +/// scope exclusive. Implemented as an actor so it is safe to await from async +/// XCTest lifecycle methods (DispatchSemaphore.wait is unavailable in async +/// contexts on newer Apple Swift toolchains). +actor TestIsolationGate { + static let shared = TestIsolationGate() + + private var held = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if held { + await withCheckedContinuation { (continuation: CheckedContinuation) in + waiters.append(continuation) + } + } + held = true + } + + func release() { + if waiters.isEmpty { + held = false + return + } + let next = waiters.removeFirst() + next.resume() + } +} + +/// In-memory `UserDefaultsManaging` so StoredState tests never touch +/// `UserDefaults.standard` (see archived CHG-0007). +final class InMemoryUserDefaults: UserDefaultsManaging, @unchecked Sendable { + private var storage: [String: Any] = [:] + private let lock = NSLock() + + func object(forKey key: String) -> Any? { + lock.lock() + defer { lock.unlock() } + return storage[key] + } + + func set(_ value: Any?, forKey key: String) { + lock.lock() + defer { lock.unlock() } + storage[key] = value + } + + func removeObject(forKey key: String) { + lock.lock() + defer { lock.unlock() } + storage.removeValue(forKey: key) + } + + /// Snapshot of keys currently held (for hermetic assertions). + var keys: [String] { + lock.lock() + defer { lock.unlock() } + return Array(storage.keys) + } +} diff --git a/fledge.toml b/fledge.toml index a25f97c..85ff921 100644 --- a/fledge.toml +++ b/fledge.toml @@ -4,6 +4,7 @@ [tasks] build = "./Scripts/build.sh" test = "./Scripts/test.sh" +test-parallel = "./Scripts/test-parallel.sh" release-build = "./Scripts/build.sh -c release" smoke = "./Scripts/smoke.sh" # Manifest-only: warns if `.build/release/aps` is missing (created by release/install hooks). @@ -11,8 +12,8 @@ plugin-validate = "fledge plugins validate ." # Keep specsync out of lanes.verify: Trust/SpecSync run it as a top-level gate # so verification commands cannot recurse into specsync check. spec = "specsync check" -check = "fledge run build && fledge run test && fledge run smoke && fledge run plugin-validate && fledge run spec" +check = "fledge run build && fledge run test && fledge run test-parallel && fledge run smoke && fledge run plugin-validate && fledge run spec" [lanes.verify] -description = "Native quality gate (build, test, smoke, plugin manifest)" -steps = ["build", "test", "smoke", "plugin-validate"] +description = "Native quality gate (build, test, parallel isolation, smoke, plugin manifest)" +steps = ["build", "test", "test-parallel", "smoke", "plugin-validate"] diff --git a/specs/aps-cli/testing.md b/specs/aps-cli/testing.md index 8aebfaf..2c15946 100644 --- a/specs/aps-cli/testing.md +++ b/specs/aps-cli/testing.md @@ -3,8 +3,13 @@ - Unit: DemoKey metadata, parseBool, invalid set values - Integration: StateStore round-trips via `@testable import aps` - Unit: ObservedDependency stats mutation + Combine observation +- Isolation: each `APSTests` case scopes a temp FileState path, hermetic + in-memory UserDefaults (`Application.override(\.userDefaults)`), resets + Application demo keys / stats / `DynamicKeyStorage` memory in setUp/tearDown, + and serializes Application access under `swift test --parallel` +- Regression: `Scripts/test-parallel.sh` (`swift test --parallel`) must pass - Smoke: `Scripts/smoke.sh` (Unix) and `Scripts/smoke.ps1` (Windows / PowerShell) for flag/note persistence, reset, and `aps stats` -- Windows CI: `swift test` + `Scripts/smoke.ps1` on `windows-latest` (`windows-smoke.yml`) +- CI: `ci.yml` matrix runs macOS + Linux (fail if either fails); Windows: `swift test` + `Scripts/smoke.ps1` on `windows-latest` (`windows-smoke.yml`) - Encrypted-file `secret` round-trip / wrong-passphrase `secretUnlockFailed` / corrupt envelope `decodingFailed`. - Secret SET with wrong passphrase leaves ciphertext unchanged; root `--state-dir` peel; safer reset; schema lock. diff --git a/specs/state-store/testing.md b/specs/state-store/testing.md index 4b448a1..632ec63 100644 --- a/specs/state-store/testing.md +++ b/specs/state-store/testing.md @@ -6,6 +6,9 @@ - dump includes dependency-driven timestamp and all keys - DemoStats ObservedDependency records mutations and Combine publishes on change - watchStatsBlocking detects dependency mutation +- Isolation: hermetic UserDefaults for StoredState `flag`; per-case FileState + path; `DynamicKeyStorage.resetProcessMemory()` between cases; parallel suite + via `Scripts/test-parallel.sh` - Encrypted-file `secret` round-trip / wrong-passphrase `secretUnlockFailed` / corrupt envelope `decodingFailed`. - Secret SET unlock-before-rewrite; parallel schema RMW under SchemaFileLock.