mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
* feat(doctor): add ocp doctor with --json + next_action contract Implements scripts/doctor.mjs with semver-aware path selection (noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON contract documented in the design spec. Service health + OAuth checks integrated; mockable via opts.mockHealth for unit tests. 8 unit tests cover the kind dispatch tree and the next_action shape for each kind. No cli.js citation needed: this is OCP-internal tooling with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(doctor): handle unparseable version + empty health body Three issues raised by code-quality reviewer onb65201b: 1. semverCompare returned 0 for unparseable input, causing fromSupported=true and kind=noop for an install with unreadable package.json. Now treats unparseable currentVersion as fresh_install candidate. 2. mockHealth: { status: 200, body: null } routed to fix_oauth (because health.body?.auth?.ok was undefined → falsy). 200 with empty body is server-broken, not OAuth-broken; now routes to fix_service. 3. Removed unused KIND_ENUM declaration (dead code). Two regression tests added. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(upgrade): add scripts/upgrade.mjs + scripts/lib/snapshot.mjs Implements the upgrade dispatcher (noop / dry-run / light delegation / full path) and the snapshot writer/reader/list module. Full path snapshots plist + db + admin-key + openclaw.json before mutating, runs the 6 phases (pre-flight, snapshot, fetch+install, reconfigure, restart, post-flight), and emits a heads-up before launchctl bootout per notify_before_prod_service_restart.md policy. mockExec/mockDoctor injection points let tests verify the phase ordering without touching the real shell. fresh_install + rollback paths are deferred to Bundle 3. No cli.js citation needed: this is OCP-internal upgrade tooling with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(upgrade): error path completeness + observability 5 issues raised by code-quality reviewer onc12013a: A. exec() wrapper now captures stderr from execSync failures and re-throws with `phase X failed: <stderr>` instead of the terse "Command failed: ..." default. Operators see the actual git/npm error. B. runFullUpgrade body wrapped in try/catch; any error after phase 2 (snapshot written) carries snapshotPath + phases + hint pointing at `ocp update --rollback`. Aligns with the post-flight failure pattern. C. CLI entrypoint now prints snapshotPath + hint on error. Plus minor: - snapshot.mjs tryCopy logs a [snapshot] warn line instead of silently swallowing copy errors (e.g. permission-denied admin-key) - heads-up window 1s → 3s, more operable per the policy intent - opts.yes intent comment added (Bundle 3 will use) One regression test added. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(upgrade): fresh-install + rollback paths Implements the two missing branches of runUpgrade dispatcher: - runFreshInstall: gated by --yes, runs doctor.next_action.ai_executable steps in order, fails fast on first error, attaches steps[] to thrown errors. Accepts mockExec for unit tests. - runRollback: locates latest or named snapshot in ~/.ocp/, reads from-commit.txt, restores plist + db + admin-key + service file (with per-file warn lines on copy failure), git-checkouts the from-commit, npm installs at that revision, restarts the service. --list shows all snapshots; --dry-run prints the plan without mutation. Both paths use the same exec() error-wrap pattern as runFullUpgrade (stderr capture, phases attached to thrown errors, restart heads-up). CLI entrypoint extended to parse --rollback / --list / --target / and optional positional snapshot path after --rollback. 6 unit tests cover: --yes gate, fresh_install ai_executable run, --rollback --list, no-snapshots error, --rollback --dry-run, mock-exec restore. No cli.js citation needed: this is OCP-internal upgrade tooling with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(upgrade): nit fixes from Bundle 3 code-quality review 3 micro-fixes on48e9408: 1. Remove unused mkdirSync import 2. snapshot-not-found error message hints "must be inside ~/.ocp/upgrade-snapshot-*" 3. runFreshInstall failure now includes e.stderr (or e.message fallback) in the thrown error and steps[].error so non-interactive callers see the actual reason Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(ocp): cmd_update dispatches via doctor; --rollback added; light path preserved cmd_update now calls scripts/doctor.mjs to determine which path to take: noop → "already at latest" exit 0 update → existing light path (git pull + npm install + restart), extracted into _cmd_update_light helper to keep the daily case fast and shell-only upgrade → exec node scripts/upgrade.mjs (full path with snapshot + post-flight) fresh_install → exec node scripts/upgrade.mjs (gated by --yes) fix_oauth/fix_service → print error referring user to `ocp doctor` cmd_update --rollback path: exec node scripts/upgrade.mjs --rollback "$@" forwards remaining args (--list, --dry-run, optional snapshot path). cmd_update_help expanded to document new flags. cmd_update --check fast path is preserved exactly (no doctor call there). No cli.js citation needed: this is OCP-internal CLI dispatch with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ocp): forward all args to cmd_update so multi-flag invocations work Bug found via runtime smoke test: ./ocp update --rollback --list → "no snapshots" (wrong; should list) Root cause: dispatch was `cmd_update "\${1:-}"` (only first arg). When user typed `--rollback --list`, cmd_update only received `--rollback`, the shift left $@ empty, and exec node ... --rollback got no flags. Other commands using "\${1:-}" don't need multi-arg, but cmd_update now does (--rollback --list, --rollback --dry-run, --target X --yes, etc.). Change: dispatch is now `cmd_update "\$@"`. cmd_update internals already handle multi-arg correctly (\$1 == --check fast path; \$1 == --rollback shift+forward; otherwise doctor-driven). Verified: ./ocp update --check → existing behaviour preserved ./ocp update --rollback --list → "Found 0 snapshots:" exit 0 ./ocp update --rollback --dry-run → no-snapshot error exit 1 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(release): v3.15.0 — README AI prompt blocks + Upgrading rewrite + CHANGELOG §Installation, §Upgrading, §Troubleshooting each start with a copy-paste AI prompt block for Claude Code / Cursor / Copilot. The Upgrading section explains the three paths (light / full / fresh-install) and rollback usage. All Commands table gains an `ocp doctor` row. package.json bumped to 3.15.0. CHANGELOG.md gains the v3.15.0 entry covering doctor, the cross-version update path, --rollback, fresh-install routing, and AI prompt blocks. Notes the dependency on PR #90 (plist env merge bug fix, already merged). No cli.js citation needed: docs + version bump only, no server.mjs change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(readme): show --yes in rollback usage examples Per Iron Rule 10 reviewer nit on PR #91: live rollback requires --yes even for interactive humans. Update §Upgrading examples to show the canonical human form. (AI agents pass --yes by convention; humans were hitting a confusing "requires --yes" error following the prior README.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(scripts): CLI entrypoint guard resilient to symlinked install paths Bug found via integration test on MacBook Pro (macOS /tmp → /private/tmp): `import.meta.url === \`file://\${process.argv[1]}\`` evaluates false when the install path traverses a symlink, because import.meta.url is canonicalised but process.argv[1] is not. Result: ./ocp doctor (and ./ocp update via upgrade.mjs) exit silently with code 0 and no output, instead of running. Fix: use fileURLToPath + realpathSync on both sides of the comparison. Affects any install at a symlinked path (/tmp, NFS mounts, /var/ paths, docker bind mounts, etc.). Normal ~/ocp installs were unaffected. No cli.js citation needed: this is OCP-internal CLI dispatch with no corresponding cli.js operation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
839 lines
31 KiB
JavaScript
839 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Integration test for Quota + Cache features.
|
|
* Tests database layer functions directly — no server needed.
|
|
*/
|
|
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
|
import { createHash } from "node:crypto";
|
|
import { strict as assert } from "node:assert";
|
|
import { unlinkSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
|
|
// Use a test database to avoid corrupting real data
|
|
const TEST_DB = join(homedir(), ".ocp", "ocp-test.db");
|
|
try { unlinkSync(TEST_DB); } catch {}
|
|
|
|
// Monkey-patch DB_PATH for testing (override the module-level variable)
|
|
// Since keys.mjs uses lazy init, we can set env before first getDb() call
|
|
process.env.HOME = homedir(); // ensure consistent
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
passed++;
|
|
console.log(` ✓ ${name}`);
|
|
} catch (e) {
|
|
failed++;
|
|
console.log(` ✗ ${name}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
console.log("\n=== OCP Feature Tests (Quota + Cache) ===\n");
|
|
|
|
// Initialize DB
|
|
const db = getDb();
|
|
|
|
// ── Quota Tests ──
|
|
console.log("Quota:");
|
|
|
|
const key1 = createKey("test-user-1");
|
|
const key2 = createKey("test-user-2");
|
|
|
|
test("createKey returns id, key, name", () => {
|
|
assert.ok(key1.id);
|
|
assert.ok(key1.key.startsWith("ocp_"));
|
|
assert.equal(key1.name, "test-user-1");
|
|
});
|
|
|
|
test("listKeys includes quota fields", () => {
|
|
const keys = listKeys();
|
|
assert.ok(keys.length >= 2);
|
|
const k = keys.find(k => k.name === "test-user-1");
|
|
assert.ok("quota_daily" in k);
|
|
assert.ok("quota_weekly" in k);
|
|
assert.ok("quota_monthly" in k);
|
|
assert.equal(k.quota_daily, null);
|
|
});
|
|
|
|
test("checkQuota returns null when no quota set", () => {
|
|
const result = checkQuota(key1.id, key1.name);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("checkQuota returns null for null keyId", () => {
|
|
assert.equal(checkQuota(null, "anon"), null);
|
|
assert.equal(checkQuota(undefined, "anon"), null);
|
|
});
|
|
|
|
test("updateKeyQuota sets daily quota (partial update)", () => {
|
|
const ok = updateKeyQuota(key1.id, { daily: 5 });
|
|
assert.ok(ok);
|
|
const quota = getKeyQuota(key1.id);
|
|
assert.equal(quota.daily.limit, 5);
|
|
assert.equal(quota.weekly.limit, null); // not touched
|
|
assert.equal(quota.monthly.limit, null);
|
|
});
|
|
|
|
test("updateKeyQuota partial update preserves existing values", () => {
|
|
updateKeyQuota(key1.id, { weekly: 20 });
|
|
const quota = getKeyQuota(key1.id);
|
|
assert.equal(quota.daily.limit, 5); // preserved from previous call
|
|
assert.equal(quota.weekly.limit, 20);
|
|
});
|
|
|
|
test("checkQuota passes when under limit", () => {
|
|
// Record 3 usages (limit is 5 daily)
|
|
for (let i = 0; i < 3; i++) {
|
|
recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
|
|
}
|
|
const result = checkQuota(key1.id, key1.name);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("checkQuota returns exceeded when at limit", () => {
|
|
// Record 2 more to hit limit (3 + 2 = 5)
|
|
for (let i = 0; i < 2; i++) {
|
|
recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
|
|
}
|
|
const result = checkQuota(key1.id, key1.name);
|
|
assert.ok(result);
|
|
assert.equal(result.period, "daily");
|
|
assert.equal(result.limit, 5);
|
|
assert.equal(result.used, 5);
|
|
assert.ok(result.resetsIn);
|
|
});
|
|
|
|
test("checkQuota ignores failed requests in count", () => {
|
|
// key2 has quota of 2 daily
|
|
updateKeyQuota(key2.id, { daily: 2 });
|
|
recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 0, elapsedMs: 500, success: false });
|
|
recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
|
|
const result = checkQuota(key2.id, key2.name);
|
|
assert.equal(result, null); // only 1 successful, limit is 2
|
|
});
|
|
|
|
test("getKeyQuota returns correct used counts", () => {
|
|
const quota = getKeyQuota(key1.id);
|
|
assert.equal(quota.daily.used, 5);
|
|
assert.equal(quota.daily.limit, 5);
|
|
});
|
|
|
|
test("findKey works by id and name", () => {
|
|
const byId = findKey(String(key1.id));
|
|
assert.ok(byId);
|
|
assert.equal(byId.name, "test-user-1");
|
|
const byName = findKey("test-user-1");
|
|
assert.ok(byName);
|
|
// Compare by name since auto-increment IDs may vary across runs
|
|
assert.equal(byName.name, "test-user-1");
|
|
assert.equal(findKey("nonexistent"), null);
|
|
});
|
|
|
|
// ── Cache Tests ──
|
|
console.log("\nCache:");
|
|
|
|
// Clean slate for cache tests
|
|
clearCache();
|
|
|
|
const msgs1 = [{ role: "user", content: "Hello world" }];
|
|
const msgs2 = [{ role: "user", content: "Different prompt" }];
|
|
|
|
test("cacheHash is deterministic", () => {
|
|
const h1 = cacheHash("sonnet", msgs1);
|
|
const h2 = cacheHash("sonnet", msgs1);
|
|
assert.equal(h1, h2);
|
|
});
|
|
|
|
test("cacheHash differs for different models", () => {
|
|
const h1 = cacheHash("sonnet", msgs1);
|
|
const h2 = cacheHash("opus", msgs1);
|
|
assert.notEqual(h1, h2);
|
|
});
|
|
|
|
test("cacheHash differs for different messages", () => {
|
|
const h1 = cacheHash("sonnet", msgs1);
|
|
const h2 = cacheHash("sonnet", msgs2);
|
|
assert.notEqual(h1, h2);
|
|
});
|
|
|
|
test("cacheHash includes temperature in hash", () => {
|
|
const h1 = cacheHash("sonnet", msgs1, {});
|
|
const h2 = cacheHash("sonnet", msgs1, { temperature: 0.5 });
|
|
const h3 = cacheHash("sonnet", msgs1, { temperature: 1.0 });
|
|
assert.notEqual(h1, h2);
|
|
assert.notEqual(h2, h3);
|
|
});
|
|
|
|
test("cacheHash includes max_tokens in hash", () => {
|
|
const h1 = cacheHash("sonnet", msgs1, {});
|
|
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
|
assert.notEqual(h1, h2);
|
|
});
|
|
|
|
test("getCachedResponse returns null for miss", () => {
|
|
const hash = cacheHash("sonnet", msgs1);
|
|
const result = getCachedResponse(hash, 3600000);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("setCachedResponse + getCachedResponse roundtrip", () => {
|
|
const hash = cacheHash("sonnet", msgs1);
|
|
setCachedResponse(hash, "sonnet", "Hello! I am Claude.");
|
|
const result = getCachedResponse(hash, 3600000);
|
|
assert.ok(result);
|
|
assert.equal(result.response, "Hello! I am Claude.");
|
|
assert.equal(result.hits, 1);
|
|
});
|
|
|
|
test("getCachedResponse increments hit counter", () => {
|
|
const hash = cacheHash("sonnet", msgs1);
|
|
const r1 = getCachedResponse(hash, 3600000);
|
|
const r2 = getCachedResponse(hash, 3600000);
|
|
assert.equal(r1.hits, 2);
|
|
assert.equal(r2.hits, 3);
|
|
});
|
|
|
|
test("getCachedResponse respects TTL (expired entry)", () => {
|
|
// Insert a backdated cache entry directly
|
|
const d = getDb();
|
|
const oldHash = "test_expired_hash_12345";
|
|
d.prepare("INSERT OR REPLACE INTO response_cache (hash, model, response, created_at) VALUES (?, ?, ?, datetime('now', '-2 hours'))").run(oldHash, "sonnet", "Old response");
|
|
// TTL of 1 hour should not return a 2-hour-old entry
|
|
const result = getCachedResponse(oldHash, 3600000);
|
|
assert.equal(result, null);
|
|
// Clean up the backdated entry so it doesn't affect subsequent tests
|
|
d.prepare("DELETE FROM response_cache WHERE hash = ?").run(oldHash);
|
|
});
|
|
|
|
test("getCacheStats returns correct counts", () => {
|
|
const stats = getCacheStats();
|
|
assert.equal(stats.entries, 1);
|
|
assert.ok(stats.totalHits >= 3);
|
|
assert.ok(stats.sizeBytes > 0);
|
|
});
|
|
|
|
test("setCachedResponse upserts on conflict", () => {
|
|
const hash = cacheHash("sonnet", msgs1);
|
|
setCachedResponse(hash, "sonnet", "Updated response!");
|
|
const result = getCachedResponse(hash, 3600000);
|
|
assert.equal(result.response, "Updated response!");
|
|
assert.equal(result.hits, 1); // reset after upsert
|
|
});
|
|
|
|
test("clearCache removes all entries", () => {
|
|
// Add another entry
|
|
const hash2 = cacheHash("sonnet", msgs2);
|
|
setCachedResponse(hash2, "sonnet", "Another response");
|
|
const statsBefore = getCacheStats();
|
|
assert.equal(statsBefore.entries, 2);
|
|
|
|
const cleared = clearCache();
|
|
assert.equal(cleared, 2);
|
|
|
|
const statsAfter = getCacheStats();
|
|
assert.equal(statsAfter.entries, 0);
|
|
});
|
|
|
|
test("clearCache with TTL only removes old entries", () => {
|
|
// Add fresh entry
|
|
const hash = cacheHash("sonnet", msgs1);
|
|
setCachedResponse(hash, "sonnet", "Fresh response");
|
|
|
|
// Clear with TTL of 1 hour — fresh entry should survive
|
|
const cleared = clearCache(3600000);
|
|
assert.equal(cleared, 0);
|
|
|
|
const stats = getCacheStats();
|
|
assert.equal(stats.entries, 1);
|
|
|
|
// Clean up
|
|
clearCache();
|
|
});
|
|
|
|
// ── PR-A: Per-key isolation (D1), cache_control bypass (D2), chunked replay (D3) ──
|
|
console.log("\nPR-A Cache Upgrade:");
|
|
|
|
const msgsBase = [{ role: "user", content: "Shared prompt text" }];
|
|
|
|
test("D1: cacheHash with two distinct keyIds produces different hashes", () => {
|
|
const h1 = cacheHash("sonnet", msgsBase, { keyId: "key-aaa" });
|
|
const h2 = cacheHash("sonnet", msgsBase, { keyId: "key-bbb" });
|
|
assert.notEqual(h1, h2);
|
|
});
|
|
|
|
test("D1: cacheHash with keyId=undefined and keyId='anon' produce the same hash", () => {
|
|
const hUndef = cacheHash("sonnet", msgsBase, { keyId: undefined });
|
|
const hAnon = cacheHash("sonnet", msgsBase, { keyId: "anon" });
|
|
assert.equal(hUndef, hAnon);
|
|
});
|
|
|
|
test("D1: cacheHash with keyId=null and keyId='anon' produce the same hash", () => {
|
|
const hNull = cacheHash("sonnet", msgsBase, { keyId: null });
|
|
const hAnon = cacheHash("sonnet", msgsBase, { keyId: "anon" });
|
|
assert.equal(hNull, hAnon);
|
|
});
|
|
|
|
test("D1: v2 prefix — hash differs from a v1-style baseline (no prefix)", () => {
|
|
// Reproduce a v1-style hash manually to confirm v2 differs
|
|
const v1 = createHash("sha256")
|
|
.update("sonnet")
|
|
.update(msgsBase[0].role)
|
|
.update(msgsBase[0].content)
|
|
.digest("hex");
|
|
const v2 = cacheHash("sonnet", msgsBase, { keyId: "anon" });
|
|
assert.notEqual(v1, v2);
|
|
});
|
|
|
|
test("D1: cacheHash is reproducible for same keyId (determinism)", () => {
|
|
const h1 = cacheHash("sonnet", msgsBase, { keyId: "key-xyz" });
|
|
const h2 = cacheHash("sonnet", msgsBase, { keyId: "key-xyz" });
|
|
assert.equal(h1, h2);
|
|
});
|
|
|
|
test("D2: hasCacheControl returns true for top-level cache_control on message", () => {
|
|
const msgs = [{ role: "user", cache_control: { type: "ephemeral" }, content: "hello" }];
|
|
assert.equal(hasCacheControl(msgs), true);
|
|
});
|
|
|
|
test("D2: hasCacheControl returns true for nested cache_control in content array", () => {
|
|
const msgs = [{ role: "user", content: [{ type: "text", text: "x", cache_control: { type: "ephemeral" } }] }];
|
|
assert.equal(hasCacheControl(msgs), true);
|
|
});
|
|
|
|
test("D2: hasCacheControl returns false for plain string content", () => {
|
|
const msgs = [{ role: "user", content: "plain string" }];
|
|
assert.equal(hasCacheControl(msgs), false);
|
|
});
|
|
|
|
test("D2: hasCacheControl returns false for content array without cache_control", () => {
|
|
const msgs = [{ role: "user", content: [{ type: "text", text: "x" }] }];
|
|
assert.equal(hasCacheControl(msgs), false);
|
|
});
|
|
|
|
test("D2: hasCacheControl handles null/empty input gracefully", () => {
|
|
assert.equal(hasCacheControl(null), false);
|
|
assert.equal(hasCacheControl([]), false);
|
|
assert.equal(hasCacheControl([null, undefined]), false);
|
|
});
|
|
|
|
// D3: chunked stream replay — verify the logic by simulating what server.mjs does
|
|
test("D3: 160-char cached response produces 2 chunks at 80 codepoints/chunk", () => {
|
|
const content = "a".repeat(160);
|
|
const CACHE_REPLAY_CHUNK_SIZE = 80;
|
|
const codepoints = Array.from(content);
|
|
const chunks = [];
|
|
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
|
|
chunks.push(codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join(""));
|
|
}
|
|
assert.equal(chunks.length, 2);
|
|
assert.equal(chunks[0].length, 80);
|
|
assert.equal(chunks[1].length, 80);
|
|
});
|
|
|
|
test("D3: chunked replay uses Array.from — multibyte codepoints stay intact", () => {
|
|
// Each Chinese character is 1 codepoint but 3 UTF-8 bytes
|
|
const chinese = "你好世界".repeat(25); // 100 codepoints
|
|
const CACHE_REPLAY_CHUNK_SIZE = 80;
|
|
const codepoints = Array.from(chinese);
|
|
const chunks = [];
|
|
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
|
|
chunks.push(codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join(""));
|
|
}
|
|
assert.equal(chunks.length, 2);
|
|
assert.equal(Array.from(chunks[0]).length, 80);
|
|
assert.equal(Array.from(chunks[1]).length, 20);
|
|
// Verify each character is a complete codepoint (no mojibake)
|
|
for (const chunk of chunks) {
|
|
for (const cp of Array.from(chunk)) {
|
|
assert.equal(cp.length <= 2, true); // surrogate pairs are length 2, single chars length 1
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── PR-B Singleflight tests (async) ──
|
|
async function asyncTest(name, fn) {
|
|
try {
|
|
await fn();
|
|
passed++;
|
|
console.log(` ✓ ${name}`);
|
|
} catch (e) {
|
|
failed++;
|
|
console.log(` ✗ ${name}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function runSingleflightTests() {
|
|
console.log("\nPR-B Singleflight:");
|
|
|
|
// 1. Basic dedup: 10 concurrent calls with same hash execute fn only once.
|
|
await asyncTest("basic dedup: 10 concurrent callers execute fn only once", async () => {
|
|
let callCount = 0;
|
|
const fn = () => new Promise(resolve => {
|
|
callCount++;
|
|
setTimeout(() => resolve("result-A"), 20);
|
|
});
|
|
const results = await Promise.all(Array.from({ length: 10 }, () => singleflight("sf-dedup-1", fn)));
|
|
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
|
|
assert.ok(results.every(r => r === "result-A"), "all 10 callers should receive the same return value");
|
|
});
|
|
|
|
// 2. Failure fan-out: all followers reject when leader rejects.
|
|
await asyncTest("failure fan-out: all followers reject with leader error", async () => {
|
|
let callCount = 0;
|
|
const fn = () => new Promise((_, reject) => {
|
|
callCount++;
|
|
setTimeout(() => reject(new Error("upstream-fail")), 20);
|
|
});
|
|
const promises = Array.from({ length: 10 }, () => singleflight("sf-fail-1", fn));
|
|
const results = await Promise.allSettled(promises);
|
|
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
|
|
assert.ok(results.every(r => r.status === "rejected"), "all 10 should be rejected");
|
|
assert.ok(results.every(r => r.reason?.message === "upstream-fail"), "all should share the same error message");
|
|
});
|
|
|
|
// 3a. Map cleanup after success: inflight count returns to 0 after promise resolves.
|
|
await asyncTest("map cleanup after success: inflight=0 after promise settles", async () => {
|
|
const fn = () => new Promise(resolve => setTimeout(() => resolve("done"), 10));
|
|
await singleflight("sf-cleanup-ok", fn);
|
|
const stats = getInflightStats();
|
|
assert.equal(stats.inflight, 0, `expected inflight=0 after settlement, got ${stats.inflight}`);
|
|
});
|
|
|
|
// 3b. Map cleanup after failure: inflight count returns to 0 after promise rejects.
|
|
await asyncTest("map cleanup after failure: inflight=0 after promise rejects", async () => {
|
|
const fn = () => new Promise((_, reject) => setTimeout(() => reject(new Error("fail")), 10));
|
|
try { await singleflight("sf-cleanup-fail", fn); } catch {}
|
|
const stats = getInflightStats();
|
|
assert.equal(stats.inflight, 0, `expected inflight=0 after rejection, got ${stats.inflight}`);
|
|
});
|
|
|
|
// 4. Different hashes don't share: two parallel calls with distinct hashes both execute.
|
|
await asyncTest("different hashes do not share a singleflight entry", async () => {
|
|
let countA = 0;
|
|
let countB = 0;
|
|
const fnA = () => new Promise(resolve => { countA++; setTimeout(() => resolve("A"), 20); });
|
|
const fnB = () => new Promise(resolve => { countB++; setTimeout(() => resolve("B"), 20); });
|
|
const [rA, rB] = await Promise.all([singleflight("sf-hash-A", fnA), singleflight("sf-hash-B", fnB)]);
|
|
assert.equal(countA, 1);
|
|
assert.equal(countB, 1);
|
|
assert.equal(rA, "A");
|
|
assert.equal(rB, "B");
|
|
});
|
|
|
|
// 5. getInflightStats shape: returns { inflight: number, requesters: number }.
|
|
await asyncTest("getInflightStats returns correct shape", async () => {
|
|
// Verify shape against a settled state (inflight=0 is still the right shape).
|
|
const stats = getInflightStats();
|
|
assert.equal(typeof stats.inflight, "number", "inflight should be a number");
|
|
assert.equal(typeof stats.requesters, "number", "requesters should be a number");
|
|
// Also verify live counts: start a pending fn, check inflight>0, then resolve.
|
|
const { promise: blocker, resolve: resolveBlocker } = Promise.withResolvers();
|
|
const fn = () => blocker;
|
|
const p = singleflight("sf-stats-shape", fn);
|
|
const liveStats = getInflightStats();
|
|
assert.ok(liveStats.inflight >= 1, `expected inflight>=1, got ${liveStats.inflight}`);
|
|
resolveBlocker("ok");
|
|
await p;
|
|
});
|
|
|
|
// 6. Sequential calls don't share: singleflight is for concurrent dedup only.
|
|
await asyncTest("sequential calls with same hash each execute fn independently", async () => {
|
|
let callCount = 0;
|
|
const fn = () => new Promise(resolve => { callCount++; setTimeout(() => resolve(callCount), 10); });
|
|
const r1 = await singleflight("sf-sequential", fn);
|
|
const r2 = await singleflight("sf-sequential", fn);
|
|
assert.equal(callCount, 2, `fn should have been called twice, got ${callCount}`);
|
|
assert.equal(r1, 1);
|
|
assert.equal(r2, 2);
|
|
});
|
|
}
|
|
|
|
await runSingleflightTests();
|
|
|
|
// ── Plist Env Merge Tests ──
|
|
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
|
|
console.log("\nPlist env merge:");
|
|
|
|
const SAMPLE_TEMPLATE_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>dev.ocp.proxy</string>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>CLAUDE_PROXY_PORT</key>
|
|
<string>3478</string>
|
|
<key>CLAUDE_BIND</key>
|
|
<string>127.0.0.1</string>
|
|
<key>CLAUDE_AUTH_MODE</key>
|
|
<string>multi</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`;
|
|
|
|
const SAMPLE_EXISTING_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>dev.ocp.proxy</string>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>CLAUDE_PROXY_PORT</key>
|
|
<string>3456</string>
|
|
<key>CLAUDE_BIND</key>
|
|
<string>127.0.0.1</string>
|
|
<key>CLAUDE_AUTH_MODE</key>
|
|
<string>none</string>
|
|
<key>CLAUDE_HEARTBEAT_INTERVAL</key>
|
|
<string>2000</string>
|
|
<key>CLAUDE_CACHE_TTL</key>
|
|
<string>600</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`;
|
|
|
|
test("mergePlistEnv preserves unknown user keys", () => {
|
|
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
assert.match(merged, /<key>CLAUDE_HEARTBEAT_INTERVAL<\/key>\s*<string>2000<\/string>/);
|
|
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/);
|
|
});
|
|
|
|
test("mergePlistEnv overrides known template keys", () => {
|
|
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
assert.match(merged, /<key>CLAUDE_PROXY_PORT<\/key>\s*<string>3478<\/string>/);
|
|
assert.match(merged, /<key>CLAUDE_AUTH_MODE<\/key>\s*<string>multi<\/string>/);
|
|
});
|
|
|
|
test("mergePlistEnv first-install returns template unchanged when existing is null", () => {
|
|
const merged = mergePlistEnv(null, SAMPLE_TEMPLATE_PLIST);
|
|
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
|
});
|
|
|
|
test("mergePlistEnv first-install returns template unchanged when existing is empty", () => {
|
|
const merged = mergePlistEnv("", SAMPLE_TEMPLATE_PLIST);
|
|
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
|
});
|
|
|
|
const SAMPLE_TEMPLATE_SYSTEMD = `[Unit]
|
|
Description=OCP — Open Claude Proxy
|
|
After=network.target
|
|
|
|
[Service]
|
|
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
|
Environment=CLAUDE_PROXY_PORT=3478
|
|
Environment=CLAUDE_BIND=127.0.0.1
|
|
Environment=CLAUDE_AUTH_MODE=multi
|
|
Restart=always
|
|
`;
|
|
|
|
const SAMPLE_EXISTING_SYSTEMD = `[Unit]
|
|
Description=OCP — Open Claude Proxy
|
|
After=network.target
|
|
|
|
[Service]
|
|
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
|
Environment=CLAUDE_PROXY_PORT=3456
|
|
Environment=CLAUDE_BIND=127.0.0.1
|
|
Environment=CLAUDE_AUTH_MODE=none
|
|
Environment=CLAUDE_HEARTBEAT_INTERVAL=2000
|
|
Environment=CLAUDE_CACHE_TTL=600
|
|
Restart=always
|
|
`;
|
|
|
|
test("mergeSystemdEnv preserves unknown user Environment lines", () => {
|
|
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
assert.match(merged, /Environment=CLAUDE_HEARTBEAT_INTERVAL=2000/);
|
|
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/);
|
|
});
|
|
|
|
test("mergeSystemdEnv overrides known template keys", () => {
|
|
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
assert.match(merged, /Environment=CLAUDE_PROXY_PORT=3478/);
|
|
assert.match(merged, /Environment=CLAUDE_AUTH_MODE=multi/);
|
|
});
|
|
|
|
test("mergeSystemdEnv first-install returns template unchanged", () => {
|
|
assert.equal(mergeSystemdEnv(null, SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
|
assert.equal(mergeSystemdEnv("", SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
|
});
|
|
|
|
test("mergePlistEnv is idempotent", () => {
|
|
const r1 = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
|
});
|
|
|
|
test("mergeSystemdEnv is idempotent", () => {
|
|
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
|
});
|
|
|
|
// ── Doctor JSON Contract Tests ──
|
|
import { runDoctor } from "./scripts/doctor.mjs";
|
|
|
|
console.log("\nDoctor:");
|
|
|
|
test("doctor --json shape: required top-level keys", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
|
for (const k of ["schema_version", "ready_to_upgrade", "current_version", "latest_version",
|
|
"from_version_supported", "fail_count", "warn_count", "checks", "next_action"]) {
|
|
assert.ok(k in result, `missing key: ${k}`);
|
|
}
|
|
assert.equal(result.schema_version, "1");
|
|
});
|
|
|
|
test("doctor detects from-version < v3.4.0 → fresh_install", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.2.0", mockLatest: "v3.14.0" });
|
|
assert.equal(result.from_version_supported, false);
|
|
assert.equal(result.next_action.kind, "fresh_install");
|
|
assert.ok(Array.isArray(result.next_action.ai_executable));
|
|
assert.ok(result.next_action.ai_executable.length > 0);
|
|
});
|
|
|
|
test("doctor next_action.kind enum is one of allowed values", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
|
const ALLOWED = ["noop", "update", "upgrade", "fresh_install", "fix_oauth", "fix_service"];
|
|
assert.ok(ALLOWED.includes(result.next_action.kind), `kind=${result.next_action.kind} not in enum`);
|
|
});
|
|
|
|
test("doctor noop when current==latest", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.0" });
|
|
assert.equal(result.next_action.kind, "noop");
|
|
assert.equal(result.ready_to_upgrade, true);
|
|
});
|
|
|
|
test("doctor patch-bump same minor → update kind", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.1" });
|
|
assert.equal(result.next_action.kind, "update");
|
|
});
|
|
|
|
test("doctor cross-minor → upgrade kind", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
|
assert.equal(result.next_action.kind, "upgrade");
|
|
});
|
|
|
|
test("doctor OAuth FAIL → fix_oauth kind", async () => {
|
|
const result = await runDoctor({
|
|
skipNetwork: false,
|
|
mockVersion: "v3.10.0",
|
|
mockLatest: "v3.14.0",
|
|
mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } }
|
|
});
|
|
assert.equal(result.next_action.kind, "fix_oauth");
|
|
assert.ok(result.next_action.ai_executable.some(c => c.includes("install.cjs")));
|
|
});
|
|
|
|
test("doctor service down → fix_service kind", async () => {
|
|
const result = await runDoctor({
|
|
skipNetwork: false,
|
|
mockVersion: "v3.10.0",
|
|
mockLatest: "v3.14.0",
|
|
mockHealth: { error: "ECONNREFUSED" }
|
|
});
|
|
assert.equal(result.next_action.kind, "fix_service");
|
|
});
|
|
|
|
test("doctor unparseable version → fresh_install", async () => {
|
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "garbage", mockLatest: "v3.14.0" });
|
|
assert.equal(result.from_version_supported, false);
|
|
assert.equal(result.next_action.kind, "fresh_install");
|
|
});
|
|
|
|
test("doctor empty health body → fix_service (not fix_oauth)", async () => {
|
|
const result = await runDoctor({
|
|
skipNetwork: false,
|
|
mockVersion: "v3.10.0",
|
|
mockLatest: "v3.14.0",
|
|
mockHealth: { status: 200, body: null }
|
|
});
|
|
assert.equal(result.next_action.kind, "fix_service");
|
|
});
|
|
|
|
// ── Upgrade Tests ──
|
|
import { runUpgrade } from "./scripts/upgrade.mjs";
|
|
|
|
console.log("\nUpgrade:");
|
|
|
|
test("upgrade --dry-run prints plan, no side effects", async () => {
|
|
const result = await runUpgrade({
|
|
dryRun: true,
|
|
yes: true,
|
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" }, current_version: "v3.10.0", latest_version: "v3.14.0" }
|
|
});
|
|
assert.equal(result.executed, false);
|
|
assert.ok(result.plan.length > 0);
|
|
assert.ok(result.plan.some(line => line.toLowerCase().includes("snapshot")));
|
|
});
|
|
|
|
test("upgrade noop returns early when current==latest", async () => {
|
|
const result = await runUpgrade({
|
|
yes: true,
|
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "noop" }, current_version: "v3.14.0", latest_version: "v3.14.0" }
|
|
});
|
|
assert.equal(result.path, "noop");
|
|
assert.equal(result.executed, true);
|
|
assert.equal(result.changed, false);
|
|
});
|
|
|
|
test("upgrade aborts on doctor FAIL", async () => {
|
|
await assert.rejects(async () => {
|
|
await runUpgrade({
|
|
yes: true,
|
|
mockDoctor: { ready_to_upgrade: false, fail_count: 1, next_action: { kind: "fix_oauth" } }
|
|
});
|
|
}, /doctor FAIL/);
|
|
});
|
|
|
|
test("upgrade full path executes 5 phases", async () => {
|
|
const result = await runUpgrade({
|
|
yes: true,
|
|
dryRun: false,
|
|
mockExec: true,
|
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
|
|
current_version: "v3.10.0", latest_version: "v3.14.0" }
|
|
});
|
|
assert.equal(result.path, "upgrade");
|
|
// Plan asks for 6 phases by name; verify each appears as a phase entry
|
|
const phaseNames = result.phases.map(p => p.name);
|
|
for (const expected of ["pre-flight", "snapshot", "fetch+install", "reconfigure", "restart", "post-flight"]) {
|
|
assert.ok(phaseNames.includes(expected), `missing phase: ${expected}; got ${phaseNames.join(",")}`);
|
|
}
|
|
});
|
|
|
|
// ── Snapshot Tests ──
|
|
import { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs";
|
|
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join as testJoin } from "node:path";
|
|
|
|
console.log("\nSnapshot:");
|
|
|
|
test("writeSnapshot creates dir + manifest files", () => {
|
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
|
const dotOcp = testJoin(root, ".ocp");
|
|
tMkdirSync(dotOcp, { recursive: true });
|
|
testWriteFile(testJoin(dotOcp, "ocp.db"), "fake-sqlite-bytes");
|
|
|
|
const path = writeSnapshot({
|
|
homeDir: root,
|
|
fromCommit: "abc1234",
|
|
fromVersion: "v3.10.0",
|
|
toVersion: "v3.14.0",
|
|
extraFiles: []
|
|
});
|
|
const m = readSnapshot(path);
|
|
assert.equal(m.fromCommit, "abc1234");
|
|
assert.equal(m.fromVersion, "v3.10.0");
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("listSnapshots returns sorted by ISO timestamp", () => {
|
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-list-"));
|
|
const dotOcp = testJoin(root, ".ocp");
|
|
tMkdirSync(dotOcp, { recursive: true });
|
|
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
|
}
|
|
const list = listSnapshots(root);
|
|
assert.equal(list.length, 3);
|
|
assert.ok(list[0].path.includes("2026-05-01"));
|
|
assert.ok(list[2].path.includes("2026-05-03"));
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
|
// Use mockExec=true so no real commands are run.
|
|
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
|
const result = await runUpgrade({
|
|
yes: true,
|
|
dryRun: false,
|
|
mockExec: true,
|
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
|
|
current_version: "v3.10.0", latest_version: "v3.14.0" }
|
|
});
|
|
assert.ok(result.snapshotPath, "successful upgrade returns snapshotPath");
|
|
assert.equal(result.path, "upgrade");
|
|
assert.equal(result.executed, true);
|
|
});
|
|
|
|
test("upgrade fresh_install requires --yes for non-interactive", async () => {
|
|
await assert.rejects(async () => {
|
|
await runUpgrade({
|
|
yes: false,
|
|
mockExec: true,
|
|
mockDoctor: { ready_to_upgrade: false, from_version_supported: false,
|
|
next_action: { kind: "fresh_install", ai_executable: ["echo would-rm-rf"] },
|
|
current_version: "v3.2.0", latest_version: "v3.14.0" }
|
|
});
|
|
}, /requires --yes/);
|
|
});
|
|
|
|
test("upgrade fresh_install with --yes runs ai_executable", async () => {
|
|
const result = await runUpgrade({
|
|
yes: true,
|
|
mockExec: true,
|
|
mockDoctor: { ready_to_upgrade: false, from_version_supported: false,
|
|
next_action: { kind: "fresh_install",
|
|
ai_executable: ["echo step-1", "echo step-2", "echo step-3"] },
|
|
current_version: "v3.2.0", latest_version: "v3.14.0" }
|
|
});
|
|
assert.equal(result.path, "fresh_install");
|
|
assert.equal(result.steps.length, 3);
|
|
});
|
|
|
|
test("rollback --list returns snapshots", async () => {
|
|
const result = await runUpgrade({
|
|
rollback: true,
|
|
list: true,
|
|
mockSnapshots: [
|
|
{ name: "upgrade-snapshot-2026-05-01T10:00:00Z", path: "/tmp/snap-1" },
|
|
{ name: "upgrade-snapshot-2026-05-02T10:00:00Z", path: "/tmp/snap-2" }
|
|
]
|
|
});
|
|
assert.equal(result.path, "rollback-list");
|
|
assert.equal(result.snapshots.length, 2);
|
|
});
|
|
|
|
test("rollback with no snapshots fails clearly", async () => {
|
|
await assert.rejects(async () => {
|
|
await runUpgrade({ rollback: true, dryRun: true, mockSnapshots: [] });
|
|
}, /no upgrade snapshots/);
|
|
});
|
|
|
|
test("rollback --dry-run produces a plan without mutation", async () => {
|
|
const result = await runUpgrade({
|
|
rollback: true,
|
|
dryRun: true,
|
|
mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }],
|
|
mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" }
|
|
});
|
|
assert.equal(result.path, "rollback-dry-run");
|
|
assert.equal(result.executed, false);
|
|
assert.ok(result.plan.length > 0);
|
|
});
|
|
|
|
test("rollback latest snapshot restores files (mockExec)", async () => {
|
|
const result = await runUpgrade({
|
|
rollback: true,
|
|
yes: true,
|
|
mockExec: true,
|
|
mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }],
|
|
mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" }
|
|
});
|
|
assert.equal(result.path, "rollback");
|
|
assert.equal(result.executed, true);
|
|
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
|
});
|
|
|
|
// ── Cleanup ──
|
|
closeDb();
|
|
|
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
|
process.exit(failed > 0 ? 1 : 0);
|