diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 92ef7753fec6..0dff70c4020e 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -406,6 +406,23 @@ jobs: ;; esac + # Start from main's git objects, so checkout fetches only what changed + # since then instead of the whole tree and its submodules + # (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at + # this commit over HTTPS because nothing is checked out yet. A miss, or a + # reused self-hosted workspace, leaves checkout to work as before. + - name: Restore git object seed + continue-on-error: true + timeout-minutes: 3 + env: + CI_CACHE_R2_PUBLIC_URL: ${{ vars.CI_CACHE_R2_PUBLIC_URL || 'https://ci-cache.cmux.com' }} + run: | + set -euo pipefail + script="$RUNNER_TEMP/git-seed.sh" + curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \ + -o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh" + bash "$script" restore "$GITHUB_WORKSPACE" + - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | @@ -419,6 +436,21 @@ jobs: fi - name: Checkout + id: checkout + continue-on-error: true + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.resolve-ref.outputs.sha }} + submodules: recursive + + # A seeded repository that checkout cannot use (a submodule moved to + # another URL) must not fail the retry the same way. + - name: Discard the git object seed after a failed checkout + if: steps.checkout.outcome == 'failure' + run: rm -rf "$GITHUB_WORKSPACE/.git" + + - name: Retry checkout without the git object seed + if: steps.checkout.outcome == 'failure' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.resolve-ref.outputs.sha }} @@ -837,6 +869,23 @@ jobs: ;; esac + # Start from main's git objects, so checkout fetches only what changed + # since then instead of the whole tree and its submodules + # (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at + # this commit over HTTPS because nothing is checked out yet. A miss, or a + # reused self-hosted workspace, leaves checkout to work as before. + - name: Restore git object seed + continue-on-error: true + timeout-minutes: 3 + env: + CI_CACHE_R2_PUBLIC_URL: ${{ vars.CI_CACHE_R2_PUBLIC_URL || 'https://ci-cache.cmux.com' }} + run: | + set -euo pipefail + script="$RUNNER_TEMP/git-seed.sh" + curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \ + -o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh" + bash "$script" restore "$GITHUB_WORKSPACE" + - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | @@ -850,6 +899,21 @@ jobs: fi - name: Checkout + id: checkout + continue-on-error: true + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.resolve-ref.outputs.sha }} + submodules: recursive + + # A seeded repository that checkout cannot use (a submodule moved to + # another URL) must not fail the retry the same way. + - name: Discard the git object seed after a failed checkout + if: steps.checkout.outcome == 'failure' + run: rm -rf "$GITHUB_WORKSPACE/.git" + + - name: Retry checkout without the git object seed + if: steps.checkout.outcome == 'failure' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.resolve-ref.outputs.sha }} diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 7a7c0c185d9a..d5ebb2306d63 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -413,7 +413,49 @@ jobs: *) echo "::error::Unsupported Swift package: $SELECTED_PACKAGE"; exit 1 ;; esac + # Start from main's git objects, so checkout fetches only what changed + # since then instead of the whole tree and its submodules + # (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at + # this commit over HTTPS because nothing is checked out yet. A miss, or a + # reused self-hosted workspace, leaves checkout to work as before. + - name: Restore git object seed + continue-on-error: true + timeout-minutes: 3 + run: | + set -euo pipefail + script="$RUNNER_TEMP/git-seed.sh" + curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \ + -o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh" + bash "$script" restore "$GITHUB_WORKSPACE" + + - name: Clear stale git locks (self-hosted reused workspace) + shell: bash + run: | + # Self-hosted macOS runners reuse the workspace. A job cancelled or + # killed mid-checkout can leave a stale .git/modules/*/index.lock that + # fails every later submodule checkout (e.g. ghostty). Clear them first. + ws="${GITHUB_WORKSPACE:-$PWD}" + rm -f "$ws/.git/index.lock" 2>/dev/null || true + if [ -d "$ws/.git/modules" ]; then + find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true + fi + - name: Checkout + id: checkout + continue-on-error: true + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.detect-ios-changes.outputs.target_sha }} + persist-credentials: false + submodules: false + + # A seeded repository that checkout cannot use must not fail the retry the same way. + - name: Discard the git object seed after a failed checkout + if: steps.checkout.outcome == 'failure' + run: rm -rf "$GITHUB_WORKSPACE/.git" + + - name: Retry checkout without the git object seed + if: steps.checkout.outcome == 'failure' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.detect-ios-changes.outputs.target_sha }} @@ -577,7 +619,51 @@ jobs: fi ;; esac + + # Start from main's git objects, so checkout fetches only what changed + # since then instead of the whole tree and its submodules + # (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at + # this commit over HTTPS because nothing is checked out yet. A miss, or a + # reused self-hosted workspace, leaves checkout to work as before. + - name: Restore git object seed + continue-on-error: true + timeout-minutes: 3 + run: | + set -euo pipefail + script="$RUNNER_TEMP/git-seed.sh" + curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \ + -o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh" + bash "$script" restore "$GITHUB_WORKSPACE" + + - name: Clear stale git locks (self-hosted reused workspace) + shell: bash + run: | + # Self-hosted macOS runners reuse the workspace. A job cancelled or + # killed mid-checkout can leave a stale .git/modules/*/index.lock that + # fails every later submodule checkout (e.g. ghostty). Clear them first. + ws="${GITHUB_WORKSPACE:-$PWD}" + rm -f "$ws/.git/index.lock" 2>/dev/null || true + if [ -d "$ws/.git/modules" ]; then + find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true + fi + - name: Checkout + id: checkout + continue-on-error: true + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.detect-ios-changes.outputs.target_sha }} + persist-credentials: false + submodules: recursive + + # A seeded repository that checkout cannot use (a submodule moved to + # another URL) must not fail the retry the same way. + - name: Discard the git object seed after a failed checkout + if: steps.checkout.outcome == 'failure' + run: rm -rf "$GITHUB_WORKSPACE/.git" + + - name: Retry checkout without the git object seed + if: steps.checkout.outcome == 'failure' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.detect-ios-changes.outputs.target_sha }} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d87afd253346..7b7710a59c0d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11191,27 +11191,17 @@ struct CMUXCLI { } case "set-color": - let (hexOpt, rem1) = parseOption(rest, name: "--hex") - // --color is an alias for --hex (mirrors the `custom_color` - // response field the RPC accepts under the `color` key). - // Always consume --color so it cannot be mistaken for the group id - // when both flags are passed; --hex wins. - let (colorOpt, rem0) = parseOption(rem1, name: "--color") + let (hexOpt, rem0) = parseOption(rest, name: "--hex") params["group_id"] = try resolveGroupId(in: rem0) - // Treat --hex/--color with no value (or `""`) as a clear. - params["hex"] = hexOpt ?? colorOpt ?? "" + // Treat --hex with no value (or `--hex ""`) as a clear. + params["hex"] = hexOpt ?? "" let resp = try client.sendV2(method: "workspace.group.set_color", params: params) printWorkspaceGroupResponse(resp, jsonOutput: jsonOutput, idFormat: idFormat) case "set-icon": - let (symbolOpt, rem1) = parseOption(rest, name: "--symbol") - // --icon is an alias for --symbol (mirrors the `icon_symbol` - // response field the RPC accepts under the `icon` key). - // Always consume --icon so it cannot be mistaken for the group id - // when both flags are passed; --symbol wins. - let (iconOpt, rem0) = parseOption(rem1, name: "--icon") + let (symbolOpt, rem0) = parseOption(rest, name: "--symbol") params["group_id"] = try resolveGroupId(in: rem0) - params["symbol"] = symbolOpt ?? iconOpt ?? "" + params["symbol"] = symbolOpt ?? "" let resp = try client.sendV2(method: "workspace.group.set_icon", params: params) printWorkspaceGroupResponse(resp, jsonOutput: jsonOutput, idFormat: idFormat) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift index 4783e39c27ea..c149177a1633 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift @@ -359,46 +359,9 @@ extension ControlCommandCoordinator { guard let gid = uuid(params, "group_id") else { return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) } - let colorParam = Self.aliasStringParam(params, canonical: "hex", alias: "color") - // `custom_color` is the response field name, not an accepted input - // key: echoed back alone it would read as a set while actually - // clearing the override (#9594 class). Point the caller at the real - // keys instead. - if params["custom_color"] != nil, case .absent = colorParam { - return .err( - code: "invalid_params", - message: "unknown key custom_color; set_color accepts hex or color", - data: .object(["custom_color": params["custom_color"] ?? .null]) - ) - } - // `hex` is the canonical key and `color` its alias. Accept - // "hex"/"color": null to clear the override, or omit both entirely. - let normalized: String? - switch colorParam { - case .absent: - normalized = nil - case .supplied(let raw): - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - normalized = nil - } else if let canonical = Self.normalizeHexColor(trimmed) { - // Store the renderer's canonical spelling so a request cannot - // park a value the renderer would refuse to display. - normalized = canonical - } else { - return .err( - code: "invalid_params", - message: "color must be a 6-digit hex color like #FF3EA5 (leading # optional)", - data: .object([params["hex"] != nil ? "hex" : "color": .string(raw)]) - ) - } - case .typeMismatch(let value): - return .err( - code: "invalid_params", - message: "color must be a string holding a hex color", - data: .object([params["hex"] != nil ? "hex" : "color": value]) - ) - } + // Accept "hex": null to clear the override, or omit it entirely. + let hex: String? = rawString(params, "hex").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let normalized: String? = (hex?.isEmpty == false) ? hex : nil guard let ok = context?.controlSetWorkspaceGroupColor( routing: routingSelectors(params), groupID: gid, hex: normalized ) else { @@ -414,31 +377,8 @@ extension ControlCommandCoordinator { guard let gid = uuid(params, "group_id") else { return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) } - let symbolParam = Self.aliasStringParam(params, canonical: "symbol", alias: "icon") - // `icon_symbol` is the response field name, not an accepted input - // key: echoed back alone it would read as a set while actually - // clearing the symbol. Point the caller at the real keys instead. - if params["icon_symbol"] != nil, case .absent = symbolParam { - return .err( - code: "invalid_params", - message: "unknown key icon_symbol; set_icon accepts symbol or icon", - data: .object(["icon_symbol": params["icon_symbol"] ?? .null]) - ) - } - let normalized: String? - switch symbolParam { - case .absent: - normalized = nil - case .supplied(let raw): - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - normalized = trimmed.isEmpty ? nil : trimmed - case .typeMismatch(let value): - return .err( - code: "invalid_params", - message: "symbol must be a string", - data: .object([params["symbol"] != nil ? "symbol" : "icon": value]) - ) - } + let symbol: String? = rawString(params, "symbol").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let normalized: String? = (symbol?.isEmpty == false) ? symbol : nil guard let result = context?.controlSetWorkspaceGroupIcon( routing: routingSelectors(params), groupID: gid, symbol: normalized ) else { @@ -499,52 +439,6 @@ extension ControlCommandCoordinator { // MARK: - Local helpers - /// How a string-typed RPC parameter (and its alias) was supplied. - enum AliasStringParam { - /// Neither key present, or the winning key is JSON `null` — the - /// documented spelling for "clear the override". - case absent - /// The winning key holds a JSON string (untrimmed). - case supplied(String) - /// The winning key holds some other JSON type. Surfacing this as - /// `invalid_params` keeps a mistyped value from silently clearing. - case typeMismatch(JSONValue) - } - - /// Resolves `canonical` — falling back to `alias` — in `params`. The - /// canonical key wins whenever it is present, so an alias can never - /// override an explicit canonical `null` clear. - static func aliasStringParam( - _ params: [String: JSONValue], - canonical: String, - alias: String - ) -> AliasStringParam { - for key in [canonical, alias] { - guard let value = params[key] else { continue } - if case .string(let string) = value { - return .supplied(string) - } - if case .null = value { - return .absent - } - return .typeMismatch(value) - } - return .absent - } - - /// The canonical stored spelling for a group color: `#RRGGBB` (uppercase, - /// `#`-prefixed). This mirrors the renderer's own rule - /// (`WorkspaceTabColorSettings.normalizedHex`), which accepts a missing - /// leading `#` but only ever displays 6-digit values — so short and alpha - /// forms are rejected here rather than stored where they would silently - /// never render. - static func normalizeHexColor(_ value: String) -> String? { - let body = value.hasPrefix("#") ? String(value.dropFirst()) : value - // ASCII only: `Character.isHexDigit` also accepts fullwidth digits, - // which the renderer's `UInt64(_:radix:)` parse rejects. - guard body.count == 6, body.allSatisfy({ $0.isASCII && $0.isHexDigit }) else { return nil } - return "#" + body.uppercased() - } /// The localized workspace-group error strings, resolved by the app /// conformance against the app bundle. diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceGroupColorIconTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceGroupColorIconTests.swift deleted file mode 100644 index 31ba142d2640..000000000000 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWorkspaceGroupColorIconTests.swift +++ /dev/null @@ -1,400 +0,0 @@ -import Foundation -import Testing -@testable import CmuxControlSocket - -/// Regression coverage for #9594: `workspace.group.set_color` and -/// `workspace.group.set_icon` answered a success-shaped response with a `null` -/// override whenever the request spelled the value key anything other than -/// `hex` / `symbol` — silently clearing the stored value instead of setting it -/// or naming the offending parameter. -/// -/// Review follow-ups on the same silent-clear class: colors are normalized to -/// the renderer's canonical `#RRGGBB` spelling (leading `#` optional, since -/// `set-color --hex FF3EA5` already worked through the renderer; short and -/// alpha forms rejected because they store but never render), and echo-back -/// response keys (`custom_color`, `icon_symbol`) plus non-string values return -/// `invalid_params` instead of silently clearing. -@MainActor -@Suite("Control command workspace-group color and icon setters") -struct ControlCommandCoordinatorWorkspaceGroupColorIconTests { - @Test func colorAliasSetsTheOverride() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - let groupID = UUID() - - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(groupID.uuidString), - "color": .string("#FF3EA5"), - ] - )) else { - Issue.record("set_color via the `color` alias did not succeed") - return - } - - #expect(context.setColors.count == 1) - #expect(context.setColors.first?.groupID == groupID) - #expect(context.setColors.first?.hex == "#FF3EA5") - #expect(payload["custom_color"] == .string("#FF3EA5")) - } - - @Test func hexRemainsTheCanonicalKey() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": .string("#FF3EA5"), - ] - )) else { - Issue.record("set_color via `hex` did not succeed") - return - } - - #expect(context.setColors.first?.hex == "#FF3EA5") - #expect(payload["custom_color"] == .string("#FF3EA5")) - } - - @Test func emptyHexStillClears() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": .string(""), - ] - )) else { - Issue.record("set_color clear did not succeed") - return - } - - #expect(context.setColors.first?.hex == nil) - #expect(payload["custom_color"] == .null) - } - - @Test func nonHexColorValueIsRejectedNamingTheParameter() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - guard case .err(let code, let message, let data) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "color": .string("Magenta"), - ] - )) else { - Issue.record("named-color set_color was not rejected") - return - } - - #expect(code == "invalid_params") - #expect(message.contains("color")) - #expect(context.setColors.isEmpty) - guard case .object(let errData) = data else { - Issue.record("rejection did not name the offending parameter") - return - } - #expect(errData["color"] == .string("Magenta")) - } - - @Test func malformedHexLengthsAreRejectedNotApplied() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - // Short and alpha forms would store a value the display path never - // renders (it only accepts 6-digit RRGGBB), and the off-by-one - // lengths are not hex colors at all; all must be rejected, not - // stored or cleared. - for badValue in ["#F3A", "#F3AB", "#12345", "#1234567", "#FF3EA5C8", "#12", "#123456789", "#\u{FF26}\u{FF26}3EA5"] { - guard case .err(let code, _, _) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": .string(badValue), - ] - )) else { - Issue.record("\(badValue) was not rejected") - continue - } - #expect(code == "invalid_params") - } - #expect(context.setColors.isEmpty) - - // Bare 6-digit hex is accepted: the renderer's `normalizedHex` takes - // it with or without the leading `#`, so rejecting it here would - // regress `set-color --hex FF3EA5`. - for goodValue in ["#FF3EA5", "FF3EA5"] { - guard case .ok = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": .string(goodValue), - ] - )) else { - Issue.record("\(goodValue) was rejected") - continue - } - } - #expect(context.setColors.count == 2) - } - - @Test func bareAndLowercaseHexNormalizeToCanonicalRRGGBB() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - // Whatever spelling arrives, the stored override and the echoed - // `custom_color` must be the renderer's canonical `#RRGGBB`, so a - // lowercase or bare value can never sit in storage unrendered. - for raw in ["FF3EA5", "ff3ea5", " #ff3ea5 "] { - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": .string(raw), - ] - )) else { - Issue.record("\(raw) was rejected") - continue - } - #expect(context.setColors.last?.hex == "#FF3EA5") - #expect(payload["custom_color"] == .string("#FF3EA5")) - } - #expect(context.setColors.count == 3) - } - - @Test func customColorEchoKeyAloneIsRejectedNotAClear() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - // `custom_color` is the response field name; echoing it back alone - // must return invalid_params instead of reading as a clear. - for echoed in [JSONValue.string("#FF3EA5"), .null] { - guard case .err(let code, let message, _) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "custom_color": echoed, - ] - )) else { - Issue.record("custom_color-only request was not rejected") - continue - } - #expect(code == "invalid_params") - #expect(message.contains("hex")) - } - #expect(context.setColors.isEmpty) - } - - @Test func nonStringHexValueIsRejectedNotAClear() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - for badValue in [JSONValue.int(123), .bool(true), .object(["value": .string("#FF3EA5")])] { - guard case .err(let code, _, _) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": badValue, - ] - )) else { - Issue.record("non-string hex was not rejected") - continue - } - #expect(code == "invalid_params") - } - #expect(context.setColors.isEmpty) - } - - @Test func nullHexAndNullColorStillClear() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - for key in ["hex", "color"] { - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - key: .null, - ] - )) else { - Issue.record("null \(key) clear did not succeed") - continue - } - #expect(context.setColors.last?.hex == nil) - #expect(payload["custom_color"] == .null) - } - #expect(context.setColors.count == 2) - } - - @Test func nonHexHexValueIsRejectedNamingTheParameter() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - guard case .err(let code, _, _) = coordinator.handle(request( - "workspace.group.set_color", - [ - "group_id": .string(UUID().uuidString), - "hex": .string("Magenta"), - ] - )) else { - Issue.record("named-color set_color via `hex` was not rejected") - return - } - - #expect(code == "invalid_params") - #expect(context.setColors.isEmpty) - } - - @Test func iconAliasSetsTheSymbol() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - let groupID = UUID() - context.storedIconSymbol = "person.fill" - - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_icon", - [ - "group_id": .string(groupID.uuidString), - "icon": .string("person.fill"), - ] - )) else { - Issue.record("set_icon via the `icon` alias did not succeed") - return - } - - #expect(context.setIcons.first?.groupID == groupID) - #expect(context.setIcons.first?.symbol == "person.fill") - #expect(payload["icon_symbol"] == .string("person.fill")) - } - - @Test func symbolRemainsTheCanonicalKey() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - context.storedIconSymbol = "person.fill" - - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_icon", - [ - "group_id": .string(UUID().uuidString), - "symbol": .string("person.fill"), - ] - )) else { - Issue.record("set_icon via `symbol` did not succeed") - return - } - - #expect(context.setIcons.first?.symbol == "person.fill") - #expect(payload["icon_symbol"] == .string("person.fill")) - } - - @Test func iconSymbolEchoKeyAloneIsRejectedNotAClear() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - // `icon_symbol` is the response field name; echoing it back alone - // must return invalid_params instead of reading as a clear. - for echoed in [JSONValue.string("person.fill"), .null] { - guard case .err(let code, let message, _) = coordinator.handle(request( - "workspace.group.set_icon", - [ - "group_id": .string(UUID().uuidString), - "icon_symbol": echoed, - ] - )) else { - Issue.record("icon_symbol-only request was not rejected") - continue - } - #expect(code == "invalid_params") - #expect(message.contains("symbol")) - } - #expect(context.setIcons.isEmpty) - } - - @Test func nonStringSymbolValueIsRejectedNotAClear() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - for badValue in [JSONValue.int(3), .bool(false)] { - guard case .err(let code, _, _) = coordinator.handle(request( - "workspace.group.set_icon", - [ - "group_id": .string(UUID().uuidString), - "symbol": badValue, - ] - )) else { - Issue.record("non-string symbol was not rejected") - continue - } - #expect(code == "invalid_params") - } - #expect(context.setIcons.isEmpty) - } - - @Test func nullSymbolAndNullIconStillClear() { - let context = FakeWorkspaceGroupColorIconContext() - let coordinator = ControlCommandCoordinator(context: context) - - for key in ["symbol", "icon"] { - guard case .ok(.object(let payload)) = coordinator.handle(request( - "workspace.group.set_icon", - [ - "group_id": .string(UUID().uuidString), - key: .null, - ] - )) else { - Issue.record("null \(key) clear did not succeed") - continue - } - #expect(context.setIcons.last?.symbol == nil) - #expect(payload["icon_symbol"] == .null) - } - #expect(context.setIcons.count == 2) - } - - private func request( - _ method: String, - _ params: [String: JSONValue] = [:] - ) -> ControlRequest { - ControlRequest(id: .int(1), method: method, params: params) - } -} - -@MainActor -private final class FakeWorkspaceGroupColorIconContext: ControlCommandContext { - struct ColorCall { - var groupID: UUID - var hex: String? - } - - struct IconCall { - var groupID: UUID - var symbol: String? - } - - var setColors: [ColorCall] = [] - var setIcons: [IconCall] = [] - var storedIconSymbol: String? - - func controlSetWorkspaceGroupColor( - routing: ControlRoutingSelectors, - groupID: UUID, - hex: String? - ) -> Bool? { - setColors.append(ColorCall(groupID: groupID, hex: hex)) - return true - } - - func controlSetWorkspaceGroupIcon( - routing: ControlRoutingSelectors, - groupID: UUID, - symbol: String? - ) -> (found: Bool, storedSymbol: String?)? { - setIcons.append(IconCall(groupID: groupID, symbol: symbol)) - return (true, storedIconSymbol) - } -} diff --git a/tests/test_ci_git_seed.py b/tests/test_ci_git_seed.py index 45fef198293a..76c48dbc3420 100644 --- a/tests/test_ci_git_seed.py +++ b/tests/test_ci_git_seed.py @@ -212,15 +212,30 @@ def test_macos_jobs_restore_the_seed_before_checkout(self): with self.subTest(job=job): self.assertIn("Restore git object seed", self.steps_before_checkout(text, job)) + def test_e2e_and_ios_macos_jobs_restore_the_seed_before_checkout(self): + for workflow, jobs in ( + ("test-e2e.yml", ("build", "test")), + ("test-ios.yml", ("mobile-core-package", "ios-simulator-build")), + ): + text = (WORKFLOWS / workflow).read_text() + for job in jobs: + with self.subTest(workflow=workflow, job=job): + self.assertIn("Restore git object seed", self.steps_before_checkout(text, job)) + def test_a_failed_seeded_checkout_retries_without_the_seed(self): - text = (WORKFLOWS / "ci-macos.yml").read_text() - for job in ("macos-compile-admission", "app-host-unit-tests", "swift-package-tests"): - with self.subTest(job=job): - body = re.search(rf"^ {job}:\n(.*?)(?=^ [A-Za-z0-9_-]+:\n)", text, re.S | re.M).group(1) - discard = body.index("Discard the git object seed after a failed checkout") - retry = body.index("- name: Retry checkout", discard) - self.assertIn('rm -rf "$GITHUB_WORKSPACE/.git"', body[discard:retry]) - self.assertIn("steps.checkout.outcome == 'failure'", body[retry:retry + 200]) + for workflow, jobs in ( + ("ci-macos.yml", ("macos-compile-admission", "app-host-unit-tests", "swift-package-tests")), + ("test-e2e.yml", ("build", "test")), + ("test-ios.yml", ("mobile-core-package", "ios-simulator-build")), + ): + text = (WORKFLOWS / workflow).read_text() + for job in jobs: + with self.subTest(workflow=workflow, job=job): + body = re.search(rf"^ {job}:\n(.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", text, re.S | re.M).group(1) + discard = body.index("Discard the git object seed after a failed checkout") + retry = body.index("- name: Retry checkout", discard) + self.assertIn('rm -rf "$GITHUB_WORKSPACE/.git"', body[discard:retry]) + self.assertIn("steps.checkout.outcome == 'failure'", body[retry:retry + 200]) def test_only_main_saves_the_seed(self): text = (WORKFLOWS / "seed-derived-data.yml").read_text() diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index d21594ddb405..8f900eb15f64 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -181,6 +181,12 @@ allowed = { ("build", "seed", "Adopt the DerivedData seed", ""), ("build", None, "Forget the adopted-build inode override", ""), ("test", "parallel-product", "Read the compiled test product over parallel range requests", ""), + # The git object seed: a miss leaves checkout to fetch everything, and a + # checkout the seed breaks is retried without it by the next steps. + ("build", None, "Restore git object seed", ""), + ("build", "checkout", "Checkout", "actions/checkout"), + ("test", None, "Restore git object seed", ""), + ("test", "checkout", "Checkout", "actions/checkout"), # The owned-pool rescue marker: without it the run is only not watched. ("runner", "marker", "Mark a run on a persistent macOS pool", ""), ("runner", None, "Upload the persistent pool marker", "actions/upload-artifact"),