// Upgrade Survivor Assertions tests cover upgrade survivor assertions script behavior. import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { describe, expect, it } from "vitest"; const ASSERTIONS_PATH = "scripts/e2e/lib/upgrade-survivor/assertions.mjs"; function writeJson(path: string, value: unknown): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); } function writeMigratedSessionState(stateDir: string): void { const agentSessionsDir = join(stateDir, "agents", "main", "sessions"); const agentDbDir = join(stateDir, "agents", "main", "agent"); mkdirSync(agentSessionsDir, { recursive: true }); mkdirSync(agentDbDir, { recursive: true }); const db = new DatabaseSync(join(agentDbDir, "openclaw-agent.sqlite")); try { db.exec(` CREATE TABLE session_nodes ( session_key TEXT PRIMARY KEY, current_session_id TEXT NOT NULL, entry_json TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE session_windows ( session_id TEXT PRIMARY KEY, session_key TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE transcript_events ( session_id TEXT NOT NULL, seq INTEGER NOT NULL, event_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (session_id, seq), FOREIGN KEY (session_id) REFERENCES session_windows(session_id) ON DELETE CASCADE ); `); const insertSession = db.prepare(` INSERT INTO session_windows (session_id, session_key, created_at, updated_at) VALUES (?, ?, ?, ?) `); const insertEntry = db.prepare(` INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, ?, ?) `); const insertTranscript = db.prepare(` INSERT INTO transcript_events (session_id, seq, event_json, created_at) VALUES (?, ?, ?, ?) `); const migratedSessions = [ { entry: { skillsSnapshot: { prompt: "legacy prompt survives as metadata", }, }, sessionId: "upgrade-main-session", sessionKey: "agent:main:main", }, { entry: {}, sessionId: "upgrade-direct-session", sessionKey: "agent:main:+15551234567", }, { entry: {}, sessionId: "upgrade-group-session", sessionKey: "agent:main:slack:channel:cupgrade", }, ]; for (const { entry, sessionId, sessionKey } of migratedSessions) { insertSession.run(sessionId, sessionKey, 1710000000000, 1710000000000); insertEntry.run(sessionKey, sessionId, JSON.stringify(entry), 1710000000000); insertTranscript.run( sessionId, 1, JSON.stringify({ type: "session", id: sessionId }), 1710000000000, ); } } finally { db.close(); } } function createMigratedSessionFileStore( stateDir: string, options: { includePrompt?: boolean } = {}, ): Record> { const agentSessionsDir = join(stateDir, "agents", "main", "sessions"); const main: Record = { sessionId: "upgrade-main-session", sessionFile: join(agentSessionsDir, "upgrade-main-session.jsonl"), }; if (options.includePrompt !== false) { main.skillsSnapshot = { prompt: "legacy prompt survives as metadata", }; } return { "agent:main:main": main, "agent:main:+15551234567": { sessionId: "upgrade-direct-session", sessionFile: join(agentSessionsDir, "upgrade-direct-session.jsonl"), }, "agent:main:slack:channel:cupgrade": { sessionId: "upgrade-group-session", sessionFile: join(agentSessionsDir, "upgrade-group-session.jsonl"), }, }; } function writeMigratedSessionFiles( stateDir: string, options: { includePrompt?: boolean } = {}, ): void { const agentSessionsDir = join(stateDir, "agents", "main", "sessions"); mkdirSync(agentSessionsDir, { recursive: true }); writeJson( join(agentSessionsDir, "sessions.json"), createMigratedSessionFileStore(stateDir, options), ); for (const sessionId of [ "upgrade-main-session", "upgrade-direct-session", "upgrade-group-session", ]) { writeFileSync( join(agentSessionsDir, `${sessionId}.jsonl`), `${JSON.stringify({ type: "session", id: sessionId })}\n`, ); } } function writeLegacyCacheSessionState( stateDir: string, options: { empty?: boolean; includePrompt?: boolean; replaceNodes?: boolean } = {}, ) { const dbPath = join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); const db = new DatabaseSync(dbPath); try { if (options.replaceNodes) { db.exec("DROP TABLE session_nodes;"); } db.exec(` CREATE TABLE cache_entries ( scope TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL ); `); if (options.empty) { return; } const insert = db.prepare( "INSERT INTO cache_entries (scope, key, value_json) VALUES (?, ?, ?)", ); for (const [key, entry] of Object.entries(createMigratedSessionFileStore(stateDir, options))) { insert.run("session_entries", key, JSON.stringify(entry)); } } finally { db.close(); } } function writeLegacySessionEntriesState(stateDir: string): void { const dbPath = join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); const db = new DatabaseSync(dbPath); try { db.exec(` DROP TABLE session_nodes; CREATE TABLE session_entries ( session_key TEXT PRIMARY KEY, session_id TEXT NOT NULL, entry_json TEXT NOT NULL, updated_at INTEGER NOT NULL ); `); const insert = db.prepare(` INSERT INTO session_entries (session_key, session_id, entry_json, updated_at) VALUES (?, ?, ?, ?) `); for (const [key, entry] of Object.entries(createMigratedSessionFileStore(stateDir))) { const sessionId = entry.sessionId; if (typeof sessionId !== "string") { throw new TypeError(`missing fixture session id for ${key}`); } insert.run(key, sessionId, JSON.stringify(entry), 1710000000000); } } finally { db.close(); } } function runSessionStateAssertion(setup: (stateDir: string) => void): void { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-session-state-")); try { const stateDir = join(root, "state"); const workspace = join(root, "workspace"); mkdirSync(join(stateDir, "agents", "main", "sessions"), { recursive: true }); mkdirSync(workspace, { recursive: true }); writeFileSync(join(workspace, "IDENTITY.md"), "# survivor\n"); writeJson(join(stateDir, "agents", "main", "sessions", "legacy-session.json"), { id: "legacy-session", }); setup(stateDir); execFileSync(process.execPath, [ASSERTIONS_PATH, "assert-state"], { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_TEST_WORKSPACE_DIR: workspace, OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "base", }, stdio: "pipe", }); } finally { rmSync(root, { force: true, recursive: true }); } } function assertConfiguredPluginState(params: { installPath?: string } = {}): void { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-")); try { const stateDir = join(root, "state"); const workspace = join(root, "workspace"); const matrixInstallDir = params.installPath ?? join(stateDir, "extensions", "matrix"); mkdirSync(join(stateDir, "agents", "main", "sessions"), { recursive: true }); mkdirSync(join(stateDir, "plugins"), { recursive: true }); mkdirSync(matrixInstallDir, { recursive: true }); mkdirSync(workspace, { recursive: true }); writeFileSync(join(workspace, "IDENTITY.md"), "# survivor\n"); writeJson(join(stateDir, "agents", "main", "sessions", "legacy-session.json"), { id: "legacy-session", }); writeMigratedSessionState(stateDir); writeJson(join(matrixInstallDir, "package.json"), { name: "@openclaw/matrix", }); writeJson(join(stateDir, "plugins", "installs.json"), { installRecords: { matrix: { source: "clawhub", spec: "clawhub:@openclaw/matrix", installPath: matrixInstallDir, clawhubPackage: "@openclaw/matrix", clawhubChannel: "official", artifactKind: "npm-pack", }, }, plugins: [{ pluginId: "matrix", enabled: true }], }); const coveragePath = join(root, "coverage.json"); writeJson(coveragePath, { acceptedIntents: ["configured-plugin-installs"], skippedIntents: [], }); execFileSync(process.execPath, [ASSERTIONS_PATH, "assert-state"], { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_TEST_WORKSPACE_DIR: workspace, OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON: coveragePath, OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "configured-plugin-installs", }, stdio: "pipe", }); } finally { rmSync(root, { force: true, recursive: true }); } } function createUpdateRunSelfUpgradeSummary() { const sourceVersion = "2026.4.26"; const targetVersion = "2026.7.2"; const note = "QA-UPDATE-RUN-PACKAGE-SELF-UPGRADE"; return { status: "passed", source: { spec: `openclaw@${sourceVersion}`, version: sourceVersion }, target: { tag: "latest", resolvedVersion: targetVersion }, installedVersion: targetVersion, expectedRestartNote: note, updateRpcResult: { ok: true, result: { status: "ok", before: { version: sourceVersion }, after: { version: targetVersion }, steps: [{ name: "package manager install" }], }, restart: { scheduled: true }, sentinel: { payload: { message: note } }, }, restartSentinel: { kind: "update", status: "ok", message: note, stats: { before: { version: sourceVersion }, after: { version: targetVersion }, }, }, qaChannelInstallRecord: { source: "path", sourcePath: "/tmp/source/dist/extensions/qa-channel", installPath: "/tmp/source/dist/extensions/qa-channel", version: "2026.4.25", }, sourcePluginInspect: { plugin: { id: "qa-channel", status: "loaded" }, }, targetPluginIndex: { installRecords: { "qa-channel": { source: "path", sourcePath: "/tmp/source/dist/extensions/qa-channel", installPath: "/tmp/source/dist/extensions/qa-channel", version: "2026.4.25", }, }, }, supervisorHandoff: { servicePid: 4242, systemctlInvocations: ["--user start openclaw-gateway.service"], monitorEvents: [ "source Gateway exited through supervised update handoff", "starting installed service without provider suppression", "service Gateway started pid=4242", ], }, gateway: { healthz: { body: { ok: true, status: "live" } }, readyz: { body: { ready: true } }, status: { cli: { version: targetVersion }, gateway: { version: targetVersion }, rpc: { ok: true, version: targetVersion }, }, }, qaChannel: { status: { channelAccounts: { "qa-channel": [{ accountId: "default", running: true, restartPending: false }], }, }, busPollsAfterRestart: 2, }, }; } function assertUpdateRunSelfUpgrade(summary: ReturnType) { const root = mkdtempSync(join(tmpdir(), "openclaw-update-run-self-upgrade-")); try { const summaryPath = join(root, "summary.json"); writeJson(summaryPath, summary); execFileSync( process.execPath, [ASSERTIONS_PATH, "assert-update-run-self-upgrade", summaryPath], { stdio: "pipe" }, ); } finally { rmSync(root, { force: true, recursive: true }); } } describe("upgrade survivor assertions", () => { it("lists the dependency-free scenario contract", () => { const scenarios = JSON.parse( execFileSync(process.execPath, [ASSERTIONS_PATH, "list-scenarios"], { encoding: "utf8", }), ) as string[]; expect(scenarios).toContain("base"); expect(scenarios).toContain("acpx-openclaw-tools-bridge"); expect(new Set(scenarios).size).toBe(scenarios.length); }); it("seeds recent ordered legacy session timestamps", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-seed-")); try { const stateDir = join(root, "state"); const workspace = join(root, "workspace"); mkdirSync(stateDir, { recursive: true }); mkdirSync(workspace, { recursive: true }); const beforeSeed = Date.now(); execFileSync(process.execPath, [ASSERTIONS_PATH, "seed"], { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_TEST_WORKSPACE_DIR: workspace, OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "base", }, stdio: "pipe", }); const afterSeed = Date.now(); const sessions = JSON.parse( readFileSync(join(stateDir, "sessions", "sessions.json"), "utf8"), ) as Record; const seededRows = [ sessions.main, sessions["+15551234567"], sessions["slack:channel:CUPGRADE"], ]; expect(seededRows.map((row) => row?.sessionId)).toEqual([ "upgrade-main-session", "upgrade-direct-session", "upgrade-group-session", ]); const timestamps = seededRows.map((row) => row?.updatedAt); for (const timestamp of timestamps) { expect(typeof timestamp).toBe("number"); } const [mainUpdatedAt, directUpdatedAt, groupUpdatedAt] = timestamps as [ number, number, number, ]; expect(directUpdatedAt - mainUpdatedAt).toBe(100); expect(groupUpdatedAt - mainUpdatedAt).toBe(200); expect(mainUpdatedAt).toBeLessThan(directUpdatedAt); expect(directUpdatedAt).toBeLessThan(groupUpdatedAt); const dayMs = 24 * 60 * 60 * 1000; const thirtyDaysMs = 30 * dayMs; for (const [timestamp, offset] of [ [mainUpdatedAt, 0], [directUpdatedAt, 100], [groupUpdatedAt, 200], ] as const) { expect(timestamp).toBeGreaterThanOrEqual(beforeSeed - dayMs + offset); expect(timestamp).toBeLessThanOrEqual(afterSeed - dayMs + offset); expect(timestamp).toBeGreaterThan(afterSeed - thirtyDaysMs); expect(timestamp).toBeLessThanOrEqual(afterSeed); } } finally { rmSync(root, { force: true, recursive: true }); } }); it("accepts the ACPX OpenClaw tools bridge scenario during seed", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-")); try { const stateDir = join(root, "state"); const workspace = join(root, "workspace"); mkdirSync(stateDir, { recursive: true }); mkdirSync(workspace, { recursive: true }); execFileSync(process.execPath, [ASSERTIONS_PATH, "seed"], { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_TEST_WORKSPACE_DIR: workspace, OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "acpx-openclaw-tools-bridge", }, stdio: "pipe", }); } finally { rmSync(root, { force: true, recursive: true }); } }); it("asserts the ACPX OpenClaw tools bridge config survived", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-config-")); try { const configPath = join(root, "openclaw.json"); const coveragePath = join(root, "coverage.json"); writeJson(configPath, { plugins: { allow: ["acpx"], entries: { acpx: { enabled: true, config: { openClawToolsMcpBridge: true, }, }, }, }, }); writeJson(coveragePath, { acceptedIntents: ["acpx-openclaw-tools-bridge"], skippedIntents: [], }); execFileSync(process.execPath, [ASSERTIONS_PATH, "assert-config"], { env: { ...process.env, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON: coveragePath, OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "acpx-openclaw-tools-bridge", }, stdio: "pipe", }); } finally { rmSync(root, { force: true, recursive: true }); } }); it("accepts official ClawHub npm-pack installs for configured external plugins", () => { expect(() => assertConfiguredPluginState()).not.toThrow(); }); it("prefers session_nodes over stale file and cache session stores", () => { expect(() => runSessionStateAssertion((stateDir) => { writeMigratedSessionState(stateDir); writeMigratedSessionFiles(stateDir, { includePrompt: false }); writeLegacyCacheSessionState(stateDir, { includePrompt: false }); }), ).not.toThrow(); }); it("does not mask missing session_nodes rows with a valid file store", () => { expect(() => runSessionStateAssertion((stateDir) => { writeMigratedSessionState(stateDir); const db = new DatabaseSync( join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"), ); try { db.exec("DELETE FROM session_nodes;"); } finally { db.close(); } writeMigratedSessionFiles(stateDir); }), ).toThrow(/main legacy session row missing/); }); it("does not mask empty legacy cache_entries with a valid file store", () => { expect(() => runSessionStateAssertion((stateDir) => { writeMigratedSessionState(stateDir); writeLegacyCacheSessionState(stateDir, { empty: true, replaceNodes: true }); writeMigratedSessionFiles(stateDir); }), ).toThrow(/main legacy session row missing/); }); it("prefers legacy cache_entries over a stale file session store", () => { expect(() => runSessionStateAssertion((stateDir) => { writeMigratedSessionState(stateDir); writeLegacyCacheSessionState(stateDir, { replaceNodes: true }); writeMigratedSessionFiles(stateDir, { includePrompt: false }); }), ).not.toThrow(); }); it("prefers legacy session_entries over stale file and cache session stores", () => { expect(() => runSessionStateAssertion((stateDir) => { writeMigratedSessionState(stateDir); writeLegacySessionEntriesState(stateDir); writeLegacyCacheSessionState(stateDir, { includePrompt: false }); writeMigratedSessionFiles(stateDir, { includePrompt: false }); }), ).not.toThrow(); }); it("uses the file session store when SQLite has no supported session table", () => { expect(() => runSessionStateAssertion((stateDir) => { const agentDbDir = join(stateDir, "agents", "main", "agent"); mkdirSync(agentDbDir, { recursive: true }); const db = new DatabaseSync(join(agentDbDir, "openclaw-agent.sqlite")); try { db.exec("CREATE TABLE unrelated_state (key TEXT PRIMARY KEY);"); } finally { db.close(); } writeMigratedSessionFiles(stateDir); }), ).not.toThrow(); }); it("accepts a SQLite-only migrated session store", () => { expect(() => runSessionStateAssertion((stateDir) => { writeMigratedSessionState(stateDir); }), ).not.toThrow(); }); it("rejects ClawHub npm-pack installs outside the managed extensions root", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-outside-")); try { expect(() => assertConfiguredPluginState({ installPath: join(root, "outside-matrix") }), ).toThrow(/managed extensions root/); } finally { rmSync(root, { force: true, recursive: true }); } }); it("accepts executed update.run package transition and post-restart health evidence", () => { expect(() => assertUpdateRunSelfUpgrade(createUpdateRunSelfUpgradeSummary())).not.toThrow(); }); it("rejects no-op update.run package transitions", () => { const summary = createUpdateRunSelfUpgradeSummary(); summary.target.resolvedVersion = summary.source.version; summary.installedVersion = summary.source.version; summary.updateRpcResult.result.after.version = summary.source.version; summary.restartSentinel.stats.after.version = summary.source.version; summary.gateway.status.gateway.version = summary.source.version; expect(() => assertUpdateRunSelfUpgrade(summary)).toThrow(/did not advance beyond source/); }); it("rejects unsupported update.run paths that did not execute package steps", () => { const summary = createUpdateRunSelfUpgradeSummary(); summary.updateRpcResult.ok = false; summary.updateRpcResult.result.status = "skipped"; summary.updateRpcResult.result.steps = []; expect(() => assertUpdateRunSelfUpgrade(summary)).toThrow(/did not report ok/); }); it("rejects QA channel payloads without a canonical path install record", () => { const summary = createUpdateRunSelfUpgradeSummary(); summary.qaChannelInstallRecord.source = "npm"; expect(() => assertUpdateRunSelfUpgrade(summary)).toThrow(/was not path-installed/); }); it("rejects upgrades that lose the path install during SQLite migration", () => { const summary = createUpdateRunSelfUpgradeSummary(); Reflect.deleteProperty(summary.targetPluginIndex.installRecords, "qa-channel"); expect(() => assertUpdateRunSelfUpgrade(summary)).toThrow( /target SQLite index did not preserve/, ); }); it("rejects source fixtures that were never runtime-loaded", () => { const summary = createUpdateRunSelfUpgradeSummary(); summary.sourcePluginInspect.plugin.status = "error"; expect(() => assertUpdateRunSelfUpgrade(summary)).toThrow(/source package did not load/); }); it("rejects duplicate target service starts during the supervised handoff", () => { const summary = createUpdateRunSelfUpgradeSummary(); summary.supervisorHandoff.systemctlInvocations.push( "--user --quiet start openclaw-gateway.service", ); expect(() => assertUpdateRunSelfUpgrade(summary)).toThrow(/target exactly once/); }); });