openclaw/test/scripts/mobile-release-ref.test.ts
Peter Steinberger 3cb68020b6
refactor(scripts): deslop shared tooling
## What Problem This Solves

Repository scripts retain private copies of shared tooling helpers and an unused translation subprocess runner, increasing maintenance work across sibling scripts.

## User Impact

Internal tooling cleanup: existing flags, diagnostics, generated bytes, JSON formats, exit codes, and cleanup policies stay the same. The build wrapper additionally detects forbidden dynamic-import warnings when compiler output splits the warning marker across chunks.

## Why This Change Was Made

- Remove the retired i18n process runner. Its final production caller moved to the shared formatter in #95534; retain real CLI/privacy/provider tests and run their subprocesses with the existing dependency.
- Share Docker resource, signal-trap, platform, workspace-staging, and plugin-selection helpers while retaining each caller's environment precedence and lifecycle decisions.
- Reuse existing E2E fixture JSON/assertion and package-path helpers; retain distinct write modes, recovery ordering, and mounted/frozen harness contracts.
- Share macOS guest desktop-user/home resolution through its existing owner while preserving per-caller timeout policy.
- Route release/mobile flags through the existing version flag specifications, preserving split-only values, duplicate/mode rules, help timing, and error text. Reuse existing retry sleep and comparator owners and remove an unreachable iOS output branch.
- Reuse existing guard entrypoint, failure-trailer, diagnostic-line, and metadata-normalization owners.

Measured reduction: **732 net production lines**, with test changes counted separately. PR tooling and its protected import closure, CI planners/shards, baselines, and generated artifacts are unchanged.

## Fixes Found Along the Way

The tsdown scanner checked each raw output chunk for its warning marker. It now checks the existing combined-line buffer. The regression exercises every split inside the marker and fails on the original source for the intended missing-warning assertion.

## Evidence

- Blacksmith Testbox `tbx_01m3tp4sabwd0807kj9jkqqmbr`: frozen dependency install and candidate source-byte verification; all nine changed test files and 59 selected sibling files passed, including real CLI, package fixture, recovery, Docker harness, and Parallels transport contracts.
- Generated channel metadata is byte-identical; differential metadata normalization cases passed.
- SDK surface check passed. Independent isolated Codex review completed with no P0–P2 findings.
- Initial changed checks caught an invalid direct source import in a proposed snapshot-distance consolidation; that independent change was withdrawn. No boundary exception was added. The final changed checks pass, including script/test lint and Docker shell/scheduler checks; madge reports 0 cycles and the runtime import check reports 0 cycles.
- Per-file single-worker wall time for changed suites: translation 9.92s; Docker helper 38.88s; live Docker auth 1.71s; mobile ref 2.36s; mobile release 6.23s; release preparation 2.42s; version 2.04s; installer 15.01s; tsdown 2.70s. The existing Docker helper suite executes shell/process cleanup and container-command boundary fixtures; new assertions reuse those fixtures. New parser/scanner cases use no sleeps or polling.

Hosted CI will be verified against this PR's exact pushed head. No live deployment or release was performed.
2026-10-01 05:14:07 +00:00

313 lines
11 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { copyFileSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
initializeAndroidStoreRelease,
mobileReleaseRefFor,
parseArgs,
preflightMobileReleaseRef,
recordMobileReleaseRef,
resolveMobileReleaseRef,
} from "../../scripts/mobile-release-ref.ts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const SCRIPT_PATH = path.join(process.cwd(), "scripts", "mobile-release-ref.ts");
const tempRoots = useAutoCleanupTempDirTracker(afterEach);
function run(command: string, args: string[], cwd: string): string {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function git(cwd: string, args: string[]): string {
return run("git", args, cwd);
}
function createFixtureRepo(): { remote: string; root: string; sha: string } {
const root = tempRoots.make("openclaw-mobile-release-ref-");
const remote = path.join(root, "remote.git");
const checkout = path.join(root, "checkout");
git(root, ["init", "--bare", remote]);
git(root, ["clone", remote, checkout]);
git(checkout, ["config", "user.email", "release@example.com"]);
git(checkout, ["config", "user.name", "Release Test"]);
writeFileSync(path.join(checkout, "README.md"), "release\n", "utf8");
git(checkout, ["add", "README.md"]);
git(checkout, ["commit", "-m", "initial"]);
const sha = git(checkout, ["rev-parse", "HEAD"]).trim();
git(checkout, ["push", "origin", "HEAD:main"]);
return {
remote: "origin",
root: checkout,
sha,
};
}
describe("mobile-release-ref", () => {
it("renders platform release refs from store identities", () => {
expect(mobileReleaseRefFor({ platform: "ios", version: "2026.6.10", build: "8" })).toBe(
"refs/openclaw/mobile-releases/ios/2026.6.10-8",
);
expect(
mobileReleaseRefFor({
platform: "android",
version: "2026.6.10",
versionCode: "2026061008",
}),
).toBe("refs/openclaw/mobile-releases/android/2026.6.10-2026061008");
});
it("validates platform-specific numeric identities", () => {
expect(() =>
mobileReleaseRefFor({ platform: "ios", version: "2026.6.10", build: "0" }),
).toThrow("Invalid iOS build");
expect(() =>
mobileReleaseRefFor({
platform: "android",
version: "2026.6.10",
versionCode: "not-a-code",
}),
).toThrow("Invalid Android versionCode");
expect(() =>
mobileReleaseRefFor({
platform: "android",
version: "2026.6.10",
versionCode: "2026061101",
}),
).toThrow("Expected 2026061001 through 2026061099");
expect(() =>
mobileReleaseRefFor({ platform: "ios", version: "2026.06.10", build: "8" }),
).toThrow("Invalid mobile release version");
});
it("parses CLI commands and rejects missing platform-specific fields", () => {
expect(
parseArgs([
"record",
"--",
"--platform",
"android",
"--version",
"2026.6.10",
"--version-code",
"2026061008",
"--sha",
"HEAD",
]),
).toMatchObject({
command: "record",
platform: "android",
version: "2026.6.10",
versionCode: "2026061008",
});
expect(() =>
mobileReleaseRefFor({
platform: "android",
version: "2026.6.10",
}),
).toThrow("Invalid Android versionCode");
});
it("preserves flag ordering and fails before treating a missing value as help", () => {
expect(
parseArgs(["resolve", "--platform", "android", "--", "--platform", "ios"]),
).toMatchObject({ platform: "ios", sha: "HEAD" });
expect(() => parseArgs(["resolve", "--root", "-h"])).toThrow("Missing value for --root.");
expect(() => parseArgs(["resolve", "--platform=ios"])).toThrow(
"Unknown argument: --platform=ios",
);
expect(() => parseArgs(["resolve", "--help", "--unknown"])).toThrow("Usage:");
});
it("records immutable platform refs and resolves the recorded SHA through the CLI", () => {
const fixture = createFixtureRepo();
const iosOptions = {
build: "8",
command: "record" as const,
platform: "ios" as const,
remote: fixture.remote,
rootDir: fixture.root,
sha: "HEAD",
version: "2026.6.10",
versionCode: null,
};
expect(preflightMobileReleaseRef(iosOptions).status).toBe("available");
expect(recordMobileReleaseRef(iosOptions)).toMatchObject({
ref: "refs/openclaw/mobile-releases/ios/2026.6.10-8",
sha: fixture.sha,
status: "created",
});
expect(recordMobileReleaseRef(iosOptions).status).toBe("already-recorded");
expect(resolveMobileReleaseRef(iosOptions)).toMatchObject({
ref: "refs/openclaw/mobile-releases/ios/2026.6.10-8",
sha: fixture.sha,
});
const androidOptions = {
build: null,
command: "record" as const,
platform: "android" as const,
remote: fixture.remote,
rootDir: fixture.root,
sha: "HEAD",
version: "2026.6.10",
versionCode: "2026061008",
};
recordMobileReleaseRef(androidOptions);
writeFileSync(path.join(fixture.root, "README.md"), "next\n", "utf8");
git(fixture.root, ["add", "README.md"]);
git(fixture.root, ["commit", "-m", "next"]);
expect(() => recordMobileReleaseRef(androidOptions)).toThrow("already points at");
// CLI resolution must return the recorded build even after checkout HEAD advances.
const stdout = run(
process.execPath,
[
"--import",
"tsx",
SCRIPT_PATH,
"resolve",
"--platform",
"ios",
"--version",
"2026.6.10",
"--build",
"8",
"--root",
fixture.root,
],
process.cwd(),
);
expect(stdout).toBe(`${fixture.sha}\trefs/openclaw/mobile-releases/ios/2026.6.10-8\n`);
});
it("runs the CLI entrypoint from a path containing spaces", () => {
const root = tempRoots.make("openclaw mobile release ref-");
const scriptDir = path.join(root, "script dir");
const scriptPath = path.join(scriptDir, "mobile-release-ref.ts");
mkdirSync(scriptDir, { recursive: true });
writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8");
copyFileSync(SCRIPT_PATH, scriptPath);
symlinkSync(
path.join(path.dirname(SCRIPT_PATH), "lib"),
path.join(scriptDir, "lib"),
"junction",
);
const stdout = run(
process.execPath,
["--import", "tsx", realpathSync(scriptPath), "--help"],
process.cwd(),
);
expect(stdout).toContain("scripts/mobile-release-ref.ts preflight");
});
it("records Android v2 plans and preserves the immutable cutover maximum across source commits", () => {
const fixture = createFixtureRepo();
const planPath = path.join(fixture.root, "android-plan.json");
const plan = {
schemaVersion: 2,
gatewayVersion: "2026.9.6",
revision: 0,
buildNumber: 1,
version: "2026.9.60",
versionCode: 2026090454,
wearVersionCode: 2026090455,
legacyMaxVersionCode: 2026090453,
sourceSha: fixture.sha,
releaseNotesBaselines: [
{ audience: "phone", version: null, build: null },
{ audience: "wear", version: null, build: null },
],
};
const writePlan = (value = plan) => writeFileSync(planPath, JSON.stringify(value), "utf8");
writePlan();
const cliArgs = ["--plan", planPath, "--root", fixture.root];
const options = parseArgs(["record", ...cliArgs]);
const ref = "refs/openclaw/mobile-releases/android/v2/2026.9.6/0/1/2026090454-2026090455";
const marker = "refs/openclaw/mobile-releases/android/cutover-v2/2026090453";
expect(options).toMatchObject({ platform: "android", sha: fixture.sha, version: "2026.9.60" });
expect(() => parseArgs(["record", ...cliArgs, "--sha", "HEAD"])).toThrow("does not match");
expect(() => parseArgs(["record", ...cliArgs, "--platform", "ios"])).toThrow("does not match");
expect(() => parseArgs(["initialize-android", "--platform", "android"])).toThrow(
"requires --plan",
);
expect(preflightMobileReleaseRef(options).status).toBe("available");
const initializeOutput = run(
process.execPath,
["--import", "tsx", SCRIPT_PATH, "initialize-android", ...cliArgs],
process.cwd(),
);
expect(initializeOutput).toContain(`${marker} recorded ${fixture.sha}`);
expect(initializeAndroidStoreRelease(options)).toEqual({
ref: marker,
sha: fixture.sha,
status: "already-recorded",
});
// Git accepted this push, but the caller lost the response. The owner must read it back.
let pushes = 0;
expect(
recordMobileReleaseRef(options, {
execFileSync(command, args, execOptions) {
const result = execFileSync(command, args, execOptions);
if (args[0] === "push") {
pushes += 1;
throw new Error("response lost after accepted push");
}
return result;
},
}),
).toEqual({ ref, sha: fixture.sha, status: "already-recorded" });
expect(pushes).toBe(1);
expect(recordMobileReleaseRef(options).status).toBe("already-recorded");
const resolved = run(
process.execPath,
["--import", "tsx", SCRIPT_PATH, "resolve", ...cliArgs],
process.cwd(),
);
expect(resolved).toBe(`${fixture.sha}\t${ref}\n`);
writeFileSync(path.join(fixture.root, "README.md"), "next\n", "utf8");
git(fixture.root, ["add", "README.md"]);
git(fixture.root, ["commit", "-m", "next"]);
const nextSha = git(fixture.root, ["rev-parse", "HEAD"]).trim();
expect(() => preflightMobileReleaseRef({ ...options, sha: nextSha })).toThrow(
"source SHA does not match",
);
writePlan({ ...plan, sourceSha: nextSha });
const nextOptions = parseArgs(["record", ...cliArgs]);
expect(initializeAndroidStoreRelease(nextOptions)).toEqual({
ref: marker,
sha: fixture.sha,
status: "already-recorded",
});
expect(() => recordMobileReleaseRef(nextOptions)).toThrow("already points at");
expect(() => resolveMobileReleaseRef(nextOptions)).toThrow(
"does not record Android plan source",
);
writePlan({ ...plan, sourceSha: nextSha, legacyMaxVersionCode: 2026090452 });
expect(() =>
initializeAndroidStoreRelease(parseArgs(["initialize-android", ...cliArgs])),
).toThrow("does not match planned");
const conflictingMarker = "refs/openclaw/mobile-releases/android/cutover-v2/2026090452";
git(fixture.root, ["push", "origin", `${nextSha}:${conflictingMarker}`]);
expect(() => initializeAndroidStoreRelease(options)).toThrow(
"Multiple Android store version cutover markers",
);
});
});