Initial commit

This commit is contained in:
2026-06-19 12:46:02 -04:00
commit f64eeae96c
28 changed files with 11796 additions and 0 deletions

373
tests/config.test.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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();

View 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
View 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
View 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
View 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
View 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 };
}