mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-04 14:47:41 +00:00
test(e2e): select prerelease companions, keep failed outcomes, retain Doctor diagnostics in the upgrade survivor (#149705)
* test(e2e): repair upgrade survivor fixtures and diagnostics Select published prerelease companion cohorts through moving tags, record unpublished companions without dropping the remaining operator-state proof, keep unknown and failed update outcomes, and retain bounded Doctor migration evidence. Separate historical keyed rosters from explicit ownership. Consolidates harness work from upd-matrix-L7, upd-matrix-L8, and upd-matrix-L9, with availability and diagnostics findings from upd-matrix-L4 and upd-matrix-L6. Product code is unchanged. * test(e2e): isolate survivor fixture execution Materialize injected shell runners so the Darwin Bash guard can replay them. Use the current source redactor in installed-version unit diagnostics so existing build artifacts do not consume the command timeout. Preserve all assertions and timeout values.
This commit is contained in:
parent
6cffe6ea52
commit
b884e39bb2
11 changed files with 673 additions and 141 deletions
|
|
@ -502,6 +502,23 @@ Start with the artifact identity:
|
|||
including baseline version, candidate version, scenario, phase timings, and
|
||||
config recipe coverage.
|
||||
|
||||
The `legacy-operator-state` survivor installs a matching published companion
|
||||
through a moving tag (`latest`, `beta`, or `alpha`). If npm confirms that the
|
||||
exact companion version was never published, the row records that companion as
|
||||
unavailable in `baselineCompanion` and continues the remaining operator-state
|
||||
and external-plugin migration checks. Registry errors still fail fixture setup.
|
||||
An update that has not started reports an `unknown` outcome; a failed update
|
||||
attempt reports `failed`, independently of later scenario assertions.
|
||||
|
||||
Failure capture retains the latest session SQLite migration manifest and Doctor
|
||||
issue report in the private diagnostics snapshot, subject to bounded size limits.
|
||||
The published diagnostics contain issue histograms and capture omissions rather
|
||||
than raw session details. Collect private artifacts before removing the test host.
|
||||
Container-owned observation directories can require `sudo tar` on that isolated
|
||||
host; keep their permissions intact. When collecting through a remote wrapper,
|
||||
use distinct success/failure download destinations. A successful download does
|
||||
not change the survivor exit code or `summary.status`.
|
||||
|
||||
Prefer rerunning the failed exact lane with the same package artifact over
|
||||
rerunning the whole release umbrella.
|
||||
|
||||
|
|
|
|||
|
|
@ -1229,14 +1229,15 @@ function readInstalledPluginIndex() {
|
|||
return index;
|
||||
}
|
||||
|
||||
function assertBaselinePlugin([expectedVersion, pluginId = "discord"]) {
|
||||
function assertBaselinePlugin([expectedVersion, pluginId, tag]) {
|
||||
assert(["latest", "beta", "alpha"].includes(tag), "baseline plugin selector is not moving");
|
||||
const record = readInstalledPluginIndex().installRecords[pluginId];
|
||||
assert(record?.source === "npm", "baseline plugin was not installed from npm");
|
||||
assert(record.spec === `@openclaw/${pluginId}@latest`, "baseline plugin selector became pinned");
|
||||
assert(record.spec === `@openclaw/${pluginId}@${tag}`, "baseline plugin selector changed");
|
||||
const installed = readJson(path.join(resolveHomePath(record.installPath), "package.json"));
|
||||
assert(installed.name === `@openclaw/${pluginId}`, "baseline plugin package identity changed");
|
||||
assert(installed.version === expectedVersion, "baseline plugin is not the baseline version");
|
||||
console.log(`Baseline npm plugin: @openclaw/${pluginId}@${expectedVersion}, selector=latest.`);
|
||||
console.log(`Baseline npm plugin: @openclaw/${pluginId}@${expectedVersion}, selector=${tag}.`);
|
||||
}
|
||||
|
||||
function assertExternalPluginInstall(records, pluginId, packageName) {
|
||||
|
|
|
|||
|
|
@ -331,19 +331,18 @@ function adaptStepForBaseline(
|
|||
throw new Error(`config recipe step ${step.id} is missing its JSON value`);
|
||||
}
|
||||
const agents = JSON.parse(agentsJson);
|
||||
// Explicit ownership was introduced in beta.2; beta.1 requires a
|
||||
// legacy default marker, so this boundary must compare prereleases too.
|
||||
// Keyed rosters shipped before explicit ownership; those baselines still
|
||||
// require the legacy default marker.
|
||||
if (compareReleaseVersions(baselineVersion ?? "", "2026.8.1-beta.2") === -1) {
|
||||
agents.list = Object.entries<Record<string, unknown>>(agents.entries).map(([id, entry]) => {
|
||||
entry.id = id;
|
||||
if (id === "main") {
|
||||
entry.default = true;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
delete agents.entries;
|
||||
agents.entries.main.default = true;
|
||||
delete agents.ownership;
|
||||
}
|
||||
if (compareReleaseVersions(baselineVersion ?? "", "2026.7.2-beta.4") === -1) {
|
||||
agents.list = Object.entries<Record<string, unknown>>(agents.entries).map(([id, entry]) =>
|
||||
Object.assign(entry, { id }),
|
||||
);
|
||||
delete agents.entries;
|
||||
}
|
||||
if (isReleaseBefore(baselineVersion, "2026.4.0")) {
|
||||
delete agents.defaults?.skills;
|
||||
for (const agent of agents.list) {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,15 @@ const outputLimit = 16 * 1024;
|
|||
const privateLimit = 8 * 1024 * 1024;
|
||||
const publicLimit = 512 * 1024;
|
||||
const entryLimit = 128;
|
||||
const migrationFileLimit = 2 * 1024 * 1024;
|
||||
const migrationDirectory = "session-sqlite-migration-runs";
|
||||
const migrationLabels = {
|
||||
manifest: "session migration manifest",
|
||||
failureReport: "session migration failure report",
|
||||
};
|
||||
const logNames = [
|
||||
"baseline-install.log",
|
||||
"baseline-companion.json",
|
||||
"install.log",
|
||||
"update.json",
|
||||
"update.err",
|
||||
|
|
@ -707,6 +714,147 @@ function captureMigrationEvidence(stateRoot, artifactRoot, observationRoot) {
|
|||
);
|
||||
}
|
||||
|
||||
function sessionMigrationProjection(raw, kind, runId, sanitize = (text) => text) {
|
||||
if (typeof raw !== "string" || Buffer.byteLength(raw) > migrationFileLimit) {
|
||||
throw new Error();
|
||||
}
|
||||
const value = JSON.parse(raw);
|
||||
const manifest = kind === "manifest";
|
||||
const version = manifest ? value.openClawVersion : value.version;
|
||||
if (
|
||||
value.runId !== runId ||
|
||||
typeof version !== "string" ||
|
||||
version.length === 0 ||
|
||||
version.length > 80 ||
|
||||
(manifest && ![1, 2, 3, 4].includes(value.manifestVersion))
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
const targets = boundedList(value.targets).map((target) => {
|
||||
if (
|
||||
typeof target.agentId !== "string" ||
|
||||
target.agentId.length === 0 ||
|
||||
target.agentId.length > 128 ||
|
||||
!["not_run", "passed", "failed"].includes(target.validationBeforeArchive) ||
|
||||
!Array.isArray(target.issues)
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
const histogram = new Map();
|
||||
for (const issue of target.issues) {
|
||||
if (typeof issue?.code !== "string" || !/^[a-z][a-z0-9_]{0,79}$/.test(issue.code)) {
|
||||
throw new Error();
|
||||
}
|
||||
histogram.set(issue.code, (histogram.get(issue.code) ?? 0) + 1);
|
||||
}
|
||||
const moves = {};
|
||||
for (const field of ["completedMoves", "plannedMoves"]) {
|
||||
if (manifest && !Array.isArray(target[field])) {
|
||||
throw new Error();
|
||||
}
|
||||
const count = manifest ? target[field].length : target[field];
|
||||
if (!Number.isSafeInteger(count) || count < 0) {
|
||||
throw new Error();
|
||||
}
|
||||
moves[field] = count;
|
||||
}
|
||||
return Object.assign(
|
||||
{
|
||||
agentId: sanitize(target.agentId, migrationLabels[kind]),
|
||||
validationBeforeArchive: target.validationBeforeArchive,
|
||||
issueCount: target.issues.length,
|
||||
issueHistogram: boundedList([...histogram])
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([code, count]) => ({ code: sanitize(code, migrationLabels[kind]), count })),
|
||||
},
|
||||
moves,
|
||||
);
|
||||
});
|
||||
return { version: sanitize(version, migrationLabels[kind]), targets };
|
||||
}
|
||||
|
||||
function readSessionMigration(stateRoot) {
|
||||
const snapshot = { runId: null, manifest: null, failureReport: null };
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(ownedPath(stateRoot, migrationDirectory));
|
||||
} catch {
|
||||
omissions["session migration"] = reasons[0];
|
||||
return snapshot;
|
||||
}
|
||||
if (names.length > entryLimit) {
|
||||
omissions["session migration"] = reasons[1];
|
||||
return snapshot;
|
||||
}
|
||||
// Pick the latest owned run, never follow paths supplied by its manifest.
|
||||
const runs = names
|
||||
.filter((name) => /^session-sqlite-\d{1,16}-[0-9a-f]{8}\.json$/.test(name))
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.split("-")[2]) - Number(left.split("-")[2]) || left.localeCompare(right),
|
||||
);
|
||||
if (!runs.length) {
|
||||
omissions["session migration"] = reasons[0];
|
||||
return snapshot;
|
||||
}
|
||||
snapshot.runId = runs[0].slice(0, -5);
|
||||
for (const [kind, suffix] of [
|
||||
["manifest", ".json"],
|
||||
["failureReport", ".failure.json"],
|
||||
]) {
|
||||
const raw = readOwned(
|
||||
stateRoot,
|
||||
path.join(migrationDirectory, `${snapshot.runId}${suffix}`),
|
||||
migrationLabels[kind],
|
||||
migrationFileLimit,
|
||||
);
|
||||
if (raw === null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
sessionMigrationProjection(raw, kind, snapshot.runId);
|
||||
snapshot[kind] = raw;
|
||||
} catch {
|
||||
omissions[migrationLabels[kind]] = reasons[3];
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function publishedSessionMigration(snapshot, sanitize) {
|
||||
const report = { availability: "unavailable", manifest: null, failureReport: null };
|
||||
if (!snapshot || snapshot.runId === null) {
|
||||
return report;
|
||||
}
|
||||
if (
|
||||
typeof snapshot.runId !== "string" ||
|
||||
!/^session-sqlite-\d{1,16}-[0-9a-f]{8}$/.test(snapshot.runId)
|
||||
) {
|
||||
omissions["session migration"] = reasons[3];
|
||||
return report;
|
||||
}
|
||||
for (const kind of Object.keys(migrationLabels)) {
|
||||
if (snapshot[kind] === null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const projected = sessionMigrationProjection(snapshot[kind], kind, snapshot.runId, sanitize);
|
||||
if (Buffer.byteLength(JSON.stringify(projected)) > outputLimit) {
|
||||
omissions[migrationLabels[kind]] = reasons[1];
|
||||
continue;
|
||||
}
|
||||
report[kind] = projected;
|
||||
report.availability = "captured";
|
||||
} catch {
|
||||
omissions[migrationLabels[kind]] = reasons[3];
|
||||
}
|
||||
}
|
||||
if (report.availability === "captured") {
|
||||
report.runId = sanitize(snapshot.runId, "session migration");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function armUpgradeProcessCapture() {
|
||||
const command = process.argv[2];
|
||||
const artifactRoot = process.env.OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT;
|
||||
|
|
@ -1169,6 +1317,11 @@ async function capture(artifactRoot, phase, exitStatus, signal = "", observation
|
|||
omissions["child exit"] = reasons[3];
|
||||
}
|
||||
}
|
||||
report.sessionMigration = readSessionMigration(stateRoot);
|
||||
if (Buffer.byteLength(`${JSON.stringify(report, null, 2)}\n`) > privateLimit) {
|
||||
report.sessionMigration = { runId: null, manifest: null, failureReport: null };
|
||||
omissions["session migration"] = reasons[1];
|
||||
}
|
||||
writeReport(
|
||||
artifactRoot,
|
||||
path.join(artifactRoot, "diagnostics"),
|
||||
|
|
@ -1393,10 +1546,34 @@ function publishedSuccessSummary(artifactRoot, sanitize) {
|
|||
}
|
||||
timings[key] = value;
|
||||
}
|
||||
let baselineCompanion = null;
|
||||
const companion = snapshot.baselineCompanion;
|
||||
if (companion !== null && companion !== undefined) {
|
||||
if (
|
||||
!["@openclaw/discord", "@openclaw/msteams"].includes(companion.package) ||
|
||||
typeof companion.version !== "string" ||
|
||||
!/^\d{4}\.\d{1,2}\.\d{1,3}(?:-(?:\d+|(?:alpha|beta)\.\d+))?$/.test(companion.version) ||
|
||||
!["available", "unavailable"].includes(companion.availability) ||
|
||||
(companion.availability === "available"
|
||||
? companion.reason !== null
|
||||
: typeof companion.reason !== "string" ||
|
||||
!companion.reason ||
|
||||
companion.reason.length > 200)
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
baselineCompanion = {
|
||||
package: sanitize(companion.package, "baseline companion"),
|
||||
version: sanitize(companion.version, "baseline companion"),
|
||||
availability: companion.availability,
|
||||
reason: sanitize(companion.reason, "baseline companion"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "passed",
|
||||
baseline: textFields(snapshot.baseline, ["spec", "version"], sanitize),
|
||||
candidate: textFields(snapshot.candidate, ["kind", "version"], sanitize),
|
||||
baselineCompanion,
|
||||
...textFields(
|
||||
snapshot,
|
||||
[
|
||||
|
|
@ -1445,6 +1622,9 @@ export function publishDiagnostics(
|
|||
redactSensitiveText,
|
||||
outcome = "failed",
|
||||
) {
|
||||
for (const label of Object.keys(omissions)) {
|
||||
delete omissions[label];
|
||||
}
|
||||
if (outcome === "passed") {
|
||||
writeReport(
|
||||
artifactRoot,
|
||||
|
|
@ -1472,6 +1652,7 @@ export function publishDiagnostics(
|
|||
entriesPerCollection: entryLimit,
|
||||
indexJsonBytes: indexLimit,
|
||||
indexSourceBytesPerFile: 64 * 1024 * 1024,
|
||||
migrationBytesPerFile: migrationFileLimit,
|
||||
},
|
||||
logs: {},
|
||||
service: {},
|
||||
|
|
@ -1492,6 +1673,8 @@ export function publishDiagnostics(
|
|||
"post-core",
|
||||
"plugin identity",
|
||||
...["doctor", "sessions", "archives", "sibling"].map((section) => `migration-${section}`),
|
||||
"session migration",
|
||||
...Object.values(migrationLabels),
|
||||
]) {
|
||||
if (reasons.includes(snapshot.omissions?.[label])) {
|
||||
omissions[label] = snapshot.omissions[label];
|
||||
|
|
@ -1551,6 +1734,7 @@ export function publishDiagnostics(
|
|||
report.config.sha256 = snapshot.config.sha256;
|
||||
}
|
||||
report.postCore = publishedPostCore(snapshot.postCore, sanitize);
|
||||
report.sessionMigration = publishedSessionMigration(snapshot.sessionMigration, sanitize);
|
||||
report.doctorResults = { availability: "unknown", observations: [] };
|
||||
try {
|
||||
const observations = boundedList(snapshot.doctorResults).map((pair) =>
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ survival_assert_stage="survival"
|
|||
baseline_spec=""
|
||||
baseline_version=""
|
||||
baseline_plugin_version=""
|
||||
baseline_companion_availability=""
|
||||
baseline_version_expected="0"
|
||||
candidate_version=""
|
||||
candidate_contract=""
|
||||
|
|
@ -151,7 +152,6 @@ last_update_observation_root=""
|
|||
workshop_doctor_observation_root=""
|
||||
idempotence_seconds=""
|
||||
run_completed="0"
|
||||
update_outcome=""
|
||||
update_exit_code=""
|
||||
|
||||
BASELINE_INSTALL_LOG="$ARTIFACT_ROOT/baseline-install.log"
|
||||
|
|
@ -206,7 +206,7 @@ MOBILE_PAIRING_CANDIDATE_RESTART_EVIDENCE="$ARTIFACT_ROOT/mobile-pairing-candida
|
|||
MOBILE_PAIRING_FINAL_EVIDENCE="$ARTIFACT_ROOT/mobile-pairing-final.json"
|
||||
HISTORICAL_PACKAGE_REPLACEMENT_EVIDENCE="$ARTIFACT_ROOT/historical-package-replacement.json"
|
||||
export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON="$CONFIG_COVERAGE_JSON"
|
||||
rm -f "$SUMMARY_JSON" "$CONFIG_COVERAGE_JSON" "$ARTIFACT_ROOT/backup-rollback.json"
|
||||
rm -f "$SUMMARY_JSON" "$CONFIG_COVERAGE_JSON" "$ARTIFACT_ROOT/backup-rollback.json" "$ARTIFACT_ROOT/baseline-companion.json"
|
||||
: >"$PHASE_LOG"
|
||||
|
||||
validate_baseline_package_spec() {
|
||||
|
|
@ -300,7 +300,7 @@ write_summary() {
|
|||
SUMMARY_CANDIDATE_INSTALL_MODE="$candidate_install_mode" \
|
||||
SUMMARY_SCENARIO="$SCENARIO" \
|
||||
SUMMARY_UPDATE_RESTART_MODE="$UPDATE_RESTART_MODE" \
|
||||
SUMMARY_UPDATE_OUTCOME="${update_outcome:-success}" \
|
||||
SUMMARY_UPDATE_OUTCOME="${update_outcome:-unknown}" \
|
||||
SUMMARY_UPDATE_REPAIR_REQUIRED="$update_repair_required" \
|
||||
SUMMARY_UPDATE_RESTART_SOURCE="$update_restart_source" \
|
||||
SUMMARY_INITIAL_UPDATE_OBSERVATION_ROOT="$initial_update_observation_root" \
|
||||
|
|
@ -364,7 +364,8 @@ const summary = {
|
|||
installedVersion: process.env.SUMMARY_INSTALLED_VERSION || null,
|
||||
candidateInstallMode: process.env.SUMMARY_CANDIDATE_INSTALL_MODE || "updater",
|
||||
updateRestartMode: process.env.SUMMARY_UPDATE_RESTART_MODE || "manual",
|
||||
updateOutcome: process.env.SUMMARY_UPDATE_OUTCOME || "success",
|
||||
updateOutcome: process.env.SUMMARY_UPDATE_OUTCOME || "unknown",
|
||||
baselineCompanion: readJsonOrNull(path.join(path.dirname(process.env.SUMMARY_JSON), "baseline-companion.json")),
|
||||
updateRecovery: process.env.SUMMARY_UPDATE_REPAIR_REQUIRED === "1" ? "capability-consent" : null,
|
||||
updateRestartSource: process.env.SUMMARY_UPDATE_RESTART_SOURCE || null,
|
||||
firstHopPostCore,
|
||||
|
|
@ -709,6 +710,7 @@ assert_prepublish_plugin_install() {
|
|||
local allow_pending="${1:-0}" plugin_id="whatsapp" help consent
|
||||
local consent_supported=0 pending_args=()
|
||||
if [ "$SCENARIO" = "legacy-operator-state" ]; then
|
||||
[ "$baseline_companion_availability" != "unavailable" ] || return 0
|
||||
plugin_id="discord"
|
||||
elif [ "$SCENARIO" = "msteams-polls" ]; then
|
||||
plugin_id="msteams"
|
||||
|
|
@ -753,13 +755,37 @@ if (!release) throw new Error("Invalid baseline release version");
|
|||
process.stdout.write(release.correctionNumber === undefined ? release.version : release.baseVersion);
|
||||
NODE
|
||||
)"
|
||||
local lookup_status=0
|
||||
openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" npm view "@openclaw/$baseline_plugin@$baseline_plugin_version" version \
|
||||
--registry=https://registry.npmjs.org --json >"$fixture_root/baseline/availability.json" \
|
||||
2>"$fixture_root/baseline/availability.err" || lookup_status=$?
|
||||
baseline_companion_availability="$(node --input-type=module - \
|
||||
"$fixture_root/baseline/availability.json" "$lookup_status" "$baseline_plugin" \
|
||||
"$baseline_plugin_version" "$SCENARIO" "$ARTIFACT_ROOT/baseline-companion.json" <<'NODE'
|
||||
import fs from "node:fs";
|
||||
const [file, status, plugin, version, scenario, receipt] = process.argv.slice(2);
|
||||
const result = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const unavailable = status !== "0" && result?.error?.code === "E404" && scenario === "legacy-operator-state";
|
||||
if (!unavailable && (status !== "0" || result !== version)) {
|
||||
throw new Error(`Could not verify published companion @openclaw/${plugin}@${version} (npm exit ${status})`);
|
||||
}
|
||||
const availability = unavailable ? "unavailable" : "available";
|
||||
fs.writeFileSync(receipt, `${JSON.stringify({
|
||||
package: `@openclaw/${plugin}`, version, availability,
|
||||
reason: unavailable ? "Exact companion version is not published on npm (E404)." : null,
|
||||
}, null, 2)}\n`);
|
||||
process.stdout.write(availability);
|
||||
NODE
|
||||
)" || return "$?"
|
||||
local baseline_tarball
|
||||
baseline_tarball="$(npm pack "@openclaw/$baseline_plugin@$baseline_plugin_version" \
|
||||
--registry=https://registry.npmjs.org --pack-destination "$fixture_root/baseline" --silent)"
|
||||
registry_args+=("@openclaw/$baseline_plugin" "$baseline_plugin_version" "$fixture_root/baseline/$baseline_tarball")
|
||||
registry_dist_tags="latest=$baseline_plugin_version,beta=$baseline_plugin_version"
|
||||
if [ "$baseline_companion_availability" = "available" ]; then
|
||||
baseline_tarball="$(npm pack "@openclaw/$baseline_plugin@$baseline_plugin_version" \
|
||||
--registry=https://registry.npmjs.org --pack-destination "$fixture_root/baseline" --silent)"
|
||||
registry_args+=("@openclaw/$baseline_plugin" "$baseline_plugin_version" "$fixture_root/baseline/$baseline_tarball")
|
||||
fi
|
||||
registry_dist_tags="latest=$baseline_plugin_version,beta=$baseline_plugin_version,alpha=$baseline_plugin_version"
|
||||
else
|
||||
registry_dist_tags="latest=$candidate_version,beta=$candidate_version"
|
||||
registry_dist_tags="latest=$candidate_version,beta=$candidate_version,alpha=$candidate_version"
|
||||
fi
|
||||
registry_args+=("openclaw" "$candidate_version" "$CANDIDATE_SPEC")
|
||||
fi
|
||||
|
|
@ -999,10 +1025,17 @@ apply_baseline_config_recipe() {
|
|||
}
|
||||
|
||||
install_companion_plugins() {
|
||||
local plugin="${1:-discord}"
|
||||
local plugin="${1:-discord}" tag="latest"
|
||||
if [ "$baseline_companion_availability" = "unavailable" ]; then
|
||||
echo "Skipping baseline companion @openclaw/$plugin@$baseline_plugin_version: exact version is not published on npm (E404); see baseline-companion.json."
|
||||
return 0
|
||||
fi
|
||||
if [[ "$baseline_plugin_version" =~ -(alpha|beta)\.[1-9][0-9]*$ ]]; then
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
openclaw_e2e_fixture_plugin_command openclaw -- \
|
||||
plugins install "@openclaw/$plugin@latest"
|
||||
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-baseline-plugin "$baseline_plugin_version" "$plugin"
|
||||
plugins install "@openclaw/$plugin@$tag"
|
||||
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-baseline-plugin "$baseline_plugin_version" "$plugin" "$tag"
|
||||
}
|
||||
|
||||
seed_legacy_operator_gateway() {
|
||||
|
|
@ -1448,6 +1481,7 @@ update_candidate() {
|
|||
"NODE_OPTIONS=$update_node_options"
|
||||
)
|
||||
local update_status=0
|
||||
update_outcome="failed"
|
||||
if [ "$SCENARIO" = "recovery-cleanup" ]; then
|
||||
# Keep sampler output outside the old updater's JSON and join its process group.
|
||||
openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" node scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs \
|
||||
|
|
|
|||
|
|
@ -207,7 +207,10 @@ phase() {
|
|||
}
|
||||
${phases}
|
||||
`;
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
// The Darwin Bash guard must be able to replay this injected runner.
|
||||
const scriptPath = path.join(root, "runner.sh");
|
||||
writeFileSync(scriptPath, script);
|
||||
const result = spawnSync("bash", [scriptPath], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ const SURVIVOR_SCRIPT_PATH = path.resolve("scripts/e2e/upgrade-survivor-docker.s
|
|||
const E2E_INSTANCE_SCRIPT_PATH = path.resolve("scripts/lib/openclaw-e2e-instance.sh");
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function writePublishedRunner(root: string, script: string) {
|
||||
// The Darwin Bash guard replays the complete fixture through /bin/bash.
|
||||
const file = path.join(root, "published-runner.sh");
|
||||
writeFileSync(file, script);
|
||||
return file;
|
||||
}
|
||||
|
||||
function run(...args: string[]) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], {
|
||||
encoding: "utf8",
|
||||
|
|
@ -96,7 +103,7 @@ probe_status=0
|
|||
prepare_update_restart_probe || probe_status=$?
|
||||
exit "$probe_status"
|
||||
`;
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
const result = spawnSync("bash", [writePublishedRunner(root, script)], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -203,8 +210,9 @@ export const { redactSensitiveText } = await tsImport(${JSON.stringify(path.reso
|
|||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`${setup}
|
||||
writePublishedRunner(
|
||||
root,
|
||||
`${setup}
|
||||
trap - EXIT ERR INT TERM
|
||||
update_repair_required=0
|
||||
mkdir -p "$HOME/.config/systemd/user" "$OPENCLAW_STATE_DIR"
|
||||
|
|
@ -254,6 +262,7 @@ openclaw_e2e_maybe_timeout() {
|
|||
fi
|
||||
exit "$probe_status"
|
||||
`,
|
||||
),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
|
|
@ -343,8 +352,9 @@ exit "$probe_status"
|
|||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`${setup}
|
||||
writePublishedRunner(
|
||||
root,
|
||||
`${setup}
|
||||
trap - EXIT ERR INT TERM
|
||||
SCENARIO=base
|
||||
UPDATE_RESTART_MODE=auto-auth
|
||||
|
|
@ -365,6 +375,7 @@ probe_status=0
|
|||
repair_fixture_plugin_consent || probe_status=$?
|
||||
exit "$probe_status"
|
||||
`,
|
||||
),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
|
|
@ -392,8 +403,9 @@ exit "$probe_status"
|
|||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`${setup}
|
||||
writePublishedRunner(
|
||||
root,
|
||||
`${setup}
|
||||
trap - EXIT ERR INT TERM
|
||||
handler() {
|
||||
${conditional ? "return 47" : "bash -c 'exit 47'"}
|
||||
|
|
@ -401,6 +413,7 @@ handler() {
|
|||
}
|
||||
${conditional ? 'probe_status=0; phase preparation handler || probe_status=$?; exit "$probe_status"' : "phase preparation handler"}
|
||||
`,
|
||||
),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
|
|
|
|||
|
|
@ -361,59 +361,69 @@ esac
|
|||
});
|
||||
|
||||
it.each([
|
||||
{ version: "2026.3.13", legacy: true },
|
||||
{ version: "2026.7.1-2", legacy: true },
|
||||
{ version: "2026.8.1-beta.1", legacy: true },
|
||||
{ version: "2026.8.1-beta.2", legacy: false },
|
||||
{ version: "2026.8.1", legacy: false },
|
||||
{ version: null, legacy: false },
|
||||
])("authors one version-correct recovery roster for $version", ({ version, legacy }) => {
|
||||
const steps = resolveUpgradeSurvivorConfigStepsForBaseline("recovery-cleanup", version);
|
||||
const agentSteps = steps.filter(
|
||||
(step) =>
|
||||
step.argv[0] === "config" && step.argv[1] === "set" && step.argv[2]?.startsWith("agents"),
|
||||
);
|
||||
expect(agentSteps).toHaveLength(1);
|
||||
expect(agentSteps[0]?.argv.slice(0, 3)).toEqual(["config", "set", "agents"]);
|
||||
const agents = JSON.parse(agentSteps[0]?.argv[3] ?? "{}");
|
||||
const ids = legacy
|
||||
? agents.list.map((agent: { id: string }) => agent.id)
|
||||
: Object.keys(agents.entries);
|
||||
expect(ids).toEqual(["main", "ops", "recovery-clean", "recovery-protected"]);
|
||||
const ops = legacy
|
||||
? agents.list.find((agent: { id: string }) => agent.id === "ops")
|
||||
: agents.entries.ops;
|
||||
expect(ops.fastModeDefault).toBe(version === "2026.3.13" ? undefined : true);
|
||||
expect(agents.defaults.heartbeat.every).toBe("0m");
|
||||
if (legacy) {
|
||||
expect(agents.ownership).toBeUndefined();
|
||||
expect(agents.list.filter((agent: { default?: boolean }) => agent.default)).toEqual([
|
||||
expect.objectContaining({ id: "main" }),
|
||||
]);
|
||||
} else {
|
||||
expect(agents.ownership).toBe("explicit");
|
||||
expect(AgentsSchema.safeParse(agents).success).toBe(true);
|
||||
}
|
||||
const baseStep = resolveUpgradeSurvivorConfigStepsForBaseline("base", version).find(
|
||||
(step) => step.id === "agents",
|
||||
);
|
||||
const baseAgents = JSON.parse(baseStep?.argv[3] ?? "{}");
|
||||
expect(
|
||||
legacy
|
||||
? baseAgents.list.map((agent: { id: string }) => agent.id)
|
||||
: Object.keys(baseAgents.entries),
|
||||
).toEqual(["main", "ops"]);
|
||||
expect(configLeafWrites(steps)).toContainEqual({
|
||||
path: "channels.whatsapp",
|
||||
value: JSON.parse(
|
||||
readFileSync(
|
||||
"scripts/e2e/lib/upgrade-survivor/config-recipe/channels-whatsapp.json",
|
||||
"utf8",
|
||||
{ version: "2026.3.13", legacy: true, explicit: false },
|
||||
{ version: "2026.7.1-2", legacy: true, explicit: false },
|
||||
{ version: "2026.7.2-beta.3", legacy: true, explicit: false },
|
||||
{ version: "2026.7.2-beta.4", legacy: false, explicit: false },
|
||||
{ version: "2026.7.2-beta.5", legacy: false, explicit: false },
|
||||
{ version: "2026.8.1-beta.1", legacy: false, explicit: false },
|
||||
{ version: "2026.8.1-beta.2", legacy: false, explicit: true },
|
||||
{ version: "2026.8.1", legacy: false, explicit: true },
|
||||
{ version: null, legacy: false, explicit: true },
|
||||
])(
|
||||
"authors one version-correct recovery roster for $version",
|
||||
({ version, legacy, explicit }) => {
|
||||
const steps = resolveUpgradeSurvivorConfigStepsForBaseline("recovery-cleanup", version);
|
||||
const agentSteps = steps.filter(
|
||||
(step) =>
|
||||
step.argv[0] === "config" && step.argv[1] === "set" && step.argv[2]?.startsWith("agents"),
|
||||
);
|
||||
expect(agentSteps).toHaveLength(1);
|
||||
expect(agentSteps[0]?.argv.slice(0, 3)).toEqual(["config", "set", "agents"]);
|
||||
const agents = JSON.parse(agentSteps[0]?.argv[3] ?? "{}");
|
||||
expect(legacy ? agents.entries : agents.list).toBeUndefined();
|
||||
const ids = legacy
|
||||
? agents.list.map((agent: { id: string }) => agent.id)
|
||||
: Object.keys(agents.entries);
|
||||
expect(ids).toEqual(["main", "ops", "recovery-clean", "recovery-protected"]);
|
||||
const ops = legacy
|
||||
? agents.list.find((agent: { id: string }) => agent.id === "ops")
|
||||
: agents.entries.ops;
|
||||
expect(ops.fastModeDefault).toBe(version === "2026.3.13" ? undefined : true);
|
||||
expect(agents.defaults.heartbeat.every).toBe("0m");
|
||||
if (!explicit) {
|
||||
expect(agents.ownership).toBeUndefined();
|
||||
const entries: Record<string, { default?: boolean }> = legacy
|
||||
? Object.fromEntries(agents.list.map(({ id, ...entry }: { id: string }) => [id, entry]))
|
||||
: agents.entries;
|
||||
expect(Object.entries(entries).filter(([, entry]) => entry.default)).toEqual([
|
||||
["main", expect.objectContaining({ default: true })],
|
||||
]);
|
||||
} else {
|
||||
expect(agents.ownership).toBe("explicit");
|
||||
expect(AgentsSchema.safeParse(agents).success).toBe(true);
|
||||
}
|
||||
const baseStep = resolveUpgradeSurvivorConfigStepsForBaseline("base", version).find(
|
||||
(step) => step.id === "agents",
|
||||
);
|
||||
const baseAgents = JSON.parse(baseStep?.argv[3] ?? "{}");
|
||||
expect(
|
||||
legacy
|
||||
? baseAgents.list.map((agent: { id: string }) => agent.id)
|
||||
: Object.keys(baseAgents.entries),
|
||||
).toEqual(["main", "ops"]);
|
||||
expect(configLeafWrites(steps)).toContainEqual({
|
||||
path: "channels.whatsapp",
|
||||
value: JSON.parse(
|
||||
readFileSync(
|
||||
"scripts/e2e/lib/upgrade-survivor/config-recipe/channels-whatsapp.json",
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
expect(steps.at(-1)?.id).toBe("validate");
|
||||
});
|
||||
});
|
||||
expect(steps.at(-1)?.id).toBe("validate");
|
||||
},
|
||||
);
|
||||
|
||||
it("removes unsupported scenario config for older baselines", () => {
|
||||
const steps = resolveUpgradeSurvivorConfigStepsForBaseline("feishu-channel", "2026.3.13");
|
||||
|
|
@ -437,7 +447,7 @@ esac
|
|||
},
|
||||
);
|
||||
|
||||
it.each(["2026.3.13", "2026.4.1", "2026.8.1-beta.1"])(
|
||||
it.each(["2026.3.13", "2026.4.1", "2026.7.2-beta.3"])(
|
||||
"preserves the legacy agent contract for baseline %s",
|
||||
(version) => {
|
||||
const agentStep = resolveUpgradeSurvivorConfigStepsForBaseline("base", version).find(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
|
|
@ -251,11 +252,54 @@ describe("upgrade survivor rollback publication", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function seedSessionMigration(root: string, issueCount = 1) {
|
||||
const directory = join(root, "session-sqlite-migration-runs");
|
||||
mkdirSync(directory);
|
||||
const runId = "session-sqlite-1789528941490-661aa836";
|
||||
const target = {
|
||||
agentId: "private-agent-value",
|
||||
sqlitePath: join(root, "private.sqlite"),
|
||||
storePath: join(root, "private-sessions.json"),
|
||||
validationBeforeArchive: "passed",
|
||||
issues: Array.from({ length: issueCount }, (_, index) => ({
|
||||
code: index < 10 ? "transcript_missing" : "active_sqlite_transcript_jsonl",
|
||||
message: `private-issue-value ${"x".repeat(150)}`,
|
||||
sessionKey: "private-session-key",
|
||||
})),
|
||||
};
|
||||
const manifest = `${JSON.stringify(
|
||||
{
|
||||
runId,
|
||||
manifestVersion: 3,
|
||||
openClawVersion: "2026.8.1",
|
||||
startedAt: "2026-09-16T03:22:21.490Z",
|
||||
failureReports: { jsonPath: "/outside/do-not-read.json" },
|
||||
targets: [{ ...target, completedMoves: [], plannedMoves: [] }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
const failureReport = `${JSON.stringify(
|
||||
{
|
||||
runId,
|
||||
version: "2026.8.1",
|
||||
restoreStatus: "not_attempted",
|
||||
targets: [{ ...target, completedMoves: 0, plannedMoves: 0 }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
writeFileSync(join(directory, `${runId}.json`), manifest);
|
||||
writeFileSync(join(directory, `${runId}.failure.json`), failureReport);
|
||||
return { directory, runId, manifest, failureReport };
|
||||
}
|
||||
|
||||
describe("upgrade survivor first-hop process evidence", () => {
|
||||
it.each([0, 1])("retains first-hop identities and Doctor IPC on exit %i", async (code) => {
|
||||
const root = realpathSync(tempDirs.make("survivor-first-hop-"));
|
||||
const artifacts = join(root, "artifacts");
|
||||
mkdirSync(artifacts);
|
||||
const migration = code === 1 ? seedSessionMigration(root, 4791) : null;
|
||||
const tmp = join(root, "tmp");
|
||||
const ipcRoot = join(tmp, `openclaw${process.getuid ? `-${process.getuid()}` : ""}`);
|
||||
mkdirSync(ipcRoot, { recursive: true, mode: 0o700 });
|
||||
|
|
@ -353,10 +397,24 @@ if (process.argv[2] === 'update') {
|
|||
},
|
||||
);
|
||||
expect(capture.status, capture.stderr).toBe(0);
|
||||
if (migration) {
|
||||
expect(Buffer.byteLength(migration.manifest)).toBeGreaterThan(256 * 1024);
|
||||
expect(Buffer.byteLength(migration.failureReport)).toBeGreaterThan(256 * 1024);
|
||||
const raw = JSON.parse(readFileSync(join(artifacts, "diagnostics/raw.json"), "utf8"));
|
||||
expect(raw.sessionMigration).toEqual({
|
||||
runId: migration.runId,
|
||||
manifest: migration.manifest,
|
||||
failureReport: migration.failureReport,
|
||||
});
|
||||
// Publication must still work after the failed container's runtime is gone.
|
||||
rmSync(migration.directory, { recursive: true });
|
||||
}
|
||||
const { publishDiagnostics } = await import(observer);
|
||||
const published = join(root, "published");
|
||||
publishDiagnostics(artifacts, published, (text: string) =>
|
||||
text.replaceAll("private-doctor-value", "[REDACTED]"),
|
||||
text
|
||||
.replaceAll("private-doctor-value", "[REDACTED]")
|
||||
.replaceAll("private-agent-value", "main"),
|
||||
);
|
||||
const report = JSON.parse(readFileSync(join(published, "failure.json"), "utf8"));
|
||||
expect(report.doctorResults).toEqual({
|
||||
|
|
@ -379,17 +437,52 @@ if (process.argv[2] === 'update') {
|
|||
],
|
||||
});
|
||||
expect(JSON.stringify(report)).not.toMatch(
|
||||
/private-doctor-value|private-ignored-value|private-config-value/,
|
||||
/private-doctor-value|private-ignored-value|private-config-value|private-issue-value|private-agent-value|private-session-key|outside\/do-not-read/,
|
||||
);
|
||||
if (migration) {
|
||||
const projected = {
|
||||
version: "2026.8.1",
|
||||
targets: [
|
||||
{
|
||||
agentId: "main",
|
||||
validationBeforeArchive: "passed",
|
||||
completedMoves: 0,
|
||||
plannedMoves: 0,
|
||||
issueCount: 4791,
|
||||
issueHistogram: [
|
||||
{ code: "active_sqlite_transcript_jsonl", count: 4781 },
|
||||
{ code: "transcript_missing", count: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(report.sessionMigration).toEqual({
|
||||
availability: "captured",
|
||||
runId: migration.runId,
|
||||
manifest: projected,
|
||||
failureReport: projected,
|
||||
});
|
||||
}
|
||||
const rawPath = join(artifacts, "diagnostics/raw.json");
|
||||
const raw = JSON.parse(readFileSync(rawPath, "utf8"));
|
||||
raw.doctorResults[0].exited.parentPid++;
|
||||
if (migration) {
|
||||
raw.sessionMigration.manifest = JSON.stringify({
|
||||
...JSON.parse(migration.manifest),
|
||||
runId: "session-sqlite-1789528941490-00000000",
|
||||
});
|
||||
}
|
||||
writeFileSync(rawPath, JSON.stringify(raw));
|
||||
const mismatched = join(root, "mismatched");
|
||||
publishDiagnostics(artifacts, mismatched, (text: string) => text);
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(mismatched, "failure.json"), "utf8")).doctorResults,
|
||||
).toEqual({ availability: "unknown", observations: [] });
|
||||
if (migration) {
|
||||
const changed = JSON.parse(readFileSync(join(mismatched, "failure.json"), "utf8"));
|
||||
expect(changed.sessionMigration.manifest).toBeNull();
|
||||
expect(changed.omissions["session migration manifest"]).toBe("invalid observation; omitted");
|
||||
}
|
||||
const serialized = JSON.stringify(reports);
|
||||
expect(serialized).not.toContain("private-argument-value");
|
||||
expect(serialized).not.toContain("private-environment-value");
|
||||
|
|
@ -397,6 +490,109 @@ if (process.argv[2] === 'update') {
|
|||
expect(serialized).not.toMatch(/private-ignored-value|private-config-value/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", "missing or unsafe file"],
|
||||
["oversized", "input exceeds cap; omitted whole"],
|
||||
["malformed", "invalid observation; omitted"],
|
||||
...(process.platform === "win32" ? [] : [["symlink", "missing or unsafe file"]]),
|
||||
])("retains manifest evidence when the migration failure report is %s", async (kind, reason) => {
|
||||
const root = realpathSync(tempDirs.make("survivor-migration-evidence-"));
|
||||
const artifacts = join(root, "artifacts");
|
||||
mkdirSync(artifacts);
|
||||
const migration = seedSessionMigration(root);
|
||||
const failurePath = join(migration.directory, `${migration.runId}.failure.json`);
|
||||
rmSync(failurePath);
|
||||
if (kind === "oversized" || kind === "malformed") {
|
||||
writeFileSync(failurePath, kind === "oversized" ? "x".repeat(2 * 1024 * 1024 + 1) : "{");
|
||||
} else if (kind === "symlink") {
|
||||
const external = join(root, "external.json");
|
||||
writeFileSync(external, migration.failureReport);
|
||||
symlinkSync(external, failurePath);
|
||||
}
|
||||
const captured = spawnSync(process.execPath, [observer, "capture", artifacts, "doctor", "1"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: root,
|
||||
OPENCLAW_CONFIG_PATH: join(root, "absent.json"),
|
||||
},
|
||||
});
|
||||
expect(captured.status, captured.stderr).toBe(0);
|
||||
const raw = JSON.parse(readFileSync(join(artifacts, "diagnostics/raw.json"), "utf8"));
|
||||
expect(raw.sessionMigration.manifest).toBe(migration.manifest);
|
||||
expect(raw.sessionMigration.failureReport).toBeNull();
|
||||
expect(raw.omissions["session migration failure report"]).toBe(reason);
|
||||
const { publishDiagnostics } = await import(observer);
|
||||
const published = join(root, "published");
|
||||
publishDiagnostics(artifacts, published, (text: string) => text);
|
||||
const report = JSON.parse(readFileSync(join(published, "failure.json"), "utf8"));
|
||||
expect(report.sessionMigration).toMatchObject({
|
||||
availability: "captured",
|
||||
failureReport: null,
|
||||
});
|
||||
expect(report.omissions["session migration failure report"]).toBe(reason);
|
||||
});
|
||||
|
||||
it.each(["available", "unavailable", "absent", "invalid"])(
|
||||
"publishes %s baseline companion coverage in successful receipts",
|
||||
async (availability) => {
|
||||
const root = realpathSync(tempDirs.make("survivor-companion-receipt-"));
|
||||
const baselineCompanion =
|
||||
availability === "absent"
|
||||
? null
|
||||
: {
|
||||
package: "@openclaw/discord",
|
||||
version: "2026.8.1-beta.1",
|
||||
availability,
|
||||
reason:
|
||||
availability === "available"
|
||||
? null
|
||||
: "Exact companion version is not published on npm (E404).",
|
||||
};
|
||||
writeFileSync(
|
||||
join(root, "summary.json"),
|
||||
JSON.stringify({
|
||||
status: "passed",
|
||||
baseline: { spec: "2026.8.1-beta.1", version: "2026.8.1-beta.1" },
|
||||
candidate: { kind: "package", version: "2026.9.4" },
|
||||
scenario: "legacy-operator-state",
|
||||
installedVersion: "2026.9.4",
|
||||
candidateInstallMode: "published",
|
||||
updateRestartMode: "manual",
|
||||
updateOutcome: "success",
|
||||
phases: [],
|
||||
baselineCompanion,
|
||||
}),
|
||||
);
|
||||
const { publishDiagnostics } = await import(observer);
|
||||
const published = join(root, "published");
|
||||
const publish = () =>
|
||||
publishDiagnostics(
|
||||
root,
|
||||
published,
|
||||
(text: string) => text.replaceAll("E404", "redacted"),
|
||||
"passed",
|
||||
);
|
||||
if (availability === "invalid") {
|
||||
expect(publish).toThrow();
|
||||
expect(existsSync(join(published, "summary.json"))).toBe(false);
|
||||
return;
|
||||
}
|
||||
publish();
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(published, "summary.json"), "utf8")).baselineCompanion,
|
||||
).toEqual(
|
||||
baselineCompanion
|
||||
? {
|
||||
...baselineCompanion,
|
||||
reason: baselineCompanion.reason?.replaceAll("E404", "redacted") ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"outside",
|
||||
"wrong-name",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ describe.skipIf(process.platform === "win32")(
|
|||
"survivor installed version after update failure",
|
||||
() => {
|
||||
it.each<UpdateFault>([
|
||||
{ packageState: "not-started", installedVersion: baselineVersion, exitCode: 17 },
|
||||
{ packageState: "swapped", installedVersion: candidateVersion, exitCode: 1 },
|
||||
{ packageState: "missing", installedVersion: null, exitCode: 42 },
|
||||
{ packageState: "broken", installedVersion: null, exitCode: 43 },
|
||||
|
|
@ -43,12 +44,22 @@ describe.skipIf(process.platform === "win32")(
|
|||
for (const directory of [state, tmp, packageRoot, bin]) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
const redactor = join(home, "redactor.mjs");
|
||||
// Unit diagnostics use the current source owner without loading a built SDK graph.
|
||||
writeFileSync(
|
||||
redactor,
|
||||
`import { tsImport } from ${JSON.stringify(resolve("node_modules/tsx/dist/esm/api/index.mjs"))};
|
||||
export const { redactSensitiveText } = await tsImport(${JSON.stringify(resolve("src/logging/redact.ts"))}, import.meta.url);
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(packageRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: baselineVersion }),
|
||||
);
|
||||
const entrypoint = join(packageRoot, "openclaw.mjs");
|
||||
const targetVersion = fixture.targetVersion ?? candidateVersion;
|
||||
const beforeUpdate = fixture.packageState === "not-started";
|
||||
const failurePhase = beforeUpdate ? "install-baseline" : "update-candidate";
|
||||
const targetPackage = join(home, fixture.targetVersion ? "future.tgz" : "candidate.tgz");
|
||||
// Inject the package-swap/finalization fault at the executable boundary.
|
||||
// The real update owner, package reader, assertions and exit summary still run.
|
||||
|
|
@ -98,6 +109,7 @@ if (args[0] === 'update') {
|
|||
install-baseline)
|
||||
normalize_baseline
|
||||
installed_version="$(read_installed_version)"
|
||||
${beforeUpdate ? `return ${fixture.exitCode}` : ""}
|
||||
;;
|
||||
resolve-candidate) candidate_version=${candidateVersion} ;;
|
||||
update-candidate) ${fixture.targetVersion ? `update_candidate 0 "file:${targetPackage}" "${targetVersion}"` : '"$@"'} ;;
|
||||
|
|
@ -118,6 +130,7 @@ trap 'case "$BASH_COMMAND" in "phase "*) install_fixture_phases ;; esac' DEBUG
|
|||
OPENCLAW_HOME: home,
|
||||
OPENCLAW_STATE_DIR: state,
|
||||
OPENCLAW_CONFIG_PATH: join(state, "openclaw.json"),
|
||||
OPENCLAW_E2E_REDACTOR_MODULE: redactor,
|
||||
TMPDIR: tmp,
|
||||
OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT: join(home, "runtime"),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON: summaryPath,
|
||||
|
|
@ -127,25 +140,31 @@ trap 'case "$BASH_COMMAND" in "phase "*) install_fixture_phases ;; esac' DEBUG
|
|||
},
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(fixture.exitCode);
|
||||
expect(readFileSync(join(artifacts, "update.err"), "utf8")).toContain(
|
||||
"target Doctor fixture failed",
|
||||
);
|
||||
const summary = JSON.parse(readFileSync(summaryPath, "utf8"));
|
||||
expect(summary).toMatchObject({
|
||||
status: "failed",
|
||||
updateOutcome: beforeUpdate ? "unknown" : "failed",
|
||||
baseline: { version: baselineVersion },
|
||||
failure: {
|
||||
phase: "update-candidate",
|
||||
message: `phase update-candidate failed with status ${fixture.exitCode}`,
|
||||
phase: failurePhase,
|
||||
message: `phase ${failurePhase} failed with status ${fixture.exitCode}`,
|
||||
},
|
||||
});
|
||||
const diagnostics = JSON.parse(
|
||||
readFileSync(join(artifacts, "diagnostics", "raw.json"), "utf8"),
|
||||
);
|
||||
expect(diagnostics).toMatchObject({
|
||||
phase: "update-candidate",
|
||||
phase: failurePhase,
|
||||
exitStatus: fixture.exitCode,
|
||||
});
|
||||
expect(summary.installedVersion).toBe(fixture.installedVersion);
|
||||
if (beforeUpdate) {
|
||||
expect(diagnostics.logs["update.json"]).toBeNull();
|
||||
return;
|
||||
}
|
||||
expect(readFileSync(join(artifacts, "update.err"), "utf8")).toContain(
|
||||
"target Doctor fixture failed",
|
||||
);
|
||||
const calls: string[][] = readFileSync(join(home, "calls"), "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
|
|
@ -155,7 +174,6 @@ trap 'case "$BASH_COMMAND" in "phase "*) install_fixture_phases ;; esac' DEBUG
|
|||
const updateCall = expectDefined(calls[0], "expected the update command");
|
||||
expect(updateCall[updateCall.indexOf("--tag") + 1]).toBe(`file:${targetPackage}`);
|
||||
expect(calls[1]).toEqual(["config", "validate", "--json"]);
|
||||
expect(summary.installedVersion).toBe(fixture.installedVersion);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -451,26 +451,40 @@ describe("standalone upgrade survivor live OpenAI probe", () => {
|
|||
|
||||
describe("legacy operator baseline plugin cohort", () => {
|
||||
it.each([
|
||||
["2026.7.1-1", "2026.7.1"],
|
||||
["2026.7.1-2", "2026.7.1"],
|
||||
["2026.8.1", "2026.8.1"],
|
||||
["2026.8.1-beta.3", "2026.8.1-beta.3"],
|
||||
])("installs the published plugin cohort for core %s", (baseline, pluginVersion) => {
|
||||
const root = tempDirs.make("openclaw-survivor-plugin-cohort-");
|
||||
const source = readFileSync("scripts/e2e/lib/upgrade-survivor/run.sh", "utf8");
|
||||
const runner = join(root, "run.sh");
|
||||
// Exercise registry preparation and installation together; only external
|
||||
// npm/CLI operations are replaced so a corrected core never pins a nonexistent plugin.
|
||||
writeFileSync(
|
||||
runner,
|
||||
`${source.slice(0, source.indexOf("phase storage-preflight"))}
|
||||
["2026.7.1-1", "2026.7.1", "latest", ""],
|
||||
["2026.7.1-2", "2026.7.1", "latest", ""],
|
||||
["2026.8.1", "2026.8.1", "latest", ""],
|
||||
["2026.7.2-beta.7", "2026.7.2-beta.7", "beta", ""],
|
||||
["2026.8.1-alpha.3", "2026.8.1-alpha.3", "alpha", ""],
|
||||
["2026.8.1-beta.1", "2026.8.1-beta.1", "beta", "E404"],
|
||||
["2026.8.1-beta.1", "2026.8.1-beta.1", "beta", "ECONNRESET"],
|
||||
])(
|
||||
"checks the published plugin cohort for core %s (%s, %s, %s)",
|
||||
(baseline, pluginVersion, tag, errorCode) => {
|
||||
const root = tempDirs.make("openclaw-survivor-plugin-cohort-");
|
||||
const source = readFileSync("scripts/e2e/lib/upgrade-survivor/run.sh", "utf8");
|
||||
const runner = join(root, "run.sh");
|
||||
// Exercise registry preparation and installation together; only external
|
||||
// npm/CLI operations are replaced so a corrected core never pins a nonexistent plugin.
|
||||
writeFileSync(
|
||||
runner,
|
||||
`${source.slice(0, source.indexOf("phase storage-preflight"))}
|
||||
trap - EXIT ERR HUP INT TERM
|
||||
baseline_version="$BASELINE_VERSION"
|
||||
candidate_version=2026.9.4
|
||||
npm() {
|
||||
if [ -n "$NPM_LOOKUP_ERROR" ]; then
|
||||
printf '%s\\n' "$NPM_LOOKUP_RESULT"
|
||||
return 1
|
||||
fi
|
||||
if [ "$1" = view ]; then
|
||||
printf '%s\\n' "$NPM_LOOKUP_RESULT"
|
||||
return
|
||||
fi
|
||||
printf '%s\\n' "$2" >"$CAPTURE_DIR/packed-spec"
|
||||
printf 'discord.tgz\\n'
|
||||
}
|
||||
openclaw_e2e_maybe_timeout() { shift; "$@"; }
|
||||
openclaw_prepublish_plugin_registry_start() {
|
||||
printf '%s\\n' "$@" >"$CAPTURE_DIR/registry-args"
|
||||
printf '%s\\n' "$OPENCLAW_NPM_REGISTRY_DIST_TAGS" >"$CAPTURE_DIR/dist-tags"
|
||||
|
|
@ -486,40 +500,83 @@ node() {
|
|||
fi
|
||||
}
|
||||
configure_plugin_registry baseline
|
||||
cp "$CAPTURE_DIR/dist-tags" "$CAPTURE_DIR/baseline-dist-tags"
|
||||
cp "$CAPTURE_DIR/registry-args" "$CAPTURE_DIR/baseline-registry-args"
|
||||
install_companion_plugins
|
||||
configure_plugin_registry
|
||||
if [ "$baseline_companion_availability" = unavailable ]; then
|
||||
assert_prepublish_plugin_install
|
||||
fi
|
||||
write_summary passed ""
|
||||
printf '%s\\n' "$baseline_version" >"$CAPTURE_DIR/core-version"
|
||||
`,
|
||||
);
|
||||
const result = spawnSync("bash", [runner], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASELINE_VERSION: baseline,
|
||||
CAPTURE_DIR: root,
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE: `openclaw@${baseline}`,
|
||||
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "legacy-operator-state",
|
||||
OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT: join(root, "runtime"),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON: join(root, "artifacts", "summary.json"),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC: join(root, "candidate.tgz"),
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(root, "packed-spec"), "utf8")).toBe(
|
||||
`@openclaw/discord@${pluginVersion}\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "dist-tags"), "utf8")).toBe(
|
||||
`latest=${pluginVersion},beta=${pluginVersion}\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "registry-args"), "utf8")).toContain(
|
||||
`@openclaw/discord\n${pluginVersion}\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "assert-args"), "utf8")).toBe(
|
||||
`scripts/e2e/lib/upgrade-survivor/assertions.mjs\nassert-baseline-plugin\n${pluginVersion}\ndiscord\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "install-args"), "utf8")).toBe(
|
||||
"openclaw\n--\nplugins\ninstall\n@openclaw/discord@latest\n",
|
||||
);
|
||||
expect(readFileSync(join(root, "core-version"), "utf8")).toBe(`${baseline}\n`);
|
||||
});
|
||||
);
|
||||
const result = spawnSync("bash", [runner], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASELINE_VERSION: baseline,
|
||||
CAPTURE_DIR: root,
|
||||
NPM_LOOKUP_ERROR: errorCode ?? "",
|
||||
NPM_LOOKUP_RESULT: JSON.stringify(
|
||||
errorCode ? { error: { code: errorCode } } : pluginVersion,
|
||||
),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE: `openclaw@${baseline}`,
|
||||
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "legacy-operator-state",
|
||||
OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT: join(root, "runtime"),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON: join(root, "artifacts", "summary.json"),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC: join(root, "candidate.tgz"),
|
||||
},
|
||||
});
|
||||
if (errorCode === "ECONNRESET") {
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Could not verify published companion");
|
||||
expect(existsSync(join(root, "install-args"))).toBe(false);
|
||||
return;
|
||||
}
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
const unavailable = errorCode === "E404";
|
||||
const summary = JSON.parse(readFileSync(join(root, "artifacts", "summary.json"), "utf8"));
|
||||
expect(summary.baselineCompanion).toEqual({
|
||||
package: "@openclaw/discord",
|
||||
version: pluginVersion,
|
||||
availability: unavailable ? "unavailable" : "available",
|
||||
reason: unavailable ? "Exact companion version is not published on npm (E404)." : null,
|
||||
});
|
||||
if (unavailable) {
|
||||
expect(result.stdout).toContain("Skipping baseline companion");
|
||||
expect(existsSync(join(root, "packed-spec"))).toBe(false);
|
||||
expect(existsSync(join(root, "install-args"))).toBe(false);
|
||||
expect(existsSync(join(root, "assert-args"))).toBe(false);
|
||||
expect(readFileSync(join(root, "registry-args"), "utf8")).toContain("openclaw\n2026.9.4\n");
|
||||
return;
|
||||
}
|
||||
expect(readFileSync(join(root, "packed-spec"), "utf8")).toBe(
|
||||
`@openclaw/discord@${pluginVersion}\n`,
|
||||
);
|
||||
for (const { file, expectedVersion } of [
|
||||
{ file: "baseline-dist-tags", expectedVersion: pluginVersion },
|
||||
{ file: "dist-tags", expectedVersion: "2026.9.4" },
|
||||
]) {
|
||||
const tags = Object.fromEntries(
|
||||
readFileSync(join(root, file), "utf8")
|
||||
.trim()
|
||||
.split(",")
|
||||
.map((entry) => entry.split("=")),
|
||||
);
|
||||
expect(tags[tag]).toBe(expectedVersion);
|
||||
}
|
||||
expect(readFileSync(join(root, "baseline-registry-args"), "utf8")).toContain(
|
||||
`@openclaw/discord\n${pluginVersion}\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "assert-args"), "utf8")).toBe(
|
||||
`scripts/e2e/lib/upgrade-survivor/assertions.mjs\nassert-baseline-plugin\n${pluginVersion}\ndiscord\n${tag}\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "install-args"), "utf8")).toBe(
|
||||
`openclaw\n--\nplugins\ninstall\n@openclaw/discord@${tag}\n`,
|
||||
);
|
||||
expect(readFileSync(join(root, "core-version"), "utf8")).toBe(`${baseline}\n`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue