import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; const HELPER = path.resolve("scripts/e2e/lib/release-plugin-marketplace/lifecycle-assertions.mjs"); const tempDirs = new Set(); afterEach(() => { cleanupTempDirs(tempDirs); }); function writeIndex( stateDir: string, pluginId: string, record: Record, packageVersion: string, ) { const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); fs.mkdirSync(path.dirname(databasePath), { recursive: true }); const db = new DatabaseSync(databasePath); try { db.exec(` CREATE TABLE IF NOT EXISTS installed_plugin_index ( index_key TEXT NOT NULL PRIMARY KEY, version INTEGER NOT NULL, host_contract_version TEXT NOT NULL, compat_registry_version TEXT NOT NULL, migration_version INTEGER NOT NULL, policy_hash TEXT NOT NULL, generated_at_ms INTEGER NOT NULL, refresh_reason TEXT, install_records_json TEXT NOT NULL, plugins_json TEXT NOT NULL, diagnostics_json TEXT NOT NULL, warning TEXT, updated_at_ms INTEGER NOT NULL ); `); const now = Date.now(); db.prepare( ` INSERT OR REPLACE INTO installed_plugin_index ( index_key, version, host_contract_version, compat_registry_version, migration_version, policy_hash, generated_at_ms, refresh_reason, install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( "installed-plugin-index", 1, "test", "test", 1, "test", now, "source-changed", JSON.stringify({ [pluginId]: record }), JSON.stringify([{ pluginId, packageVersion }]), "[]", null, now, ); } finally { db.close(); } } function runHelper(home: string, args: string[]) { const stateDir = path.join(home, ".openclaw"); return spawnSync(process.execPath, [HELPER, ...args], { encoding: "utf8", env: { ...process.env, HOME: home, OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), OPENCLAW_STATE_DIR: stateDir, }, }); } function writeMarketplaceState(home: string, version: string) { const pluginId = "release-marketplace-plugin"; const stateDir = path.join(home, ".openclaw"); const installPath = path.join(stateDir, "extensions", pluginId); fs.mkdirSync(installPath, { recursive: true }); fs.writeFileSync( path.join(installPath, "package.json"), `${JSON.stringify({ name: `@openclaw/${pluginId}`, version })}\n`, "utf8", ); fs.writeFileSync( path.join(stateDir, "openclaw.json"), `${JSON.stringify({ plugins: { entries: { [pluginId]: { enabled: true } } }, })}\n`, "utf8", ); writeIndex( stateDir, pluginId, { source: "marketplace", installPath, version, marketplaceName: "Release Fixture Marketplace", marketplaceSource: "release-fixtures", marketplacePlugin: pluginId, }, version, ); return { installPath, pluginId }; } function clearMarketplaceIndex(home: string) { const databasePath = path.join(home, ".openclaw", "state", "openclaw.sqlite"); const db = new DatabaseSync(databasePath); try { db.prepare( ` UPDATE installed_plugin_index SET install_records_json = ?, plugins_json = ?, updated_at_ms = ? WHERE index_key = ? `, ).run("{}", "[]", Date.now(), "installed-plugin-index"); } finally { db.close(); } } describe("release plugin marketplace lifecycle assertions", () => { it("checks canonical marketplace metadata and stable managed install paths", () => { const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-"); const { installPath, pluginId } = writeMarketplaceState(home, "0.0.1"); const installPathFile = path.join(home, "install-path.txt"); const initial = runHelper(home, [ "assert-marketplace-state", pluginId, "0.0.1", "release-fixtures", pluginId, installPathFile, ]); expect(initial.stderr).toBe(""); expect(initial.status).toBe(0); expect(fs.readFileSync(installPathFile, "utf8").trim()).toBe(installPath); writeMarketplaceState(home, "0.0.2"); const updated = runHelper(home, [ "assert-marketplace-state", pluginId, "0.0.2", "release-fixtures", pluginId, installPathFile, ]); expect(updated.stderr).toBe(""); expect(updated.status).toBe(0); }); it("rejects marketplace state with the wrong persisted version", () => { const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-"); const { pluginId } = writeMarketplaceState(home, "0.0.1"); const result = runHelper(home, [ "assert-marketplace-state", pluginId, "0.0.2", "release-fixtures", pluginId, path.join(home, "install-path.txt"), ]); expect(result.status).toBe(1); expect(result.stderr).toContain("expected install record version 0.0.2, got 0.0.1"); }); it("seeds uninstall state and verifies complete cleanup with sentinels preserved", () => { const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-"); const { installPath, pluginId } = writeMarketplaceState(home, "0.0.2"); const sentinelPluginId = "release-marketplace-other"; const sentinelPath = path.join(home, "marketplace", sentinelPluginId); const installPathFile = path.join(home, "install-path.txt"); fs.mkdirSync(sentinelPath, { recursive: true }); const seeded = runHelper(home, [ "seed-marketplace-uninstall-state", pluginId, sentinelPluginId, sentinelPath, installPathFile, ]); expect(seeded.stderr).toBe(""); expect(seeded.status).toBe(0); const configPath = path.join(home, ".openclaw", "openclaw.json"); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); expect(config.plugins.allow).toEqual([pluginId, sentinelPluginId]); expect(config.plugins.deny).toEqual([pluginId, sentinelPluginId]); expect(config.plugins.load.paths).toEqual([installPath, sentinelPath]); delete config.plugins.entries[pluginId]; config.plugins.allow = [sentinelPluginId]; config.plugins.deny = [sentinelPluginId]; config.plugins.load.paths = [sentinelPath]; fs.writeFileSync(configPath, `${JSON.stringify(config)}\n`, "utf8"); fs.rmSync(installPath, { recursive: true }); clearMarketplaceIndex(home); const verified = runHelper(home, [ "assert-marketplace-uninstalled", pluginId, sentinelPluginId, sentinelPath, installPathFile, ]); expect(verified.stderr).toBe(""); expect(verified.status).toBe(0); }); it("checks the exact dry-run and update outcome text", () => { const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-"); const logPath = path.join(home, "update.log"); fs.writeFileSync(logPath, "Would update release-marketplace-plugin: 0.0.1 -> 0.0.2.\n", "utf8"); const result = runHelper(home, [ "assert-update-log", logPath, "Would update release-marketplace-plugin: 0.0.1 -> 0.0.2.", ]); expect(result.stderr).toBe(""); expect(result.status).toBe(0); }); });