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:
Peter Steinberger 2026-09-16 01:40:47 -07:00 • committed by GitHub
parent 6cffe6ea52
commit b884e39bb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 673 additions and 141 deletions

View file

@ -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.

View file

@ -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) {

View file

@ -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) {

View file

@ -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) =>

View file

@ -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 \

View file

@ -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,

View file

@ -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",

View file

@ -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(

View file

@ -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",

View file

@ -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);
});
},
);

View file

@ -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`);
},
);
});