mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(ios): qualify releases against the stable Gateway (#161682)
This commit is contained in:
parent
63144fe0c4
commit
bb812693f3
10 changed files with 1121 additions and 40 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -5365,6 +5365,7 @@ jobs:
|
|||
|
||||
ios-release-e2e:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.validation_tier == 'full' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.checkout_revision == github.sha && needs.preflight.outputs.compatibility_target != 'true' && needs.preflight.outputs.run_ios_build == 'true' }}
|
||||
|
|
|
|||
75
.github/workflows/ios-release-e2e.yml
vendored
75
.github/workflows/ios-release-e2e.yml
vendored
|
|
@ -18,6 +18,7 @@ on:
|
|||
default: stock
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
|
@ -35,6 +36,7 @@ jobs:
|
|||
set -euo pipefail
|
||||
export PROOF_PATH="$RUNNER_TEMP/ios-release-e2e-proof.json"
|
||||
echo "PROOF_PATH=$PROOF_PATH" >> "$GITHUB_ENV"
|
||||
echo "GATEWAY_SELECTION_DIR=$RUNNER_TEMP/ios-release-gateway-selection" >> "$GITHUB_ENV"
|
||||
echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
python3 - <<'PY'
|
||||
import json, os, re
|
||||
|
|
@ -66,6 +68,48 @@ jobs:
|
|||
test -f scripts/lib/ios-release-e2e-native.ts
|
||||
test -f test/helpers/openclaw-test-instance.ts
|
||||
|
||||
- name: Find this run's stable Gateway selection
|
||||
id: gateway-selection
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const name = `ios-release-gateway-selection-${context.runId}`;
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
...context.repo,
|
||||
run_id: context.runId,
|
||||
name,
|
||||
per_page: 100,
|
||||
});
|
||||
const matches = artifacts.filter((artifact) => artifact.name === name);
|
||||
if (matches.length > 1) {
|
||||
throw new Error('Multiple stable Gateway selections exist for this run.');
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
if (Number(process.env.GITHUB_RUN_ATTEMPT) !== 1) {
|
||||
throw new Error('This rerun has no saved stable Gateway selection. Start a new workflow run.');
|
||||
}
|
||||
core.setOutput('create', 'true');
|
||||
core.setOutput('artifact-id', '');
|
||||
return;
|
||||
}
|
||||
const artifact = matches[0];
|
||||
if (artifact.expired || artifact.workflow_run?.id !== context.runId ||
|
||||
artifact.workflow_run?.head_sha !== process.env.TARGET_SHA) {
|
||||
throw new Error('The saved stable Gateway selection is expired or belongs to another source/run. Start a new workflow run.');
|
||||
}
|
||||
core.setOutput('create', 'false');
|
||||
core.setOutput('artifact-id', String(artifact.id));
|
||||
|
||||
- name: Restore this run's stable Gateway selection
|
||||
if: steps.gateway-selection.outputs.artifact-id != ''
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ steps.gateway-selection.outputs.artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ github.run_id }}
|
||||
path: ${{ env.GATEWAY_SELECTION_DIR }}
|
||||
digest-mismatch: error
|
||||
|
||||
- name: Start shared toolchain installation clock
|
||||
id: install-start
|
||||
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -77,6 +121,32 @@ jobs:
|
|||
install-bun: "false"
|
||||
cache-mode: "off"
|
||||
|
||||
- name: Prepare stable Gateway selection
|
||||
env:
|
||||
RESTORED_SELECTION: ${{ steps.gateway-selection.outputs.artifact-id != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RESTORED_SELECTION" == true ]]; then
|
||||
test -f "$GATEWAY_SELECTION_DIR/selection.json"
|
||||
test -f "$GATEWAY_SELECTION_DIR/package.json"
|
||||
test -f "$GATEWAY_SELECTION_DIR/package-lock.json"
|
||||
fi
|
||||
node --import ./scripts/tsx.mjs scripts/ios-release-gateway.ts \
|
||||
--target-sha "$TARGET_SHA" --selection-dir "$GATEWAY_SELECTION_DIR"
|
||||
|
||||
- name: Save this run's stable Gateway selection
|
||||
if: steps.gateway-selection.outputs.create == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ios-release-gateway-selection-${{ github.run_id }}
|
||||
path: |
|
||||
${{ env.GATEWAY_SELECTION_DIR }}/selection.json
|
||||
${{ env.GATEWAY_SELECTION_DIR }}/package.json
|
||||
${{ env.GATEWAY_SELECTION_DIR }}/package-lock.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Select Xcode and install pinned native tools
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -112,7 +182,8 @@ jobs:
|
|||
OPENCLAW_CI_SIMSLIM_BINARY: ${{ steps.simslim.outputs.binary }}
|
||||
run: |
|
||||
node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \
|
||||
--mode "$E2E_MODE" --target-sha "$TARGET_SHA" --output "$PROOF_PATH"
|
||||
--mode "$E2E_MODE" --target-sha "$TARGET_SHA" --output "$PROOF_PATH" \
|
||||
--gateway-selection "$GATEWAY_SELECTION_DIR"
|
||||
|
||||
- name: Complete sanitized timing proof
|
||||
if: always()
|
||||
|
|
@ -139,7 +210,7 @@ jobs:
|
|||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ios-release-e2e-${{ inputs.mode }}
|
||||
name: ios-release-e2e-${{ inputs.mode }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-release-e2e-proof.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
|
|
|||
3
.github/workflows/ios-store-release.yml
vendored
3
.github/workflows/ios-store-release.yml
vendored
|
|
@ -109,8 +109,9 @@ jobs:
|
|||
((github.event_name == 'schedule' && vars.IOS_TESTFLIGHT_ENABLED == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'release' || inputs.operation == 'testflight')))
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
# Qualification rebuilds tracked plugin manifests; keep its workspace separate
|
||||
# Keep native qualification products and disposable Gateway state separate
|
||||
# from the clean checkout that admits the release.
|
||||
uses: ./.github/workflows/ios-release-e2e.yml
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -158,19 +158,31 @@ runtime supporting iPhone 17 Pro and arm64, and the repository's pinned native t
|
|||
|
||||
```bash
|
||||
node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \
|
||||
--mode stock --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-stock.json
|
||||
--mode stock --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-stock.json \
|
||||
--gateway-selection /tmp/ios-e2e-gateway-selection
|
||||
|
||||
./scripts/install-simslim.sh /tmp/ios-e2e-tools
|
||||
OPENCLAW_CI_SIMSLIM_BINARY=/tmp/ios-e2e-tools/simslim \
|
||||
node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \
|
||||
--mode compare --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-compare.json
|
||||
--mode compare --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-compare.json \
|
||||
--gateway-selection /tmp/ios-e2e-gateway-selection
|
||||
```
|
||||
|
||||
The gate requires a clean tracked and untracked source tree at the exact SHA;
|
||||
gitignored build outputs are allowed. It selects the newest available iOS runtime
|
||||
that supports the test device and architecture, and records that runtime in its proof.
|
||||
It builds the Gateway runtime and ad-hoc-signed
|
||||
Debug `OpenClawUITests` simulator products once. Ad-hoc signing preserves Keychain
|
||||
It qualifies the candidate iOS app against the published stable Gateway selected
|
||||
from npm's `latest` tag. The first invocation saves the exact Gateway version,
|
||||
package integrity, dependency lock, source SHA, and Node/npm versions in the
|
||||
selection directory. Later invocations using that directory validate and reuse
|
||||
the saved selection without resolving `latest` again. Without `--gateway-selection`,
|
||||
the directory defaults to the output path with `.gateway` appended. Keep it for
|
||||
replay; use a new directory to select a newer stable Gateway. Selection replay
|
||||
requires the same source SHA and Node/npm versions.
|
||||
|
||||
The harness installs the selected package in an isolated directory using the saved
|
||||
dependency lock and builds ad-hoc-signed Debug `OpenClawUITests` simulator products
|
||||
once. Ad-hoc signing preserves Keychain
|
||||
entitlements without certificates or provisioning profiles; this is not a signed
|
||||
Release build. Each arm starts an isolated real Gateway, then prepares its setup
|
||||
handler and state worker with `device.pair.setupStatus` before booting one new
|
||||
|
|
@ -207,12 +219,13 @@ node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \
|
|||
# Exercise Gateway startup, setup-status preparation, and code issuance without native resources.
|
||||
node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \
|
||||
--mode stock --target-sha "$(git rev-parse HEAD)" \
|
||||
--gateway-only --output /tmp/ios-e2e-gateway.json
|
||||
--gateway-only --output /tmp/ios-e2e-gateway.json \
|
||||
--gateway-selection /tmp/ios-e2e-gateway-selection
|
||||
```
|
||||
|
||||
These diagnostics produce `native-build`/`built` or `gateway-probe`/`probe-passed`
|
||||
proofs, respectively. Neither is release qualification. Gateway runtime preparation
|
||||
continues to use the existing build owner's cache in every mode.
|
||||
proofs, respectively. Neither is release qualification. The Gateway probe uses the
|
||||
same published-package selection and installation path as full qualification.
|
||||
|
||||
The stock gate runs for both upload destinations in **iOS Store Release** after
|
||||
native tool setup and before signing assets are accessed. It qualifies the checked-out `main` commit used for release
|
||||
|
|
@ -228,6 +241,16 @@ Manual dispatch of `iOS Release E2E` qualifies the selected workflow revision;
|
|||
it does not accept an alternate target SHA. CI callers must also use their own
|
||||
revision.
|
||||
|
||||
Each fresh workflow run resolves the stable Gateway once and saves
|
||||
`selection.json`, `package.json`, and `package-lock.json` in the
|
||||
`ios-release-gateway-selection-RUN_ID` artifact before native tool installation
|
||||
and qualification. The artifact is retained for 30 days. All qualification arms
|
||||
and reruns, including **Re-run all jobs**, reuse that run's selection. A missing,
|
||||
expired, invalid, or source-mismatched selection stops a rerun; start a new
|
||||
workflow run to make a fresh selection. No workflow input is needed. To replay
|
||||
locally, download and extract that artifact and pass its directory with
|
||||
`--gateway-selection` at the same source SHA and Node/npm versions.
|
||||
|
||||
Compare runs four serial matched pairs in stock/slim, slim/stock, stock/slim,
|
||||
slim/stock order, for eight independently prepared arms. SimSlim keeps the existing
|
||||
conservative search/family-only profile. A preparation or live-test failure stops
|
||||
|
|
@ -247,8 +270,10 @@ peak, or reboot-preparation memory. Missing/invalid samples or gaps over three
|
|||
seconds fail measurement. A stock gate without the meter requires no measurements.
|
||||
Raw XCTest bundles and fixture logs stay private and are cleaned with owned
|
||||
resources. If owned cleanup cannot be confirmed, the working root is retained.
|
||||
Only sanitized JSON proof is uploaded, including on failure, with fixed operation
|
||||
labels, phase durations, setup RPC progress, and bounded exit/error diagnostics.
|
||||
Alongside the Gateway selection artifact, sanitized JSON proof is uploaded,
|
||||
including on failure, as `ios-release-e2e-MODE-RUN_ID-RUN_ATTEMPT`. It records
|
||||
the candidate source and selected Gateway identities, fixed operation labels,
|
||||
phase durations, setup RPC progress, and bounded exit/error diagnostics.
|
||||
Raw logs and setup codes are excluded. Setup-code timeouts are preparation failures
|
||||
and prevent native test execution.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ export type Operation =
|
|||
| "xcode-version"
|
||||
| "simslim-version"
|
||||
| "simulator-runtime"
|
||||
| "gateway-build"
|
||||
| "gateway-install"
|
||||
| "gateway-preflight"
|
||||
| "native-generate"
|
||||
| "native-build"
|
||||
| "native-build-reuse"
|
||||
|
|
@ -468,6 +469,7 @@ async function main() {
|
|||
"build-dir": { type: "string" },
|
||||
"build-only": { type: "boolean", default: false },
|
||||
"gateway-only": { type: "boolean", default: false },
|
||||
"gateway-selection": { type: "string" },
|
||||
},
|
||||
});
|
||||
if (
|
||||
|
|
@ -521,6 +523,8 @@ async function main() {
|
|||
proof,
|
||||
buildDir: values["build-dir"],
|
||||
gatewayOnly: values["gateway-only"],
|
||||
buildOnly: values["build-only"],
|
||||
gatewaySelectionDir: path.resolve(values["gateway-selection"] ?? `${values.output}.gateway`),
|
||||
onProgress: writeProof,
|
||||
});
|
||||
cleanup = native.cleanup;
|
||||
|
|
|
|||
38
scripts/ios-release-gateway.ts
Normal file
38
scripts/ios-release-gateway.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { selectIOSReleaseGateway } from "./lib/ios-release-gateway.js";
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"target-sha": { type: "string" },
|
||||
"selection-dir": { type: "string" },
|
||||
},
|
||||
});
|
||||
if (!values["target-sha"] || !values["selection-dir"]) {
|
||||
throw new Error("usage: --target-sha <full-sha> --selection-dir <directory>");
|
||||
}
|
||||
const abort = new AbortController();
|
||||
const cancel = () => abort.abort();
|
||||
process.on("SIGINT", cancel);
|
||||
process.on("SIGTERM", cancel);
|
||||
try {
|
||||
const selection = await selectIOSReleaseGateway({
|
||||
targetSha: values["target-sha"],
|
||||
selectionDir: path.resolve(values["selection-dir"]),
|
||||
signal: abort.signal,
|
||||
});
|
||||
console.log(`Selected stable Gateway ${selection.version} (${selection.sourceSha}).`);
|
||||
} finally {
|
||||
process.removeListener("SIGINT", cancel);
|
||||
process.removeListener("SIGTERM", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : "Stable Gateway selection failed.");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type Operation,
|
||||
type TrialDependencies,
|
||||
} from "../ios-release-e2e.js";
|
||||
import { prepareIOSReleaseGateway } from "./ios-release-gateway.js";
|
||||
import { hasUnjoinedWork, runManagedCommand } from "./managed-child-process.mjs";
|
||||
|
||||
const DEVICE_TYPE = "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro";
|
||||
|
|
@ -38,6 +39,8 @@ export async function createNativeDependencies(options: {
|
|||
signal: AbortSignal;
|
||||
proof: Record<string, unknown>;
|
||||
buildDir?: string;
|
||||
gatewaySelectionDir?: string;
|
||||
buildOnly?: boolean;
|
||||
gatewayOnly?: boolean;
|
||||
onProgress?: () => Promise<void>;
|
||||
}): Promise<{
|
||||
|
|
@ -246,14 +249,78 @@ export async function createNativeDependencies(options: {
|
|||
await rm(root, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
const buildStarted = performance.now();
|
||||
await command(
|
||||
"gateway-build",
|
||||
process.execPath,
|
||||
["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"],
|
||||
{ timeoutMs: 1_200_000 },
|
||||
);
|
||||
options.proof.gatewayBuildMs = performance.now() - buildStarted;
|
||||
const { createOpenClawTestInstance } =
|
||||
await import("../../test/helpers/openclaw-test-instance.js");
|
||||
const { callGateway } = await import("../../src/gateway/call.js");
|
||||
let gateway: Awaited<ReturnType<typeof prepareIOSReleaseGateway>> | undefined;
|
||||
if (!options.buildOnly) {
|
||||
const installStarted = performance.now();
|
||||
gateway = await phase("gateway-install", async () => {
|
||||
if (!options.gatewaySelectionDir) {
|
||||
throw new OperationError("gateway-install", "not-found");
|
||||
}
|
||||
try {
|
||||
return await prepareIOSReleaseGateway({
|
||||
selectionDir: options.gatewaySelectionDir,
|
||||
installDir: path.join(root, "gateway"),
|
||||
targetSha: options.targetSha,
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (hasUnjoinedWork(error)) {
|
||||
preserveResources();
|
||||
}
|
||||
throw operationError("gateway-install", error);
|
||||
}
|
||||
});
|
||||
options.proof.gateway = gateway.identity;
|
||||
options.proof.gatewayInstallMs = performance.now() - installStarted;
|
||||
const installedGateway = gateway;
|
||||
await phase("gateway-preflight", async () => {
|
||||
let preflight: OpenClawTestInstance | undefined;
|
||||
let failure: OperationError | undefined;
|
||||
try {
|
||||
preflight = await createOpenClawTestInstance({
|
||||
name: "ios-release-e2e-preflight",
|
||||
cwd: installedGateway.cwd,
|
||||
entrypoint: installedGateway.entrypoint,
|
||||
config: { gateway: { controlUi: { enabled: false } } },
|
||||
env: gatewayEnv,
|
||||
signal: options.signal,
|
||||
});
|
||||
await preflight.startGateway();
|
||||
await callGateway({
|
||||
config: {},
|
||||
configPath: preflight.configPath,
|
||||
url: preflight.url,
|
||||
token: preflight.gatewayToken,
|
||||
ignoreEnvUrlOverride: true,
|
||||
deviceIdentity: null,
|
||||
sharedStateMode: "read-only",
|
||||
method: "device.pair.setupStatus",
|
||||
params: { setupId: randomUUID() },
|
||||
// Pairing methods are intentionally unadvertised; prove setupStatus by calling it.
|
||||
requiredMethods: ["chat.send", "chat.history"],
|
||||
timeoutMs: 30_000,
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (hasUnjoinedWork(error)) {
|
||||
preserveResources();
|
||||
}
|
||||
failure = operationError("gateway-preflight", error);
|
||||
}
|
||||
try {
|
||||
await preflight?.cleanup();
|
||||
} catch {
|
||||
preserveResources();
|
||||
failure = new OperationError("cleanup", "cleanup-unconfirmed");
|
||||
}
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
const nativeStarted = performance.now();
|
||||
const buildArgs = [
|
||||
"-project",
|
||||
|
|
@ -323,9 +390,6 @@ export async function createNativeDependencies(options: {
|
|||
options.proof.nativeBuildReused = build.reused;
|
||||
options.proof.nativeBuildMs = performance.now() - nativeStarted;
|
||||
}
|
||||
const { createOpenClawTestInstance } =
|
||||
await import("../../test/helpers/openclaw-test-instance.js");
|
||||
const { callGateway } = await import("../../src/gateway/call.js");
|
||||
return {
|
||||
assertCurrentSource,
|
||||
cleanup,
|
||||
|
|
@ -337,6 +401,10 @@ export async function createNativeDependencies(options: {
|
|||
},
|
||||
measure: Boolean(binary),
|
||||
async create(arm, index) {
|
||||
if (!gateway) {
|
||||
throw new OperationError("gateway-install", "not-found");
|
||||
}
|
||||
const installedGateway = gateway;
|
||||
currentTrial = index;
|
||||
const fixtureEvidence: Record<string, unknown> = { trial: index };
|
||||
const fixtures = (options.proof.fixtures ??= []) as Record<string, unknown>[];
|
||||
|
|
@ -492,7 +560,8 @@ export async function createNativeDependencies(options: {
|
|||
try {
|
||||
instance = await createOpenClawTestInstance({
|
||||
name: `ios-release-e2e-${index}`,
|
||||
cwd,
|
||||
cwd: installedGateway.cwd,
|
||||
entrypoint: installedGateway.entrypoint,
|
||||
config,
|
||||
env: gatewayEnv,
|
||||
});
|
||||
|
|
|
|||
302
scripts/lib/ios-release-gateway.ts
Normal file
302
scripts/lib/ios-release-gateway.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { hasUnjoinedWork, runManagedCommand } from "./managed-child-process.mjs";
|
||||
import { parseReleaseVersion } from "./release-version.mjs";
|
||||
|
||||
const REGISTRY = "https://registry.npmjs.org";
|
||||
const GITHUB = "https://api.github.com/repos/openclaw/openclaw/git";
|
||||
const SHA = /^[a-f0-9]{40}$/u;
|
||||
const DIGEST = /^[a-f0-9]{64}$/u;
|
||||
const selectionSchema = z
|
||||
.object({
|
||||
schema: z.literal(1),
|
||||
targetSha: z.string().regex(SHA),
|
||||
version: z.string(),
|
||||
integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]{86}==$/u),
|
||||
tarball: z.string().url(),
|
||||
sourceSha: z.string().regex(SHA),
|
||||
packageSha256: z.string().regex(DIGEST),
|
||||
lockSha256: z.string().regex(DIGEST),
|
||||
nodeVersion: z.string(),
|
||||
npmVersion: z.string(),
|
||||
platform: z.string(),
|
||||
arch: z.string(),
|
||||
})
|
||||
.strict();
|
||||
type Selection = z.infer<typeof selectionSchema>;
|
||||
type SelectionOptions = { selectionDir: string; targetSha: string; signal: AbortSignal };
|
||||
|
||||
function digest(bytes: string | Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function requireStable(version: string): void {
|
||||
const parsed = parseReleaseVersion(version);
|
||||
if (!parsed || parsed.channel !== "stable" || parsed.patch >= 33) {
|
||||
throw new Error("The stable Gateway must be a regular stable OpenClaw release.");
|
||||
}
|
||||
}
|
||||
|
||||
async function metadata(url: string, signal: AbortSignal): Promise<unknown> {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
|
||||
headers: { Accept: "application/json", "User-Agent": "openclaw-ios-qualification" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Stable Gateway metadata request failed (HTTP ${response.status}).`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function sourceSha(version: string, signal: AbortSignal): Promise<string> {
|
||||
const objectSchema = z.object({
|
||||
object: z.object({ type: z.string(), sha: z.string().regex(SHA) }),
|
||||
});
|
||||
let { object } = objectSchema.parse(await metadata(`${GITHUB}/ref/tags/v${version}`, signal));
|
||||
if (object.type === "tag") {
|
||||
({ object } = objectSchema.parse(await metadata(`${GITHUB}/tags/${object.sha}`, signal)));
|
||||
}
|
||||
if (object.type !== "commit") {
|
||||
throw new Error("Stable Gateway release tag must resolve to a commit.");
|
||||
}
|
||||
return object.sha;
|
||||
}
|
||||
|
||||
async function npm(args: string[], cwd: string, signal: AbortSignal): Promise<string> {
|
||||
// Package lifecycle scripts get task-owned state and public-registry access, never operator credentials.
|
||||
const home = await mkdtemp(path.join(path.dirname(cwd), ".ios-gateway-npm-"));
|
||||
let unjoined = false;
|
||||
try {
|
||||
await Promise.all([
|
||||
writeFile(path.join(home, "user.npmrc"), ""),
|
||||
writeFile(path.join(home, "global.npmrc"), ""),
|
||||
mkdir(path.join(home, "tmp")),
|
||||
]);
|
||||
let stdout = "";
|
||||
const overflow = new AbortController();
|
||||
let bytes = 0;
|
||||
const code = await runManagedCommand({
|
||||
bin: "npm",
|
||||
args,
|
||||
cwd,
|
||||
env: {
|
||||
PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
HOME: home,
|
||||
TMPDIR: path.join(home, "tmp"),
|
||||
LANG: "en_US.UTF-8",
|
||||
CI: "true",
|
||||
npm_config_userconfig: path.join(home, "user.npmrc"),
|
||||
npm_config_globalconfig: path.join(home, "global.npmrc"),
|
||||
npm_config_registry: REGISTRY,
|
||||
npm_config_cache: path.join(home, "cache"),
|
||||
npm_config_update_notifier: "false",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeoutMs: 900_000,
|
||||
signal: AbortSignal.any([signal, overflow.signal]),
|
||||
requireProcessTreeExit: true,
|
||||
onReady(child) {
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream?.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 16 * 1024 * 1024) {
|
||||
overflow.abort();
|
||||
} else if (stream === child.stdout) {
|
||||
stdout += chunk.toString("utf8");
|
||||
} else {
|
||||
process.stderr.write(chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (code !== 0 || overflow.signal.aborted) {
|
||||
throw new Error(`Stable Gateway npm ${args[0]} failed (exit ${code}).`);
|
||||
}
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
unjoined = hasUnjoinedWork(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!unjoined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateLock(selection: Selection, packageBytes: string, lockBytes: string): void {
|
||||
if (
|
||||
digest(packageBytes) !== selection.packageSha256 ||
|
||||
digest(lockBytes) !== selection.lockSha256
|
||||
) {
|
||||
throw new Error("Stable Gateway selection manifests changed.");
|
||||
}
|
||||
const wrapper = z
|
||||
.object({ private: z.literal(true), dependencies: z.object({ openclaw: z.string() }).strict() })
|
||||
.parse(JSON.parse(packageBytes));
|
||||
const lock = z
|
||||
.object({
|
||||
lockfileVersion: z.literal(3),
|
||||
packages: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.parse(JSON.parse(lockBytes));
|
||||
const root = z
|
||||
.object({ dependencies: z.object({ openclaw: z.string() }) })
|
||||
.parse(lock.packages[""]);
|
||||
const installed = z
|
||||
.object({ version: z.string(), resolved: z.string(), integrity: z.string() })
|
||||
.parse(lock.packages["node_modules/openclaw"]);
|
||||
if (
|
||||
wrapper.dependencies.openclaw !== selection.version ||
|
||||
root.dependencies.openclaw !== selection.version ||
|
||||
installed.version !== selection.version ||
|
||||
installed.resolved !== selection.tarball ||
|
||||
installed.integrity !== selection.integrity
|
||||
) {
|
||||
throw new Error("Stable Gateway package lock does not match the selected release.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readSelection(options: SelectionOptions): Promise<Selection> {
|
||||
const selection = selectionSchema.parse(
|
||||
JSON.parse(await readFile(path.join(options.selectionDir, "selection.json"), "utf8")),
|
||||
);
|
||||
requireStable(selection.version);
|
||||
if (
|
||||
selection.targetSha !== options.targetSha ||
|
||||
selection.tarball !== `${REGISTRY}/openclaw/-/openclaw-${selection.version}.tgz`
|
||||
) {
|
||||
throw new Error("Stable Gateway selection does not match this qualification target.");
|
||||
}
|
||||
if (
|
||||
selection.nodeVersion !== process.version ||
|
||||
selection.platform !== process.platform ||
|
||||
selection.arch !== process.arch
|
||||
) {
|
||||
throw new Error(
|
||||
"Replay requires the Node version and platform recorded in the Gateway selection.",
|
||||
);
|
||||
}
|
||||
const [packageBytes, lockBytes] = await Promise.all([
|
||||
readFile(path.join(options.selectionDir, "package.json"), "utf8"),
|
||||
readFile(path.join(options.selectionDir, "package-lock.json"), "utf8"),
|
||||
]);
|
||||
validateLock(selection, packageBytes, lockBytes);
|
||||
if ((await npm(["--version"], options.selectionDir, options.signal)) !== selection.npmVersion) {
|
||||
throw new Error("Replay requires the npm version recorded in the Gateway selection.");
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
/** The directory is the replayable input artifact; an incomplete existing selection never floats to latest. */
|
||||
export async function selectIOSReleaseGateway(options: SelectionOptions): Promise<Selection> {
|
||||
options.signal.throwIfAborted();
|
||||
if (!SHA.test(options.targetSha)) {
|
||||
throw new Error("Stable Gateway selection requires a full candidate source SHA.");
|
||||
}
|
||||
const selectionDir = path.resolve(options.selectionDir);
|
||||
const { lstat } = await import("node:fs/promises");
|
||||
const exists = await lstat(selectionDir).then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
if (exists) {
|
||||
return readSelection({ ...options, selectionDir });
|
||||
}
|
||||
await mkdir(path.dirname(selectionDir), { recursive: true });
|
||||
const staging = await mkdtemp(path.join(path.dirname(selectionDir), ".ios-gateway-selection-"));
|
||||
let unjoined = false;
|
||||
try {
|
||||
const info = z
|
||||
.object({
|
||||
name: z.literal("openclaw"),
|
||||
version: z.string(),
|
||||
dist: z.object({ tarball: z.string(), integrity: z.string() }),
|
||||
})
|
||||
.parse(await metadata(`${REGISTRY}/openclaw/latest`, options.signal));
|
||||
requireStable(info.version);
|
||||
if (info.dist.tarball !== `${REGISTRY}/openclaw/-/openclaw-${info.version}.tgz`) {
|
||||
throw new Error("Stable Gateway metadata has an unexpected tarball URL.");
|
||||
}
|
||||
const sha = await sourceSha(info.version, options.signal);
|
||||
const npmVersion = await npm(["--version"], staging, options.signal);
|
||||
const packageBytes = `${JSON.stringify({ name: "openclaw-ios-qualification", version: "0.0.0", private: true, dependencies: { openclaw: info.version } }, null, 2)}\n`;
|
||||
await writeFile(path.join(staging, "package.json"), packageBytes);
|
||||
await npm(
|
||||
["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
staging,
|
||||
options.signal,
|
||||
);
|
||||
const lockBytes = await readFile(path.join(staging, "package-lock.json"), "utf8");
|
||||
const selection = selectionSchema.parse({
|
||||
schema: 1,
|
||||
targetSha: options.targetSha,
|
||||
version: info.version,
|
||||
integrity: info.dist.integrity,
|
||||
tarball: info.dist.tarball,
|
||||
sourceSha: sha,
|
||||
packageSha256: digest(packageBytes),
|
||||
lockSha256: digest(lockBytes),
|
||||
nodeVersion: process.version,
|
||||
npmVersion,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
validateLock(selection, packageBytes, lockBytes);
|
||||
await writeFile(
|
||||
path.join(staging, "selection.json"),
|
||||
`${JSON.stringify(selection, null, 2)}\n`,
|
||||
);
|
||||
options.signal.throwIfAborted();
|
||||
await rename(staging, selectionDir);
|
||||
return selection;
|
||||
} catch (error) {
|
||||
unjoined = hasUnjoinedWork(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!unjoined) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareIOSReleaseGateway(options: SelectionOptions & { installDir: string }) {
|
||||
const selection = await selectIOSReleaseGateway(options);
|
||||
await mkdir(options.installDir);
|
||||
await Promise.all(
|
||||
["package.json", "package-lock.json"].map((file) =>
|
||||
copyFile(path.join(options.selectionDir, file), path.join(options.installDir, file)),
|
||||
),
|
||||
);
|
||||
await npm(["ci", "--no-audit", "--no-fund"], options.installDir, options.signal);
|
||||
const cwd = path.join(options.installDir, "node_modules", "openclaw");
|
||||
const [packageBytes, buildBytes, installedLock] = await Promise.all([
|
||||
readFile(path.join(cwd, "package.json"), "utf8"),
|
||||
readFile(path.join(cwd, "dist", "build-info.json"), "utf8"),
|
||||
readFile(path.join(options.installDir, "package-lock.json"), "utf8"),
|
||||
]);
|
||||
const pkg = z
|
||||
.object({
|
||||
name: z.literal("openclaw"),
|
||||
version: z.string(),
|
||||
bin: z.object({ openclaw: z.literal("openclaw.mjs") }),
|
||||
})
|
||||
.parse(JSON.parse(packageBytes));
|
||||
const build = z.object({ commit: z.string().regex(SHA) }).parse(JSON.parse(buildBytes));
|
||||
if (
|
||||
pkg.version !== selection.version ||
|
||||
build.commit !== selection.sourceSha ||
|
||||
digest(installedLock) !== selection.lockSha256
|
||||
) {
|
||||
throw new Error("Installed Gateway identity differs from the selected stable release.");
|
||||
}
|
||||
options.signal.throwIfAborted();
|
||||
return { cwd, entrypoint: [path.join(cwd, "openclaw.mjs")], identity: selection };
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type TrialDependencies,
|
||||
} from "../../scripts/ios-release-e2e.js";
|
||||
import { createNativeDependencies } from "../../scripts/lib/ios-release-e2e-native.js";
|
||||
import { ensureGatewaySupportsRequiredFeatures } from "../../src/gateway/call-required-features.js";
|
||||
import { GatewayTransportError } from "../../src/gateway/transport-error.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
import { evaluateWorkflowExpression } from "./ci-workflow.test-support.js";
|
||||
|
|
@ -27,6 +28,7 @@ import { evaluateWorkflowExpression } from "./ci-workflow.test-support.js";
|
|||
const nativeMocks = vi.hoisted(() => ({
|
||||
command: vi.fn(),
|
||||
build: vi.fn(),
|
||||
install: vi.fn(),
|
||||
gateway: vi.fn(),
|
||||
rpc: vi.fn(),
|
||||
}));
|
||||
|
|
@ -37,6 +39,9 @@ vi.mock("../../scripts/lib/managed-child-process.mjs", async (importOriginal) =>
|
|||
vi.mock("../../scripts/lib/ios-release-e2e-build.js", () => ({
|
||||
prepareIOSReleaseNativeBuild: nativeMocks.build,
|
||||
}));
|
||||
vi.mock("../../scripts/lib/ios-release-gateway.js", () => ({
|
||||
prepareIOSReleaseGateway: nativeMocks.install,
|
||||
}));
|
||||
vi.mock("../helpers/openclaw-test-instance.js", () => ({
|
||||
createOpenClawTestInstance: nativeMocks.gateway,
|
||||
}));
|
||||
|
|
@ -49,6 +54,7 @@ afterEach(() => {
|
|||
vi.unstubAllEnvs();
|
||||
nativeMocks.command.mockReset();
|
||||
nativeMocks.build.mockReset();
|
||||
nativeMocks.install.mockReset();
|
||||
nativeMocks.gateway.mockReset();
|
||||
nativeMocks.rpc.mockReset();
|
||||
});
|
||||
|
|
@ -171,7 +177,7 @@ it("writes a failure proof when the real CLI rejects an impossible target", () =
|
|||
trials: [],
|
||||
errors: ["gate-setup-failed"],
|
||||
});
|
||||
expect(proof.gatewayBuildMs).toBeUndefined();
|
||||
expect(proof.gatewayInstallMs).toBeUndefined();
|
||||
expect(proof.nativeBuildMs).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -425,6 +431,94 @@ describe("release qualification workflow authority", () => {
|
|||
const workflow = parse(readFileSync(".github/workflows/ios-release-e2e.yml", "utf8"));
|
||||
const release = parse(readFileSync(".github/workflows/ios-store-release.yml", "utf8"));
|
||||
const ci = parse(readFileSync(".github/workflows/ci.yml", "utf8"));
|
||||
const selectionArtifact = {
|
||||
id: 456,
|
||||
name: "ios-release-gateway-selection-123",
|
||||
expired: false,
|
||||
workflow_run: { id: 123, head_sha: "a".repeat(40) },
|
||||
};
|
||||
async function findGatewaySelection(
|
||||
attempt: number,
|
||||
artifacts: (typeof selectionArtifact)[],
|
||||
outputs: Map<string, string>,
|
||||
apiFailure?: Error,
|
||||
) {
|
||||
const { runInNewContext } = await import("node:vm");
|
||||
const step = workflow.jobs.qualify.steps.find(
|
||||
(candidate: { id?: string }) => candidate.id === "gateway-selection",
|
||||
);
|
||||
const listWorkflowRunArtifacts = vi.fn();
|
||||
const paginate = apiFailure
|
||||
? vi.fn().mockRejectedValue(apiFailure)
|
||||
: vi.fn().mockResolvedValue(artifacts);
|
||||
const execution: unknown = runInNewContext(`(async () => { ${step.with.script} })()`, {
|
||||
github: { rest: { actions: { listWorkflowRunArtifacts } }, paginate },
|
||||
context: { repo: { owner: "openclaw", repo: "openclaw" }, runId: 123 },
|
||||
core: { setOutput: (name: string, value: string) => outputs.set(name, value) },
|
||||
process: {
|
||||
env: {
|
||||
GITHUB_RUN_ATTEMPT: String(attempt),
|
||||
TARGET_SHA: selectionArtifact.workflow_run.head_sha,
|
||||
},
|
||||
},
|
||||
});
|
||||
await execution;
|
||||
expect(paginate).toHaveBeenCalledWith(listWorkflowRunArtifacts, {
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
run_id: 123,
|
||||
name: selectionArtifact.name,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
it.each([
|
||||
["new workflow run", 1, [], { create: "true", "artifact-id": "" }],
|
||||
[
|
||||
"existing first-attempt selection",
|
||||
1,
|
||||
[selectionArtifact],
|
||||
{ create: "false", "artifact-id": "456" },
|
||||
],
|
||||
["workflow rerun", 2, [selectionArtifact], { create: "false", "artifact-id": "456" }],
|
||||
])("preserves the Gateway selection for %s", async (_name, attempt, artifacts, expected) => {
|
||||
const outputs = new Map<string, string>();
|
||||
await findGatewaySelection(attempt, artifacts, outputs);
|
||||
expect(Object.fromEntries(outputs)).toEqual(expected);
|
||||
});
|
||||
it.each([
|
||||
["missing rerun pin", [], "no saved stable Gateway selection"],
|
||||
[
|
||||
"expired pin",
|
||||
[{ ...selectionArtifact, expired: true }],
|
||||
"expired or belongs to another source/run",
|
||||
],
|
||||
[
|
||||
"duplicate pins",
|
||||
[selectionArtifact, selectionArtifact],
|
||||
"Multiple stable Gateway selections",
|
||||
],
|
||||
[
|
||||
"different source",
|
||||
[{ ...selectionArtifact, workflow_run: { id: 123, head_sha: "b".repeat(40) } }],
|
||||
"expired or belongs to another source/run",
|
||||
],
|
||||
[
|
||||
"different run",
|
||||
[{ ...selectionArtifact, workflow_run: { ...selectionArtifact.workflow_run, id: 124 } }],
|
||||
"expired or belongs to another source/run",
|
||||
],
|
||||
])("rejects %s without authorizing a new selection", async (_name, artifacts, error) => {
|
||||
const outputs = new Map<string, string>();
|
||||
await expect(findGatewaySelection(2, artifacts, outputs)).rejects.toThrow(error);
|
||||
expect(outputs.size).toBe(0);
|
||||
});
|
||||
it("fails artifact lookup errors without treating the pin as missing", async () => {
|
||||
const outputs = new Map<string, string>();
|
||||
await expect(
|
||||
findGatewaySelection(1, [], outputs, new Error("artifact API unavailable")),
|
||||
).rejects.toThrow("artifact API unavailable");
|
||||
expect(outputs.size).toBe(0);
|
||||
});
|
||||
it.each([
|
||||
["manual current revision", {}, true],
|
||||
["CI current revision", { caller: "ci" }, true],
|
||||
|
|
@ -459,6 +553,9 @@ describe("release qualification workflow authority", () => {
|
|||
},
|
||||
});
|
||||
expect(execution.status === 0).toBe(admitted);
|
||||
expect(readFileSync(path.join(root, "env"), "utf8")).toContain(
|
||||
`GATEWAY_SELECTION_DIR=${root}/ios-release-gateway-selection\n`,
|
||||
);
|
||||
const proof = JSON.parse(readFileSync(path.join(root, "ios-release-e2e-proof.json"), "utf8"));
|
||||
expect(proof).toMatchObject({
|
||||
status: "failed",
|
||||
|
|
@ -470,7 +567,7 @@ describe("release qualification workflow authority", () => {
|
|||
const qualification = release.jobs[releaseJob.needs];
|
||||
expect(qualification).toMatchObject({
|
||||
uses: "./.github/workflows/ios-release-e2e.yml",
|
||||
permissions: { contents: "read" },
|
||||
permissions: { actions: "read", contents: "read" },
|
||||
with: { target_sha: "${{ github.sha }}", mode: "stock" },
|
||||
});
|
||||
expect(qualification.if).toBe(releaseJob.if);
|
||||
|
|
@ -510,7 +607,8 @@ describe("release qualification workflow authority", () => {
|
|||
).toBe(outcome !== "skipped");
|
||||
}
|
||||
expect(recovery.with["if-no-files-found"]).toBe("error");
|
||||
expect(workflow.permissions).toEqual({ contents: "read" });
|
||||
expect(workflow.permissions).toEqual({ actions: "read", contents: "read" });
|
||||
expect(ci.jobs["ios-release-e2e"].permissions).toEqual({ actions: "read", contents: "read" });
|
||||
expect(workflow.jobs.qualify["runs-on"]).toBe("xcode-27-xlarge");
|
||||
expect(workflow.jobs.qualify.environment).toBeUndefined();
|
||||
expect(workflow.on.workflow_dispatch.inputs.target_sha).toBeUndefined();
|
||||
|
|
@ -528,11 +626,14 @@ describe("release qualification workflow authority", () => {
|
|||
expect(verify.run).toContain("test -f scripts/ios-release-e2e.ts");
|
||||
expect(verify.if).toBeUndefined();
|
||||
expect(workflow.jobs.qualify.env.OPENCLAW_CI_SIMSLIM_BINARY).toBeUndefined();
|
||||
const upload = steps.find((step: { uses?: string }) =>
|
||||
step.uses?.startsWith("actions/upload-artifact@"),
|
||||
const upload = steps.find(
|
||||
(step: { name: string }) => step.name === "Upload sanitized proof only",
|
||||
);
|
||||
expect(upload.if).toBe("always()");
|
||||
expect(upload.with.path).toBe("${{ runner.temp }}/ios-release-e2e-proof.json");
|
||||
expect(upload.with.name).toBe(
|
||||
"ios-release-e2e-${{ inputs.mode }}-${{ github.run_id }}-${{ github.run_attempt }}",
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
["full", "a".repeat(40), true],
|
||||
|
|
@ -587,6 +688,10 @@ describe("native command adapter", () => {
|
|||
"unsupported-runtime-architecture",
|
||||
"non-ios-runtime",
|
||||
"cleanup-failure",
|
||||
"gateway-install-failure",
|
||||
"gateway-install-unjoined",
|
||||
"gateway-preflight-failure",
|
||||
"gateway-preflight-cleanup-failure",
|
||||
"build-unjoined",
|
||||
"build-exit",
|
||||
"boot-timeout",
|
||||
|
|
@ -598,6 +703,7 @@ describe("native command adapter", () => {
|
|||
"gateway-exit-during-boot",
|
||||
"cancel-during-boot",
|
||||
"gateway-only",
|
||||
"native-build-only",
|
||||
"setup-code-timeout",
|
||||
"setup-code-rpc-timeout",
|
||||
"test-unjoined",
|
||||
|
|
@ -631,6 +737,42 @@ describe("native command adapter", () => {
|
|||
vi.stubEnv("OPENCLAW_CI_SIMSLIM_BINARY", "");
|
||||
const instances: { cleanup: ReturnType<typeof vi.fn> }[] = [];
|
||||
const lifecycle: string[] = [];
|
||||
const gatewayIdentity = {
|
||||
version: "2026.9.29",
|
||||
integrity: "sha512-YQ==",
|
||||
sourceSha: "2".repeat(40),
|
||||
lockSha256: "3".repeat(64),
|
||||
nodeVersion: process.version,
|
||||
npmVersion: "11.6.2",
|
||||
};
|
||||
let installedGatewayPath = "";
|
||||
nativeMocks.install.mockImplementation(async (options) => {
|
||||
lifecycle.push("gateway-install");
|
||||
if (scenario.startsWith("gateway-install-")) {
|
||||
throw Object.assign(
|
||||
new Error("private package installation failure"),
|
||||
scenario === "gateway-install-unjoined"
|
||||
? { code: "ETIMEDOUT", processTreeState: "live" }
|
||||
: {},
|
||||
);
|
||||
}
|
||||
installedGatewayPath = path.join(options.installDir, "node_modules/openclaw");
|
||||
return {
|
||||
cwd: installedGatewayPath,
|
||||
entrypoint: ["openclaw.mjs"],
|
||||
identity: gatewayIdentity,
|
||||
};
|
||||
});
|
||||
const preflightCleanup = vi.fn(async () => {
|
||||
lifecycle.push("gateway-preflight-cleanup");
|
||||
if (scenario === "gateway-preflight-cleanup-failure") {
|
||||
throw new Error("private preflight cleanup failure");
|
||||
}
|
||||
});
|
||||
const fixtureRpcCalls = () =>
|
||||
nativeMocks.rpc.mock.calls.filter(
|
||||
([options]) => options.configPath !== "/private/preflight/config.json",
|
||||
);
|
||||
let simulatorReady = false;
|
||||
let sourceChanged = false;
|
||||
let exitMock: (() => void) | undefined;
|
||||
|
|
@ -649,6 +791,20 @@ describe("native command adapter", () => {
|
|||
let nativeCommandActive = false;
|
||||
let historyReadBeforeCommandExit = false;
|
||||
nativeMocks.rpc.mockImplementation(async (options) => {
|
||||
if (options.configPath === "/private/preflight/config.json") {
|
||||
expect(options).toMatchObject({
|
||||
method: "device.pair.setupStatus",
|
||||
});
|
||||
lifecycle.push("gateway-preflight-rpc");
|
||||
expect(nativeMocks.build).not.toHaveBeenCalled();
|
||||
ensureGatewaySupportsRequiredFeatures({
|
||||
required: options.requiredMethods,
|
||||
supported: scenario === "gateway-preflight-failure" ? [] : ["chat.send", "chat.history"],
|
||||
kind: "method",
|
||||
attemptedMethod: options.method,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
if (options.method === "chat.history") {
|
||||
historyReadBeforeCommandExit = nativeCommandActive;
|
||||
if (scenario === "reply-failure-history-error") {
|
||||
|
|
@ -707,7 +863,23 @@ describe("native command adapter", () => {
|
|||
}
|
||||
return { setupCode: `synthetic-code-${instances.length}` };
|
||||
});
|
||||
nativeMocks.gateway.mockImplementation(async () => {
|
||||
nativeMocks.gateway.mockImplementation(async (options) => {
|
||||
expect(options).toMatchObject({
|
||||
cwd: installedGatewayPath,
|
||||
entrypoint: ["openclaw.mjs"],
|
||||
});
|
||||
if (options.name === "ios-release-e2e-preflight") {
|
||||
lifecycle.push("gateway-preflight-create");
|
||||
return {
|
||||
url: "ws://127.0.0.1:19999",
|
||||
gatewayToken: "synthetic-preflight-token",
|
||||
configPath: "/private/preflight/config.json",
|
||||
startGateway: vi.fn(async () => {
|
||||
lifecycle.push("gateway-preflight-start");
|
||||
}),
|
||||
cleanup: preflightCleanup,
|
||||
};
|
||||
}
|
||||
expect(simulatorReady).toBe(false);
|
||||
lifecycle.push("gateway-create");
|
||||
const index = instances.length + 1;
|
||||
|
|
@ -879,6 +1051,10 @@ describe("native command adapter", () => {
|
|||
} else if (args.includes("delete")) {
|
||||
lifecycle.push("simulator-delete");
|
||||
} else if (args.includes("build-for-testing")) {
|
||||
if (scenario !== "native-build-only") {
|
||||
expect(preflightCleanup).toHaveBeenCalledOnce();
|
||||
expect(lifecycle).toContain("gateway-preflight-rpc");
|
||||
}
|
||||
appContainer = path.join(
|
||||
path.dirname(args[args.indexOf("-derivedDataPath") + 1]!),
|
||||
"app-container",
|
||||
|
|
@ -1082,6 +1258,8 @@ describe("native command adapter", () => {
|
|||
targetSha: "1".repeat(40),
|
||||
signal: abort.signal,
|
||||
gatewayOnly: scenario === "gateway-only",
|
||||
buildOnly: scenario === "native-build-only",
|
||||
gatewaySelectionDir: path.join(temp, "selection"),
|
||||
proof,
|
||||
onProgress: async () => {
|
||||
progressSnapshots.push(JSON.stringify(proof));
|
||||
|
|
@ -1127,6 +1305,38 @@ describe("native command adapter", () => {
|
|||
expect(proof.resourcesPreserved).toBe(scenario === "build-unjoined" ? true : undefined);
|
||||
return;
|
||||
}
|
||||
if (scenario.startsWith("gateway-install-")) {
|
||||
await expect(admission).rejects.toMatchObject({
|
||||
diagnostic: {
|
||||
operation: "gateway-install",
|
||||
code: scenario === "gateway-install-unjoined" ? "timeout" : "failed",
|
||||
},
|
||||
});
|
||||
expect(nativeMocks.gateway).not.toHaveBeenCalled();
|
||||
expect(nativeMocks.build).not.toHaveBeenCalled();
|
||||
expect(readdirSync(temp)).toHaveLength(scenario === "gateway-install-unjoined" ? 1 : 0);
|
||||
expect(proof.resourcesPreserved).toBe(
|
||||
scenario === "gateway-install-unjoined" ? true : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (scenario.startsWith("gateway-preflight-")) {
|
||||
await expect(admission).rejects.toMatchObject({
|
||||
diagnostic:
|
||||
scenario === "gateway-preflight-cleanup-failure"
|
||||
? { operation: "cleanup", code: "cleanup-unconfirmed" }
|
||||
: { operation: "gateway-preflight", code: "failed" },
|
||||
});
|
||||
expect(nativeMocks.build).not.toHaveBeenCalled();
|
||||
expect(preflightCleanup).toHaveBeenCalledOnce();
|
||||
expect(instances).toEqual([]);
|
||||
expect(created).toBe(0);
|
||||
expect(lifecycle).not.toContain("mock-start");
|
||||
expect(readdirSync(temp)).toHaveLength(
|
||||
scenario === "gateway-preflight-cleanup-failure" ? 1 : 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const native = await admission;
|
||||
expect(proof).toMatchObject({
|
||||
xcode: scenario === "different-xcode" ? "26.6" : "27.0",
|
||||
|
|
@ -1150,6 +1360,24 @@ describe("native command adapter", () => {
|
|||
: "com.apple.CoreSimulator.SimRuntime.iOS-26-5",
|
||||
});
|
||||
try {
|
||||
if (scenario === "native-build-only") {
|
||||
expect(nativeMocks.build).toHaveBeenCalledOnce();
|
||||
expect(nativeMocks.install).not.toHaveBeenCalled();
|
||||
expect(nativeMocks.gateway).not.toHaveBeenCalled();
|
||||
expect(nativeMocks.rpc).not.toHaveBeenCalled();
|
||||
expect(created).toBe(0);
|
||||
expect(proof.gateway).toBeUndefined();
|
||||
expect(proof.gatewayInstallMs).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(nativeMocks.install).toHaveBeenCalledExactlyOnceWith({
|
||||
selectionDir: path.join(temp, "selection"),
|
||||
installDir: expect.stringMatching(/\/openclaw-ios-release-e2e-[^/]+\/gateway$/u),
|
||||
targetSha: "1".repeat(40),
|
||||
signal: abort.signal,
|
||||
});
|
||||
expect(proof.gateway).toEqual(gatewayIdentity);
|
||||
expect(proof.gatewayInstallMs).toEqual(expect.any(Number));
|
||||
if (scenario === "gateway-only") {
|
||||
const gatewayProbe = await native.dependencies.create("stock", 1);
|
||||
try {
|
||||
|
|
@ -1158,6 +1386,11 @@ describe("native command adapter", () => {
|
|||
await gatewayProbe.cleanup();
|
||||
}
|
||||
expect(lifecycle).toEqual([
|
||||
"gateway-install",
|
||||
"gateway-preflight-create",
|
||||
"gateway-preflight-start",
|
||||
"gateway-preflight-rpc",
|
||||
"gateway-preflight-cleanup",
|
||||
"mock-start",
|
||||
"gateway-create",
|
||||
"gateway-start",
|
||||
|
|
@ -1170,7 +1403,7 @@ describe("native command adapter", () => {
|
|||
expect(nativeMocks.build).not.toHaveBeenCalled();
|
||||
expect(created).toBe(0);
|
||||
expect(joinedMocks).toBe(1);
|
||||
expect(nativeMocks.rpc.mock.calls.map(([options]) => options.method)).toEqual([
|
||||
expect(fixtureRpcCalls().map(([options]) => options.method)).toEqual([
|
||||
"device.pair.setupStatus",
|
||||
"device.pair.setupCode",
|
||||
]);
|
||||
|
|
@ -1281,7 +1514,7 @@ describe("native command adapter", () => {
|
|||
expect(lifecycle.at(-1)).toBe("simulator-delete");
|
||||
} else {
|
||||
expect(created).toBe(0);
|
||||
expect(nativeMocks.rpc).not.toHaveBeenCalled();
|
||||
expect(fixtureRpcCalls()).toHaveLength(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1309,7 +1542,7 @@ describe("native command adapter", () => {
|
|||
],
|
||||
},
|
||||
]);
|
||||
expect(nativeMocks.rpc).toHaveBeenCalledOnce();
|
||||
expect(fixtureRpcCalls()).toHaveLength(1);
|
||||
expect(created).toBe(0);
|
||||
expect(lifecycle).not.toContain("setup-code");
|
||||
expect(lifecycle).not.toContain("live-test");
|
||||
|
|
@ -1337,7 +1570,7 @@ describe("native command adapter", () => {
|
|||
},
|
||||
]);
|
||||
expect(proof.resourcesPreserved).toBe(true);
|
||||
expect(nativeMocks.rpc).toHaveBeenCalledOnce();
|
||||
expect(fixtureRpcCalls()).toHaveLength(1);
|
||||
expect(created).toBe(0);
|
||||
expect(instances[0]?.cleanup).toHaveBeenCalledOnce();
|
||||
expect(joinedMocks).toBe(1);
|
||||
|
|
@ -1510,9 +1743,8 @@ describe("native command adapter", () => {
|
|||
expect(created).toBe(1);
|
||||
expect(joinedMocks).toBe(1);
|
||||
for (const [index, instance] of instances.entries()) {
|
||||
expect(nativeMocks.rpc).toHaveBeenCalledTimes(2);
|
||||
expect(nativeMocks.rpc).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(fixtureRpcCalls()).toHaveLength(2);
|
||||
expect(fixtureRpcCalls()[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "device.pair.setupStatus",
|
||||
params: {
|
||||
|
|
@ -1524,7 +1756,7 @@ describe("native command adapter", () => {
|
|||
url: `ws://127.0.0.1:${20001 + index}`,
|
||||
}),
|
||||
);
|
||||
expect(nativeMocks.rpc).toHaveBeenNthCalledWith(2, {
|
||||
expect(fixtureRpcCalls()[1]?.[0]).toEqual({
|
||||
config: {},
|
||||
configPath: `/private/fixture-${index + 1}/config.json`,
|
||||
url: `ws://127.0.0.1:${20001 + index}`,
|
||||
|
|
@ -1627,7 +1859,7 @@ describe("native command adapter", () => {
|
|||
expect(
|
||||
commands.filter(({ args }) => args.includes("delete")).map(({ args }) => args.at(-1)),
|
||||
).toEqual(["11111111-2222-3333-4444-000000000001"]);
|
||||
expect(nativeMocks.gateway.mock.calls[0]?.[0]).toMatchObject({
|
||||
expect(nativeMocks.gateway.mock.calls[1]?.[0]).toMatchObject({
|
||||
config: {
|
||||
gateway: { controlUi: { enabled: false } },
|
||||
agents: { defaults: { model: { primary: "openai/ios-e2e" } } },
|
||||
|
|
|
|||
338
test/scripts/ios-release-gateway.test.ts
Normal file
338
test/scripts/ios-release-gateway.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { ChildProcess } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
prepareIOSReleaseGateway,
|
||||
selectIOSReleaseGateway,
|
||||
} from "../../scripts/lib/ios-release-gateway.js";
|
||||
import type { RunManagedCommandOptions } from "../../scripts/lib/managed-child-process.mts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const command = vi.hoisted(() => vi.fn<(options: RunManagedCommandOptions) => Promise<number>>());
|
||||
vi.mock("../../scripts/lib/managed-child-process.mjs", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../scripts/lib/managed-child-process.mjs")>()),
|
||||
runManagedCommand: command,
|
||||
}));
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const targetSha = "1".repeat(40);
|
||||
const sourceSha = "2".repeat(40);
|
||||
const tagSha = "3".repeat(40);
|
||||
const version = "2026.9.7";
|
||||
const npmVersion = "11.20.0";
|
||||
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
|
||||
const tarball = `https://registry.npmjs.org/openclaw/-/openclaw-${version}.tgz`;
|
||||
|
||||
afterEach(() => {
|
||||
command.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function writeJson(file: string, value: unknown) {
|
||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readJson(file: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function fixture(options: { annotatedTag?: boolean } = {}) {
|
||||
const root = tempDirs.make("ios-release-gateway-");
|
||||
const selectionDir = path.join(root, "selection");
|
||||
const installDir = path.join(root, "install");
|
||||
const signal = new AbortController().signal;
|
||||
const state = {
|
||||
metadata: { name: "openclaw", version, dist: { integrity, tarball } },
|
||||
sourceSha,
|
||||
npmVersion,
|
||||
installedVersion: version,
|
||||
installedCommit: sourceSha,
|
||||
installExitCode: 0,
|
||||
cancelInstall: undefined as AbortController | undefined,
|
||||
};
|
||||
const lock = {
|
||||
name: "ios-release-gateway-fixture",
|
||||
version: "1.0.0",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": { dependencies: { openclaw: version } },
|
||||
"node_modules/openclaw": {
|
||||
version,
|
||||
resolved: tarball,
|
||||
integrity,
|
||||
dependencies: { "fixture-transitive": "^1.0.0" },
|
||||
},
|
||||
"node_modules/fixture-transitive": {
|
||||
version: "1.2.3",
|
||||
resolved: "https://registry.npmjs.org/fixture-transitive/-/fixture-transitive-1.2.3.tgz",
|
||||
integrity: `sha512-${Buffer.alloc(64, 2).toString("base64")}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
const requests: string[] = [];
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
requests.push(url);
|
||||
if (url === "https://registry.npmjs.org/openclaw/latest") {
|
||||
return Response.json(state.metadata);
|
||||
}
|
||||
if (url.endsWith(`/tags/v${version}`)) {
|
||||
return Response.json({
|
||||
object: {
|
||||
type: options.annotatedTag ? "tag" : "commit",
|
||||
sha: options.annotatedTag ? tagSha : state.sourceSha,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (options.annotatedTag && url.endsWith(`/git/tags/${tagSha}`)) {
|
||||
return Response.json({ object: { type: "commit", sha: state.sourceSha } });
|
||||
}
|
||||
throw new Error(`Unexpected metadata request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const installs: Array<{ packageBytes: string; lockBytes: string }> = [];
|
||||
command.mockImplementation(async (operation: RunManagedCommandOptions) => {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
const child = new ChildProcess();
|
||||
child.stdout = stdout;
|
||||
child.stderr = stderr;
|
||||
operation.onReady?.(child);
|
||||
try {
|
||||
expect(path.basename(operation.bin)).toMatch(/^npm(?:\.cmd)?$/u);
|
||||
const args = operation.args ?? [];
|
||||
if (args.includes("--version")) {
|
||||
stdout.write(`${state.npmVersion}\n`);
|
||||
return 0;
|
||||
}
|
||||
const cwd = operation.cwd;
|
||||
if (!cwd) {
|
||||
throw new Error("npm requires an isolated working directory");
|
||||
}
|
||||
if (args.includes("--package-lock-only")) {
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining(["install", "--ignore-scripts", "--no-audit", "--no-fund"]),
|
||||
);
|
||||
expect(readJson(path.join(cwd, "package.json")).dependencies).toEqual({
|
||||
openclaw: version,
|
||||
});
|
||||
writeJson(path.join(cwd, "package-lock.json"), lock);
|
||||
return 0;
|
||||
}
|
||||
if (args.includes("ci")) {
|
||||
installs.push({
|
||||
packageBytes: readFileSync(path.join(cwd, "package.json"), "utf8"),
|
||||
lockBytes: readFileSync(path.join(cwd, "package-lock.json"), "utf8"),
|
||||
});
|
||||
if (state.cancelInstall) {
|
||||
expect(operation.signal?.aborted).toBe(false);
|
||||
state.cancelInstall.abort(new Error("fixture install cancellation"));
|
||||
expect(operation.signal?.aborted).toBe(true);
|
||||
operation.signal?.throwIfAborted();
|
||||
}
|
||||
if (state.installExitCode) {
|
||||
return state.installExitCode;
|
||||
}
|
||||
const packageRoot = path.join(cwd, "node_modules", "openclaw");
|
||||
mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
||||
writeJson(path.join(packageRoot, "package.json"), {
|
||||
name: "openclaw",
|
||||
version: state.installedVersion,
|
||||
bin: { openclaw: "openclaw.mjs" },
|
||||
});
|
||||
writeJson(path.join(packageRoot, "dist", "build-info.json"), {
|
||||
version: state.installedVersion,
|
||||
commit: state.installedCommit,
|
||||
});
|
||||
writeFileSync(path.join(packageRoot, "openclaw.mjs"), "// synthetic package entrypoint\n");
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`Unexpected npm command: ${args.join(" ")}`);
|
||||
} finally {
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
}
|
||||
});
|
||||
return { root, selectionDir, installDir, signal, state, lock, fetch, requests, installs };
|
||||
}
|
||||
|
||||
describe("iOS stable Gateway package qualification", () => {
|
||||
it.each([false, true])(
|
||||
"freezes the published selection and replays its exact dependency graph (annotated tag: %s)",
|
||||
async (annotatedTag) => {
|
||||
const f = fixture({ annotatedTag });
|
||||
const selected = await selectIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
targetSha,
|
||||
signal: f.signal,
|
||||
});
|
||||
expect(selected).toMatchObject({ targetSha, version, sourceSha, integrity, tarball });
|
||||
const packageBytes = readFileSync(path.join(f.selectionDir, "package.json"), "utf8");
|
||||
const lockBytes = readFileSync(path.join(f.selectionDir, "package-lock.json"), "utf8");
|
||||
expect(selected).toMatchObject({
|
||||
packageSha256: createHash("sha256").update(packageBytes).digest("hex"),
|
||||
lockSha256: createHash("sha256").update(lockBytes).digest("hex"),
|
||||
});
|
||||
expect(f.requests).toHaveLength(annotatedTag ? 3 : 2);
|
||||
f.fetch.mockImplementation(async () => {
|
||||
throw new Error("The registry and moving latest tag are unavailable during replay");
|
||||
});
|
||||
const prepared = await prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
signal: f.signal,
|
||||
targetSha,
|
||||
});
|
||||
expect(prepared.identity).toEqual(selected);
|
||||
expect(prepared.cwd).toBe(path.join(f.installDir, "node_modules", "openclaw"));
|
||||
expect(prepared.entrypoint).toEqual([path.join(prepared.cwd, "openclaw.mjs")]);
|
||||
expect(f.installs).toEqual([{ packageBytes, lockBytes }]);
|
||||
expect(f.fetch).toHaveBeenCalledTimes(annotatedTag ? 3 : 2);
|
||||
expect(
|
||||
command.mock.calls.filter(([operation]) => operation.args?.includes("--package-lock-only")),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["integrity", "source SHA", "prerelease"])(
|
||||
"rejects invalid published %s before installing a Gateway",
|
||||
async (invalid) => {
|
||||
const f = fixture();
|
||||
if (invalid === "integrity") {
|
||||
f.state.metadata.dist.integrity = "not-an-integrity";
|
||||
} else if (invalid === "source SHA") {
|
||||
f.state.sourceSha = "main";
|
||||
} else {
|
||||
f.state.metadata.version = "2026.9.8-beta.1";
|
||||
}
|
||||
await expect(
|
||||
prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
signal: f.signal,
|
||||
targetSha,
|
||||
}),
|
||||
).rejects.toThrow(/Invalid string|Invalid.*format|regular stable OpenClaw release/u);
|
||||
expect(f.installs).toHaveLength(0);
|
||||
expect(existsSync(path.join(f.selectionDir, "selection.json"))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a nonregistry tarball even when npm's lock agrees with the metadata", async () => {
|
||||
const f = fixture();
|
||||
const wrongTarball = "https://example.invalid/openclaw.tgz";
|
||||
f.state.metadata.dist.tarball = wrongTarball;
|
||||
f.lock.packages["node_modules/openclaw"].resolved = wrongTarball;
|
||||
await expect(
|
||||
prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
signal: f.signal,
|
||||
targetSha,
|
||||
}),
|
||||
).rejects.toThrow(/tarball|registry|qualification target/u);
|
||||
expect(f.installs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a resolved lock whose package integrity differs from the selected artifact", async () => {
|
||||
const f = fixture();
|
||||
f.lock.packages["node_modules/openclaw"].integrity =
|
||||
`sha512-${Buffer.alloc(64, 3).toString("base64")}`;
|
||||
await expect(
|
||||
prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
signal: f.signal,
|
||||
targetSha,
|
||||
}),
|
||||
).rejects.toThrow(/package lock does not match/u);
|
||||
expect(f.installs).toHaveLength(0);
|
||||
expect(existsSync(path.join(f.selectionDir, "selection.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses an incomplete saved selection without replacing it with latest", async () => {
|
||||
const f = fixture();
|
||||
mkdirSync(f.selectionDir);
|
||||
await expect(
|
||||
selectIOSReleaseGateway({ selectionDir: f.selectionDir, targetSha, signal: f.signal }),
|
||||
).rejects.toThrow(/ENOENT/u);
|
||||
expect(f.fetch).not.toHaveBeenCalled();
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["package.json", "package-lock.json", "target SHA", "Node", "npm"])(
|
||||
"refuses changed saved %s instead of reselecting latest",
|
||||
async (invalid) => {
|
||||
const f = fixture();
|
||||
await selectIOSReleaseGateway({ selectionDir: f.selectionDir, targetSha, signal: f.signal });
|
||||
f.fetch.mockClear();
|
||||
const manifestPath = path.join(f.selectionDir, "selection.json");
|
||||
if (invalid === "package.json" || invalid === "package-lock.json") {
|
||||
writeFileSync(path.join(f.selectionDir, invalid), "{}\n");
|
||||
} else if (invalid === "npm") {
|
||||
f.state.npmVersion = "11.21.0";
|
||||
} else if (invalid === "Node") {
|
||||
writeJson(manifestPath, { ...readJson(manifestPath), nodeVersion: "v0.0.0" });
|
||||
}
|
||||
await expect(
|
||||
prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
signal: f.signal,
|
||||
targetSha: invalid === "target SHA" ? "4".repeat(40) : targetSha,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/manifests changed|qualification target|Node version and platform|npm version/u,
|
||||
);
|
||||
expect(f.fetch).not.toHaveBeenCalled();
|
||||
expect(f.installs).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["version", "source"])(
|
||||
"rejects an installed package with the wrong %s",
|
||||
async (wrong) => {
|
||||
const f = fixture();
|
||||
if (wrong === "version") {
|
||||
f.state.installedVersion = "2026.9.6";
|
||||
} else {
|
||||
f.state.installedCommit = "4".repeat(40);
|
||||
}
|
||||
await expect(
|
||||
prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
signal: f.signal,
|
||||
targetSha,
|
||||
}),
|
||||
).rejects.toThrow(/Installed Gateway identity differs/);
|
||||
expect(f.installs).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["failure", "cancellation"])(
|
||||
"propagates npm ci %s without a source fallback",
|
||||
async (outcome) => {
|
||||
const f = fixture();
|
||||
const abort = new AbortController();
|
||||
if (outcome === "failure") {
|
||||
f.state.installExitCode = 23;
|
||||
} else {
|
||||
f.state.cancelInstall = abort;
|
||||
}
|
||||
await expect(
|
||||
prepareIOSReleaseGateway({
|
||||
selectionDir: f.selectionDir,
|
||||
installDir: f.installDir,
|
||||
targetSha,
|
||||
signal: abort.signal,
|
||||
}),
|
||||
).rejects.toThrow(outcome === "failure" ? /23/ : /cancellation/);
|
||||
expect(f.installs).toHaveLength(1);
|
||||
expect(existsSync(path.join(f.selectionDir, "selection.json"))).toBe(true);
|
||||
expect(command.mock.calls.at(-1)?.[0].args).toContain("ci");
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue