mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
ci(ios): select PR simulator smoke groups by source owner
Keep app and XCTest compilation on every admitted iOS smoke job. Select the voice/media/typography and Access/chat lifecycle simulator groups from their runtime, test, fixture and build owners, and omit simulator preparation when neither group is selected. Preserve full scheduled/manual/release coverage. Add OPENCLAW_CI_IOS_SIMULATOR_FULL to restore both PR groups, emit selection reasons in preflight and job summaries, and include the planner in the trusted preflight/platform harnesses. Retain every existing test case and assertion. Test cost: the complete iOS workflow file passed 67 cases in 70.65s locally while native compilation overlapped; the three integrated workflow/checkout files passed 653 cases in 182.09s on Linux. Native build-only and lifecycle-only paths passed with an unchanged app executable hash and no simulator use for build-only.
This commit is contained in:
parent
839907a43b
commit
288ace5c4e
12 changed files with 528 additions and 28 deletions
3
.github/actions/git-owner/owner.py
vendored
3
.github/actions/git-owner/owner.py
vendored
|
|
@ -462,11 +462,12 @@ def checkout_harness(sha):
|
|||
action = ".github/actions/setup-node-env/action.yml"
|
||||
node_setup_scripts = ("scripts/lib/pnpm-lockfile-documents.mjs",)
|
||||
evidence_scripts = ("scripts/ios-screenshot-evidence.mjs", "scripts/lib/direct-run.mjs", "scripts/ci-static-step.sh")
|
||||
platform_scripts = ("scripts/lib/swift-toolchain.sh",)
|
||||
platform_scripts = ("scripts/lib/swift-toolchain.sh", "scripts/lib/ci-ios-smoke-plan.mjs")
|
||||
upgrade_scripts = ("scripts/lib/release-upgrade-baseline.mjs", "scripts/lib/release-version.mjs")
|
||||
# The manifest builder runs from the harness and imports these siblings by file-relative paths.
|
||||
preflight_scripts = (
|
||||
"scripts/ci-build-manifest.mjs",
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs",
|
||||
"scripts/lib/release-context.mjs",
|
||||
"scripts/lib/release-version.mjs",
|
||||
)
|
||||
|
|
|
|||
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
|
|
@ -215,6 +215,9 @@ jobs:
|
|||
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
|
||||
run_openclawkit_tests: ${{ steps.manifest.outputs.run_openclawkit_tests }}
|
||||
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
|
||||
run_ios_voice_cleanup_tests: ${{ steps.manifest.outputs.run_ios_voice_cleanup_tests }}
|
||||
run_ios_lifecycle_tests: ${{ steps.manifest.outputs.run_ios_lifecycle_tests }}
|
||||
ios_simulator_selection: ${{ steps.manifest.outputs.ios_simulator_selection }}
|
||||
run_ios_screenshots: ${{ steps.changed_scope.outputs.run_ios_screenshots }}
|
||||
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
||||
run_android_access_native: ${{ steps.manifest.outputs.run_android_access_native }}
|
||||
|
|
@ -741,11 +744,12 @@ jobs:
|
|||
action = ".github/actions/setup-node-env/action.yml"
|
||||
node_setup_scripts = ("scripts/lib/pnpm-lockfile-documents.mjs",)
|
||||
evidence_scripts = ("scripts/ios-screenshot-evidence.mjs", "scripts/lib/direct-run.mjs", "scripts/ci-static-step.sh")
|
||||
platform_scripts = ("scripts/lib/swift-toolchain.sh",)
|
||||
platform_scripts = ("scripts/lib/swift-toolchain.sh", "scripts/lib/ci-ios-smoke-plan.mjs")
|
||||
upgrade_scripts = ("scripts/lib/release-upgrade-baseline.mjs", "scripts/lib/release-version.mjs")
|
||||
# The manifest builder runs from the harness and imports these siblings by file-relative paths.
|
||||
preflight_scripts = (
|
||||
"scripts/ci-build-manifest.mjs",
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs",
|
||||
"scripts/lib/release-context.mjs",
|
||||
"scripts/lib/release-version.mjs",
|
||||
)
|
||||
|
|
@ -1420,6 +1424,7 @@ jobs:
|
|||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}
|
||||
OPENCLAW_CI_UI_E2E_FULL: ${{ vars.OPENCLAW_CI_UI_E2E_FULL || 'false' }}
|
||||
OPENCLAW_CI_IOS_SIMULATOR_FULL: ${{ vars.OPENCLAW_CI_IOS_SIMULATOR_FULL || 'false' }}
|
||||
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
|
||||
OPENCLAW_CI_CHANGED_PATHS_FILE: ${{ steps.changed_scope.outputs.changed_paths_file }}
|
||||
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}
|
||||
|
|
@ -5051,6 +5056,7 @@ jobs:
|
|||
HISTORICAL_TARGET: *historical_target
|
||||
IOS_CI_PHASE: ${{ matrix.phase }}
|
||||
IOS_MAIN_TIER: ${{ needs.preflight.outputs.validation_tier == 'main' }}
|
||||
IOS_SIMULATOR_SELECTION: ${{ needs.preflight.outputs.ios_simulator_selection }}
|
||||
steps:
|
||||
- *platform_checkout_step
|
||||
|
||||
|
|
@ -5146,19 +5152,29 @@ jobs:
|
|||
echo "Swift lint wrappers are absent; skipping iOS lint for this frozen target."
|
||||
fi
|
||||
|
||||
- name: Prepare iOS simulator
|
||||
- name: Configure iOS build and report simulator selection
|
||||
if: (matrix.phase == 'smoke' || matrix.phase == 'tests') && needs.preflight.outputs.compatibility_target != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'JS'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { formatIosSimulatorSelectionSummary } from "./.ci-harness/scripts/lib/ci-ios-smoke-plan.mjs";
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatIosSimulatorSelectionSummary(JSON.parse(process.env.IOS_SIMULATOR_SELECTION)));
|
||||
JS
|
||||
if [[ "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ]]; then
|
||||
# Keep native architecture and test-bundle compilation even without simulator execution.
|
||||
smoke_settings="$RUNNER_TEMP/openclaw-ios-smoke.xcconfig"
|
||||
printf 'ARCHS = %s\nCOMPILER_INDEX_STORE_ENABLE = NO\n' "$(uname -m)" > "$smoke_settings"
|
||||
echo "XCODE_XCCONFIG_FILE=$smoke_settings" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare iOS simulator
|
||||
if: (matrix.phase == 'tests' || (matrix.phase == 'smoke' && (needs.preflight.outputs.run_ios_voice_cleanup_tests == 'true' || needs.preflight.outputs.run_ios_lifecycle_tests == 'true'))) && needs.preflight.outputs.compatibility_target != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||||
simulator_id="$(select_ios_test_simulator)"
|
||||
echo "IOS_SIMULATOR_ID=$simulator_id" >> "$GITHUB_ENV"
|
||||
if [[ "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ]]; then
|
||||
# Apply to the embedded Watch build and the later XCTest build too.
|
||||
smoke_settings="$RUNNER_TEMP/openclaw-ios-smoke.xcconfig"
|
||||
printf 'ARCHS = %s\nCOMPILER_INDEX_STORE_ENABLE = NO\n' "$(uname -m)" > "$smoke_settings"
|
||||
echo "XCODE_XCCONFIG_FILE=$smoke_settings" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Build iOS app
|
||||
if: matrix.phase == 'smoke' || matrix.phase == 'tests'
|
||||
|
|
@ -5166,7 +5182,11 @@ jobs:
|
|||
set -euo pipefail
|
||||
if [[ ( "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ) && "$HISTORICAL_TARGET" != "true" ]]; then
|
||||
# Full manual validation retains the universal simulator build.
|
||||
export IOS_DEST="platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?}"
|
||||
if [[ -n "${IOS_SIMULATOR_ID:-}" ]]; then
|
||||
export IOS_DEST="platform=iOS Simulator,id=$IOS_SIMULATOR_ID"
|
||||
else
|
||||
export IOS_DEST="generic/platform=iOS Simulator"
|
||||
fi
|
||||
fi
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
|
|
@ -5252,7 +5272,7 @@ jobs:
|
|||
|
||||
- name: Run focused iOS voice cleanup simulator tests
|
||||
id: ios_voice_cleanup_tests
|
||||
if: (matrix.phase == 'smoke' || matrix.phase == 'tests') && needs.preflight.outputs.compatibility_target != 'true'
|
||||
if: (matrix.phase == 'tests' || (matrix.phase == 'smoke' && needs.preflight.outputs.run_ios_voice_cleanup_tests == 'true')) && needs.preflight.outputs.compatibility_target != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||||
|
|
@ -5286,7 +5306,7 @@ jobs:
|
|||
|
||||
- name: Run focused iOS lifecycle simulator tests
|
||||
id: ios_lifecycle_tests
|
||||
if: (matrix.phase == 'smoke' || matrix.phase == 'tests') && needs.preflight.outputs.compatibility_target != 'true'
|
||||
if: (matrix.phase == 'tests' || (matrix.phase == 'smoke' && needs.preflight.outputs.run_ios_lifecycle_tests == 'true')) && needs.preflight.outputs.compatibility_target != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||||
|
|
|
|||
|
|
@ -306,3 +306,5 @@ Every section heading from the previous single-page version keeps its anchor her
|
|||
- [Maturity scorecard](/maturity/scorecard)
|
||||
- [Install overview](/install)
|
||||
- [Release channels](/install/development-channels)
|
||||
|
||||
Ordinary PR iOS smoke keeps app and test-bundle compilation while selecting its two simulator groups by source owner. With neither group selected, simulator preparation is skipped. `OPENCLAW_CI_IOS_SIMULATOR_FULL=true` restores full PR execution; [selection and routing](/ci/scope-and-routing/selection) documents the owners and full hourly/release coverage.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
|
|||
|
||||
Labeler skips PR edits without title or base-branch changes. These ignored edits use isolated per-run concurrency groups so they cannot cancel running labeling or replace useful pending work. Opened, reopened, synchronize, and title/base-edit events retain the shared per-PR group and supersede older labeling runs. Issue labeling and manual backfills retain their existing non-cancelling ref group.
|
||||
|
||||
Affected pull requests, `main` pushes, and exact-head `release_gate` fallbacks run one required `ios-build (smoke)` phase: the existing `pnpm ios:build` command, Swift lint, focused voice cleanup tests, and native Access client, transfer, and session-store tests, with their Xcode, Swift, and Watch Rust tooling. Smoke omits Rust tests and the broader app/notification lifecycle and Watch simulator suites. A failed, cancelled, or unexpectedly skipped smoke still fails `openclaw/ci-gate`.
|
||||
Affected pull requests, `main` pushes, and exact-head `release_gate` fallbacks retain one required `ios-build (smoke)` phase and its Xcode, Swift, and Watch Rust tooling. Current smoke always runs Swift lint and compiles the app and test bundles once with `build-for-testing`. On ordinary PRs, `scripts/lib/ci-ios-smoke-plan.mjs` selects the existing voice/media/typography and Access/chat lifecycle simulator groups from their source owners. Both groups own the app's real hosted startup and linked runtime sources; the voice group also audits Watch and ActivityWidget typography. Their selected test files and fixtures select their respective group. Build/test configuration and unavailable path metadata retain both groups.
|
||||
|
||||
When neither simulator group is selected, smoke uses a generic simulator build destination and skips simulator selection, boot, and SimSlim preparation. Selected groups keep separate `test-without-building` invocations against the same built products. The preflight and iOS job summaries record each group's selection and reasons. Repository variable `OPENCLAW_CI_IOS_SIMULATOR_FULL=true` (or `1`) restores both groups for every selected PR job; it does not select an otherwise unneeded iOS job. Main, scheduled, manual, release-gate, and compatibility planning retain full group selection. Existing historical execution contracts remain unchanged.
|
||||
|
||||
Smoke still omits Rust tests and the broader app/notification lifecycle and Watch simulator suites. A failed, cancelled, or unexpectedly skipped smoke still fails `openclaw/ci-gate`.
|
||||
|
||||
Hourly main-tier CI retains `ios-build (tests)`, including Rust, voice cleanup, native Access, and every focused app/notification lifecycle test, including its Watch messaging and activation coverage. Managed attachment UI/export proof, Watch operation simulator suites, and Watch delivery UI proof belong to full-tier manual/release validation; hourly runs also exclude screenshot capture. Main-tier and smoke builds use the selected simulator's native architecture without compiler indexing; voice and lifecycle tests retain logs and xcresult bundles without Xcode's verbose diagnostic collection. Only a canceled selected scheduled `ios-build` on canonical `main` can pass the aggregate with a coalescing notice; this delegates proof to a later job rather than validating the canceled revision. Other failures, unexpected skips, and cancellations remain failures. See [scheduled iOS aggregation](/ci/scheduled-workflows#hourly-main-ci).
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|||
import path, { matchesGlob } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveTestGitCommits } from "../.github/actions/git-owner/test-prerequisites.mjs";
|
||||
import {
|
||||
formatIosSimulatorSelectionSummary,
|
||||
resolveIosSimulatorTestSelection,
|
||||
} from "./lib/ci-ios-smoke-plan.mjs";
|
||||
import { resolveReleaseContextIdentity } from "./lib/release-context.mjs";
|
||||
import { classifyReleaseTrain, parseReleaseVersion } from "./lib/release-version.mjs";
|
||||
|
||||
|
|
@ -548,6 +552,16 @@ if (runtimePullRequest && runNodeFull) {
|
|||
onSelection: (selection) => nodeSelectionReasons.push(selection),
|
||||
});
|
||||
}
|
||||
const fullIosSimulatorPr = parseCiEnvFlag(process.env.OPENCLAW_CI_IOS_SIMULATOR_FULL);
|
||||
const iosSimulatorSelection = resolveIosSimulatorTestSelection(changedPaths, {
|
||||
enabled: runIosBuild,
|
||||
forceFull: !runtimePullRequest || releaseGate || compatibilityTarget || fullIosSimulatorPr,
|
||||
fullReason: fullIosSimulatorPr
|
||||
? "OPENCLAW_CI_IOS_SIMULATOR_FULL"
|
||||
: compatibilityTarget
|
||||
? "compatibility target"
|
||||
: "scheduled, main, or release validation",
|
||||
});
|
||||
const uiOwnerScope = {
|
||||
unit: runUiTests,
|
||||
mocked: runControlUiE2e,
|
||||
|
|
@ -1432,6 +1446,9 @@ const manifest = {
|
|||
(!frozenTarget || compatibilityTarget || supportsCurrentMacosSwiftCi),
|
||||
run_openclawkit_tests: runMacos && !npmQualification && supportsOpenClawKitTests,
|
||||
run_ios_build: runIosBuild,
|
||||
run_ios_voice_cleanup_tests: iosSimulatorSelection.voice.selected,
|
||||
run_ios_lifecycle_tests: iosSimulatorSelection.lifecycle.selected,
|
||||
ios_simulator_selection: iosSimulatorSelection,
|
||||
run_android_job: runAndroid,
|
||||
run_android_access_native: runAndroidAccessNative,
|
||||
use_compatible_android_ci: useCompatibleAndroidCi,
|
||||
|
|
@ -1760,6 +1777,12 @@ if (releaseFastLane) {
|
|||
}
|
||||
}
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
if (runIosBuild) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
formatIosSimulatorSelectionSummary(iosSimulatorSelection),
|
||||
);
|
||||
}
|
||||
if (uiE2eSelection) {
|
||||
const escapeSummaryCell = (value) =>
|
||||
String(value)
|
||||
|
|
|
|||
116
scripts/lib/ci-ios-smoke-plan.mjs
Normal file
116
scripts/lib/ci-ios-smoke-plan.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// OpenClawTests is hosted by the real OpenClaw app (apps/ios/project.yml).
|
||||
// Its startup reaches RootTabs, onboarding/settings, and NodeAppModel services.
|
||||
// The voice group's typography suite also reads every app/Watch Swift source.
|
||||
const sharedOwners = [
|
||||
/^apps\/ios\/Sources\//u,
|
||||
/^apps\/ios\/Resources\//u,
|
||||
/^apps\/shared\/OpenClawKit\/Sources\//u,
|
||||
/^apps\/swabble\/Sources\/SwabbleKit\//u,
|
||||
/^apps\/shared\/mermaid\//u,
|
||||
/^packages\/mermaid-renderer\//u,
|
||||
/^apps\/ios\/(?:project\.yml|[^/]+\.plist|[^/]+\.xcconfig|Config\/|Tests\/Info\.plist)/u,
|
||||
/^apps\/(?:shared\/OpenClawKit|swabble)\/Package\.(?:swift|resolved)$/u,
|
||||
/^apps\/macos\/Tests\/OpenClawIPCTests\/GatewayWebSocketTestSupport\.swift$/u,
|
||||
/^apps\/shared\/OpenClawKit\/Tests\/OpenClawKitTests\/(?:NativeGatewayWebSocketFixture|ChatMermaidRenderModelTests|ChatSelectableTextViewTests|ChatPasteboardTests)\.swift$/u,
|
||||
];
|
||||
|
||||
const buildOwners = [
|
||||
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/u,
|
||||
/^\.github\/(?:workflows\/ci\.yml|actions\/)/u,
|
||||
/^config\/swift(?:lint\.yml|format)$/u,
|
||||
/^scripts\/(?:ci-build-manifest\.mjs|ci-changed-scope\.mjs|prepare-apple-mermaid\.mjs|select-ios-simulator\.mjs)$/u,
|
||||
/^scripts\/lib\/(?:ci-ios-smoke-plan\.mjs|swift-toolchain\.sh|(?:ios|mobile)-version\.ts|release-version\.mjs|version-script-args\.ts)$/u,
|
||||
/^scripts\/(?:check-swift-tools|format-swift|install-simslim|install-swift-tools|install-xcodegen|lint-swift|ios-configure-signing|ios-simulator-prepare|ios-team-id|ios-write-version-xcconfig)\.sh$/u,
|
||||
/^scripts\/(?:ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$/u,
|
||||
];
|
||||
|
||||
const voiceOwners = [
|
||||
/^apps\/ios\/WatchApp\/(?:Sources\/|Info\.plist$)/u,
|
||||
/^apps\/ios\/ActivityWidget\//u,
|
||||
/^apps\/ios\/Tests\/(?:TalkRealtimeVoiceSessionCleanupTests|TalkRealtimeConsultCancellationTests|TalkRealtimeTranscriptWriteQueueTests|TalkModeConfigParsingTests|ManagedDocumentEnvelopeTests|IOSMediaArtifactLoaderTests|OpenClawTypographyTests)\.swift$/u,
|
||||
/^apps\/ios\/Tests\/Fixtures\/managed-document-message\.json$/u,
|
||||
];
|
||||
|
||||
const lifecycleOwners = [
|
||||
/^apps\/ios\/Tests\/(?:CloudflareAccessClientTests|CloudflareAccessTransferTests|CloudflareAccessSessionStoreTests|CloudflareAccessTestTokens|ChatTypingFocusTests|ChatSendHydrationTests)\.swift$/u,
|
||||
];
|
||||
|
||||
/** Select simulator execution only; the app and test products still compile. */
|
||||
export function resolveIosSimulatorTestSelection(
|
||||
changedPaths,
|
||||
{ enabled = true, forceFull = false, fullReason = "full validation" } = {},
|
||||
) {
|
||||
const selection = (mode, voice, lifecycle) => ({
|
||||
mode,
|
||||
voice: { selected: enabled && voice.length > 0, reasons: voice },
|
||||
lifecycle: { selected: enabled && lifecycle.length > 0, reasons: lifecycle },
|
||||
});
|
||||
if (!enabled) {
|
||||
return selection("not-selected", ["iOS job not selected"], ["iOS job not selected"]);
|
||||
}
|
||||
if (forceFull) {
|
||||
return selection("full", [fullReason], [fullReason]);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(changedPaths) ||
|
||||
changedPaths.some(
|
||||
(file) =>
|
||||
typeof file !== "string" ||
|
||||
!file ||
|
||||
file.startsWith("/") ||
|
||||
file.split("/").some((part) => part === "." || part === "..") ||
|
||||
/[\\\r\n\0]/u.test(file),
|
||||
)
|
||||
) {
|
||||
return selection(
|
||||
"full",
|
||||
["changed paths unavailable or invalid"],
|
||||
["changed paths unavailable or invalid"],
|
||||
);
|
||||
}
|
||||
const voice = [];
|
||||
const lifecycle = [];
|
||||
for (const file of [...new Set(changedPaths)].toSorted((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
)) {
|
||||
if (buildOwners.some((pattern) => pattern.test(file))) {
|
||||
return selection("full", [`build/test input: ${file}`], [`build/test input: ${file}`]);
|
||||
}
|
||||
if (sharedOwners.some((pattern) => pattern.test(file))) {
|
||||
voice.push(`shared app/test owner: ${file}`);
|
||||
lifecycle.push(`shared app/test owner: ${file}`);
|
||||
continue;
|
||||
}
|
||||
if (voiceOwners.some((pattern) => pattern.test(file))) {
|
||||
voice.push(`voice/media/typography owner: ${file}`);
|
||||
}
|
||||
if (lifecycleOwners.some((pattern) => pattern.test(file))) {
|
||||
lifecycle.push(`Access/chat lifecycle owner: ${file}`);
|
||||
}
|
||||
}
|
||||
return selection("changed-owners", voice, lifecycle);
|
||||
}
|
||||
|
||||
export function formatIosSimulatorSelectionSummary(selection) {
|
||||
const escape = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replace(/[\\`*_{}[\]()#+.!|]/gu, "\\$&")
|
||||
.replace(/[\r\n]/gu, " ");
|
||||
return (
|
||||
"### iOS simulator test selection\n\n" +
|
||||
`- Mode: ${escape(selection.mode)}.\n` +
|
||||
"- App and test-bundle compilation remain required when the iOS job is selected.\n\n" +
|
||||
"| Group | Run | Reasons |\n| --- | --- | --- |\n" +
|
||||
["voice", "lifecycle"]
|
||||
.map((group) => {
|
||||
const { selected, reasons } = selection[group];
|
||||
const why = reasons.length ? reasons.map(escape).join("; ") : "No changed owner";
|
||||
return `| ${group} | ${selected ? "yes" : "no"} | ${why} |\n`;
|
||||
})
|
||||
.join("") +
|
||||
"\n"
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,17 @@ type PolicyTestWatch = {
|
|||
// this inventory covers the remaining tests that changed targeting cannot
|
||||
// discover from imports alone.
|
||||
const policyTestWatches: readonly PolicyTestWatch[] = [
|
||||
{
|
||||
testFile: "test/scripts/ios-lifecycle-workflow.test.ts",
|
||||
watchGlobs: [
|
||||
".github/workflows/ci.yml",
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs",
|
||||
"apps/ios/project.yml",
|
||||
"apps/ios/Tests/**",
|
||||
"apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift",
|
||||
"apps/shared/OpenClawKit/Tests/OpenClawKitTests/NativeGatewayWebSocketFixture.swift",
|
||||
],
|
||||
},
|
||||
// Browser-served route owners are not imports of the Playwright entry point.
|
||||
...UI_E2E_OWNER_WATCHES.map(({ testFile, watchGlobs }): PolicyTestWatch => ({
|
||||
testFile,
|
||||
|
|
|
|||
|
|
@ -587,6 +587,7 @@ it("materializes an executable preflight manifest from the workflow revision", a
|
|||
".github/actions/git-owner/test-prerequisites.mjs",
|
||||
".github/actions/git-owner/test-prerequisites.json",
|
||||
"scripts/ci-build-manifest.mjs",
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs",
|
||||
"scripts/lib/release-context.mjs",
|
||||
"scripts/lib/release-version.mjs",
|
||||
]) {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ function expectedHarnessSparseCheckoutArgs(linux: boolean) {
|
|||
"/scripts/changed-lanes.mts",
|
||||
"/scripts/lib/merge-head-diff-base.mjs",
|
||||
]
|
||||
: ["/scripts/lib/swift-toolchain.sh"]),
|
||||
: ["/scripts/lib/swift-toolchain.sh", "/scripts/lib/ci-ios-smoke-plan.mjs"]),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -406,6 +406,12 @@ it.concurrent.each([
|
|||
const preflightScripts = {
|
||||
"scripts/ci-build-manifest.mjs": readFileSync("scripts/ci-build-manifest.mjs", "utf8"),
|
||||
};
|
||||
const simulatorScripts = {
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs": readFileSync(
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs",
|
||||
"utf8",
|
||||
),
|
||||
};
|
||||
const releasePolicy = Object.fromEntries(
|
||||
[
|
||||
"scripts/lib/release-context.mjs",
|
||||
|
|
@ -478,6 +484,7 @@ it.concurrent.each([
|
|||
...nodeSetupScripts,
|
||||
...platformScripts,
|
||||
...preflightScripts,
|
||||
...simulatorScripts,
|
||||
...releasePolicy,
|
||||
...candidateFiles,
|
||||
})) {
|
||||
|
|
@ -667,6 +674,15 @@ it.concurrent.each([
|
|||
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
|
||||
}
|
||||
}
|
||||
for (const [name, contents] of Object.entries(simulatorScripts)) {
|
||||
const ownsSimulator = preflight || kind === "platform";
|
||||
expect(existsSync(path.join(harness, name)), name).toBe(ownsSimulator);
|
||||
if (ownsSimulator) {
|
||||
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
|
||||
writeFileSync(path.join(workspace, name), "throw new Error('candidate planner');\n");
|
||||
expect(readFileSync(path.join(harness, name), "utf8")).toBe(contents);
|
||||
}
|
||||
}
|
||||
for (const [name, contents] of Object.entries(releasePolicy)) {
|
||||
const ownsPolicy = preflight
|
||||
? name !== "scripts/lib/release-upgrade-baseline.mjs"
|
||||
|
|
|
|||
|
|
@ -619,7 +619,7 @@ export function runCiManifestFixture(options: {
|
|||
);
|
||||
const trustedReleasePolicy = path.join(root, ".ci-harness/scripts/lib");
|
||||
mkdirSync(trustedReleasePolicy, { recursive: true });
|
||||
for (const name of ["release-context.mjs", "release-version.mjs"]) {
|
||||
for (const name of ["release-context.mjs", "release-version.mjs", "ci-ios-smoke-plan.mjs"]) {
|
||||
writeFileSync(path.join(trustedReleasePolicy, name), readFileSync(`scripts/lib/${name}`));
|
||||
}
|
||||
copyFileSync(
|
||||
|
|
|
|||
|
|
@ -2326,6 +2326,8 @@ describe("ci workflow guards", () => {
|
|||
for (const family of [
|
||||
"run_build_artifacts",
|
||||
"run_ios_build",
|
||||
"run_ios_voice_cleanup_tests",
|
||||
"run_ios_lifecycle_tests",
|
||||
"run_macos_swift",
|
||||
"run_checks_windows",
|
||||
"run_ui_tests",
|
||||
|
|
@ -2516,6 +2518,8 @@ describe("ci workflow guards", () => {
|
|||
preflightOutputs: {
|
||||
compatibility_target: String(historical),
|
||||
run_openclawkit_tests: "true",
|
||||
run_ios_voice_cleanup_tests: "true",
|
||||
run_ios_lifecycle_tests: "true",
|
||||
release_scope: "full",
|
||||
},
|
||||
};
|
||||
|
|
@ -2548,6 +2552,7 @@ describe("ci workflow guards", () => {
|
|||
: {
|
||||
smoke: [
|
||||
"Swift lint",
|
||||
...(historical ? [] : ["Configure iOS build and report simulator selection"]),
|
||||
...(historical ? [] : ["Prepare iOS simulator"]),
|
||||
"Build iOS app",
|
||||
...(historical ? [] : ["Run focused iOS voice cleanup simulator tests"]),
|
||||
|
|
@ -2557,6 +2562,7 @@ describe("ci workflow guards", () => {
|
|||
tests: [
|
||||
"Test Watch RTC engine",
|
||||
"Swift lint",
|
||||
"Configure iOS build and report simulator selection",
|
||||
"Prepare iOS simulator",
|
||||
"Build iOS app",
|
||||
"Run focused iOS voice cleanup simulator tests",
|
||||
|
|
@ -8032,6 +8038,13 @@ describe("ci workflow guards", () => {
|
|||
run_macos_swift: "false",
|
||||
run_openclawkit_tests: "false",
|
||||
run_ios_build: "false",
|
||||
run_ios_voice_cleanup_tests: "false",
|
||||
run_ios_lifecycle_tests: "false",
|
||||
ios_simulator_selection: JSON.stringify({
|
||||
mode: "not-selected",
|
||||
voice: { selected: false, reasons: ["iOS job not selected"] },
|
||||
lifecycle: { selected: false, reasons: ["iOS job not selected"] },
|
||||
}),
|
||||
run_android: "false",
|
||||
run_android_job: "false",
|
||||
run_android_access_native: "false",
|
||||
|
|
|
|||
|
|
@ -4,13 +4,20 @@ import {
|
|||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import {
|
||||
formatIosSimulatorSelectionSummary,
|
||||
resolveIosSimulatorTestSelection,
|
||||
} from "../../scripts/lib/ci-ios-smoke-plan.mjs";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
import { runCiManifestFixture } from "./ci-workflow-manifest.test-support.js";
|
||||
import { evaluateWorkflowExpression } from "./ci-workflow.test-support.js";
|
||||
|
||||
type Command = { tool: string; args: string[]; destination?: string; settings?: string };
|
||||
|
||||
|
|
@ -22,9 +29,16 @@ function isTestCommand(command: Command) {
|
|||
}
|
||||
|
||||
type Step = { name?: string; run?: string; if?: string };
|
||||
const workflow: { jobs: Record<string, { env?: Record<string, string>; steps?: Step[] }> } = parse(
|
||||
readFileSync(".github/workflows/ci.yml", "utf8"),
|
||||
);
|
||||
const workflow: {
|
||||
jobs: Record<
|
||||
string,
|
||||
{
|
||||
env?: Record<string, string>;
|
||||
steps?: Step[];
|
||||
strategy?: { matrix?: { phase?: string } };
|
||||
}
|
||||
>;
|
||||
} = parse(readFileSync(".github/workflows/ci.yml", "utf8"));
|
||||
const watchStep = workflow.jobs["ios-build"]?.steps?.find(
|
||||
(step) => step.name === "Run focused Apple Watch operation simulator tests",
|
||||
);
|
||||
|
|
@ -38,6 +52,9 @@ const prepareStep = workflow.jobs["ios-build"]?.steps?.find(
|
|||
(step) => step.name === "Prepare iOS simulator",
|
||||
);
|
||||
const buildStep = workflow.jobs["ios-build"]?.steps?.find((step) => step.name === "Build iOS app");
|
||||
const configureStep = workflow.jobs["ios-build"]?.steps?.find(
|
||||
(step) => step.name === "Configure iOS build and report simulator selection",
|
||||
);
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function runSimulatorStep(mode = "ready", steps = [watchStep], env: Record<string, string> = {}) {
|
||||
|
|
@ -48,6 +65,7 @@ function runSimulatorStep(mode = "ready", steps = [watchStep], env: Record<strin
|
|||
mkdirSync(bin, { recursive: true });
|
||||
mkdirSync(harnessLib, { recursive: true });
|
||||
copyFileSync("scripts/lib/swift-toolchain.sh", path.join(harnessLib, "swift-toolchain.sh"));
|
||||
copyFileSync("scripts/lib/ci-ios-smoke-plan.mjs", path.join(harnessLib, "ci-ios-smoke-plan.mjs"));
|
||||
mkdirSync(product, { recursive: true });
|
||||
const runner = path.join(root, "tools.mjs");
|
||||
writeFileSync(
|
||||
|
|
@ -127,7 +145,9 @@ if (tool === "installer") {
|
|||
}
|
||||
}
|
||||
const environmentFile = path.join(root, "github-env");
|
||||
const summaryFile = path.join(root, "summary.md");
|
||||
writeFileSync(environmentFile, "");
|
||||
writeFileSync(summaryFile, "");
|
||||
const script = steps
|
||||
.map((step) => {
|
||||
if (!step?.run) {
|
||||
|
|
@ -148,6 +168,8 @@ if (tool === "installer") {
|
|||
WATCH_FIXTURE_ROOT: root,
|
||||
WATCH_FIXTURE_MODE: mode,
|
||||
GITHUB_ENV: environmentFile,
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
IOS_SIMULATOR_SELECTION: JSON.stringify(resolveIosSimulatorTestSelection(null)),
|
||||
IOS_CI_PHASE: "smoke",
|
||||
IOS_MAIN_TIER: "false",
|
||||
HISTORICAL_TARGET: "false",
|
||||
|
|
@ -167,12 +189,14 @@ if (tool === "installer") {
|
|||
result,
|
||||
commands,
|
||||
product,
|
||||
summary: readFileSync(summaryFile, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("SimSlim workflow admission", () => {
|
||||
it("prewarms and slims the build's exact iPhone before testing", () => {
|
||||
const { result, commands } = runSimulatorStep("voice-slim", [
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
voiceStep,
|
||||
|
|
@ -190,7 +214,11 @@ describe.skipIf(process.platform === "win32")("SimSlim workflow admission", () =
|
|||
});
|
||||
|
||||
it.each(["missing-installer", "missing-prepare"])("keeps %s targets stock", (mode) => {
|
||||
const { result, commands } = runSimulatorStep(`voice-slim-${mode}`, [prepareStep, buildStep]);
|
||||
const { result, commands } = runSimulatorStep(`voice-slim-${mode}`, [
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(commands.some(({ tool }) => tool === "simslim" || tool === "installer")).toBe(false);
|
||||
expect(commands.some(({ tool }) => tool === "pnpm")).toBe(true);
|
||||
|
|
@ -198,6 +226,7 @@ describe.skipIf(process.platform === "win32")("SimSlim workflow admission", () =
|
|||
|
||||
it.each(["install", "on", "verify", "boot"])("stops before XCTest on %s failure", (mode) => {
|
||||
const { result, commands } = runSimulatorStep(`voice-slim-${mode}-failed`, [
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
voiceStep,
|
||||
|
|
@ -295,9 +324,13 @@ describe.skipIf(process.platform === "win32")("iOS voice cleanup workflow", () =
|
|||
});
|
||||
|
||||
it("retains universal build settings and verbose diagnostics in full manual validation", () => {
|
||||
const { result, commands } = runSimulatorStep("voice", [prepareStep, buildStep, voiceStep], {
|
||||
IOS_CI_PHASE: "tests",
|
||||
});
|
||||
const { result, commands } = runSimulatorStep(
|
||||
"voice",
|
||||
[configureStep, prepareStep, buildStep, voiceStep],
|
||||
{
|
||||
IOS_CI_PHASE: "tests",
|
||||
},
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const appBuild = commands.find((command) => command.tool === "pnpm");
|
||||
expect(appBuild?.destination).toBe("");
|
||||
|
|
@ -310,6 +343,7 @@ describe.skipIf(process.platform === "win32")("iOS voice cleanup workflow", () =
|
|||
|
||||
it("fails after the overlapping build without XCTest when the selected iPhone cannot boot", () => {
|
||||
const { result, commands } = runSimulatorStep("voice-boot-failed", [
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
voiceStep,
|
||||
|
|
@ -328,7 +362,7 @@ describe.skipIf(process.platform === "win32")("iOS voice cleanup workflow", () =
|
|||
(phase, main) => {
|
||||
const { result, commands } = runSimulatorStep(
|
||||
"voice",
|
||||
[prepareStep, buildStep, voiceStep, iosStep],
|
||||
[configureStep, prepareStep, buildStep, voiceStep, iosStep],
|
||||
{ IOS_CI_PHASE: phase, IOS_MAIN_TIER: main },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
|
@ -374,6 +408,33 @@ describe.skipIf(process.platform === "win32")("iOS voice cleanup workflow", () =
|
|||
"-only-testing:OpenClawTests/OpenClawTypographyTests",
|
||||
]);
|
||||
expect(build.args).toEqual(expect.arrayContaining(["-collect-test-diagnostics", "never"]));
|
||||
if (phase === "smoke") {
|
||||
const sources = readdirSync("apps/ios/Tests", { recursive: true })
|
||||
.filter((file): file is string => typeof file === "string" && file.endsWith(".swift"))
|
||||
.map((file) => ({
|
||||
file: `apps/ios/Tests/${file}`,
|
||||
source: readFileSync(path.join("apps/ios/Tests", file), "utf8"),
|
||||
}));
|
||||
const testCommands = builds.filter(isTestCommand);
|
||||
for (const [index, group] of ["voice", "lifecycle"].entries()) {
|
||||
const selectors = testCommands[index]!.args.filter((arg) =>
|
||||
arg.startsWith("-only-testing:"),
|
||||
);
|
||||
for (const selector of selectors) {
|
||||
const suite = selector.split("/")[1]!;
|
||||
const declarations = sources.filter(({ source }) =>
|
||||
new RegExp(`\\b(?:struct|class|enum)\\s+${suite}\\b`, "u").test(source),
|
||||
);
|
||||
expect(declarations, `Source owner for ${selector}`).toHaveLength(1);
|
||||
const file = declarations[0]!.file;
|
||||
const selection = resolveIosSimulatorTestSelection([file]);
|
||||
expect(
|
||||
group === "voice" ? selection.voice.selected : selection.lifecycle.selected,
|
||||
`The ${group} group must run when its ${suite} source ${file} changes`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -389,7 +450,12 @@ describe.skipIf(process.platform === "win32")("iOS Access simulator workflow", (
|
|||
expect(iosStep?.if).toContain("matrix.phase == 'smoke'");
|
||||
expect(iosStep?.if).toContain("needs.preflight.outputs.compatibility_target != 'true'");
|
||||
expect(workflow.jobs["ios-build"]?.env?.IOS_CI_PHASE).toBe("${{ matrix.phase }}");
|
||||
const { result, commands } = runSimulatorStep("voice", [prepareStep, buildStep, iosStep]);
|
||||
const { result, commands } = runSimulatorStep("voice", [
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
iosStep,
|
||||
]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const tests = commands.filter(isTestCommand);
|
||||
expect(tests).toHaveLength(1);
|
||||
|
|
@ -405,9 +471,13 @@ describe.skipIf(process.platform === "win32")("iOS Access simulator workflow", (
|
|||
});
|
||||
|
||||
it("keeps full lifecycle and UI tests alongside Access tests in full validation", () => {
|
||||
const { result, commands } = runSimulatorStep("voice", [prepareStep, buildStep, iosStep], {
|
||||
IOS_CI_PHASE: "tests",
|
||||
});
|
||||
const { result, commands } = runSimulatorStep(
|
||||
"voice",
|
||||
[configureStep, prepareStep, buildStep, iosStep],
|
||||
{
|
||||
IOS_CI_PHASE: "tests",
|
||||
},
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const tests = commands.filter((command) => command.tool === "xcodebuild");
|
||||
expect(tests).toHaveLength(2);
|
||||
|
|
@ -432,7 +502,7 @@ describe.skipIf(process.platform === "win32")("iOS Access simulator workflow", (
|
|||
(phase) => {
|
||||
const { result, commands } = runSimulatorStep(
|
||||
"voice-tests-failed",
|
||||
[prepareStep, buildStep, iosStep],
|
||||
[configureStep, prepareStep, buildStep, iosStep],
|
||||
{
|
||||
IOS_CI_PHASE: phase,
|
||||
},
|
||||
|
|
@ -442,3 +512,226 @@ describe.skipIf(process.platform === "win32")("iOS Access simulator workflow", (
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("iOS simulator owner selection", () => {
|
||||
it.each([
|
||||
["apps/ios/Sources/Voice/TalkModeManager.swift", true, true],
|
||||
["apps/ios/Sources/Onboarding/OnboardingWizardView.swift", true, true],
|
||||
["apps/ios/Sources/FutureFeature/NewService.swift", true, true],
|
||||
["apps/ios/WatchApp/Sources/WatchInboxView.swift", true, false],
|
||||
["apps/ios/ActivityWidget/OpenClawLiveActivity.swift", true, false],
|
||||
["apps/ios/Tests/TalkModeConfigParsingTests.swift", true, false],
|
||||
["apps/ios/Tests/Fixtures/managed-document-message.json", true, false],
|
||||
["apps/ios/Tests/CloudflareAccessTestTokens.swift", false, true],
|
||||
["apps/ios/Tests/ChatSendHydrationTests.swift", false, true],
|
||||
["apps/ios/Tests/RootTabsNavigationTests.swift", false, false],
|
||||
["apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift", false, false],
|
||||
["apps/shared/OpenClawKit/Sources/OpenClawNativeState/NativeState.swift", true, true],
|
||||
["apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift", true, true],
|
||||
["apps/swabble/Sources/SwabbleKit/Speech.swift", true, true],
|
||||
["apps/swabble/Sources/swabble/main.swift", false, false],
|
||||
["apps/ios/fastlane/Fastfile", false, false],
|
||||
] as const)(
|
||||
"selects the actual runtime, test, and resource owners for %s",
|
||||
(file, voice, lifecycle) => {
|
||||
const selected = resolveIosSimulatorTestSelection([file]);
|
||||
expect(selected.voice.selected).toBe(voice);
|
||||
expect(selected.lifecycle.selected).toBe(lifecycle);
|
||||
for (const group of [selected.voice, selected.lifecycle]) {
|
||||
expect(group.reasons.some((reason: string) => reason.includes(file))).toBe(group.selected);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"apps/ios/project.yml",
|
||||
"apps/ios/Config/Signing.xcconfig",
|
||||
"apps/ios/Sources/Fonts/Inter[opsz,wght].ttf",
|
||||
"apps/ios/Tests/Info.plist",
|
||||
"apps/shared/OpenClawKit/Package.swift",
|
||||
"apps/swabble/Package.resolved",
|
||||
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/Resources/Mermaid/index.html",
|
||||
"scripts/lib/swift-toolchain.sh",
|
||||
"scripts/ios-simulator-prepare.sh",
|
||||
"scripts/lib/ci-ios-smoke-plan.mjs",
|
||||
".github/workflows/ci.yml",
|
||||
"pnpm-lock.yaml",
|
||||
])("retains both groups when shared build or bundle input changes: %s", (file) => {
|
||||
const selected = resolveIosSimulatorTestSelection([file]);
|
||||
expect(selected.voice.selected).toBe(true);
|
||||
expect(selected.lifecycle.selected).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
["../apps/ios/Tests/ChatTypingFocusTests.swift"],
|
||||
["/unknown"],
|
||||
["invalid\npath"],
|
||||
])(
|
||||
"falls back to full coverage when changed paths are unavailable or invalid: %j",
|
||||
(changedPaths) => {
|
||||
const selected = resolveIosSimulatorTestSelection(changedPaths);
|
||||
expect(selected.mode).toBe("full");
|
||||
expect(selected.voice.selected).toBe(true);
|
||||
expect(selected.lifecycle.selected).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("never turns an unselected iOS job back on, even with the full-sequence override", () => {
|
||||
const selected = resolveIosSimulatorTestSelection(null, { enabled: false, forceFull: true });
|
||||
expect(selected.voice.selected).toBe(false);
|
||||
expect(selected.lifecycle.selected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function admittedSimulatorSteps(
|
||||
selection: ReturnType<typeof resolveIosSimulatorTestSelection>,
|
||||
phase = "smoke",
|
||||
compatibility = false,
|
||||
) {
|
||||
return [configureStep, prepareStep, buildStep, voiceStep, iosStep].filter((step) => {
|
||||
if (!step) {
|
||||
throw new Error("Missing iOS simulator workflow step");
|
||||
}
|
||||
if (!step.if) {
|
||||
return true;
|
||||
}
|
||||
const expression = step.if.startsWith("${{") ? step.if : `\${{ ${step.if} }}`;
|
||||
return evaluateWorkflowExpression(expression, {
|
||||
repository: "openclaw/openclaw",
|
||||
eventName: "pull_request",
|
||||
runAttempt: 1,
|
||||
matrix: { phase },
|
||||
preflightOutputs: {
|
||||
compatibility_target: String(compatibility),
|
||||
run_ios_voice_cleanup_tests: String(selection.voice.selected),
|
||||
run_ios_lifecycle_tests: String(selection.lifecycle.selected),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("iOS selected simulator workflow", () => {
|
||||
it.each([
|
||||
{ owner: "apps/ios/fastlane/Fastfile", groups: [] },
|
||||
{ owner: "apps/ios/Tests/TalkModeConfigParsingTests.swift", groups: ["voice"] },
|
||||
{ owner: "apps/ios/Tests/ChatSendHydrationTests.swift", groups: ["lifecycle"] },
|
||||
])("builds the app and runs only selected groups for $owner", ({ owner, groups }) => {
|
||||
const selection = resolveIosSimulatorTestSelection([owner]);
|
||||
const steps = admittedSimulatorSteps(selection);
|
||||
expect(steps).toContain(buildStep);
|
||||
expect(steps.includes(prepareStep)).toBe(groups.length > 0);
|
||||
const { result, commands, summary } = runSimulatorStep("voice-slim", steps, {
|
||||
IOS_SIMULATOR_SELECTION: JSON.stringify(selection),
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const builds = commands.filter(
|
||||
({ tool, args }) => tool === "xcodebuild" && args.includes("build-for-testing"),
|
||||
);
|
||||
expect(builds).toHaveLength(1);
|
||||
expect(builds[0]?.settings).toBe("ARCHS = arm64\nCOMPILER_INDEX_STORE_ENABLE = NO\n");
|
||||
expect(builds[0]?.args).toContain(
|
||||
groups.length
|
||||
? "platform=iOS Simulator,id=11111111-2222-3333-4444-555555555555"
|
||||
: "generic/platform=iOS Simulator",
|
||||
);
|
||||
const tests = commands.filter(isTestCommand);
|
||||
expect(tests).toHaveLength(groups.length);
|
||||
for (const test of tests) {
|
||||
expect(test.args).toContain("test-without-building");
|
||||
expect(test.args).not.toContain("test");
|
||||
expect(test.args).toContain(
|
||||
groups[0] === "voice"
|
||||
? "-only-testing:OpenClawTests/TalkRealtimeVoiceSessionCleanupTests"
|
||||
: "-only-testing:OpenClawTests/ChatTypingFocusTests",
|
||||
);
|
||||
}
|
||||
if (!groups.length) {
|
||||
expect(commands.some(({ tool }) => ["xcrun", "installer", "simslim"].includes(tool))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(summary).toBe(formatIosSimulatorSelectionSummary(selection));
|
||||
for (const group of ["voice", "lifecycle"]) {
|
||||
expect(summary).toContain(`| ${group} | ${groups.includes(group) ? "yes" : "no"} |`);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the full tests phase and compatibility exclusions regardless of PR group flags", () => {
|
||||
const none = resolveIosSimulatorTestSelection([]);
|
||||
expect(admittedSimulatorSteps(none, "tests")).toEqual([
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
voiceStep,
|
||||
iosStep,
|
||||
]);
|
||||
const all = resolveIosSimulatorTestSelection(null);
|
||||
expect(admittedSimulatorSteps(all)).toEqual([
|
||||
configureStep,
|
||||
prepareStep,
|
||||
buildStep,
|
||||
voiceStep,
|
||||
iosStep,
|
||||
]);
|
||||
for (const phase of ["smoke", "tests"]) {
|
||||
const compatible = admittedSimulatorSteps(all, phase, true);
|
||||
expect(compatible).toContain(buildStep);
|
||||
expect(compatible).not.toContain(prepareStep);
|
||||
expect(compatible).not.toContain(voiceStep);
|
||||
expect(compatible).not.toContain(iosStep);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ event: "pull_request", kill: "", full: false, releaseGate: false, historical: false },
|
||||
{ event: "pull_request", kill: "true", full: true, releaseGate: false, historical: false },
|
||||
{ event: "pull_request", kill: "1", full: true, releaseGate: false, historical: false },
|
||||
{ event: "schedule", kill: "", full: true, releaseGate: false, historical: false },
|
||||
{ event: "workflow_dispatch", kill: "", full: true, releaseGate: false, historical: false },
|
||||
{ event: "workflow_dispatch", kill: "", full: true, releaseGate: false, historical: true },
|
||||
{ event: "workflow_dispatch", kill: "", full: true, releaseGate: true, historical: false },
|
||||
] as const)(
|
||||
"publishes iOS manifest decisions for $event, override=$kill, release=$releaseGate, historical=$historical",
|
||||
({ event, kill, full, releaseGate, historical }) => {
|
||||
const result = runCiManifestFixture({
|
||||
bundledPlanner: true,
|
||||
historicalCompatibility: historical,
|
||||
eventName: event,
|
||||
releaseGate,
|
||||
changedPaths: ["apps/ios/Tests/ChatSendHydrationTests.swift"],
|
||||
scopeEnv: {
|
||||
OPENCLAW_CI_RUN_IOS_BUILD: "true",
|
||||
OPENCLAW_CI_IOS_SIMULATOR_FULL: kill,
|
||||
OPENCLAW_CI_VALIDATION_TIER: event === "schedule" ? "main" : "full",
|
||||
},
|
||||
});
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.outputs.run_ios_build).toBe("true");
|
||||
expect(result.outputs.run_ios_voice_cleanup_tests).toBe(String(full));
|
||||
expect(result.outputs.run_ios_lifecycle_tests).toBe("true");
|
||||
const selection: ReturnType<typeof resolveIosSimulatorTestSelection> = JSON.parse(
|
||||
result.outputs.ios_simulator_selection!,
|
||||
);
|
||||
expect(selection.voice.selected).toBe(full);
|
||||
expect(selection.lifecycle.selected).toBe(true);
|
||||
expect(result.summary).toContain("iOS simulator test selection");
|
||||
expect(result.summary).toContain(`| voice | ${full ? "yes" : "no"} |`);
|
||||
expect(result.summary).toContain("| lifecycle | yes |");
|
||||
const phases = evaluateWorkflowExpression(workflow.jobs["ios-build"]?.strategy?.matrix?.phase, {
|
||||
repository: "openclaw/openclaw",
|
||||
eventName: event,
|
||||
runAttempt: 1,
|
||||
releaseGate,
|
||||
preflightOutputs: result.outputs,
|
||||
});
|
||||
expect(phases).toEqual(
|
||||
event === "schedule" || historical
|
||||
? ["tests"]
|
||||
: event === "workflow_dispatch" && !releaseGate
|
||||
? ["release", "tests"]
|
||||
: ["smoke"],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue