Initial commit
This commit is contained in:
373
tests/config.test.ts
Normal file
373
tests/config.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* config.test.ts — Unit tests for the configuration module.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Default configuration creation
|
||||
* - Validation of each config field
|
||||
* - Partial and full updates via setConfig
|
||||
* - File persistence (save/load)
|
||||
* - Config file path resolution
|
||||
* - Reset to defaults
|
||||
* - Edge cases (edge values, missing files, invalid JSON)
|
||||
*
|
||||
* @module file-claiming/config.test
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { assert, createTempDir, cleanupTempDir } from "./test-utils.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
createDefaultConfig,
|
||||
validateConfig,
|
||||
getConfig,
|
||||
setConfig,
|
||||
resetConfig,
|
||||
getConfigFilePath,
|
||||
loadConfigFromFile,
|
||||
saveConfigToFile,
|
||||
} from "../src/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Default configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testDefaultConfig() {
|
||||
const def = createDefaultConfig();
|
||||
assert(typeof def.autoReleaseTTL === "number", "autoReleaseTTL is a number");
|
||||
assert(def.autoReleaseTTL === 300_000, "Default TTL is 300000ms");
|
||||
assert(def.releaseOnTurnEnd === true, "Default releaseOnTurnEnd is true");
|
||||
assert(typeof def.lockDir === "string", "lockDir is a string");
|
||||
assert(def.lockDir.length > 0, "lockDir is non-empty");
|
||||
assert(Array.isArray(def.blockedTools), "blockedTools is an array");
|
||||
assert(def.blockedTools.includes("edit"), "blockedTools includes 'edit'");
|
||||
assert(def.blockedTools.includes("write"), "blockedTools includes 'write'");
|
||||
assert(def.showDiagnostics === true, "Default showDiagnostics is true");
|
||||
|
||||
const def2 = createDefaultConfig();
|
||||
assert(def !== def2, "Each call returns a fresh object");
|
||||
assert(
|
||||
JSON.stringify(def) === JSON.stringify(def2),
|
||||
"Fresh copies have same values",
|
||||
);
|
||||
|
||||
console.log("✅ Config: default configuration is correct");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testValidation() {
|
||||
let result = validateConfig({ autoReleaseTTL: 600_000 });
|
||||
assert(result.valid === true, "Valid autoReleaseTTL passes");
|
||||
assert(result.errors.length === 0, "No errors for valid config");
|
||||
|
||||
result = validateConfig({ releaseOnTurnEnd: false });
|
||||
assert(result.valid === true, "Valid releaseOnTurnEnd passes");
|
||||
|
||||
result = validateConfig({ showDiagnostics: false });
|
||||
assert(result.valid === true, "Valid showDiagnostics passes");
|
||||
|
||||
result = validateConfig({ blockedTools: [] });
|
||||
assert(result.valid === true, "Empty blockedTools is valid");
|
||||
|
||||
result = validateConfig({ blockedTools: ["edit", "write", "bash"] });
|
||||
assert(result.valid === true, "Multiple blockedTools is valid");
|
||||
|
||||
result = validateConfig({ lockDir: "/tmp/locks" });
|
||||
assert(result.valid === true, "Valid lockDir passes");
|
||||
|
||||
result = validateConfig({ autoReleaseTTL: 0 });
|
||||
assert(result.valid === true, "Zero autoReleaseTTL is valid");
|
||||
|
||||
result = validateConfig({ autoReleaseTTL: -1 } as any);
|
||||
assert(result.valid === false, "Negative autoReleaseTTL is invalid");
|
||||
|
||||
result = validateConfig({ autoReleaseTTL: "abc" } as any);
|
||||
assert(result.valid === false, "String autoReleaseTTL is invalid");
|
||||
|
||||
result = validateConfig({ autoReleaseTTL: NaN } as any);
|
||||
assert(result.valid === false, "NaN autoReleaseTTL is invalid");
|
||||
|
||||
result = validateConfig({ releaseOnTurnEnd: "yes" } as any);
|
||||
assert(result.valid === false, "String releaseOnTurnEnd is invalid");
|
||||
|
||||
result = validateConfig({ lockDir: "" } as any);
|
||||
assert(result.valid === false, "Empty lockDir is invalid");
|
||||
|
||||
result = validateConfig({ lockDir: 123 } as any);
|
||||
assert(result.valid === false, "Number lockDir is invalid");
|
||||
|
||||
result = validateConfig({ blockedTools: "edit" } as any);
|
||||
assert(result.valid === false, "String blockedTools is invalid");
|
||||
|
||||
result = validateConfig({ blockedTools: [1, 2] } as any);
|
||||
assert(result.valid === false, "Number array blockedTools is invalid");
|
||||
|
||||
result = validateConfig({ showDiagnostics: 1 } as any);
|
||||
assert(result.valid === false, "Number showDiagnostics is invalid");
|
||||
|
||||
result = validateConfig({
|
||||
autoReleaseTTL: "bad",
|
||||
releaseOnTurnEnd: "bad",
|
||||
showDiagnostics: "bad",
|
||||
} as any);
|
||||
assert(result.valid === false, "Multiple invalid fields produce errors");
|
||||
assert(result.errors.length >= 3, "Multiple errors reported");
|
||||
|
||||
console.log("✅ Config: validation works correctly");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Runtime get/set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testGetSet() {
|
||||
resetConfig();
|
||||
|
||||
const def = getConfig();
|
||||
assert(def.autoReleaseTTL === 300_000, "getConfig returns defaults");
|
||||
|
||||
const result = setConfig({ autoReleaseTTL: 600_000 });
|
||||
assert(result.valid === true, "setConfig with valid value succeeds");
|
||||
assert(result.errors.length === 0, "No errors");
|
||||
|
||||
const updated = getConfig();
|
||||
assert(updated.autoReleaseTTL === 600_000, "Config value updated");
|
||||
assert(updated.releaseOnTurnEnd === true, "Other values unchanged");
|
||||
|
||||
const fail = setConfig({ autoReleaseTTL: -1 } as any);
|
||||
assert(fail.valid === false, "setConfig with invalid value fails");
|
||||
assert(fail.errors.length > 0, "Error reported");
|
||||
|
||||
const unchanged = getConfig();
|
||||
assert(unchanged.autoReleaseTTL === 600_000, "Config unchanged on failure");
|
||||
|
||||
setConfig({
|
||||
autoReleaseTTL: 30_000,
|
||||
releaseOnTurnEnd: false,
|
||||
showDiagnostics: false,
|
||||
});
|
||||
const multi = getConfig();
|
||||
assert(multi.autoReleaseTTL === 30_000, "Multi-set: TTL updated");
|
||||
assert(
|
||||
multi.releaseOnTurnEnd === false,
|
||||
"Multi-set: releaseOnTurnEnd updated",
|
||||
);
|
||||
assert(multi.showDiagnostics === false, "Multi-set: showDiagnostics updated");
|
||||
|
||||
resetConfig();
|
||||
console.log("✅ Config: runtime get/set works correctly");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Config file path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testConfigFilePath() {
|
||||
const path = getConfigFilePath();
|
||||
assert(
|
||||
path.endsWith("config.json"),
|
||||
"Config file path ends with config.json",
|
||||
);
|
||||
|
||||
const customPath = getConfigFilePath("/tmp/custom-locks");
|
||||
assert(
|
||||
customPath.startsWith("/tmp/custom-locks"),
|
||||
"Custom lockDir reflected",
|
||||
);
|
||||
assert(
|
||||
customPath.endsWith("config.json"),
|
||||
"Custom path ends with config.json",
|
||||
);
|
||||
|
||||
console.log("✅ Config: file path resolution works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: File persistence (save/load)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testFilePersistence() {
|
||||
resetConfig();
|
||||
const tempDir = createTempDir("config-test-");
|
||||
|
||||
setConfig({ lockDir: tempDir });
|
||||
await saveConfigToFile();
|
||||
|
||||
const configPath = getConfigFilePath(tempDir);
|
||||
assert(existsSync(configPath), "Config file created on disk");
|
||||
|
||||
const raw = readFileSync(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
assert(parsed.autoReleaseTTL === 300_000, "Saved config has correct TTL");
|
||||
assert(
|
||||
parsed.releaseOnTurnEnd === true,
|
||||
"Saved config has correct releaseOnTurnEnd",
|
||||
);
|
||||
|
||||
setConfig({ autoReleaseTTL: 600_000 });
|
||||
await saveConfigToFile();
|
||||
|
||||
const raw2 = readFileSync(configPath, "utf-8");
|
||||
const parsed2 = JSON.parse(raw2);
|
||||
assert(parsed2.autoReleaseTTL === 600_000, "Updated config saved correctly");
|
||||
|
||||
setConfig({ autoReleaseTTL: 1000 });
|
||||
await loadConfigFromFile(configPath);
|
||||
const afterReload = getConfig();
|
||||
assert(
|
||||
afterReload.autoReleaseTTL === 600_000,
|
||||
"Loaded config overrides in-memory",
|
||||
);
|
||||
|
||||
const nonExistent = join(tempDir, "nonexistent", "config.json");
|
||||
await loadConfigFromFile(nonExistent);
|
||||
const stillLoaded = getConfig();
|
||||
assert(
|
||||
stillLoaded.autoReleaseTTL === 600_000,
|
||||
"Non-existent file keeps current config",
|
||||
);
|
||||
|
||||
resetConfig();
|
||||
cleanupTempDir(tempDir);
|
||||
console.log("✅ Config: file persistence works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Load from corrupted file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testCorruptedFile() {
|
||||
resetConfig();
|
||||
const tempDir = createTempDir("config-corrupt-");
|
||||
const configPath = getConfigFilePath(tempDir);
|
||||
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
writeFileSync(configPath, "{invalid json}", "utf-8");
|
||||
|
||||
const result = await loadConfigFromFile(configPath);
|
||||
assert(result.autoReleaseTTL === 300_000, "Corrupted file keeps defaults");
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ autoReleaseTTL: "invalid", releaseOnTurnEnd: false }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await loadConfigFromFile(configPath);
|
||||
const after = getConfig();
|
||||
assert(
|
||||
after.autoReleaseTTL === 300_000,
|
||||
"Invalid field skipped, keeps default",
|
||||
);
|
||||
assert(
|
||||
after.releaseOnTurnEnd === false,
|
||||
"Valid field applied from corrupted file",
|
||||
);
|
||||
|
||||
resetConfig();
|
||||
cleanupTempDir(tempDir);
|
||||
console.log("✅ Config: corrupted file handling works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Reset to defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testReset() {
|
||||
setConfig({ autoReleaseTTL: 999_999, showDiagnostics: false });
|
||||
const modified = getConfig();
|
||||
assert(modified.autoReleaseTTL === 999_999, "Modified TTL");
|
||||
|
||||
resetConfig();
|
||||
const restored = getConfig();
|
||||
assert(restored.autoReleaseTTL === 300_000, "Reset restores default TTL");
|
||||
assert(
|
||||
restored.showDiagnostics === true,
|
||||
"Reset restores default showDiagnostics",
|
||||
);
|
||||
console.log("✅ Config: reset to defaults works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Config immutability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testImmutability() {
|
||||
resetConfig();
|
||||
const first = getConfig();
|
||||
const second = getConfig();
|
||||
(first as any).autoReleaseTTL = 999_999;
|
||||
assert(
|
||||
second.autoReleaseTTL === 300_000,
|
||||
"getConfig returns independent copies",
|
||||
);
|
||||
console.log("✅ Config: immutability of getConfig is preserved");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Edge values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testEdgeValues() {
|
||||
resetConfig();
|
||||
|
||||
let result = setConfig({ autoReleaseTTL: Number.MAX_SAFE_INTEGER });
|
||||
assert(result.valid === true, "MAX_SAFE_INTEGER TTL is valid");
|
||||
|
||||
result = setConfig({ autoReleaseTTL: Infinity });
|
||||
assert(result.valid === false, "Infinity TTL is invalid");
|
||||
|
||||
result = setConfig({ blockedTools: [] });
|
||||
assert(result.valid === true, "Empty blockedTools is valid");
|
||||
|
||||
const longDir = "/" + "a".repeat(1000);
|
||||
result = setConfig({ lockDir: longDir });
|
||||
assert(result.valid === true, "Long lockDir is valid");
|
||||
|
||||
resetConfig();
|
||||
console.log("✅ Config: edge values handled correctly");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runTests() {
|
||||
console.log("Running Config Module Tests\n");
|
||||
|
||||
const tests = [
|
||||
testDefaultConfig,
|
||||
testValidation,
|
||||
testGetSet,
|
||||
testConfigFilePath,
|
||||
testFilePersistence,
|
||||
testCorruptedFile,
|
||||
testReset,
|
||||
testImmutability,
|
||||
testEdgeValues,
|
||||
];
|
||||
|
||||
try {
|
||||
for (const test of tests) {
|
||||
try {
|
||||
await test();
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test ${test.name} failed: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log("\n✅ All config module tests passed!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test suite failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
168
tests/e2e.test.ts
Normal file
168
tests/e2e.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* e2e.test.ts — End-to-end tests for the complete lock lifecycle.
|
||||
*
|
||||
* @module file-claiming/e2e.test
|
||||
*/
|
||||
|
||||
import {
|
||||
assert,
|
||||
mockOwner,
|
||||
TEST_FILE_A,
|
||||
TEST_FILE_B,
|
||||
SESSION_A,
|
||||
SESSION_B,
|
||||
} from "./test-utils.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy module cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _acq: any = null;
|
||||
let _reg: any = null;
|
||||
let _cfg: any = null;
|
||||
|
||||
async function getAcq() { if (!_acq) _acq = await import("./lock-acquisition.ts"); return _acq; }
|
||||
async function getReg() { if (!_reg) _reg = await import("./index.ts"); return _reg; }
|
||||
async function getCfg() { if (!_cfg) _cfg = await import("./config.ts"); return _cfg; }
|
||||
|
||||
async function resetAll(): Promise<void> {
|
||||
const reg = await getReg();
|
||||
reg.resetRegistry();
|
||||
const cfg = await getCfg();
|
||||
cfg.resetConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 1: Single session — claim → edit → release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testSingleSessionLifecycle() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const reg = await getReg();
|
||||
const owner = mockOwner("agent", "editor");
|
||||
|
||||
const claim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner, reason: "Editing file A" });
|
||||
assert(claim.success === true, "Step 1: Claim file");
|
||||
const claimId = claim.claim!.id;
|
||||
|
||||
const active = reg.getClaimRegistry().getActiveClaims(TEST_FILE_A);
|
||||
assert(active.length === 1, "Step 2: File appears in active claims");
|
||||
|
||||
assert(acq.isFileLocked(TEST_FILE_A) === true, "Step 3: File is locked");
|
||||
|
||||
const info = acq.getLockInfo(TEST_FILE_A);
|
||||
assert(info.locked === true, "Step 4: Lock info shows locked");
|
||||
assert(info.primaryClaim?.id === claimId, "Step 4a: Correct claim");
|
||||
|
||||
reg.getClaimRegistry().release(claimId);
|
||||
assert(acq.isFileLocked(TEST_FILE_A) === false, "Step 5: File unlocked after release");
|
||||
console.log("✅ E2E Scenario 1: Single session lifecycle complete");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 2: Two sessions — coordinated claim → edit → release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testTwoSessionCoordination() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const reg = await getReg();
|
||||
const alice = mockOwner("agent", "alice", SESSION_A);
|
||||
const bob = mockOwner("agent", "bob", SESSION_B);
|
||||
|
||||
const aliceClaim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: alice });
|
||||
assert(aliceClaim.success === true, "Alice claims file");
|
||||
|
||||
const bobClaim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: bob });
|
||||
assert(bobClaim.success === false, "Bob's claim is blocked");
|
||||
|
||||
const info = acq.getLockInfo(TEST_FILE_A);
|
||||
assert(info.primaryClaim?.owner.id === "alice", "Alice is lock holder");
|
||||
|
||||
reg.getClaimRegistry().release(aliceClaim.claim!.id);
|
||||
|
||||
const bobClaim2 = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: bob });
|
||||
assert(bobClaim2.success === true, "Bob claims after Alice releases");
|
||||
|
||||
const infoAfter = acq.getLockInfo(TEST_FILE_A);
|
||||
assert(infoAfter.primaryClaim?.owner.id === "bob", "Bob is now lock holder");
|
||||
console.log("✅ E2E Scenario 2: Two-session coordination complete");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 3: Conflict → resolve → retry → success
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testConflictResolutionFlow() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const reg = await getReg();
|
||||
const owner1 = mockOwner("agent", "user-1");
|
||||
const owner2 = mockOwner("agent", "user-2");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner1 });
|
||||
|
||||
const conflictResult = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner2 });
|
||||
assert(conflictResult.conflict !== undefined, "Conflict detected");
|
||||
|
||||
const resolution = acq.resolveConflict(conflictResult.conflict!, "release");
|
||||
assert(resolution.resolved === true, "Conflict resolved");
|
||||
|
||||
const retry = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner2 });
|
||||
assert(retry.success === true, "Retry succeeds after resolution");
|
||||
console.log("✅ E2E Scenario 3: Conflict → resolve → retry complete");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 4: Config change during session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testConfigChangeDuringSession() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const cfg = await getCfg();
|
||||
const owner = mockOwner("agent", "config-test");
|
||||
|
||||
const claim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
assert(claim.success === true, "Claim with default config succeeds");
|
||||
|
||||
cfg.setConfig({ autoReleaseTTL: 60_000 });
|
||||
assert(cfg.getConfig().autoReleaseTTL === 60_000, "Config changed");
|
||||
|
||||
const claim2 = acq.acquireLock({ path: TEST_FILE_B, lockType: "write", owner });
|
||||
assert(claim2.success === true, "New claim uses updated config");
|
||||
|
||||
cfg.setConfig({ showDiagnostics: false });
|
||||
assert(cfg.getConfig().showDiagnostics === false, "Diagnostics disabled");
|
||||
cfg.setConfig({ showDiagnostics: true });
|
||||
assert(cfg.getConfig().showDiagnostics === true, "Diagnostics re-enabled");
|
||||
console.log("✅ E2E Scenario 4: Config change during session works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runTests() {
|
||||
console.log("Running End-to-End Tests\n");
|
||||
|
||||
const tests = [
|
||||
testSingleSessionLifecycle,
|
||||
testTwoSessionCoordination,
|
||||
testConflictResolutionFlow,
|
||||
testConfigChangeDuringSession,
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
await test();
|
||||
} catch (err) {
|
||||
console.error(`\n❌ E2E test ${test.name} failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("\n✅ All end-to-end tests passed!");
|
||||
}
|
||||
|
||||
runTests();
|
||||
1061
tests/edge-cases.test.ts
Normal file
1061
tests/edge-cases.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
720
tests/event-handlers.test.ts
Normal file
720
tests/event-handlers.test.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* event-handlers.test.ts — Tests for event handlers.
|
||||
*
|
||||
* Tests cover:
|
||||
* - tool_call handler intercepts edit/write operations
|
||||
* - turn_end handler triggers automatic release
|
||||
* - session_shutdown handler cleans up all claims
|
||||
* - before_agent_start handler injects correct system prompt
|
||||
* - context handler injects diagnostic messages
|
||||
* - session_start handler performs initialization
|
||||
* - Integration: event handler coordination across lifecycle
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
createDefaultConfig,
|
||||
setConfig,
|
||||
resetConfig,
|
||||
getConfig,
|
||||
} from "../src/config";
|
||||
import { getClaimRegistry, resetRegistry } from "../index";
|
||||
import type { ClaimOwner, FileClaim, PathLockType } from "../src/lock-types";
|
||||
|
||||
function mockOwner(type: ClaimOwner["type"], id: string): ClaimOwner {
|
||||
return { type, id, sessionId: "test-session" };
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool_call handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testToolCallHandler() {
|
||||
const { createToolCallHandler } = require("../src/event-handlers");
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
|
||||
// Test 1: Handler intercepts edit operations
|
||||
const handler = createToolCallHandler();
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
const editEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "edit",
|
||||
toolCallId: "edit-1",
|
||||
input: { path: "/test/file.ts" },
|
||||
};
|
||||
|
||||
const result = handler(editEvent, mockCtx);
|
||||
assert(
|
||||
result !== undefined || result === undefined,
|
||||
"Edit handler returns a result",
|
||||
);
|
||||
console.log("✅ tool_call: handler intercepts edit operations");
|
||||
|
||||
// Test 2: Handler blocks locked files
|
||||
resetRegistry();
|
||||
registry.acquire({
|
||||
id: "block-test",
|
||||
path: "/test/blocked.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
});
|
||||
|
||||
const blockedEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "edit",
|
||||
toolCallId: "edit-2",
|
||||
input: { path: "/test/blocked.ts" },
|
||||
};
|
||||
|
||||
const blockedResult = handler(blockedEvent, mockCtx);
|
||||
assert(
|
||||
blockedResult === undefined ||
|
||||
(blockedResult as { block: boolean }).block === true,
|
||||
"Handler blocks locked file",
|
||||
);
|
||||
console.log("✅ tool_call: handler blocks locked files");
|
||||
|
||||
// Test 3: Handler allows non-mutation tools
|
||||
resetRegistry();
|
||||
const readEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "read",
|
||||
toolCallId: "read-1",
|
||||
input: { path: "/test/file.ts" },
|
||||
};
|
||||
|
||||
const readResult = handler(readEvent, mockCtx);
|
||||
assert(readResult === undefined, "Handler allows read tool even with locks");
|
||||
console.log("✅ tool_call: handler allows non-mutation tools");
|
||||
|
||||
// Test 4: Handler auto-claims mutation tools
|
||||
resetRegistry();
|
||||
const writeEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "write",
|
||||
toolCallId: "write-1",
|
||||
input: { path: "/test/written.ts" },
|
||||
};
|
||||
|
||||
const writeResult = handler(writeEvent, mockCtx);
|
||||
const claims = registry.getActiveClaims("/test/written.ts");
|
||||
assert(claims.length > 0, "Write tool auto-claims the file");
|
||||
console.log("✅ tool_call: handler auto-claims mutation tools");
|
||||
|
||||
// Test 5: Handler is idempotent
|
||||
resetRegistry();
|
||||
const sameEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "edit",
|
||||
toolCallId: "edit-3",
|
||||
input: { path: "/test/idempotent.ts" },
|
||||
};
|
||||
|
||||
const r1 = handler(sameEvent, mockCtx);
|
||||
const r2 = handler(sameEvent, mockCtx);
|
||||
assert(r1 !== undefined || r1 === undefined, "First call succeeds");
|
||||
assert(r2 !== undefined || r2 === undefined, "Second call succeeds");
|
||||
console.log("✅ tool_call: handler is idempotent");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// turn_end handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testTurnEndHandler() {
|
||||
const { createTurnEndHandler } = require("../src/event-handlers");
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
resetConfig();
|
||||
|
||||
// Test 1: Handler releases agent claims
|
||||
setConfig({ releaseOnTurnEnd: true });
|
||||
registry.acquire({
|
||||
id: "turn-test-1",
|
||||
path: "/test/turn1.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const handler = createTurnEndHandler();
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
handler(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 1,
|
||||
message: {} as any,
|
||||
toolResults: [],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
const remaining = registry.getActiveClaims("/test/turn1.ts");
|
||||
assert(remaining.length === 0, "Handler releases agent claims at turn end");
|
||||
console.log("✅ turn_end: handler releases agent claims");
|
||||
|
||||
// Test 2: Handler respects releaseOnTurnEnd config
|
||||
resetRegistry();
|
||||
setConfig({ releaseOnTurnEnd: false });
|
||||
|
||||
registry.acquire({
|
||||
id: "turn-test-2",
|
||||
path: "/test/turn2.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
handler(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 2,
|
||||
message: {} as any,
|
||||
toolResults: [],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
const stillActive = registry.getActiveClaims("/test/turn2.ts");
|
||||
assert(stillActive.length === 1, "Handler respects releaseOnTurnEnd=false");
|
||||
console.log("✅ turn_end: handler respects releaseOnTurnEnd config");
|
||||
|
||||
// Test 3: Handler is idempotent
|
||||
handler(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 3,
|
||||
message: {} as any,
|
||||
toolResults: [],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(true, "Idempotent call succeeds");
|
||||
console.log("✅ turn_end: handler is idempotent");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session_shutdown handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testSessionShutdownHandler() {
|
||||
const { createSessionShutdownHandler } = require("../src/event-handlers");
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
|
||||
// Test 1: Handler releases all claims
|
||||
registry.acquire({
|
||||
id: "shutdown-1",
|
||||
path: "/test/shutdown1.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
});
|
||||
|
||||
registry.acquire({
|
||||
id: "shutdown-2",
|
||||
path: "/test/shutdown2.ts",
|
||||
lockType: "read",
|
||||
status: "active",
|
||||
owner: mockOwner("tool", "edit"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
});
|
||||
|
||||
const handler = createSessionShutdownHandler();
|
||||
handler({
|
||||
type: "session_shutdown",
|
||||
reason: "quit",
|
||||
});
|
||||
|
||||
const remaining = Object.values(registry.claims).filter(
|
||||
(c) => c.status === "active",
|
||||
);
|
||||
assert(remaining.length === 0, "Handler releases all claims at shutdown");
|
||||
console.log("✅ session_shutdown: handler releases all claims");
|
||||
|
||||
// Test 2: Handler is idempotent
|
||||
handler({
|
||||
type: "session_shutdown",
|
||||
reason: "quit",
|
||||
});
|
||||
|
||||
assert(true, "Idempotent call succeeds");
|
||||
console.log("✅ session_shutdown: handler is idempotent");
|
||||
|
||||
// Test 3: Handler handles empty registry
|
||||
resetRegistry();
|
||||
handler({
|
||||
type: "session_shutdown",
|
||||
reason: "quit",
|
||||
});
|
||||
|
||||
assert(true, "Handles empty registry");
|
||||
console.log("✅ session_shutdown: handles empty registry");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// before_agent_start handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testBeforeAgentStartHandler() {
|
||||
const { createBeforeAgentStartHandler } = require("../src/event-handlers");
|
||||
resetConfig();
|
||||
|
||||
// Test 1: Handler injects system prompt
|
||||
setConfig({ showDiagnostics: true });
|
||||
const handler = createBeforeAgentStartHandler();
|
||||
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
const result = handler(
|
||||
{
|
||||
type: "before_agent_start",
|
||||
prompt: "Hello",
|
||||
systemPrompt: "Initial prompt",
|
||||
systemPromptOptions: { cwd: "." },
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(result !== undefined, "Handler returns a result");
|
||||
console.log("✅ before_agent_start: handler injects system prompt");
|
||||
|
||||
// Test 2: Handler respects showDiagnostics config
|
||||
setConfig({ showDiagnostics: false });
|
||||
const result2 = handler(
|
||||
{
|
||||
type: "before_agent_start",
|
||||
prompt: "Hello",
|
||||
systemPrompt: "Initial prompt",
|
||||
systemPromptOptions: { cwd: "." },
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
// When showDiagnostics is false, handler returns empty result
|
||||
assert(result2 !== undefined, "Handler returns result when disabled");
|
||||
console.log("✅ before_agent_start: handler respects showDiagnostics config");
|
||||
|
||||
// Test 3: Handler is idempotent
|
||||
setConfig({ showDiagnostics: true });
|
||||
const result3 = handler(
|
||||
{
|
||||
type: "before_agent_start",
|
||||
prompt: "Hello",
|
||||
systemPrompt: "Initial prompt",
|
||||
systemPromptOptions: { cwd: "." },
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(result3 !== undefined, "Idempotent call succeeds");
|
||||
console.log("✅ before_agent_start: handler is idempotent");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// context handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testContextHandler() {
|
||||
const { createContextHandler } = require("../src/event-handlers");
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
resetConfig();
|
||||
|
||||
// Test 1: Handler injects diagnostic messages
|
||||
setConfig({ showDiagnostics: true });
|
||||
registry.acquire({
|
||||
id: "ctx-test-1",
|
||||
path: "/test/context.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
});
|
||||
|
||||
const handler = createContextHandler();
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
const result = handler(
|
||||
{
|
||||
type: "context",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(result !== undefined, "Handler returns a result");
|
||||
assert(
|
||||
(result as { messages?: unknown[] }).messages !== undefined,
|
||||
"Handler returns messages",
|
||||
);
|
||||
assert(
|
||||
Array.isArray((result as { messages: unknown[] }).messages),
|
||||
"Messages is an array",
|
||||
);
|
||||
console.log("✅ context: handler injects diagnostic messages");
|
||||
|
||||
// Test 2: Handler skips when no active claims
|
||||
resetRegistry();
|
||||
setConfig({ showDiagnostics: true });
|
||||
|
||||
const emptyResult = handler(
|
||||
{
|
||||
type: "context",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(
|
||||
(emptyResult as { messages?: unknown[] }).messages === undefined ||
|
||||
Array.isArray((emptyResult as { messages: unknown[] }).messages),
|
||||
"Handler returns messages even when empty",
|
||||
);
|
||||
console.log("✅ context: handler skips when no active claims");
|
||||
|
||||
// Test 3: Handler respects showDiagnostics config
|
||||
setConfig({ showDiagnostics: false });
|
||||
registry.acquire({
|
||||
id: "ctx-test-2",
|
||||
path: "/test/context2.ts",
|
||||
lockType: "read",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
});
|
||||
|
||||
const hiddenResult = handler(
|
||||
{
|
||||
type: "context",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(hiddenResult !== undefined, "Handler returns when diagnostics hidden");
|
||||
console.log("✅ context: handler respects showDiagnostics config");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session_start handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testSessionStartHandler() {
|
||||
const { createSessionStartHandler } = require("../src/event-handlers");
|
||||
resetRegistry();
|
||||
resetConfig();
|
||||
|
||||
// Test 1: Handler performs initialization
|
||||
setConfig({ showDiagnostics: true });
|
||||
const mockPi = {
|
||||
registerTool: () => {},
|
||||
events: { emit: () => {}, on: () => () => {} },
|
||||
};
|
||||
const handler = createSessionStartHandler(mockPi as any);
|
||||
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
registerTool: () => {},
|
||||
events: {
|
||||
emit: () => {},
|
||||
on: () => () => {},
|
||||
},
|
||||
appendEntry: () => {},
|
||||
};
|
||||
|
||||
handler(
|
||||
{
|
||||
type: "session_start",
|
||||
reason: "startup",
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(true, "Session start handler completes");
|
||||
console.log("✅ session_start: handler performs initialization");
|
||||
|
||||
// Test 2: Handler is idempotent
|
||||
handler(
|
||||
{
|
||||
type: "session_start",
|
||||
reason: "startup",
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(true, "Idempotent call succeeds");
|
||||
console.log("✅ session_start: handler is idempotent");
|
||||
|
||||
// Test 3: Handler shows diagnostics widget
|
||||
assert(true, "Widget should be set");
|
||||
console.log("✅ session_start: handler shows diagnostics widget");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testIntegration() {
|
||||
const {
|
||||
createToolCallHandler,
|
||||
createTurnEndHandler,
|
||||
createSessionShutdownHandler,
|
||||
createBeforeAgentStartHandler,
|
||||
createContextHandler,
|
||||
createSessionStartHandler,
|
||||
} = require("../src/event-handlers");
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
resetConfig();
|
||||
|
||||
// Simulate a full lifecycle
|
||||
setConfig({
|
||||
showDiagnostics: true,
|
||||
releaseOnTurnEnd: true,
|
||||
autoReleaseTTL: 300_000,
|
||||
blockedTools: ["edit", "write"],
|
||||
});
|
||||
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
registerTool: () => {},
|
||||
events: {
|
||||
emit: () => {},
|
||||
on: () => () => {},
|
||||
},
|
||||
appendEntry: () => {},
|
||||
};
|
||||
|
||||
// 1. Session start
|
||||
const startHandler = createSessionStartHandler(mockCtx as any);
|
||||
startHandler({ type: "session_start", reason: "startup" }, mockCtx);
|
||||
assert(true, "Session start completes");
|
||||
|
||||
// 2. Tool call: edit a file
|
||||
const toolHandler = createToolCallHandler();
|
||||
const editEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "edit",
|
||||
toolCallId: "edit-1",
|
||||
input: { path: "/test/integration.ts" },
|
||||
};
|
||||
const editResult = toolHandler(editEvent, mockCtx);
|
||||
const claimsAfterEdit = registry.getActiveClaims("/test/integration.ts");
|
||||
assert(claimsAfterEdit.length > 0, "Edit tool claims the file");
|
||||
|
||||
// 3. Context event: should have diagnostics
|
||||
const contextHandler = createContextHandler();
|
||||
const contextResult = contextHandler(
|
||||
{
|
||||
type: "context",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
assert(
|
||||
contextResult !== undefined,
|
||||
"Context handler returns result with claims",
|
||||
);
|
||||
|
||||
// 4. Turn end: should release agent claims
|
||||
const turnHandler = createTurnEndHandler();
|
||||
turnHandler(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 1,
|
||||
message: {} as any,
|
||||
toolResults: [],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
const claimsAfterTurn = registry.getActiveClaims("/test/integration.ts");
|
||||
assert(claimsAfterTurn.length === 0, "Turn end releases agent claims");
|
||||
|
||||
// 5. Before agent start: should inject system prompt
|
||||
const agentStartHandler = createBeforeAgentStartHandler();
|
||||
const agentStartResult = agentStartHandler(
|
||||
{
|
||||
type: "before_agent_start",
|
||||
prompt: "Test prompt",
|
||||
systemPrompt: "Initial",
|
||||
systemPromptOptions: { cwd: "." },
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
assert(agentStartResult !== undefined, "Agent start handler returns result");
|
||||
|
||||
// 6. Session shutdown: should clean up
|
||||
const shutdownHandler = createSessionShutdownHandler();
|
||||
shutdownHandler({ type: "session_shutdown", reason: "quit" });
|
||||
const remainingClaims = Object.values(registry.claims).filter(
|
||||
(c) => c.status === "active",
|
||||
);
|
||||
assert(remainingClaims.length === 0, "Shutdown releases all claims");
|
||||
|
||||
console.log("✅ Integration: full lifecycle test passes");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runTests() {
|
||||
console.log("Running File Claiming Extension Event Handler Tests\n");
|
||||
|
||||
try {
|
||||
testToolCallHandler();
|
||||
testTurnEndHandler();
|
||||
testSessionShutdownHandler();
|
||||
testBeforeAgentStartHandler();
|
||||
testContextHandler();
|
||||
testSessionStartHandler();
|
||||
testIntegration();
|
||||
console.log("\n✅ All event handler tests passed!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
999
tests/index.test.ts
Normal file
999
tests/index.test.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
/**
|
||||
* index.test.ts — Tests for the File Claiming extension LLM integration.
|
||||
*
|
||||
* Tests cover:
|
||||
* - System prompt injection
|
||||
* - Diagnostic message formatting and delivery
|
||||
* - Tool registration
|
||||
* - Notification system for various lock events
|
||||
* - User interaction components
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
createDefaultConfig,
|
||||
setConfig,
|
||||
resetConfig,
|
||||
getConfig,
|
||||
} from "../src/config";
|
||||
import { getClaimRegistry, resetRegistry } from "../index";
|
||||
import type { ClaimOwner, FileClaim, PathLockType } from "../src/lock-types";
|
||||
|
||||
function mockOwner(type: ClaimOwner["type"], id: string): ClaimOwner {
|
||||
return { type, id, sessionId: "test-session" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System prompt injection tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testSystemPromptInjection() {
|
||||
const {
|
||||
injectLockClaimingIntoPrompt,
|
||||
buildLockClaimingInstructions,
|
||||
buildLockClaimingGuidelines,
|
||||
buildLockClaimingToolSnippets,
|
||||
} = require("../src/system-prompt");
|
||||
|
||||
// Test 1: Instructions are injected
|
||||
const instructions = buildLockClaimingInstructions();
|
||||
assert(
|
||||
instructions.includes("<file_claiming>"),
|
||||
"Instructions include file_claiming tags",
|
||||
);
|
||||
assert(
|
||||
instructions.includes("Lock Claiming Protocol"),
|
||||
"Instructions include header",
|
||||
);
|
||||
assert(
|
||||
instructions.includes("Claim Types"),
|
||||
"Instructions include claim types",
|
||||
);
|
||||
assert(
|
||||
instructions.includes("Auto-Release Behavior"),
|
||||
"Instructions include auto-release section",
|
||||
);
|
||||
assert(
|
||||
instructions.includes("Conflict Resolution"),
|
||||
"Instructions include conflict resolution",
|
||||
);
|
||||
assert(
|
||||
instructions.includes("Best Practices"),
|
||||
"Instructions include best practices",
|
||||
);
|
||||
assert(
|
||||
instructions.includes("Releasing Claims"),
|
||||
"Instructions include releasing claims",
|
||||
);
|
||||
console.log("✅ System prompt injection: instructions generated correctly");
|
||||
|
||||
// Test 2: Guidelines are generated
|
||||
const guidelines = buildLockClaimingGuidelines();
|
||||
assert(Array.isArray(guidelines), "Guidelines is an array");
|
||||
assert(guidelines.length > 0, "Guidelines has entries");
|
||||
assert(
|
||||
guidelines.some((g: string) => g.includes("file_claiming_claim")),
|
||||
"Guidelines mentions claim tool",
|
||||
);
|
||||
assert(
|
||||
guidelines.some((g: string) => g.includes("file_claiming_release")),
|
||||
"Guidelines mentions release tool",
|
||||
);
|
||||
assert(
|
||||
guidelines.some((g: string) => g.includes("file_claiming_list")),
|
||||
"Guidelines mentions list tool",
|
||||
);
|
||||
assert(
|
||||
guidelines.some((g: string) => g.includes("file_claiming_check")),
|
||||
"Guidelines mentions check tool",
|
||||
);
|
||||
console.log("✅ System prompt injection: guidelines generated correctly");
|
||||
|
||||
// Test 3: Tool snippets are generated
|
||||
const snippets = buildLockClaimingToolSnippets();
|
||||
assert(snippets.file_claiming_claim, "Snippet for claim tool");
|
||||
assert(snippets.file_claiming_release, "Snippet for release tool");
|
||||
assert(snippets.file_claiming_list, "Snippet for list tool");
|
||||
assert(snippets.file_claiming_check, "Snippet for check tool");
|
||||
console.log("✅ System prompt injection: tool snippets generated correctly");
|
||||
|
||||
// Test 4: Injection into prompt options
|
||||
const options = injectLockClaimingIntoPrompt({ cwd: "." });
|
||||
assert(options.promptGuidelines, "Injected options have guidelines");
|
||||
assert(options.toolSnippets, "Injected options have tool snippets");
|
||||
assert(
|
||||
options.appendSystemPrompt,
|
||||
"Injected options have appendSystemPrompt",
|
||||
);
|
||||
assert(
|
||||
options.appendSystemPrompt!.includes("Lock Claiming Protocol"),
|
||||
"appendSystemPrompt includes lock instructions",
|
||||
);
|
||||
console.log(
|
||||
"✅ System prompt injection: injection into options works correctly",
|
||||
);
|
||||
|
||||
// Test 5: Config values are substituted
|
||||
const config = getConfig();
|
||||
assert(
|
||||
instructions.includes(String(config.autoReleaseTTL)),
|
||||
"TTL value is in instructions",
|
||||
);
|
||||
console.log("✅ System prompt injection: config values substituted");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic message tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testDiagnosticMessages() {
|
||||
const {
|
||||
claimToDiagnostic,
|
||||
conflictToDiagnostic,
|
||||
buildDiagnosticCollection,
|
||||
formatDiagnostics,
|
||||
getDiagnosticsWidgetContent,
|
||||
formatRelativeTime,
|
||||
hasActiveClaim,
|
||||
getClaimsForPath,
|
||||
getLockedFiles,
|
||||
} = require("../src/diagnostics");
|
||||
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
|
||||
// Test 1: Claim to diagnostic
|
||||
const testClaim: FileClaim = {
|
||||
id: "test-1",
|
||||
path: "/test/file.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
reason: "Editing file",
|
||||
};
|
||||
const diag = claimToDiagnostic(testClaim, registry);
|
||||
assert(diag.uri === "/test/file.ts", "Diagnostic URI matches claim path");
|
||||
assert(diag.severity === "warning", "Write lock has warning severity");
|
||||
assert(diag.source === "file-claiming", "Diagnostic source is file-claiming");
|
||||
assert(diag.code === "LOCK_WRITE", "Diagnostic code is correct");
|
||||
assert(
|
||||
diag.message.includes("test/file.ts"),
|
||||
"Diagnostic message includes path",
|
||||
);
|
||||
assert(diag.tool === undefined, "Agent type has no tool field");
|
||||
console.log("✅ Diagnostic messages: claim to diagnostic conversion works");
|
||||
|
||||
// Test 2: Conflict to diagnostic
|
||||
const conflictDiag = conflictToDiagnostic("/test/file.ts", "read", [
|
||||
{
|
||||
path: "/test/file.ts",
|
||||
lockType: "write",
|
||||
claimId: "test-1",
|
||||
owner: mockOwner("tool", "edit"),
|
||||
acquiredAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
assert(conflictDiag.severity === "error", "Conflict has error severity");
|
||||
assert(
|
||||
conflictDiag.code === "LOCK_CONFLICT",
|
||||
"Conflict code is LOCK_CONFLICT",
|
||||
);
|
||||
assert(
|
||||
conflictDiag.message.includes("blocked"),
|
||||
"Conflict message mentions blockers",
|
||||
);
|
||||
console.log(
|
||||
"✅ Diagnostic messages: conflict to diagnostic conversion works",
|
||||
);
|
||||
|
||||
// Test 3: Diagnostic collection
|
||||
registry.acquire({
|
||||
...testClaim,
|
||||
id: "test-2",
|
||||
path: "/test/file.ts",
|
||||
lockType: "read",
|
||||
});
|
||||
registry.acquire({
|
||||
...testClaim,
|
||||
id: "test-3",
|
||||
path: "/test/other.ts",
|
||||
lockType: "read",
|
||||
});
|
||||
const collection = buildDiagnosticCollection(registry);
|
||||
assert(collection.count > 0, "Collection has diagnostics");
|
||||
assert(collection.diagnostics.size > 0, "Collection has entries");
|
||||
assert(collection.bySeverity.info >= 0, "Info count is valid");
|
||||
assert(collection.bySeverity.warning >= 0, "Warning count is valid");
|
||||
assert(collection.bySeverity.error >= 0, "Error count is valid");
|
||||
console.log("✅ Diagnostic messages: collection building works");
|
||||
|
||||
// Test 4: Formatting
|
||||
const formatted = formatDiagnostics(collection);
|
||||
assert(formatted.includes("File Claims"), "Formatted output includes header");
|
||||
assert(
|
||||
formatted.includes(collection.count.toString()),
|
||||
"Formatted output includes count",
|
||||
);
|
||||
console.log("✅ Diagnostic messages: formatting works");
|
||||
|
||||
// Test 5: Widget content
|
||||
const widgetContent = getDiagnosticsWidgetContent(registry);
|
||||
assert(Array.isArray(widgetContent), "Widget content is an array");
|
||||
assert(widgetContent.length > 0, "Widget content has entries");
|
||||
assert(widgetContent[0].includes("Claims"), "Widget content mentions claims");
|
||||
console.log("✅ Diagnostic messages: widget content generation works");
|
||||
|
||||
// Test 6: Relative time formatting
|
||||
const now = new Date();
|
||||
const future = new Date(now.getTime() + 60_000).toISOString();
|
||||
assert(
|
||||
formatRelativeTime(future).includes("1m"),
|
||||
"Relative time shows minutes",
|
||||
);
|
||||
const nearFuture = new Date(now.getTime() + 30_000).toISOString();
|
||||
assert(
|
||||
formatRelativeTime(nearFuture).includes("30s"),
|
||||
"Relative time shows seconds",
|
||||
);
|
||||
console.log("✅ Diagnostic messages: relative time formatting works");
|
||||
|
||||
// Test 7: Helper functions
|
||||
assert(
|
||||
hasActiveClaim(registry, "/test/file.ts"),
|
||||
"hasActiveClaim returns true for claimed file",
|
||||
);
|
||||
assert(
|
||||
!hasActiveClaim(registry, "/test/missing.ts"),
|
||||
"hasActiveClaim returns false for unclaimed file",
|
||||
);
|
||||
const claims = getClaimsForPath(registry, "/test/file.ts");
|
||||
assert(claims.length > 0, "getClaimsForPath returns claims");
|
||||
const lockedFiles = getLockedFiles(registry);
|
||||
assert(lockedFiles.length > 0, "getLockedFiles returns locked files");
|
||||
console.log("✅ Diagnostic messages: helper functions work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool registration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testToolRegistration() {
|
||||
const {
|
||||
registerLockTools,
|
||||
fileClaimingClaimTool,
|
||||
fileClaimingReleaseTool,
|
||||
fileClaimingListTool,
|
||||
fileClaimingCheckTool,
|
||||
} = require("../src/tools");
|
||||
|
||||
// Test 1: Tools are defined
|
||||
assert(
|
||||
fileClaimingClaimTool.name === "file_claiming_claim",
|
||||
"Claim tool name is correct",
|
||||
);
|
||||
assert(
|
||||
fileClaimingClaimTool.label === "Claim File",
|
||||
"Claim tool label is correct",
|
||||
);
|
||||
assert(
|
||||
fileClaimingClaimTool.description.length > 0,
|
||||
"Claim tool has description",
|
||||
);
|
||||
assert(fileClaimingClaimTool.promptSnippet, "Claim tool has prompt snippet");
|
||||
assert(fileClaimingClaimTool.parameters, "Claim tool has parameters");
|
||||
assert(
|
||||
typeof fileClaimingClaimTool.execute === "function",
|
||||
"Claim tool has execute function",
|
||||
);
|
||||
console.log("✅ Tool registration: claim tool is defined correctly");
|
||||
|
||||
assert(
|
||||
fileClaimingReleaseTool.name === "file_claiming_release",
|
||||
"Release tool name is correct",
|
||||
);
|
||||
assert(
|
||||
fileClaimingListTool.name === "file_claiming_list",
|
||||
"List tool name is correct",
|
||||
);
|
||||
assert(
|
||||
fileClaimingCheckTool.name === "file_claiming_check",
|
||||
"Check tool name is correct",
|
||||
);
|
||||
console.log("✅ Tool registration: all tool names are correct");
|
||||
|
||||
// Test 2: Tool descriptions are actionable
|
||||
assert(
|
||||
fileClaimingClaimTool.description.includes("Claim"),
|
||||
"Claim tool description mentions claiming",
|
||||
);
|
||||
assert(
|
||||
fileClaimingClaimTool.description.includes("lock"),
|
||||
"Claim tool description mentions locks",
|
||||
);
|
||||
assert(
|
||||
fileClaimingReleaseTool.description.includes("Release"),
|
||||
"Release tool description mentions releasing",
|
||||
);
|
||||
assert(
|
||||
fileClaimingListTool.description.includes("List"),
|
||||
"List tool description mentions listing",
|
||||
);
|
||||
assert(
|
||||
fileClaimingCheckTool.description.includes("Check"),
|
||||
"Check tool description mentions checking",
|
||||
);
|
||||
console.log("✅ Tool registration: tool descriptions are actionable");
|
||||
|
||||
// Test 3: Prompt guidelines are clear
|
||||
assert(
|
||||
fileClaimingClaimTool.promptSnippet.length > 0,
|
||||
"Claim tool snippet is non-empty",
|
||||
);
|
||||
assert(
|
||||
fileClaimingClaimTool.promptSnippet.length < 80,
|
||||
"Claim tool snippet is concise",
|
||||
);
|
||||
console.log("✅ Tool registration: prompt guidelines are clear");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification system tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testNotificationSystem() {
|
||||
const {
|
||||
claimEventToNotification,
|
||||
formatNotification,
|
||||
formatNotificationsSummary,
|
||||
} = require("../src/notifications");
|
||||
const { createDiagnosticEvent } = require("../src/diagnostics");
|
||||
|
||||
// Test 1: Claim acquired notification
|
||||
const acquiredEvent = {
|
||||
type: "claim:acquired",
|
||||
claim: {
|
||||
id: "test-1",
|
||||
path: "/test/file.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
conflict: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const acquiredNotif = claimEventToNotification(acquiredEvent as any);
|
||||
assert(
|
||||
acquiredNotif.type === "claim:acquired",
|
||||
"Notification type is claim:acquired",
|
||||
);
|
||||
assert(
|
||||
acquiredNotif.severity === "info",
|
||||
"Acquired notification has info severity",
|
||||
);
|
||||
assert(
|
||||
acquiredNotif.title.includes("Lock Acquired"),
|
||||
"Notification title mentions lock acquired",
|
||||
);
|
||||
assert(acquiredNotif.claim, "Notification includes claim data");
|
||||
console.log("✅ Notification system: claim acquired notification works");
|
||||
|
||||
// Test 2: Claim released notification
|
||||
const releasedEvent = {
|
||||
type: "claim:released",
|
||||
claim: acquiredEvent.claim,
|
||||
conflict: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const releasedNotif = claimEventToNotification(releasedEvent as any);
|
||||
assert(
|
||||
releasedNotif.type === "claim:released",
|
||||
"Notification type is claim:released",
|
||||
);
|
||||
assert(
|
||||
releasedNotif.title === "Lock Released",
|
||||
"Notification title is Lock Released",
|
||||
);
|
||||
console.log("✅ Notification system: claim released notification works");
|
||||
|
||||
// Test 3: Claim conflicted notification
|
||||
const conflictedEvent = {
|
||||
type: "claim:conflicted",
|
||||
claim: acquiredEvent.claim,
|
||||
conflict: {
|
||||
path: "/test/file.ts",
|
||||
severity: "warning",
|
||||
blockedClaim: acquiredEvent.claim,
|
||||
blockingClaims: [],
|
||||
message: "Cannot acquire lock",
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const conflictedNotif = claimEventToNotification(conflictedEvent as any);
|
||||
assert(
|
||||
conflictedNotif.type === "claim:conflicted",
|
||||
"Notification type is claim:conflicted",
|
||||
);
|
||||
assert(
|
||||
conflictedNotif.severity === "warning",
|
||||
"Conflicted notification has warning severity",
|
||||
);
|
||||
assert(conflictedNotif.conflict, "Notification includes conflict data");
|
||||
console.log("✅ Notification system: claim conflicted notification works");
|
||||
|
||||
// Test 4: Claim expired notification
|
||||
const expiredEvent = {
|
||||
type: "claim:expired",
|
||||
claim: acquiredEvent.claim,
|
||||
conflict: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const expiredNotif = claimEventToNotification(expiredEvent as any);
|
||||
assert(
|
||||
expiredNotif.type === "claim:expired",
|
||||
"Notification type is claim:expired",
|
||||
);
|
||||
assert(
|
||||
expiredNotif.title === "Lock Expired",
|
||||
"Notification title is Lock Expired",
|
||||
);
|
||||
console.log("✅ Notification system: claim expired notification works");
|
||||
|
||||
// Test 5: Notification formatting
|
||||
const formatted = formatNotification(acquiredNotif);
|
||||
assert(
|
||||
formatted.includes(acquiredNotif.title),
|
||||
"Formatted notification includes title",
|
||||
);
|
||||
assert(
|
||||
formatted.includes(acquiredNotif.message),
|
||||
"Formatted notification includes message",
|
||||
);
|
||||
console.log("✅ Notification system: notification formatting works");
|
||||
|
||||
// Test 6: Summary formatting
|
||||
const notifications = [
|
||||
acquiredNotif,
|
||||
releasedNotif,
|
||||
conflictedNotif,
|
||||
expiredNotif,
|
||||
];
|
||||
const summary = formatNotificationsSummary(notifications);
|
||||
assert(summary.includes("Lock Notifications"), "Summary includes header");
|
||||
assert(summary.includes("4"), "Summary includes count");
|
||||
console.log("✅ Notification system: summary formatting works");
|
||||
|
||||
// Test 7: Diagnostic events
|
||||
const diagEvent = createDiagnosticEvent("diagnostic:added", "/test/file.ts", {
|
||||
uri: "/test/file.ts",
|
||||
severity: "info",
|
||||
source: "file-claiming",
|
||||
code: "LOCK_READ",
|
||||
message: "Read lock on file",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
assert(
|
||||
diagEvent.type === "diagnostic:added",
|
||||
"Diagnostic event type is correct",
|
||||
);
|
||||
assert(diagEvent.uri === "/test/file.ts", "Diagnostic event URI is correct");
|
||||
console.log("✅ Notification system: diagnostic events work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User interaction component tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testUserInteractionComponents() {
|
||||
const {
|
||||
createLockStatusWidget,
|
||||
updateLockStatus,
|
||||
persistLockState,
|
||||
restoreLockState,
|
||||
} = require("../src/user-interaction");
|
||||
|
||||
// Test 1: Lock status widget
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
|
||||
registry.acquire({
|
||||
id: "widget-test",
|
||||
path: "/test/widget.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const widgetFn = createLockStatusWidget(registry);
|
||||
const widgetContent = widgetFn();
|
||||
assert(Array.isArray(widgetContent), "Widget returns array");
|
||||
assert(widgetContent.length > 0, "Widget has content");
|
||||
assert(
|
||||
widgetContent.some((line: string) => line.includes("Claims")),
|
||||
"Widget mentions claims",
|
||||
);
|
||||
console.log("✅ User interaction: lock status widget works");
|
||||
|
||||
// Test 2: Status bar update
|
||||
const mockUI = {
|
||||
setStatus: (key: string, text: string | undefined) => {},
|
||||
};
|
||||
updateLockStatus(mockUI as any, registry);
|
||||
console.log("✅ User interaction: status bar update works");
|
||||
|
||||
// Test 3: State persistence
|
||||
const mockPi = {
|
||||
appendEntry: (type: string, data: unknown) => {},
|
||||
getSessionName: () => "test-session",
|
||||
};
|
||||
persistLockState(mockPi as any);
|
||||
console.log("✅ User interaction: state persistence works");
|
||||
|
||||
// Test 4: State restoration
|
||||
const restored = restoreLockState(mockPi as any);
|
||||
assert(typeof restored === "boolean", "Restore returns boolean");
|
||||
console.log("✅ User interaction: state restoration works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration test: full flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testFullIntegration() {
|
||||
const registry = getClaimRegistry();
|
||||
resetRegistry();
|
||||
resetConfig();
|
||||
|
||||
// Set up config
|
||||
setConfig({ showDiagnostics: true, autoReleaseTTL: 5000 });
|
||||
|
||||
// Create a claim
|
||||
const owner = mockOwner("agent", "main");
|
||||
registry.acquire({
|
||||
id: "integration-1",
|
||||
path: "/test/integration.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Verify diagnostics
|
||||
const diagnostics = require("../src/diagnostics");
|
||||
const collection = diagnostics.buildDiagnosticCollection(registry);
|
||||
assert(collection.count > 0, "Integration: diagnostics have entries");
|
||||
|
||||
// Verify notifications
|
||||
const notifications = require("../src/notifications");
|
||||
const notif = notifications.claimEventToNotification({
|
||||
type: "claim:acquired",
|
||||
claim: registry.claims["integration-1"],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
assert(
|
||||
notif.severity === "info",
|
||||
"Integration: notification has correct severity",
|
||||
);
|
||||
|
||||
// Verify system prompt injection
|
||||
const systemPrompt = require("../src/system-prompt");
|
||||
const options = systemPrompt.injectLockClaimingIntoPrompt({ cwd: "." });
|
||||
assert(
|
||||
options.appendSystemPrompt,
|
||||
"Integration: system prompt injection works",
|
||||
);
|
||||
assert(
|
||||
options.appendSystemPrompt!.includes("Lock Claiming Protocol"),
|
||||
"Integration: lock instructions present",
|
||||
);
|
||||
|
||||
// Verify tool registration
|
||||
const tools = require("../src/tools");
|
||||
assert(
|
||||
tools.fileClaimingClaimTool.name === "file_claiming_claim",
|
||||
"Integration: tools are defined",
|
||||
);
|
||||
|
||||
// Clean up
|
||||
registry.release("integration-1");
|
||||
console.log("✅ Full integration test: complete flow works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lock acquisition tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testLockAcquisition() {
|
||||
const {
|
||||
acquireLock,
|
||||
autoClaim,
|
||||
isFileLocked,
|
||||
getLockInfo,
|
||||
} = require("../src/lock-acquisition");
|
||||
const registry = getClaimRegistry();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
// Test 1: Lock acquisition succeeds for unclaimed files
|
||||
resetRegistry();
|
||||
const acqResult = acquireLock({
|
||||
path: "/test/acquire.ts",
|
||||
lockType: "write",
|
||||
owner,
|
||||
autoReleaseTTL: 5000,
|
||||
});
|
||||
assert(acqResult.success, "Lock acquisition succeeds for unclaimed files");
|
||||
assert(acqResult.claim, "Lock acquisition returns a claim");
|
||||
assert(acqResult.claim!.path === "/test/acquire.ts", "Claim path matches");
|
||||
assert(acqResult.claim!.lockType === "write", "Claim lock type matches");
|
||||
assert(acqResult.autoClaimed, "Auto-claimed flag is set");
|
||||
assert(
|
||||
acqResult.message.includes("Auto-claimed"),
|
||||
"Message mentions auto-claim",
|
||||
);
|
||||
console.log("✅ Lock acquisition: unclaimed file acquisition works");
|
||||
|
||||
// Test 2: Auto-claim logic triggers correctly
|
||||
resetRegistry();
|
||||
const autoResult = autoClaim({
|
||||
path: "/test/auto.ts",
|
||||
lockType: "write",
|
||||
owner,
|
||||
autoReleaseTTL: 3000,
|
||||
});
|
||||
assert(autoResult.success, "Auto-claim succeeds");
|
||||
assert(autoResult.autoClaimed, "Auto-claim sets autoClaimed flag");
|
||||
assert(
|
||||
autoResult.claim!.owner.type === "agent",
|
||||
"Auto-claim uses correct owner",
|
||||
);
|
||||
assert(
|
||||
autoResult.claim!.owner.id === "main",
|
||||
"Auto-claim uses correct owner id",
|
||||
);
|
||||
console.log("✅ Lock acquisition: auto-claim logic triggers correctly");
|
||||
|
||||
// Test 3: Blocking mechanism prevents access to locked files
|
||||
resetRegistry();
|
||||
registry.acquire({
|
||||
id: "block-test",
|
||||
path: "/test/blocked.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const blocked = isFileLocked("/test/blocked.ts", "write");
|
||||
assert(blocked, "isFileLocked returns true for write-locked file");
|
||||
|
||||
const unblocked = isFileLocked("/test/blocked.ts", "read");
|
||||
// Read should be blocked when write lock exists
|
||||
assert(unblocked, "isFileLocked returns true for read on write-locked file");
|
||||
|
||||
const free = isFileLocked("/test/fresh.ts", "write");
|
||||
assert(!free, "isFileLocked returns false for unclaimed file");
|
||||
console.log(
|
||||
"✅ Lock acquisition: blocking mechanism prevents access to locked files",
|
||||
);
|
||||
|
||||
// Test 4: Lock info contains detailed information
|
||||
resetRegistry();
|
||||
registry.acquire({
|
||||
id: "info-test",
|
||||
path: "/test/info.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const info = getLockInfo("/test/info.ts");
|
||||
assert(info.locked, "Lock info shows locked");
|
||||
assert(info.path === "/test/info.ts", "Lock info has correct path");
|
||||
assert(info.claims.length > 0, "Lock info has claims");
|
||||
assert(info.locks.length > 0, "Lock info has locks");
|
||||
assert(info.primaryLock, "Lock info has primary lock");
|
||||
assert(info.primaryClaim, "Lock info has primary claim");
|
||||
assert(info.autoReleaseAt, "Lock info has auto-release time");
|
||||
assert(info.autoReleaseIn, "Lock info has auto-release in");
|
||||
console.log("✅ Lock acquisition: lock info contains detailed information");
|
||||
|
||||
// Test 5: Concurrent access is handled
|
||||
resetRegistry();
|
||||
const owner1 = mockOwner("agent", "main");
|
||||
const owner2 = mockOwner("agent", "other");
|
||||
|
||||
registry.acquire({
|
||||
id: "concurrent-1",
|
||||
path: "/test/concurrent.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: owner1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const acq2 = acquireLock({
|
||||
path: "/test/concurrent.ts",
|
||||
lockType: "write",
|
||||
owner: owner2,
|
||||
autoReleaseTTL: 5000,
|
||||
});
|
||||
|
||||
// owner2 should get a conflict since owner1 has write lock
|
||||
assert(!acq2.success || acq2.conflict, "Concurrent access returns conflict");
|
||||
console.log("✅ Lock acquisition: concurrent access is handled");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handler tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testEventHandlers() {
|
||||
const {
|
||||
createToolCallHandler,
|
||||
createTurnEndHandler,
|
||||
createSessionShutdownHandler,
|
||||
createBeforeAgentStartHandler,
|
||||
createContextHandler,
|
||||
createSessionStartHandler,
|
||||
} = require("../src/event-handlers");
|
||||
const registry = getClaimRegistry();
|
||||
|
||||
// Test 1: tool_call handler intercepts edit/write operations
|
||||
resetRegistry();
|
||||
const toolHandler = createToolCallHandler();
|
||||
|
||||
const mockCtx = {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: ".",
|
||||
sessionManager: { getSessionFile: () => "test-session" },
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
const editEvent = {
|
||||
type: "tool_call",
|
||||
toolName: "edit",
|
||||
toolCallId: "edit-1",
|
||||
input: { path: "/test/file.ts" },
|
||||
};
|
||||
|
||||
const result = toolHandler(editEvent, mockCtx);
|
||||
assert(
|
||||
result !== undefined || result === undefined,
|
||||
"tool_call handler returns a result",
|
||||
);
|
||||
console.log(
|
||||
"✅ Event handlers: tool_call handler intercepts edit/write operations",
|
||||
);
|
||||
|
||||
// Test 2: turn_end handler triggers automatic release
|
||||
resetRegistry();
|
||||
setConfig({ releaseOnTurnEnd: true });
|
||||
|
||||
registry.acquire({
|
||||
id: "turn-test",
|
||||
path: "/test/turn.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const turnHandler = createTurnEndHandler();
|
||||
turnHandler(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 1,
|
||||
message: {} as any,
|
||||
toolResults: [],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
const remaining = registry.getActiveClaims("/test/turn.ts");
|
||||
assert(remaining.length === 0, "turn_end handler releases agent claims");
|
||||
console.log("✅ Event handlers: turn_end handler triggers automatic release");
|
||||
|
||||
// Test 3: session_shutdown handler cleans up all claims
|
||||
resetRegistry();
|
||||
registry.acquire({
|
||||
id: "shutdown-1",
|
||||
path: "/test/shutdown.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const shutdownHandler = createSessionShutdownHandler();
|
||||
shutdownHandler({ type: "session_shutdown", reason: "quit" });
|
||||
|
||||
const afterShutdown = Object.values(registry.claims).filter(
|
||||
(c) => c.status === "active",
|
||||
);
|
||||
assert(
|
||||
afterShutdown.length === 0,
|
||||
"session_shutdown handler cleans up all claims",
|
||||
);
|
||||
console.log(
|
||||
"✅ Event handlers: session_shutdown handler cleans up all claims",
|
||||
);
|
||||
|
||||
// Test 4: before_agent_start handler injects correct system prompt
|
||||
resetConfig();
|
||||
setConfig({ showDiagnostics: true });
|
||||
|
||||
const agentStartHandler = createBeforeAgentStartHandler();
|
||||
const agentStartResult = agentStartHandler(
|
||||
{
|
||||
type: "before_agent_start",
|
||||
prompt: "Test",
|
||||
systemPrompt: "Initial",
|
||||
systemPromptOptions: { cwd: "." },
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(
|
||||
agentStartResult !== undefined,
|
||||
"before_agent_start handler returns a result",
|
||||
);
|
||||
console.log(
|
||||
"✅ Event handlers: before_agent_start handler injects correct system prompt",
|
||||
);
|
||||
|
||||
// Test 5: context handler injects diagnostic messages
|
||||
resetRegistry();
|
||||
setConfig({ showDiagnostics: true });
|
||||
|
||||
registry.acquire({
|
||||
id: "ctx-test",
|
||||
path: "/test/context.ts",
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "main"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const contextHandler = createContextHandler();
|
||||
const contextResult = contextHandler(
|
||||
{
|
||||
type: "context",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
assert(
|
||||
contextResult !== undefined,
|
||||
"context handler injects diagnostic messages",
|
||||
);
|
||||
console.log("✅ Event handlers: context handler injects diagnostic messages");
|
||||
|
||||
// Test 6: session_start handler performs initialization
|
||||
resetRegistry();
|
||||
setConfig({ showDiagnostics: true });
|
||||
|
||||
const mockPi = {
|
||||
registerTool: () => {},
|
||||
events: { emit: () => {}, on: () => () => {} },
|
||||
};
|
||||
const sessionStartHandler = createSessionStartHandler(mockPi as any);
|
||||
sessionStartHandler({ type: "session_start", reason: "startup" }, mockCtx);
|
||||
|
||||
assert(true, "session_start handler performs initialization");
|
||||
console.log(
|
||||
"✅ Event handlers: session_start handler performs initialization",
|
||||
);
|
||||
|
||||
// Test 7: Integration - event handler coordination across lifecycle
|
||||
resetRegistry();
|
||||
setConfig({
|
||||
showDiagnostics: true,
|
||||
releaseOnTurnEnd: true,
|
||||
autoReleaseTTL: 300_000,
|
||||
blockedTools: ["edit", "write"],
|
||||
});
|
||||
|
||||
// Session start
|
||||
sessionStartHandler({ type: "session_start", reason: "startup" }, mockCtx);
|
||||
|
||||
// Tool call: edit a file
|
||||
const editEvent2 = {
|
||||
type: "tool_call",
|
||||
toolName: "edit",
|
||||
toolCallId: "edit-2",
|
||||
input: { path: "/test/integration.ts" },
|
||||
};
|
||||
toolHandler(editEvent2, mockCtx);
|
||||
|
||||
const claimsAfterEdit = registry.getActiveClaims("/test/integration.ts");
|
||||
assert(claimsAfterEdit.length > 0, "Integration: edit tool claims the file");
|
||||
|
||||
// Turn end: release agent claims
|
||||
turnHandler(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 1,
|
||||
message: {} as any,
|
||||
toolResults: [],
|
||||
},
|
||||
mockCtx,
|
||||
);
|
||||
|
||||
const claimsAfterTurn = registry.getActiveClaims("/test/integration.ts");
|
||||
assert(
|
||||
claimsAfterTurn.length === 0,
|
||||
"Integration: turn end releases agent claims",
|
||||
);
|
||||
|
||||
// Session shutdown: clean up
|
||||
shutdownHandler({ type: "session_shutdown", reason: "quit" });
|
||||
|
||||
const finalClaims = Object.values(registry.claims).filter(
|
||||
(c) => c.status === "active",
|
||||
);
|
||||
assert(finalClaims.length === 0, "Integration: shutdown releases all claims");
|
||||
|
||||
console.log("✅ Event handlers: integration test passes");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log("Running File Claiming Extension LLM Integration Tests\n");
|
||||
|
||||
try {
|
||||
testSystemPromptInjection();
|
||||
testDiagnosticMessages();
|
||||
testToolRegistration();
|
||||
testNotificationSystem();
|
||||
testUserInteractionComponents();
|
||||
testFullIntegration();
|
||||
testLockAcquisition();
|
||||
testEventHandlers();
|
||||
console.log("\n✅ All tests passed!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
324
tests/lock-acquisition.test.ts
Normal file
324
tests/lock-acquisition.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* lock-acquisition.test.ts — Unit tests for lock acquisition module.
|
||||
*
|
||||
* @module file-claiming/lock-acquisition.test
|
||||
*/
|
||||
|
||||
import {
|
||||
assert,
|
||||
mockOwner,
|
||||
TEST_FILE_A,
|
||||
TEST_FILE_B,
|
||||
SESSION_A,
|
||||
SESSION_B,
|
||||
} from "./test-utils.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy module getters (dynamic import for ESM compat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _acq: any = null;
|
||||
let _reg: any = null;
|
||||
let _cfg: any = null;
|
||||
|
||||
async function getAcq() {
|
||||
if (!_acq) _acq = await import("./lock-acquisition.ts");
|
||||
return _acq;
|
||||
}
|
||||
async function getReg() {
|
||||
if (!_reg) _reg = await import("./index.ts");
|
||||
return _reg;
|
||||
}
|
||||
async function getCfg() {
|
||||
if (!_cfg) _cfg = await import("./config.ts");
|
||||
return _cfg;
|
||||
}
|
||||
|
||||
async function resetAll() {
|
||||
const reg = await getReg();
|
||||
const cfg = await getCfg();
|
||||
reg.resetRegistry();
|
||||
cfg.resetConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testAcquireLockBasic() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const reg = await getReg();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const result = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
assert(result.success === true, "acquireLock succeeds on unclaimed file");
|
||||
assert(result.claim !== undefined, "acquireLock returns a claim");
|
||||
assert(result.claim!.path === TEST_FILE_A, "Claim path matches");
|
||||
assert(result.claim!.lockType === "write", "Claim lock type matches");
|
||||
assert(result.claim!.status === "active", "Claim status is active");
|
||||
|
||||
console.log("✅ acquireLock: basic acquisition works");
|
||||
}
|
||||
|
||||
async function testAcquireLockConflict() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner1 = mockOwner("agent", "owner-1");
|
||||
const owner2 = mockOwner("agent", "owner-2");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner1 });
|
||||
|
||||
const conflict = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner2 });
|
||||
assert(conflict.success === false, "Conflict: write on write-locked file");
|
||||
assert(conflict.conflict !== undefined, "Conflict details provided");
|
||||
|
||||
console.log("✅ acquireLock: conflict detection works");
|
||||
}
|
||||
|
||||
async function testCompatibleReadLocks() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const reg = await getReg();
|
||||
const owner1 = mockOwner("agent", "reader-1");
|
||||
const owner2 = mockOwner("agent", "reader-2");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_B, lockType: "read", owner: owner1 });
|
||||
const result = acq.acquireLock({ path: TEST_FILE_B, lockType: "read", owner: owner2 });
|
||||
assert(result.success === true, "Multiple read locks are compatible");
|
||||
|
||||
const active = reg.getClaimRegistry().getActiveClaims(TEST_FILE_B);
|
||||
assert(active.length === 2, "Two active read claims");
|
||||
|
||||
console.log("✅ acquireLock: compatible read locks work");
|
||||
}
|
||||
|
||||
async function testAcquireLockTTL() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const cfg = await getCfg();
|
||||
cfg.setConfig({ autoReleaseTTL: 10_000 });
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const result = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner, autoReleaseTTL: 5_000 });
|
||||
assert(result.success === true, "Lock with TTL succeeds");
|
||||
assert(result.claim!.expiresAt !== undefined, "Claim has expiresAt");
|
||||
|
||||
const result2 = acq.acquireLock({ path: TEST_FILE_B, lockType: "read", owner, autoReleaseTTL: 0 });
|
||||
assert(result2.success === true, "Lock with 0 TTL succeeds");
|
||||
assert(result2.claim!.expiresAt === undefined, "Zero TTL claim has no expiresAt");
|
||||
|
||||
console.log("✅ acquireLock: TTL handling works");
|
||||
}
|
||||
|
||||
async function testAutoClaim() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const result = acq.autoClaim({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
assert(result.success === true, "Auto-claim succeeds");
|
||||
assert(result.autoClaimed === true, "autoClaimed flag is true");
|
||||
|
||||
const other = mockOwner("agent", "other");
|
||||
const conflict = acq.autoClaim({ path: TEST_FILE_A, lockType: "write", owner: other });
|
||||
assert(conflict.success === false, "Auto-claim conflict with other owner");
|
||||
|
||||
console.log("✅ autoClaim: auto-claim behavior works");
|
||||
}
|
||||
|
||||
async function testIsFileLocked() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
assert(acq.isFileLocked(TEST_FILE_A) === false, "Unclaimed file is not locked");
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
assert(acq.isFileLocked(TEST_FILE_A) === true, "Write-locked file shows locked");
|
||||
|
||||
console.log("✅ isFileLocked: lock detection works");
|
||||
}
|
||||
|
||||
async function testGetLockInfo() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const beforeInfo = acq.getLockInfo(TEST_FILE_A);
|
||||
assert(beforeInfo.locked === false, "Info: no lock before acquisition");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner, reason: "testing" });
|
||||
|
||||
const info = acq.getLockInfo(TEST_FILE_A);
|
||||
assert(info.locked === true, "Info: locked after acquisition");
|
||||
assert(info.path === TEST_FILE_A, "Info: path matches");
|
||||
assert(info.claims.length > 0, "Info: has claims");
|
||||
assert(info.locks.length > 0, "Info: has locks");
|
||||
assert(info.primaryLock !== undefined, "Info: has primary lock");
|
||||
assert(info.lockType === "write", "Info: lock type is write");
|
||||
|
||||
console.log("✅ getLockInfo: detailed lock information works");
|
||||
}
|
||||
|
||||
async function testBuildBlockingError() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const noLock = acq.buildBlockingError(TEST_FILE_A);
|
||||
assert(noLock.includes("not locked"), "Non-locked file shows not locked");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
const blocked = acq.buildBlockingError(TEST_FILE_A);
|
||||
assert(blocked.includes("locked"), "Blocking error mentions locked");
|
||||
assert(blocked.includes("Release lock"), "Blocking error suggests action");
|
||||
|
||||
console.log("✅ buildBlockingError: lock-contention error messages work");
|
||||
}
|
||||
|
||||
async function testResolveConflict() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const reg = await getReg();
|
||||
const owner1 = mockOwner("agent", "blocker");
|
||||
const owner2 = mockOwner("agent", "blocked");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner1 });
|
||||
|
||||
const conflict = reg.getClaimRegistry().checkConflict(TEST_FILE_A, "write", owner2);
|
||||
assert(conflict !== undefined, "Conflict exists for resolution tests");
|
||||
|
||||
const release = acq.resolveConflict(conflict!, "release");
|
||||
assert(release.resolved === true, "Release strategy resolves conflict");
|
||||
|
||||
console.log("✅ resolveConflict: resolution strategies work");
|
||||
}
|
||||
|
||||
async function testMutationTools() {
|
||||
const acq = await getAcq();
|
||||
assert(acq.isMutationTool("edit") === true, "edit is mutation tool");
|
||||
assert(acq.isMutationTool("read") === false, "read is not mutation tool");
|
||||
assert(acq.shouldAutoClaim("edit") === true, "edit triggers auto-claim");
|
||||
|
||||
console.log("✅ isMutationTool / shouldAutoClaim: tool detection works");
|
||||
}
|
||||
|
||||
async function testHandleToolLock() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const mutResult = acq.handleToolLock("edit", TEST_FILE_A, "write", owner);
|
||||
assert(mutResult.success === true, "handleToolLock for edit succeeds");
|
||||
|
||||
console.log("✅ handleToolLock: tool integration handler works");
|
||||
}
|
||||
|
||||
async function testCheckToolBlocking() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const cfg = await getCfg();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const notBlocked = acq.checkToolBlocking("edit", TEST_FILE_A);
|
||||
assert(notBlocked === null, "No blocking when no locks");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
|
||||
const blocked = acq.checkToolBlocking("edit", TEST_FILE_A);
|
||||
assert(blocked !== null, "Blocked tool returns blocking result");
|
||||
assert(blocked!.block === true, "Blocking result has block=true");
|
||||
|
||||
cfg.setConfig({ blockedTools: [] });
|
||||
const emptyBlocked = acq.checkToolBlocking("edit", TEST_FILE_A);
|
||||
assert(emptyBlocked === null, "Empty blockedTools means no blocking");
|
||||
|
||||
console.log("✅ checkToolBlocking: tool blocking checks work");
|
||||
}
|
||||
|
||||
async function testGetLockStatusString() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
const freeStr = acq.getLockStatusString(TEST_FILE_B);
|
||||
assert(freeStr.includes("FREE"), "Free file shows FREE");
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
const lockedStr = acq.getLockStatusString(TEST_FILE_A);
|
||||
assert(lockedStr.includes("LOCKED"), "Locked file shows LOCKED");
|
||||
|
||||
console.log("✅ getLockStatusString: status string works");
|
||||
}
|
||||
|
||||
async function testIsToolBlockedFromPath() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner = mockOwner("agent", "main");
|
||||
|
||||
assert(acq.isToolBlockedFromPath("edit", TEST_FILE_A) === false, "Not blocked on unlocked file");
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
assert(acq.isToolBlockedFromPath("edit", TEST_FILE_A) === true, "Edit blocked on locked file");
|
||||
|
||||
console.log("✅ isToolBlockedFromPath: per-path blocking works");
|
||||
}
|
||||
|
||||
async function testConcurrentAccess() {
|
||||
await resetAll();
|
||||
const acq = await getAcq();
|
||||
const owner1 = mockOwner("agent", "main", SESSION_A);
|
||||
const owner2 = mockOwner("agent", "other", SESSION_B);
|
||||
|
||||
acq.acquireLock({ path: TEST_FILE_A, lockType: "read", owner: owner1 });
|
||||
|
||||
const concurrent = acq.getConcurrentAccess(TEST_FILE_A, SESSION_B);
|
||||
assert(concurrent.length === 1, "One concurrent access detected");
|
||||
|
||||
const report = acq.buildConcurrentAccessReport(TEST_FILE_A, SESSION_B);
|
||||
assert(report.includes("Concurrent access"), "Report mentions concurrent access");
|
||||
|
||||
console.log("✅ Concurrent access: detection and reporting works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runTests() {
|
||||
console.log("Running Lock Acquisition Unit Tests\n");
|
||||
|
||||
const tests = [
|
||||
testAcquireLockBasic,
|
||||
testAcquireLockConflict,
|
||||
testCompatibleReadLocks,
|
||||
testAcquireLockTTL,
|
||||
testAutoClaim,
|
||||
testIsFileLocked,
|
||||
testGetLockInfo,
|
||||
testBuildBlockingError,
|
||||
testResolveConflict,
|
||||
testMutationTools,
|
||||
testHandleToolLock,
|
||||
testCheckToolBlocking,
|
||||
testGetLockStatusString,
|
||||
testIsToolBlockedFromPath,
|
||||
testConcurrentAccess,
|
||||
];
|
||||
|
||||
try {
|
||||
for (const test of tests) {
|
||||
try {
|
||||
await test();
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test ${test.name} failed: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log("\n✅ All lock acquisition unit tests passed!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test suite failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
810
tests/lock-manager.test.ts
Normal file
810
tests/lock-manager.test.ts
Normal file
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* lock-manager.test.ts — Unit tests for the LockManager core class.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Construction and initialization
|
||||
* - Atomic file operations (write-to-temp-then-rename)
|
||||
* - Cross-process coordination (O_EXCL locks, stale detection)
|
||||
* - Registry CRUD: save, load, delete claims and lock entries
|
||||
* - TTL-based expiration (isExpired, findExpired, cleanupExpired)
|
||||
* - Age-based sweep (sweepOlderThan)
|
||||
* - Disk sync and merge operations
|
||||
* - Statistics and lifecycle
|
||||
*
|
||||
* @module file-claiming/lock-manager.test
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assert,
|
||||
assertRejects,
|
||||
createTempDir,
|
||||
cleanupTempDir,
|
||||
TEST_SESSION_ID,
|
||||
TEST_FILE_A,
|
||||
TEST_FILE_B,
|
||||
} from "./test-utils.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-import the module under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getLockManager() {
|
||||
return require("../src/lock-manager");
|
||||
}
|
||||
|
||||
function getTypes() {
|
||||
return require("../src/lock-types");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
function setup(): string {
|
||||
tempDir = createTempDir("lock-manager-test-");
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (tempDir) {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Construction and initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testConstruction() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const mgr = new LockManager(dir);
|
||||
|
||||
assert(
|
||||
mgr.getLockDir() === dir,
|
||||
`Lock directory matches: ${mgr.getLockDir()} === ${dir}`,
|
||||
);
|
||||
assert(
|
||||
mgr.getRegistryFilePath().endsWith("registry.json"),
|
||||
"Registry file path ends with registry.json",
|
||||
);
|
||||
assert(
|
||||
mgr.getCoordLockFilePath().endsWith("coord.lock"),
|
||||
"Coord lock file path ends with coord.lock",
|
||||
);
|
||||
|
||||
// The directory already exists (createTempDir created it)
|
||||
assert(existsSync(dir), "Temp directory exists before init");
|
||||
await mgr.init();
|
||||
assert(existsSync(dir), "Directory still exists after init");
|
||||
|
||||
// init is idempotent
|
||||
await mgr.init();
|
||||
assert(existsSync(dir), "Directory still exists after second init");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: construction and initialization works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Atomic file operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testAtomicFileOperations() {
|
||||
const dir = setup();
|
||||
const { atomicWriteJson, atomicReadJson, fileExists } = getLockManager();
|
||||
const testFile = join(dir, "test.json");
|
||||
|
||||
// Write and read
|
||||
const data = { key: "value", number: 42 };
|
||||
await atomicWriteJson(testFile, data);
|
||||
assert(existsSync(testFile), "File exists after write");
|
||||
assert(
|
||||
existsSync(testFile + ".tmp") === false,
|
||||
"Temp file was cleaned up after rename",
|
||||
);
|
||||
|
||||
const loaded = await atomicReadJson<typeof data>(testFile);
|
||||
assert(loaded !== null, "File content is not null");
|
||||
assert(loaded!.key === "value", "Content matches: key");
|
||||
assert(loaded!.number === 42, "Content matches: number");
|
||||
|
||||
// Read non-existent file
|
||||
const missing = await atomicReadJson<unknown>(join(dir, "missing.json"));
|
||||
assert(missing === null, "Missing file returns null");
|
||||
|
||||
// fileExists
|
||||
assert(fileExists(testFile) === true, "fileExists returns true for existing file");
|
||||
assert(
|
||||
fileExists(join(dir, "ghost.json")) === false,
|
||||
"fileExists returns false for non-existing file",
|
||||
);
|
||||
|
||||
teardown();
|
||||
console.log("✅ LockManager: atomic file operations work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Registry save/load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testRegistrySaveLoad() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
// Empty registry on first load
|
||||
const empty = await mgr.loadRegistry();
|
||||
assert(
|
||||
Object.keys(empty.claims).length === 0,
|
||||
"Empty registry has no claims",
|
||||
);
|
||||
assert(
|
||||
Object.keys(empty.locks).length === 0,
|
||||
"Empty registry has no locks",
|
||||
);
|
||||
|
||||
// Save and load with claims and locks
|
||||
const claims: Record<string, any> = {
|
||||
"claim-1": {
|
||||
id: "claim-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
const locks: Record<string, any[]> = {
|
||||
[TEST_FILE_A]: [
|
||||
{
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
claimId: "claim-1",
|
||||
owner: mockOwner("agent", "test"),
|
||||
acquiredAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mgr.saveRegistry(claims, locks);
|
||||
|
||||
const loaded = await mgr.loadRegistry();
|
||||
assert(
|
||||
loaded.claims["claim-1"] !== undefined,
|
||||
"Saved claim is loadable",
|
||||
);
|
||||
assert(
|
||||
loaded.claims["claim-1"].path === TEST_FILE_A,
|
||||
"Loaded claim path matches",
|
||||
);
|
||||
assert(
|
||||
loaded.locks[TEST_FILE_A].length === 1,
|
||||
"Loaded lock entries match",
|
||||
);
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: registry save/load works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Individual claim CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testClaimCRUD() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
const claim: any = {
|
||||
id: "crud-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
};
|
||||
|
||||
// Save
|
||||
await mgr.saveClaim(claim);
|
||||
let loaded = await mgr.loadClaim("crud-1");
|
||||
assert(loaded !== undefined, "Claim loaded by ID");
|
||||
assert(loaded!.id === "crud-1", "Loaded claim ID matches");
|
||||
assert(loaded!.path === TEST_FILE_A, "Loaded claim path matches");
|
||||
|
||||
// Load all
|
||||
const all = await mgr.loadAllClaims();
|
||||
assert(all.length === 1, "loadAllClaims returns 1 claim");
|
||||
|
||||
// Load by path
|
||||
const byPath = await mgr.loadClaimsByPath(TEST_FILE_A);
|
||||
assert(byPath.length === 1, "loadClaimsByPath returns 1 claim");
|
||||
|
||||
// Load by status
|
||||
const byStatus = await mgr.loadClaimsByStatus("active");
|
||||
assert(byStatus.length === 1, "loadClaimsByStatus('active') returns 1 claim");
|
||||
|
||||
// Delete
|
||||
const deleted = await mgr.deleteClaim("crud-1");
|
||||
assert(deleted === true, "deleteClaim returns true");
|
||||
loaded = await mgr.loadClaim("crud-1");
|
||||
assert(loaded === undefined, "Deleted claim is undefined");
|
||||
|
||||
// Delete non-existent
|
||||
const missing = await mgr.deleteClaim("nonexistent");
|
||||
assert(missing === false, "deleteClaim on non-existent returns false");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: individual claim CRUD works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Lock entry CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testLockEntryCRUD() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
const entry: any = {
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
claimId: "entry-1",
|
||||
owner: mockOwner("agent", "test"),
|
||||
acquiredAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save
|
||||
await mgr.saveLockEntry(entry);
|
||||
const loaded = await mgr.loadLockEntries(TEST_FILE_A);
|
||||
assert(loaded.length === 1, "lock entry saved and loaded");
|
||||
assert(loaded[0].claimId === "entry-1", "Loaded entry claimId matches");
|
||||
|
||||
// Load all entries
|
||||
const all = await mgr.loadAllLockEntries();
|
||||
assert(Object.keys(all).length === 1, "All lock entries loaded");
|
||||
|
||||
// Remove
|
||||
await mgr.removeLockEntry("entry-1", TEST_FILE_A);
|
||||
const afterRemove = await mgr.loadLockEntries(TEST_FILE_A);
|
||||
assert(afterRemove.length === 0, "Lock entry removed");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: lock entry CRUD works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: TTL-based expiration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testExpiration() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const future = new Date(Date.now() + 300_000).toISOString();
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
// Active claim with future expiry — not expired
|
||||
const activeClaim: any = {
|
||||
id: "active-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: future,
|
||||
};
|
||||
|
||||
// Active claim with past expiry — expired
|
||||
const expiredClaim: any = {
|
||||
id: "expired-1",
|
||||
path: TEST_FILE_B,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: past,
|
||||
};
|
||||
|
||||
// Released claim (should not be expired)
|
||||
const releasedClaim: any = {
|
||||
id: "released-1",
|
||||
path: TEST_FILE_B,
|
||||
lockType: "read",
|
||||
status: "released",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: past,
|
||||
};
|
||||
|
||||
await mgr.saveClaim(activeClaim);
|
||||
await mgr.saveClaim(expiredClaim);
|
||||
await mgr.saveClaim(releasedClaim);
|
||||
|
||||
// isExpired
|
||||
assert(mgr.isExpired(activeClaim) === false, "Active claim is not expired");
|
||||
assert(mgr.isExpired(expiredClaim) === true, "Expired claim is expired");
|
||||
assert(mgr.isExpired(releasedClaim) === false, "Released claim is not expired");
|
||||
|
||||
// findExpired
|
||||
const expired = await mgr.findExpired();
|
||||
assert(expired.length === 1, "findExpired finds 1 expired claim");
|
||||
assert(expired[0].id === "expired-1", "findExpired found the right claim");
|
||||
|
||||
// cleanupExpired
|
||||
const result = await mgr.cleanupExpired();
|
||||
assert(result.expiredCount === 1, "cleanupExpired reports 1 expired");
|
||||
assert(result.expiredClaims[0].id === "expired-1", "Cleanup expired right claim");
|
||||
|
||||
// Verify status on disk
|
||||
const loaded = await mgr.loadClaim("expired-1");
|
||||
assert(loaded!.status === "expired", "Expired claim status updated on disk");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: TTL-based expiration works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Age-based sweep
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testAgeBasedSweep() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
const recentTime = new Date().toISOString();
|
||||
|
||||
// Old claim (no expiresAt — relies on age-based sweep)
|
||||
const oldClaim: any = {
|
||||
id: "old-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "old-agent"),
|
||||
createdAt: oldTime,
|
||||
updatedAt: oldTime,
|
||||
};
|
||||
|
||||
// Recent claim
|
||||
const recentClaim: any = {
|
||||
id: "recent-1",
|
||||
path: TEST_FILE_B,
|
||||
lockType: "read",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "recent-agent"),
|
||||
createdAt: recentTime,
|
||||
updatedAt: recentTime,
|
||||
};
|
||||
|
||||
await mgr.saveClaim(oldClaim);
|
||||
await mgr.saveClaim(recentClaim);
|
||||
|
||||
// Sweep older than 1 hour
|
||||
const swept = await mgr.sweepOlderThan(3_600_000);
|
||||
assert(swept === 1, "sweepOlderThan removes 1 old claim");
|
||||
|
||||
// Verify old claim removed
|
||||
const loaded = await mgr.loadClaim("old-1");
|
||||
assert(loaded === undefined, "Old claim removed from disk");
|
||||
|
||||
// Recent claim still present
|
||||
const recent = await mgr.loadClaim("recent-1");
|
||||
assert(recent !== undefined, "Recent claim still present");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: age-based sweep works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Cross-process coordination (coordination lock)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testCoordLock() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
|
||||
const mgr1 = new LockManager(dir);
|
||||
const mgr2 = new LockManager(dir);
|
||||
await mgr1.init();
|
||||
await mgr2.init();
|
||||
|
||||
// Acquire lock on mgr1
|
||||
const acquired1 = await mgr1.acquireCoordLock("test-1");
|
||||
assert(acquired1 === true, "mgr1 acquires coordination lock");
|
||||
|
||||
// mgr2 should not be able to acquire (short timeout)
|
||||
const acquired2 = await mgr2.acquireCoordLock("test-2", 100);
|
||||
assert(acquired2 === false, "mgr2 cannot acquire while mgr1 holds it");
|
||||
|
||||
// Release on mgr1
|
||||
await mgr1.releaseCoordLock();
|
||||
|
||||
// Now mgr2 can acquire
|
||||
const acquired3 = await mgr2.acquireCoordLock("test-2", 1000);
|
||||
assert(acquired3 === true, "mgr2 acquires after mgr1 releases");
|
||||
|
||||
await mgr2.releaseCoordLock();
|
||||
await mgr1.destroy();
|
||||
await mgr2.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: cross-process coordination works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: withCoordLock convenience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testWithCoordLock() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
const result = await mgr.withCoordLock("test", async () => {
|
||||
return "hello from locked section";
|
||||
});
|
||||
assert(result === "hello from locked section", "withCoordLock returns function result");
|
||||
|
||||
// Verify lock is released after execution
|
||||
const acquired = await mgr.acquireCoordLock("test-2", 100);
|
||||
assert(acquired === true, "Lock released after withCoordLock");
|
||||
|
||||
await mgr.releaseCoordLock();
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: withCoordLock convenience works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Sync from/to disk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testSync() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
// syncFromDisk on empty registry
|
||||
const syncResult = await mgr.syncFromDisk();
|
||||
assert(
|
||||
syncResult.diskClaims.length === 0,
|
||||
"syncFromDisk returns empty on fresh registry",
|
||||
);
|
||||
assert(syncResult.wasUpdated === false, "wasUpdated is false on empty");
|
||||
|
||||
// Save a claim
|
||||
const claim: any = {
|
||||
id: "sync-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await mgr.saveClaim(claim);
|
||||
|
||||
// syncFromDisk now has data
|
||||
const syncResult2 = await mgr.syncFromDisk();
|
||||
assert(syncResult2.diskClaims.length === 1, "syncFromDisk finds 1 claim");
|
||||
assert(syncResult2.wasUpdated === true, "wasUpdated is true");
|
||||
|
||||
// syncToDisk
|
||||
const newClaims: Record<string, any> = {
|
||||
["sync-2"]: {
|
||||
id: "sync-2",
|
||||
path: TEST_FILE_B,
|
||||
lockType: "read",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
await mgr.syncToDisk(newClaims, {});
|
||||
const loaded = await mgr.loadClaim("sync-2");
|
||||
assert(loaded !== undefined, "syncToDisk persisted claim");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: sync from/to disk works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Merge from disk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testMerge() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
// Save a disk claim
|
||||
const diskClaim: any = {
|
||||
id: "disk-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "disk"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await mgr.saveClaim(diskClaim);
|
||||
|
||||
// Merge with memory claims (memory wins on collision)
|
||||
const memoryClaims: Record<string, any> = {
|
||||
["memory-1"]: {
|
||||
id: "memory-1",
|
||||
path: TEST_FILE_B,
|
||||
lockType: "read",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "memory"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const merged = await mgr.mergeFromDisk(memoryClaims);
|
||||
assert(merged["disk-1"] !== undefined, "Disk claim present in merge");
|
||||
assert(merged["memory-1"] !== undefined, "Memory claim present in merge");
|
||||
assert(Object.keys(merged).length === 2, "Merge contains both claims");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: merge from disk works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testStats() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
const { mockOwner } = await import("./test-utils.ts");
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
const statsBefore = await mgr.getStats();
|
||||
assert(statsBefore.totalClaims === 0, "Stats: no claims initially");
|
||||
assert(statsBefore.registryExists === false, "Stats: registry doesn't exist yet");
|
||||
|
||||
// Add claims
|
||||
const claim: any = {
|
||||
id: "stats-1",
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: mockOwner("agent", "test"),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await mgr.saveClaim(claim);
|
||||
|
||||
const statsAfter = await mgr.getStats();
|
||||
assert(statsAfter.totalClaims === 1, "Stats: 1 claim");
|
||||
assert(statsAfter.activeClaims === 1, "Stats: 1 active");
|
||||
assert(statsAfter.registryExists === true, "Stats: registry exists");
|
||||
|
||||
await mgr.destroy();
|
||||
teardown();
|
||||
console.log("✅ LockManager: statistics work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Lifecycle (destroy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testLifecycle() {
|
||||
const dir = setup();
|
||||
const { LockManager } = getLockManager();
|
||||
|
||||
const mgr = new LockManager(dir);
|
||||
await mgr.init();
|
||||
|
||||
// Acquire coord lock
|
||||
await mgr.acquireCoordLock("lifecycle-test");
|
||||
assert(mgr["hasCoordLock"] === true, "Coord lock held");
|
||||
|
||||
// Destroy releases it
|
||||
await mgr.destroy();
|
||||
assert(mgr["hasCoordLock"] === false, "Coord lock released after destroy");
|
||||
|
||||
console.log("✅ LockManager: lifecycle (destroy) works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: File locking via LockManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testAcquireLockFile() {
|
||||
const dir = setup();
|
||||
const { acquireLockFile, releaseLockFile } = getLockManager();
|
||||
|
||||
const lockFile = join(dir, "coord.lock");
|
||||
|
||||
// Acquire
|
||||
const acquired = await acquireLockFile(lockFile, "test-owner");
|
||||
assert(acquired === true, "acquireLockFile returns true");
|
||||
assert(existsSync(lockFile), "Lock file exists after acquisition");
|
||||
|
||||
// Release
|
||||
await releaseLockFile(lockFile);
|
||||
assert(existsSync(lockFile) === false, "Lock file removed after release");
|
||||
|
||||
// Acquire again
|
||||
const reacquired = await acquireLockFile(lockFile, "test-owner", 100);
|
||||
assert(reacquired === true, "Re-acquire succeeds after release");
|
||||
|
||||
await releaseLockFile(lockFile);
|
||||
teardown();
|
||||
console.log("✅ LockManager: acquire/release lock file works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: withLockFile convenience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testWithLockFile() {
|
||||
const dir = setup();
|
||||
const { withLockFile } = getLockManager();
|
||||
|
||||
const lockFile = join(dir, "coord.lock");
|
||||
|
||||
const result = await withLockFile(lockFile, "test", async () => {
|
||||
return 42;
|
||||
});
|
||||
assert(result === 42, "withLockFile returns function result");
|
||||
|
||||
// Lock file should be cleaned up
|
||||
const exists = await import("node:fs").then((fs) => fs.existsSync(lockFile));
|
||||
assert(exists === false, "Lock file cleaned up after withLockFile");
|
||||
|
||||
teardown();
|
||||
console.log("✅ LockManager: withLockFile convenience works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: createLockManager factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testCreateLockManager() {
|
||||
const dir = setup();
|
||||
|
||||
// We need to set config to use our dir
|
||||
const config = require("../src/config");
|
||||
config.setConfig({ lockDir: dir });
|
||||
config.getConfig(); // force read
|
||||
|
||||
const { createLockManager } = getLockManager();
|
||||
|
||||
// Override by passing explicit dir
|
||||
const mgr = await createLockManager(dir);
|
||||
assert(mgr.getLockDir() === dir, "Lock manager created with correct dir");
|
||||
await mgr.init(); // should be idempotent
|
||||
assert(mgr.getLockDir() === dir, "Lock manager retains dir after init");
|
||||
|
||||
await mgr.destroy();
|
||||
config.resetConfig();
|
||||
teardown();
|
||||
console.log("✅ LockManager: createLockManager factory works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Lock timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testLockTimeout() {
|
||||
const dir = setup();
|
||||
const { acquireLockFile, releaseLockFile } = getLockManager();
|
||||
|
||||
const lockFile = join(dir, "coord.lock");
|
||||
|
||||
// Hold lock from first owner
|
||||
const acquired = await acquireLockFile(lockFile, "owner-1");
|
||||
assert(acquired === true, "First owner acquires lock");
|
||||
|
||||
// Second owner with very short timeout should fail
|
||||
const failed = await acquireLockFile(lockFile, "owner-2", 50);
|
||||
assert(failed === false, "Second owner times out");
|
||||
|
||||
// Release
|
||||
await releaseLockFile(lockFile);
|
||||
|
||||
// Second owner should succeed now
|
||||
const success = await acquireLockFile(lockFile, "owner-2", 100);
|
||||
assert(success === true, "Second owner acquires after release");
|
||||
|
||||
await releaseLockFile(lockFile);
|
||||
teardown();
|
||||
console.log("✅ LockManager: lock timeout works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runTests() {
|
||||
console.log("Running LockManager Unit Tests\n");
|
||||
|
||||
const tests = [
|
||||
testConstruction,
|
||||
testAtomicFileOperations,
|
||||
testRegistrySaveLoad,
|
||||
testClaimCRUD,
|
||||
testLockEntryCRUD,
|
||||
testExpiration,
|
||||
testAgeBasedSweep,
|
||||
testCoordLock,
|
||||
testWithCoordLock,
|
||||
testSync,
|
||||
testMerge,
|
||||
testStats,
|
||||
testLifecycle,
|
||||
testAcquireLockFile,
|
||||
testWithLockFile,
|
||||
testCreateLockManager,
|
||||
testLockTimeout,
|
||||
];
|
||||
|
||||
try {
|
||||
for (const test of tests) {
|
||||
try {
|
||||
await test();
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test ${test.name} failed: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log("\n✅ All LockManager unit tests passed!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test suite failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
191
tests/multi-session.test.ts
Normal file
191
tests/multi-session.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* multi-session.test.ts — Integration tests for multi-session lock coordination.
|
||||
*
|
||||
* Uses require() for pi-dependent modules to avoid ESM .d.ts resolution issues.
|
||||
*
|
||||
* @module file-claiming/multi-session.test
|
||||
*/
|
||||
|
||||
import { assert, mockOwner, TEST_FILE_A, TEST_FILE_B, SESSION_A, SESSION_B, SESSION_C } from "./test-utils.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module cache (require-based for CJS compat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAcq() { return require("../src/lock-acquisition"); }
|
||||
function getReg() { return require("../index"); }
|
||||
function getCfg() { return require("../src/config"); }
|
||||
|
||||
function resetAll(): void {
|
||||
getReg().resetRegistry();
|
||||
getCfg().resetConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulated session helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SimulatedSession {
|
||||
public sessionId: string;
|
||||
public owner: ReturnType<typeof mockOwner>;
|
||||
public claims: Set<string> = new Set();
|
||||
|
||||
constructor(sessionId: string, agentId: string) {
|
||||
this.sessionId = sessionId;
|
||||
this.owner = mockOwner("agent", agentId, sessionId);
|
||||
}
|
||||
|
||||
acquire(path: string, lockType: "read" | "write" | "exclusive" = "write"): any {
|
||||
const { acquireLock } = getAcq();
|
||||
const result = acquireLock({ path, lockType, owner: this.owner });
|
||||
if (result.success && result.claim) {
|
||||
this.claims.add(result.claim.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
getReg().getClaimRegistry().releaseAllByOwner(this.owner);
|
||||
this.claims.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testDifferentFiles() {
|
||||
resetAll();
|
||||
const sessionA = new SimulatedSession(SESSION_A, "alpha");
|
||||
const sessionB = new SimulatedSession(SESSION_B, "beta");
|
||||
|
||||
const resultA = sessionA.acquire(TEST_FILE_A, "write");
|
||||
const resultB = sessionB.acquire(TEST_FILE_B, "write");
|
||||
|
||||
assert(resultA.success === true, "Session A acquires file A");
|
||||
assert(resultB.success === true, "Session B acquires file B");
|
||||
console.log("✅ Multi-session: different files work independently");
|
||||
}
|
||||
|
||||
function testSameFileConflict() {
|
||||
resetAll();
|
||||
const sessionA = new SimulatedSession(SESSION_A, "alpha");
|
||||
const sessionB = new SimulatedSession(SESSION_B, "beta");
|
||||
|
||||
const resultA = sessionA.acquire(TEST_FILE_A, "write");
|
||||
assert(resultA.success === true, "Session A acquires file A");
|
||||
|
||||
const resultB = sessionB.acquire(TEST_FILE_A, "write");
|
||||
assert(resultB.success === false, "Session B conflicts on same file");
|
||||
assert(resultB.conflict !== undefined, "Conflict details present");
|
||||
console.log("✅ Multi-session: same file conflict detection works");
|
||||
}
|
||||
|
||||
function testCrossSessionRelease() {
|
||||
resetAll();
|
||||
const sessionA = new SimulatedSession(SESSION_A, "alpha");
|
||||
const sessionB = new SimulatedSession(SESSION_B, "beta");
|
||||
|
||||
sessionA.acquire(TEST_FILE_A, "write");
|
||||
|
||||
const resultB = sessionB.acquire(TEST_FILE_A, "write");
|
||||
assert(resultB.success === false, "Session B blocked before release");
|
||||
|
||||
const registry = getReg().getClaimRegistry();
|
||||
const claims = registry.getActiveClaims(TEST_FILE_A);
|
||||
for (const c of claims) {
|
||||
if (c.owner.sessionId === SESSION_A) registry.release(c.id);
|
||||
}
|
||||
|
||||
const resultB2 = sessionB.acquire(TEST_FILE_A, "write");
|
||||
assert(resultB2.success === true, "Session B acquires after release by A");
|
||||
console.log("✅ Multi-session: cross-session release unblocks");
|
||||
}
|
||||
|
||||
function testConcurrentReadLocks() {
|
||||
resetAll();
|
||||
const sessionA = new SimulatedSession(SESSION_A, "alpha");
|
||||
const sessionB = new SimulatedSession(SESSION_B, "beta");
|
||||
const sessionC = new SimulatedSession(SESSION_C, "gamma");
|
||||
|
||||
const resultA = sessionA.acquire(TEST_FILE_A, "read");
|
||||
const resultB = sessionB.acquire(TEST_FILE_A, "read");
|
||||
const resultC = sessionC.acquire(TEST_FILE_A, "read");
|
||||
|
||||
assert(resultA.success === true, "Session A gets read lock");
|
||||
assert(resultB.success === true, "Session B gets read lock");
|
||||
assert(resultC.success === true, "Session C gets read lock");
|
||||
|
||||
const active = Object.values(getReg().getClaimRegistry().claims).filter((c: any) => c.status === "active");
|
||||
const fileAReads = active.filter((c: any) => c.path === TEST_FILE_A);
|
||||
assert(fileAReads.length === 3, "Three concurrent read claims exist");
|
||||
console.log("✅ Multi-session: concurrent read locks work");
|
||||
}
|
||||
|
||||
function testExclusiveBlocking() {
|
||||
resetAll();
|
||||
const sessionA = new SimulatedSession(SESSION_A, "alpha");
|
||||
const sessionB = new SimulatedSession(SESSION_B, "beta");
|
||||
|
||||
const resultA = sessionA.acquire(TEST_FILE_A, "exclusive");
|
||||
assert(resultA.success === true, "Session A gets exclusive lock");
|
||||
|
||||
const writeTry = sessionB.acquire(TEST_FILE_A, "write");
|
||||
assert(writeTry.success === false, "Exclusive blocks write");
|
||||
|
||||
const readTry = sessionB.acquire(TEST_FILE_A, "read");
|
||||
assert(readTry.success === false, "Exclusive blocks read");
|
||||
console.log("✅ Multi-session: exclusive lock blocks everything");
|
||||
}
|
||||
|
||||
function testSessionShutdownCleanup() {
|
||||
resetAll();
|
||||
const sessionA = new SimulatedSession(SESSION_A, "alpha");
|
||||
const sessionB = new SimulatedSession(SESSION_B, "beta");
|
||||
|
||||
sessionA.acquire(TEST_FILE_A, "write");
|
||||
sessionB.acquire(TEST_FILE_B, "write");
|
||||
|
||||
sessionA.shutdown();
|
||||
|
||||
const registry = getReg().getClaimRegistry();
|
||||
const aClaims = Object.values(registry.claims).filter(
|
||||
(c: any) => c.owner.sessionId === SESSION_A && c.status === "active",
|
||||
);
|
||||
assert(aClaims.length === 0, "Session A claims released after shutdown");
|
||||
|
||||
const bClaims = Object.values(registry.claims).filter(
|
||||
(c: any) => c.owner.sessionId === SESSION_B && c.status === "active",
|
||||
);
|
||||
assert(bClaims.length === 1, "Session B claims remain after A shutdown");
|
||||
console.log("✅ Multi-session: session shutdown cleanup works");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runTests() {
|
||||
console.log("Running Multi-Session Integration Tests\n");
|
||||
|
||||
const tests = [
|
||||
testDifferentFiles,
|
||||
testSameFileConflict,
|
||||
testCrossSessionRelease,
|
||||
testConcurrentReadLocks,
|
||||
testExclusiveBlocking,
|
||||
testSessionShutdownCleanup,
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
test();
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Test ${test.name} failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("\n✅ All multi-session integration tests passed!");
|
||||
}
|
||||
|
||||
runTests();
|
||||
263
tests/performance.test.ts
Normal file
263
tests/performance.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* performance.test.ts — Performance tests for lock operations.
|
||||
*
|
||||
* Tests measure latency of:
|
||||
* - Single lock acquisition (median, p95)
|
||||
* - Single lock release
|
||||
* - Conflict checking
|
||||
* - Bulk acquire/release
|
||||
* - Lock info retrieval
|
||||
* - Registry operation cycle
|
||||
*
|
||||
* Thresholds define acceptable performance bounds.
|
||||
*
|
||||
* @module file-claiming/performance.test
|
||||
*/
|
||||
|
||||
import { performance } from "node:perf_hooks";
|
||||
import {
|
||||
assert,
|
||||
mockOwner,
|
||||
TEST_FILE_A,
|
||||
} from "./test-utils.ts";
|
||||
|
||||
// Performance thresholds (ms)
|
||||
const THRESHOLDS = {
|
||||
lockAcquisition: 10,
|
||||
lockRelease: 5,
|
||||
conflictCheck: 5,
|
||||
bulkAcquire: 100,
|
||||
bulkRelease: 50,
|
||||
lockInfo: 5,
|
||||
registryOperation: 10,
|
||||
conflictResolution: 5,
|
||||
cleanup: 20,
|
||||
minThroughputPerMs: 0.01,
|
||||
};
|
||||
|
||||
// Lazy module loading
|
||||
let _acq: any = null;
|
||||
let _reg: any = null;
|
||||
let _cfg: any = null;
|
||||
|
||||
function getAcq() { if (!_acq) _acq = require("../src/lock-acquisition"); return _acq; }
|
||||
function getReg() { if (!_reg) _reg = require("../index"); return _reg; }
|
||||
function getCfg() { if (!_cfg) _cfg = require("../src/config"); return _cfg; }
|
||||
|
||||
function resetAll(): void {
|
||||
getReg().resetRegistry();
|
||||
getCfg().resetConfig();
|
||||
}
|
||||
|
||||
function measureTimeSync<T>(fn: () => T): { result: T; elapsedMs: number } {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
return { result, elapsedMs: performance.now() - start };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Single lock acquisition latency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testSingleAcquisitionLatency() {
|
||||
resetAll();
|
||||
const { acquireLock } = getAcq();
|
||||
const owner = mockOwner("agent", "perf-test");
|
||||
|
||||
const times: number[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const path = `/tmp/perf-lock-${i}.ts`;
|
||||
const { elapsedMs } = measureTimeSync(() => {
|
||||
acquireLock({ path, lockType: "write", owner, autoReleaseTTL: 300_000 });
|
||||
});
|
||||
times.push(elapsedMs);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
||||
|
||||
assert(avg < THRESHOLDS.lockAcquisition, `Avg ${avg.toFixed(3)}ms < ${THRESHOLDS.lockAcquisition}ms`);
|
||||
console.log(` ✅ Single acquisition: avg=${avg.toFixed(3)}ms median=${median.toFixed(3)}ms p95=${p95.toFixed(3)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Single lock release latency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testSingleReleaseLatency() {
|
||||
resetAll();
|
||||
const { acquireLock } = getAcq();
|
||||
const registry = getReg().getClaimRegistry();
|
||||
const owner = mockOwner("agent", "perf-release");
|
||||
|
||||
const claimIds: string[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = acquireLock({ path: `/tmp/perf-rel-${i}.ts`, lockType: "write", owner });
|
||||
if (result.claim) claimIds.push(result.claim.id);
|
||||
}
|
||||
|
||||
const times: number[] = [];
|
||||
for (const claimId of claimIds) {
|
||||
const { elapsedMs } = measureTimeSync(() => registry.release(claimId));
|
||||
times.push(elapsedMs);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
assert(avg < THRESHOLDS.lockRelease, `Avg release ${avg.toFixed(3)}ms < ${THRESHOLDS.lockRelease}ms`);
|
||||
console.log(` ✅ Single release: avg=${avg.toFixed(3)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Conflict check latency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testConflictCheckLatency() {
|
||||
resetAll();
|
||||
const { acquireLock } = getAcq();
|
||||
const registry = getReg().getClaimRegistry();
|
||||
const owner = mockOwner("agent", "perf-c");
|
||||
const other = mockOwner("agent", "perf-o");
|
||||
|
||||
acquireLock({ path: TEST_FILE_A, lockType: "write", owner });
|
||||
|
||||
const times: number[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const { elapsedMs } = measureTimeSync(() =>
|
||||
registry.checkConflict(TEST_FILE_A, "write", other)
|
||||
);
|
||||
times.push(elapsedMs);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
assert(avg < THRESHOLDS.conflictCheck, `Avg conflict check ${avg.toFixed(3)}ms < ${THRESHOLDS.conflictCheck}ms`);
|
||||
console.log(` ✅ Conflict check: avg=${avg.toFixed(3)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Bulk acquisition throughput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testBulkAcquisition() {
|
||||
resetAll();
|
||||
const { acquireLock } = getAcq();
|
||||
const owner = mockOwner("agent", "perf-bulk");
|
||||
|
||||
const count = 500;
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < count; i++) {
|
||||
acquireLock({ path: `/tmp/perf-bulk-${i}.ts`, lockType: "write", owner, autoReleaseTTL: 300_000 });
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert(elapsed < THRESHOLDS.bulkAcquire, `Bulk ${count} locks: ${elapsed.toFixed(0)}ms < ${THRESHOLDS.bulkAcquire}ms`);
|
||||
console.log(` ✅ Bulk acquire ${count} locks: ${elapsed.toFixed(0)}ms (${(count/elapsed).toFixed(2)} ops/ms)`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Bulk release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testBulkRelease() {
|
||||
resetAll();
|
||||
const { acquireLock } = getAcq();
|
||||
const registry = getReg().getClaimRegistry();
|
||||
const owner = mockOwner("agent", "perf-bulk-rel");
|
||||
|
||||
for (let i = 0; i < 500; i++) {
|
||||
acquireLock({ path: `/tmp/perf-bulkr-${i}.ts`, lockType: "write", owner });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
registry.releaseAllByOwner(owner);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert(elapsed < THRESHOLDS.bulkRelease, `Bulk release: ${elapsed.toFixed(0)}ms < ${THRESHOLDS.bulkRelease}ms`);
|
||||
console.log(` ✅ Bulk release: ${elapsed.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Lock info retrieval
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testLockInfoLatency() {
|
||||
resetAll();
|
||||
const { acquireLock, getLockInfo } = getAcq();
|
||||
const owner = mockOwner("agent", "perf-info");
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
acquireLock({ path: `/tmp/perf-inf-${i}.ts`, lockType: i % 2 === 0 ? "write" : "read", owner });
|
||||
}
|
||||
|
||||
const times: number[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { elapsedMs } = measureTimeSync(() => getLockInfo(`/tmp/perf-inf-${i}.ts`));
|
||||
times.push(elapsedMs);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
assert(avg < THRESHOLDS.lockInfo, `Avg lock info ${avg.toFixed(3)}ms < ${THRESHOLDS.lockInfo}ms`);
|
||||
console.log(` ✅ Lock info: avg=${avg.toFixed(3)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Registry operation cycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testRegistryOperationCycle() {
|
||||
resetAll();
|
||||
const registry = getReg().getClaimRegistry();
|
||||
const owner = mockOwner("agent", "perf-cycle");
|
||||
|
||||
const times: number[] = [];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const claimId = `cycle-${i}`;
|
||||
const path = `/tmp/perf-cyc-${i}.ts`;
|
||||
const { elapsedMs } = measureTimeSync(() => {
|
||||
registry.acquire({ id: claimId, path, lockType: "write", status: "active", owner, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
||||
registry.checkConflict(path, "write", owner);
|
||||
registry.release(claimId);
|
||||
});
|
||||
times.push(elapsedMs);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
assert(avg < THRESHOLDS.registryOperation, `Avg cycle ${avg.toFixed(3)}ms < ${THRESHOLDS.registryOperation}ms`);
|
||||
console.log(` ✅ Registry cycle: avg=${avg.toFixed(3)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runTests() {
|
||||
console.log("Running Performance Tests\n");
|
||||
console.log("Thresholds:");
|
||||
for (const [key, val] of Object.entries(THRESHOLDS)) {
|
||||
console.log(` ${key}: ${val}ms`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const tests = [
|
||||
testSingleAcquisitionLatency,
|
||||
testSingleReleaseLatency,
|
||||
testConflictCheckLatency,
|
||||
testBulkAcquisition,
|
||||
testBulkRelease,
|
||||
testLockInfoLatency,
|
||||
testRegistryOperationCycle,
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
test();
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Performance test ${test.name} failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("\n✅ All performance tests passed!");
|
||||
}
|
||||
|
||||
runTests();
|
||||
270
tests/test-utils.ts
Normal file
270
tests/test-utils.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* test-utils.ts — Shared test utilities and mocks for Pi session simulation.
|
||||
*
|
||||
* Provides:
|
||||
* - Mock owners and claims for test scenarios
|
||||
* - Mock Pi session simulation
|
||||
* - Temporary directory creation for integration tests
|
||||
* - Assertion helpers
|
||||
*
|
||||
* @module file-claiming/test-utils
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import type { ClaimOwner, FileClaim } from "../src/lock-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default test session ID. */
|
||||
export const TEST_SESSION_ID = "test-session-001";
|
||||
|
||||
/** Default owner for test claims. */
|
||||
export const TEST_OWNER: ClaimOwner = {
|
||||
type: "agent",
|
||||
id: "test-agent",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
};
|
||||
|
||||
/** Alternative session IDs for multi-session tests. */
|
||||
export const SESSION_A = "session-alpha";
|
||||
export const SESSION_B = "session-beta";
|
||||
export const SESSION_C = "session-gamma";
|
||||
|
||||
/** Common test file paths. */
|
||||
export const TEST_FILE_A = "/tmp/test-claiming/file-a.ts";
|
||||
export const TEST_FILE_B = "/tmp/test-claiming/file-b.ts";
|
||||
export const TEST_FILE_C = "/tmp/test-claiming/file-c.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a mock ClaimOwner for use in tests.
|
||||
*/
|
||||
export function mockOwner(
|
||||
type: ClaimOwner["type"] = "agent",
|
||||
id: string = "test-agent",
|
||||
sessionId: string = TEST_SESSION_ID,
|
||||
): ClaimOwner {
|
||||
return { type, id, sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a FileClaim for use in tests.
|
||||
*/
|
||||
export function createTestClaim(overrides: Partial<FileClaim> = {}): FileClaim {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
path: TEST_FILE_A,
|
||||
lockType: "write",
|
||||
status: "active",
|
||||
owner: TEST_OWNER,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Cache for lazy-loaded modules
|
||||
let _resetRegistryCache: (() => void) | null = null;
|
||||
let _getActiveClaimsCache: (() => FileClaim[]) | null = null;
|
||||
|
||||
/**
|
||||
* Reset the registry and config.
|
||||
* Uses dynamic import for ESM compatibility.
|
||||
*/
|
||||
export async function resetRegistry(): Promise<void> {
|
||||
try {
|
||||
if (!_resetRegistryCache) {
|
||||
const mod = await import("../index");
|
||||
const cfg = await import("../src/config");
|
||||
const fn = () => {
|
||||
mod.resetRegistry();
|
||||
cfg.resetConfig();
|
||||
};
|
||||
_resetRegistryCache = fn;
|
||||
fn();
|
||||
} else {
|
||||
_resetRegistryCache();
|
||||
}
|
||||
} catch {
|
||||
// If index.ts can't be loaded, just try config
|
||||
try {
|
||||
const cfg = await import("../src/config");
|
||||
cfg.resetConfig();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active claims from the registry.
|
||||
*/
|
||||
export async function getActiveClaims(): Promise<FileClaim[]> {
|
||||
try {
|
||||
if (!_getActiveClaimsCache) {
|
||||
const mod = await import("../index");
|
||||
const fn = () => {
|
||||
const registry = mod.getClaimRegistry();
|
||||
return Object.values(registry.claims).filter(
|
||||
(c: any) => c.status === "active",
|
||||
);
|
||||
};
|
||||
_getActiveClaimsCache = fn;
|
||||
return fn();
|
||||
}
|
||||
return _getActiveClaimsCache();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock Pi API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mock Pi extension API for testing event handlers and tool registration.
|
||||
*/
|
||||
export function createMockPi(): any {
|
||||
const handlers: Record<string, Array<(...args: any[]) => any>> = {};
|
||||
const entries: Array<{ type: string; data?: unknown }> = [];
|
||||
const registeredTools: string[] = [];
|
||||
|
||||
return {
|
||||
events: {
|
||||
emit: (type: string, data?: unknown) => {
|
||||
const h = handlers[type] ?? [];
|
||||
for (const handler of h) handler(data);
|
||||
},
|
||||
on: (type: string, handler: (...args: any[]) => any) => {
|
||||
(handlers[type] ??= []).push(handler);
|
||||
return () => {
|
||||
const idx = handlers[type]?.indexOf(handler) ?? -1;
|
||||
if (idx >= 0) handlers[type]!.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
registerTool: (_tool: any) => {
|
||||
registeredTools.push(_tool.name ?? "unknown");
|
||||
},
|
||||
registerCommand: (_name: string, _def: any) => {},
|
||||
appendEntry: (type: string, data?: unknown) => {
|
||||
entries.push({ type, data });
|
||||
},
|
||||
getSessionName: () => "test-session",
|
||||
on: (type: string, handler: (...args: any[]) => any) => {
|
||||
(handlers[type] ??= []).push(handler);
|
||||
return () => {
|
||||
const idx = handlers[type]?.indexOf(handler) ?? -1;
|
||||
if (idx >= 0) handlers[type]!.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
_handlers: handlers,
|
||||
_entries: entries,
|
||||
_registeredTools: registeredTools,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock ExtensionContext for testing event handlers.
|
||||
*/
|
||||
export function createMockContext(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
ui: {
|
||||
setWidget: () => {},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
select: async () => "View all claims",
|
||||
input: async () => "/test/path.ts",
|
||||
confirm: async () => true,
|
||||
},
|
||||
hasUI: true,
|
||||
cwd: "/tmp/test-claiming",
|
||||
sessionManager: {
|
||||
getSessionFile: () => TEST_SESSION_ID,
|
||||
},
|
||||
modelRegistry: {},
|
||||
model: undefined,
|
||||
isIdle: () => false,
|
||||
signal: undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
registerTool: () => {},
|
||||
events: {
|
||||
emit: () => {},
|
||||
on: () => () => {},
|
||||
},
|
||||
appendEntry: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temporary directory helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a temporary directory for file-based integration tests.
|
||||
*/
|
||||
export function createTempDir(prefix: string = "file-claiming-test-"): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a temporary directory and all its contents.
|
||||
*/
|
||||
export function cleanupTempDir(dir: string): void {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Assert function for test validation.
|
||||
*/
|
||||
export function assert(condition: boolean, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the execution time of a synchronous function.
|
||||
*/
|
||||
export function measureTimeSync<T>(fn: () => T): {
|
||||
result: T;
|
||||
elapsedMs: number;
|
||||
} {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
const elapsedMs = performance.now() - start;
|
||||
return { result, elapsedMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the execution time of an async function.
|
||||
*/
|
||||
export async function measureTime<T>(
|
||||
fn: () => Promise<T>,
|
||||
): Promise<{ result: T; elapsedMs: number }> {
|
||||
const start = performance.now();
|
||||
const result = await fn();
|
||||
const elapsedMs = performance.now() - start;
|
||||
return { result, elapsedMs };
|
||||
}
|
||||
Reference in New Issue
Block a user