@namzu/sdk 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/advisory/context.test.d.ts +16 -0
- package/dist/advisory/context.test.d.ts.map +1 -0
- package/dist/advisory/context.test.js +92 -0
- package/dist/advisory/context.test.js.map +1 -0
- package/dist/advisory/evaluator.test.d.ts +34 -0
- package/dist/advisory/evaluator.test.d.ts.map +1 -0
- package/dist/advisory/evaluator.test.js +172 -0
- package/dist/advisory/evaluator.test.js.map +1 -0
- package/dist/advisory/executor.test.d.ts +35 -0
- package/dist/advisory/executor.test.d.ts.map +1 -0
- package/dist/advisory/executor.test.js +233 -0
- package/dist/advisory/executor.test.js.map +1 -0
- package/dist/advisory/registry.test.d.ts +16 -0
- package/dist/advisory/registry.test.d.ts.map +1 -0
- package/dist/advisory/registry.test.js +62 -0
- package/dist/advisory/registry.test.js.map +1 -0
- package/dist/bridge/a2a/agent-card.test.d.ts +24 -0
- package/dist/bridge/a2a/agent-card.test.d.ts.map +1 -0
- package/dist/bridge/a2a/agent-card.test.js +118 -0
- package/dist/bridge/a2a/agent-card.test.js.map +1 -0
- package/dist/bridge/a2a/mapper.test.d.ts +29 -0
- package/dist/bridge/a2a/mapper.test.d.ts.map +1 -0
- package/dist/bridge/a2a/mapper.test.js +265 -0
- package/dist/bridge/a2a/mapper.test.js.map +1 -0
- package/dist/bridge/a2a/message.test.d.ts +20 -0
- package/dist/bridge/a2a/message.test.d.ts.map +1 -0
- package/dist/bridge/a2a/message.test.js +116 -0
- package/dist/bridge/a2a/message.test.js.map +1 -0
- package/dist/bridge/a2a/task.test.d.ts +29 -0
- package/dist/bridge/a2a/task.test.d.ts.map +1 -0
- package/dist/bridge/a2a/task.test.js +198 -0
- package/dist/bridge/a2a/task.test.js.map +1 -0
- package/dist/bridge/mcp/connector/adapter.test.d.ts +27 -0
- package/dist/bridge/mcp/connector/adapter.test.d.ts.map +1 -0
- package/dist/bridge/mcp/connector/adapter.test.js +203 -0
- package/dist/bridge/mcp/connector/adapter.test.js.map +1 -0
- package/dist/bridge/sse/mapper.test.d.ts +27 -0
- package/dist/bridge/sse/mapper.test.d.ts.map +1 -0
- package/dist/bridge/sse/mapper.test.js +271 -0
- package/dist/bridge/sse/mapper.test.js.map +1 -0
- package/dist/bridge/tools/connector/adapter.test.d.ts +28 -0
- package/dist/bridge/tools/connector/adapter.test.d.ts.map +1 -0
- package/dist/bridge/tools/connector/adapter.test.js +182 -0
- package/dist/bridge/tools/connector/adapter.test.js.map +1 -0
- package/dist/bridge/tools/connector/definitions.test.d.ts +23 -0
- package/dist/bridge/tools/connector/definitions.test.d.ts.map +1 -0
- package/dist/bridge/tools/connector/definitions.test.js +158 -0
- package/dist/bridge/tools/connector/definitions.test.js.map +1 -0
- package/dist/bridge/tools/connector/router.test.d.ts +21 -0
- package/dist/bridge/tools/connector/router.test.d.ts.map +1 -0
- package/dist/bridge/tools/connector/router.test.js +139 -0
- package/dist/bridge/tools/connector/router.test.js.map +1 -0
- package/dist/bus/breaker.test.d.ts +41 -0
- package/dist/bus/breaker.test.d.ts.map +1 -0
- package/dist/bus/breaker.test.js +242 -0
- package/dist/bus/breaker.test.js.map +1 -0
- package/dist/bus/index.test.d.ts +25 -0
- package/dist/bus/index.test.d.ts.map +1 -0
- package/dist/bus/index.test.js +151 -0
- package/dist/bus/index.test.js.map +1 -0
- package/dist/bus/lock.test.d.ts +44 -0
- package/dist/bus/lock.test.d.ts.map +1 -0
- package/dist/bus/lock.test.js +226 -0
- package/dist/bus/lock.test.js.map +1 -0
- package/dist/bus/ownership.test.d.ts +26 -0
- package/dist/bus/ownership.test.d.ts.map +1 -0
- package/dist/bus/ownership.test.js +205 -0
- package/dist/bus/ownership.test.js.map +1 -0
- package/dist/connector/BaseConnector.test.d.ts +21 -0
- package/dist/connector/BaseConnector.test.d.ts.map +1 -0
- package/dist/connector/BaseConnector.test.js +108 -0
- package/dist/connector/BaseConnector.test.js.map +1 -0
- package/dist/connector/builtins/http.test.d.ts +30 -0
- package/dist/connector/builtins/http.test.d.ts.map +1 -0
- package/dist/connector/builtins/http.test.js +232 -0
- package/dist/connector/builtins/http.test.js.map +1 -0
- package/dist/connector/builtins/webhook.test.d.ts +20 -0
- package/dist/connector/builtins/webhook.test.d.ts.map +1 -0
- package/dist/connector/builtins/webhook.test.js +113 -0
- package/dist/connector/builtins/webhook.test.js.map +1 -0
- package/dist/connector/execution/factory.test.d.ts +16 -0
- package/dist/connector/execution/factory.test.d.ts.map +1 -0
- package/dist/connector/execution/factory.test.js +64 -0
- package/dist/connector/execution/factory.test.js.map +1 -0
- package/dist/connector/execution/remote.test.d.ts +16 -0
- package/dist/connector/execution/remote.test.d.ts.map +1 -0
- package/dist/connector/execution/remote.test.js +53 -0
- package/dist/connector/execution/remote.test.js.map +1 -0
- package/dist/connector/mcp/adapter.test.d.ts +34 -0
- package/dist/connector/mcp/adapter.test.d.ts.map +1 -0
- package/dist/connector/mcp/adapter.test.js +199 -0
- package/dist/connector/mcp/adapter.test.js.map +1 -0
- package/dist/rag/chunking.test.d.ts +20 -0
- package/dist/rag/chunking.test.d.ts.map +1 -0
- package/dist/rag/chunking.test.js +92 -0
- package/dist/rag/chunking.test.js.map +1 -0
- package/dist/rag/context-assembler.test.d.ts +19 -0
- package/dist/rag/context-assembler.test.d.ts.map +1 -0
- package/dist/rag/context-assembler.test.js +98 -0
- package/dist/rag/context-assembler.test.js.map +1 -0
- package/dist/rag/embedding.test.d.ts +19 -0
- package/dist/rag/embedding.test.d.ts.map +1 -0
- package/dist/rag/embedding.test.js +115 -0
- package/dist/rag/embedding.test.js.map +1 -0
- package/dist/rag/ingestion.test.d.ts +22 -0
- package/dist/rag/ingestion.test.d.ts.map +1 -0
- package/dist/rag/ingestion.test.js +99 -0
- package/dist/rag/ingestion.test.js.map +1 -0
- package/dist/rag/knowledge-base.test.d.ts +17 -0
- package/dist/rag/knowledge-base.test.d.ts.map +1 -0
- package/dist/rag/knowledge-base.test.js +77 -0
- package/dist/rag/knowledge-base.test.js.map +1 -0
- package/dist/rag/rag-tool.test.d.ts +21 -0
- package/dist/rag/rag-tool.test.d.ts.map +1 -0
- package/dist/rag/rag-tool.test.js +149 -0
- package/dist/rag/rag-tool.test.js.map +1 -0
- package/dist/rag/retriever.test.d.ts +26 -0
- package/dist/rag/retriever.test.d.ts.map +1 -0
- package/dist/rag/retriever.test.js +180 -0
- package/dist/rag/retriever.test.js.map +1 -0
- package/dist/rag/vector-store.test.d.ts +38 -0
- package/dist/rag/vector-store.test.d.ts.map +1 -0
- package/dist/rag/vector-store.test.js +175 -0
- package/dist/rag/vector-store.test.js.map +1 -0
- package/dist/registry/ManagedRegistry.test.d.ts +21 -0
- package/dist/registry/ManagedRegistry.test.d.ts.map +1 -0
- package/dist/registry/ManagedRegistry.test.js +98 -0
- package/dist/registry/ManagedRegistry.test.js.map +1 -0
- package/dist/registry/Registry.test.d.ts +18 -0
- package/dist/registry/Registry.test.d.ts.map +1 -0
- package/dist/registry/Registry.test.js +79 -0
- package/dist/registry/Registry.test.js.map +1 -0
- package/dist/registry/agent/definitions.test.d.ts +15 -0
- package/dist/registry/agent/definitions.test.d.ts.map +1 -0
- package/dist/registry/agent/definitions.test.js +84 -0
- package/dist/registry/agent/definitions.test.js.map +1 -0
- package/dist/registry/connector/definitions.test.d.ts +13 -0
- package/dist/registry/connector/definitions.test.d.ts.map +1 -0
- package/dist/registry/connector/definitions.test.js +41 -0
- package/dist/registry/connector/definitions.test.js.map +1 -0
- package/dist/registry/connector/scoped.test.d.ts +21 -0
- package/dist/registry/connector/scoped.test.d.ts.map +1 -0
- package/dist/registry/connector/scoped.test.js +115 -0
- package/dist/registry/connector/scoped.test.js.map +1 -0
- package/dist/registry/plugin/index.test.d.ts +12 -0
- package/dist/registry/plugin/index.test.d.ts.map +1 -0
- package/dist/registry/plugin/index.test.js +69 -0
- package/dist/registry/plugin/index.test.js.map +1 -0
- package/dist/registry/tool/execute.test.d.ts +42 -0
- package/dist/registry/tool/execute.test.d.ts.map +1 -0
- package/dist/registry/tool/execute.test.js +281 -0
- package/dist/registry/tool/execute.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/advisory.test.d.ts +42 -0
- package/dist/runtime/query/iteration/phases/advisory.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/advisory.test.js +334 -0
- package/dist/runtime/query/iteration/phases/advisory.test.js.map +1 -0
- package/dist/test-setup.d.ts +22 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +23 -0
- package/dist/test-setup.js.map +1 -0
- package/dist/utils/logger.d.ts +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +5 -0
- package/dist/utils/logger.js.map +1 -1
- package/package.json +4 -1
- package/src/advisory/context.test.ts +109 -0
- package/src/advisory/evaluator.test.ts +192 -0
- package/src/advisory/executor.test.ts +272 -0
- package/src/advisory/registry.test.ts +75 -0
- package/src/bridge/a2a/agent-card.test.ts +140 -0
- package/src/bridge/a2a/mapper.test.ts +293 -0
- package/src/bridge/a2a/message.test.ts +138 -0
- package/src/bridge/a2a/task.test.ts +235 -0
- package/src/bridge/mcp/connector/adapter.test.ts +230 -0
- package/src/bridge/sse/mapper.test.ts +422 -0
- package/src/bridge/tools/connector/adapter.test.ts +224 -0
- package/src/bridge/tools/connector/definitions.test.ts +183 -0
- package/src/bridge/tools/connector/router.test.ts +159 -0
- package/src/bus/breaker.test.ts +274 -0
- package/src/bus/index.test.ts +183 -0
- package/src/bus/lock.test.ts +265 -0
- package/src/bus/ownership.test.ts +243 -0
- package/src/connector/BaseConnector.test.ts +130 -0
- package/src/connector/builtins/http.test.ts +290 -0
- package/src/connector/builtins/webhook.test.ts +138 -0
- package/src/connector/execution/factory.test.ts +75 -0
- package/src/connector/execution/remote.test.ts +63 -0
- package/src/connector/mcp/adapter.test.ts +249 -0
- package/src/rag/chunking.test.ts +107 -0
- package/src/rag/context-assembler.test.ts +114 -0
- package/src/rag/embedding.test.ts +130 -0
- package/src/rag/ingestion.test.ts +114 -0
- package/src/rag/knowledge-base.test.ts +106 -0
- package/src/rag/rag-tool.test.ts +167 -0
- package/src/rag/retriever.test.ts +210 -0
- package/src/rag/vector-store.test.ts +196 -0
- package/src/registry/ManagedRegistry.test.ts +118 -0
- package/src/registry/Registry.test.ts +91 -0
- package/src/registry/agent/definitions.test.ts +100 -0
- package/src/registry/connector/definitions.test.ts +51 -0
- package/src/registry/connector/scoped.test.ts +129 -0
- package/src/registry/plugin/index.test.ts +85 -0
- package/src/registry/tool/execute.test.ts +330 -0
- package/src/runtime/query/iteration/phases/advisory.test.ts +412 -0
- package/src/test-setup.ts +24 -0
- package/src/utils/logger.ts +6 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current-code invariants asserted (2026-04-21, ses_006 Phase 1):
|
|
3
|
+
*
|
|
4
|
+
* - `acquire(path, owner)` returns immediately when the file is
|
|
5
|
+
* unlocked; creates a lock with `lockId = lock_<uuid>`,
|
|
6
|
+
* `expiresAt = now + lockTimeoutMs`, emits `lock_acquired`.
|
|
7
|
+
* - `acquire` on a file already held by the SAME owner is idempotent
|
|
8
|
+
* — returns `{acquired: true, lock}` (the existing lock) and emits
|
|
9
|
+
* nothing.
|
|
10
|
+
* - `acquire` on a file held by a DIFFERENT owner emits `lock_denied`
|
|
11
|
+
* and then polls at `LOCK_ACQUIRE_POLL_INTERVAL_MS` (100ms) intervals
|
|
12
|
+
* until either the holder releases, the lock expires, or
|
|
13
|
+
* `acquireTimeoutMs` elapses. No FIFO queue; retries race.
|
|
14
|
+
* - When the holder releases before the deadline, the waiting acquire
|
|
15
|
+
* succeeds on its next poll.
|
|
16
|
+
* - When `acquireTimeoutMs` elapses, acquire returns
|
|
17
|
+
* `{acquired: false, holder, filePath}` — holder is the current
|
|
18
|
+
* lock holder's owner if the lock still exists, else empty string.
|
|
19
|
+
* - `maxLocksPerAgent` blocks further acquisitions by the same owner.
|
|
20
|
+
* The internal `tryAcquire` returns `{holder: owner}` (a
|
|
21
|
+
* "yourself" sentinel) but the public `acquire()` swallows that and
|
|
22
|
+
* keeps polling for `acquireTimeoutMs` (there's no short-circuit
|
|
23
|
+
* for cap-exceeded). When the deadline expires and no lock exists
|
|
24
|
+
* on the file, the final `holder` is `''` (empty `RunId`) because
|
|
25
|
+
* the fallback reads `this.locks.get(filePath)?.owner ?? ''`.
|
|
26
|
+
* - `release(path, owner)` deletes the lock + emits `lock_released`
|
|
27
|
+
* when the current lock's owner matches; cleans up the per-owner
|
|
28
|
+
* lock set (and prunes the set entry when it becomes empty).
|
|
29
|
+
* Returns false without emitting when no lock or owner mismatch.
|
|
30
|
+
* - `releaseAll(owner)` drops every lock owned by `owner`, emits
|
|
31
|
+
* `lock_released` per lock, returns the count, and prunes the
|
|
32
|
+
* per-owner set.
|
|
33
|
+
* - `isLocked(path)` + `getHolder(path)` auto-expire any stale lock
|
|
34
|
+
* they observe (and emit `lock_expired`) before returning the
|
|
35
|
+
* current state.
|
|
36
|
+
* - `expireStale()` sweeps every lock; expired ones are deleted and
|
|
37
|
+
* emit `lock_expired`; returns the count.
|
|
38
|
+
* - Re-acquiring a path after its lock expires succeeds and assigns
|
|
39
|
+
* a FRESH `lockId` (the old id is never reused).
|
|
40
|
+
* - Lock state is per-`RunId`; no tenant dimension (design.md §2.1
|
|
41
|
+
* aspirational).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
45
|
+
|
|
46
|
+
import type { AgentBusEvent } from '../types/bus/index.js'
|
|
47
|
+
import type { RunId } from '../types/ids/index.js'
|
|
48
|
+
import type { Logger } from '../utils/logger.js'
|
|
49
|
+
|
|
50
|
+
import { FileLockManager } from './lock.js'
|
|
51
|
+
|
|
52
|
+
function makeLogger(): Logger {
|
|
53
|
+
const stub = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
|
54
|
+
return { ...stub, child: vi.fn(() => ({ ...stub, child: vi.fn() })) } as unknown as Logger
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runId(n: number): RunId {
|
|
58
|
+
return `run_${n}` as RunId
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeManager(
|
|
62
|
+
overrides: Partial<{
|
|
63
|
+
lockTimeoutMs: number
|
|
64
|
+
acquireTimeoutMs: number
|
|
65
|
+
maxLocksPerAgent: number
|
|
66
|
+
}> = {},
|
|
67
|
+
) {
|
|
68
|
+
const events: AgentBusEvent[] = []
|
|
69
|
+
const mgr = new FileLockManager(makeLogger(), (e) => events.push(e), {
|
|
70
|
+
// short defaults keep acquire-retry tests under a second total
|
|
71
|
+
lockTimeoutMs: 60_000,
|
|
72
|
+
acquireTimeoutMs: 200,
|
|
73
|
+
maxLocksPerAgent: 10,
|
|
74
|
+
...overrides,
|
|
75
|
+
})
|
|
76
|
+
return { mgr, events }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('FileLockManager', () => {
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
vi.useRealTimers()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('acquire (happy path)', () => {
|
|
85
|
+
it('acquires an unheld lock immediately + emits lock_acquired', async () => {
|
|
86
|
+
const { mgr, events } = makeManager()
|
|
87
|
+
const before = Date.now()
|
|
88
|
+
const result = await mgr.acquire('/tmp/a.txt', runId(1))
|
|
89
|
+
expect(result.acquired).toBe(true)
|
|
90
|
+
if (result.acquired) {
|
|
91
|
+
expect(result.lock.owner).toBe(runId(1))
|
|
92
|
+
expect(result.lock.filePath).toBe('/tmp/a.txt')
|
|
93
|
+
expect(result.lock.lockId).toMatch(/^lock_[0-9a-f-]+$/)
|
|
94
|
+
expect(result.lock.acquiredAt).toBeGreaterThanOrEqual(before)
|
|
95
|
+
expect(result.lock.expiresAt).toBeGreaterThan(result.lock.acquiredAt)
|
|
96
|
+
}
|
|
97
|
+
const acquired = events.filter((e) => e.type === 'lock_acquired')
|
|
98
|
+
expect(acquired).toHaveLength(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('is idempotent for the same owner — returns the existing lock, emits nothing', async () => {
|
|
102
|
+
const { mgr, events } = makeManager()
|
|
103
|
+
const first = await mgr.acquire('/tmp/a.txt', runId(1))
|
|
104
|
+
events.length = 0
|
|
105
|
+
const second = await mgr.acquire('/tmp/a.txt', runId(1))
|
|
106
|
+
expect(second.acquired).toBe(true)
|
|
107
|
+
if (first.acquired && second.acquired) {
|
|
108
|
+
expect(second.lock.lockId).toBe(first.lock.lockId)
|
|
109
|
+
}
|
|
110
|
+
expect(events).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('acquire (contention)', () => {
|
|
115
|
+
it('emits lock_denied when another owner holds the lock and no release happens', async () => {
|
|
116
|
+
const { mgr, events } = makeManager({ acquireTimeoutMs: 120 })
|
|
117
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
118
|
+
events.length = 0
|
|
119
|
+
|
|
120
|
+
const result = await mgr.acquire('/tmp/a.txt', runId(2))
|
|
121
|
+
expect(result.acquired).toBe(false)
|
|
122
|
+
if (!result.acquired) {
|
|
123
|
+
expect(result.holder).toBe(runId(1))
|
|
124
|
+
expect(result.filePath).toBe('/tmp/a.txt')
|
|
125
|
+
}
|
|
126
|
+
expect(events.some((e) => e.type === 'lock_denied')).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('succeeds on a retry once the holder releases before the deadline', async () => {
|
|
130
|
+
const { mgr } = makeManager({ acquireTimeoutMs: 500 })
|
|
131
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
132
|
+
|
|
133
|
+
const contender = mgr.acquire('/tmp/a.txt', runId(2))
|
|
134
|
+
setTimeout(() => mgr.release('/tmp/a.txt', runId(1)), 120)
|
|
135
|
+
|
|
136
|
+
const result = await contender
|
|
137
|
+
expect(result.acquired).toBe(true)
|
|
138
|
+
if (result.acquired) expect(result.lock.owner).toBe(runId(2))
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('maxLocksPerAgent cap', () => {
|
|
143
|
+
it('denies a new acquisition when the owner is at cap — acquire polls to deadline then returns empty holder', async () => {
|
|
144
|
+
const { mgr } = makeManager({ maxLocksPerAgent: 2, acquireTimeoutMs: 60 })
|
|
145
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
146
|
+
await mgr.acquire('/tmp/b.txt', runId(1))
|
|
147
|
+
|
|
148
|
+
const over = await mgr.acquire('/tmp/c.txt', runId(1))
|
|
149
|
+
expect(over.acquired).toBe(false)
|
|
150
|
+
if (!over.acquired) {
|
|
151
|
+
// No lock exists on /tmp/c.txt, so the fallback holder is ''.
|
|
152
|
+
expect(over.holder).toBe('' as RunId)
|
|
153
|
+
expect(over.filePath).toBe('/tmp/c.txt')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('release', () => {
|
|
159
|
+
it('releases an owned lock + emits lock_released', async () => {
|
|
160
|
+
const { mgr, events } = makeManager()
|
|
161
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
162
|
+
events.length = 0
|
|
163
|
+
|
|
164
|
+
expect(mgr.release('/tmp/a.txt', runId(1))).toBe(true)
|
|
165
|
+
expect(mgr.isLocked('/tmp/a.txt')).toBe(false)
|
|
166
|
+
expect(events.some((e) => e.type === 'lock_released')).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns false + emits nothing when the caller is not the holder', async () => {
|
|
170
|
+
const { mgr, events } = makeManager()
|
|
171
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
172
|
+
events.length = 0
|
|
173
|
+
|
|
174
|
+
expect(mgr.release('/tmp/a.txt', runId(2))).toBe(false)
|
|
175
|
+
expect(mgr.isLocked('/tmp/a.txt')).toBe(true)
|
|
176
|
+
expect(events).toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('returns false when no lock exists', () => {
|
|
180
|
+
const { mgr, events } = makeManager()
|
|
181
|
+
expect(mgr.release('/tmp/never.txt', runId(1))).toBe(false)
|
|
182
|
+
expect(events).toEqual([])
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('releaseAll', () => {
|
|
187
|
+
it('drops every lock owned by the runId, emits one event per lock, returns count', async () => {
|
|
188
|
+
const { mgr, events } = makeManager()
|
|
189
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
190
|
+
await mgr.acquire('/tmp/b.txt', runId(1))
|
|
191
|
+
await mgr.acquire('/tmp/c.txt', runId(2))
|
|
192
|
+
events.length = 0
|
|
193
|
+
|
|
194
|
+
const count = mgr.releaseAll(runId(1))
|
|
195
|
+
expect(count).toBe(2)
|
|
196
|
+
expect(mgr.isLocked('/tmp/a.txt')).toBe(false)
|
|
197
|
+
expect(mgr.isLocked('/tmp/b.txt')).toBe(false)
|
|
198
|
+
expect(mgr.isLocked('/tmp/c.txt')).toBe(true)
|
|
199
|
+
expect(events.filter((e) => e.type === 'lock_released')).toHaveLength(2)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('returns 0 when the owner has no locks', () => {
|
|
203
|
+
const { mgr } = makeManager()
|
|
204
|
+
expect(mgr.releaseAll(runId(99))).toBe(0)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('expiry', () => {
|
|
209
|
+
it('expireStale drops expired locks + emits lock_expired per drop', async () => {
|
|
210
|
+
vi.useFakeTimers()
|
|
211
|
+
const { mgr, events } = makeManager({ lockTimeoutMs: 10_000 })
|
|
212
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
213
|
+
await mgr.acquire('/tmp/b.txt', runId(2))
|
|
214
|
+
events.length = 0
|
|
215
|
+
|
|
216
|
+
vi.advanceTimersByTime(10_001)
|
|
217
|
+
const expired = mgr.expireStale()
|
|
218
|
+
expect(expired).toBe(2)
|
|
219
|
+
expect(events.filter((e) => e.type === 'lock_expired')).toHaveLength(2)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('isLocked / getHolder auto-expire a stale lock before answering', async () => {
|
|
223
|
+
vi.useFakeTimers()
|
|
224
|
+
const { mgr, events } = makeManager({ lockTimeoutMs: 5_000 })
|
|
225
|
+
await mgr.acquire('/tmp/a.txt', runId(1))
|
|
226
|
+
|
|
227
|
+
vi.advanceTimersByTime(5_001)
|
|
228
|
+
events.length = 0
|
|
229
|
+
expect(mgr.isLocked('/tmp/a.txt')).toBe(false)
|
|
230
|
+
expect(events.some((e) => e.type === 'lock_expired')).toBe(true)
|
|
231
|
+
|
|
232
|
+
events.length = 0
|
|
233
|
+
expect(mgr.getHolder('/tmp/a.txt')).toBeUndefined()
|
|
234
|
+
// already expired by the previous call; no second emit
|
|
235
|
+
expect(events).toEqual([])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('a fresh acquire after expiry assigns a new lockId', async () => {
|
|
239
|
+
vi.useFakeTimers()
|
|
240
|
+
const { mgr } = makeManager({ lockTimeoutMs: 1_000 })
|
|
241
|
+
const first = await mgr.acquire('/tmp/a.txt', runId(1))
|
|
242
|
+
vi.advanceTimersByTime(1_001)
|
|
243
|
+
mgr.expireStale()
|
|
244
|
+
|
|
245
|
+
vi.useRealTimers()
|
|
246
|
+
const second = await mgr.acquire('/tmp/a.txt', runId(2))
|
|
247
|
+
expect(first.acquired && second.acquired).toBe(true)
|
|
248
|
+
if (first.acquired && second.acquired) {
|
|
249
|
+
expect(second.lock.lockId).not.toBe(first.lock.lockId)
|
|
250
|
+
expect(second.lock.owner).toBe(runId(2))
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('per-runId isolation', () => {
|
|
256
|
+
it('different runIds can hold locks on different files concurrently', async () => {
|
|
257
|
+
const { mgr } = makeManager()
|
|
258
|
+
const a = await mgr.acquire('/tmp/a.txt', runId(1))
|
|
259
|
+
const b = await mgr.acquire('/tmp/b.txt', runId(2))
|
|
260
|
+
expect(a.acquired && b.acquired).toBe(true)
|
|
261
|
+
expect(mgr.getHolder('/tmp/a.txt')).toBe(runId(1))
|
|
262
|
+
expect(mgr.getHolder('/tmp/b.txt')).toBe(runId(2))
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current-code invariants asserted (2026-04-21, ses_006 Phase 1):
|
|
3
|
+
*
|
|
4
|
+
* - `claim(path, owner)` on an unowned file creates ownership, emits
|
|
5
|
+
* `ownership_claimed`, returns `{claimed: true, ownership}`.
|
|
6
|
+
* - `claim(path, owner)` by the same owner is idempotent — returns
|
|
7
|
+
* `{claimed: true, ownership}` WITHOUT re-emitting.
|
|
8
|
+
* - `claim(path, owner)` by a different owner is denied — emits
|
|
9
|
+
* `ownership_denied`, returns `{claimed: false, currentOwner, filePath}`.
|
|
10
|
+
* - `release(path, owner)` on the current owner deletes + emits; returns
|
|
11
|
+
* true. On mismatch or missing entry: returns false; no emit.
|
|
12
|
+
* - `transfer(path, from, to)` requires the current owner to equal
|
|
13
|
+
* `from`. Success replaces the entry with a new `claimedAt`, emits
|
|
14
|
+
* `ownership_transferred`; no intervening `ownership_released` or
|
|
15
|
+
* `ownership_claimed` events. Failure returns false; no emit.
|
|
16
|
+
* - `releaseAll(owner)` sweeps every ownership for `owner`, emits
|
|
17
|
+
* `ownership_released` per entry, returns the count. Other owners'
|
|
18
|
+
* entries are untouched.
|
|
19
|
+
* - File paths are normalised via `path.resolve` before keying —
|
|
20
|
+
* `./foo/bar` and the absolute resolution of the same path collide
|
|
21
|
+
* into one ownership slot.
|
|
22
|
+
* - Ownership is not per-tenant; only per-`RunId`. There is no tenant
|
|
23
|
+
* isolation at this layer (design.md §2.1 aspirational; see §2.7).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
28
|
+
|
|
29
|
+
import type { AgentBusEvent } from '../types/bus/index.js'
|
|
30
|
+
import type { RunId } from '../types/ids/index.js'
|
|
31
|
+
import type { Logger } from '../utils/logger.js'
|
|
32
|
+
|
|
33
|
+
import { EditOwnershipTracker } from './ownership.js'
|
|
34
|
+
|
|
35
|
+
function makeLogger(): Logger {
|
|
36
|
+
const stub = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
|
37
|
+
return { ...stub, child: vi.fn(() => ({ ...stub, child: vi.fn() })) } as unknown as Logger
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function runId(n: number): RunId {
|
|
41
|
+
return `run_${n}` as RunId
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('EditOwnershipTracker', () => {
|
|
45
|
+
let events: AgentBusEvent[]
|
|
46
|
+
let tracker: EditOwnershipTracker
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
events = []
|
|
50
|
+
tracker = new EditOwnershipTracker(makeLogger(), (e) => events.push(e))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('claim', () => {
|
|
54
|
+
it('claims an unowned file, emits ownership_claimed, returns ownership', () => {
|
|
55
|
+
const before = Date.now()
|
|
56
|
+
const result = tracker.claim('/tmp/file.txt', runId(1))
|
|
57
|
+
const after = Date.now()
|
|
58
|
+
|
|
59
|
+
expect(result.claimed).toBe(true)
|
|
60
|
+
if (result.claimed) {
|
|
61
|
+
expect(result.ownership.owner).toBe(runId(1))
|
|
62
|
+
expect(result.ownership.filePath).toBe(path.resolve('/tmp/file.txt'))
|
|
63
|
+
expect(result.ownership.claimedAt).toBeGreaterThanOrEqual(before)
|
|
64
|
+
expect(result.ownership.claimedAt).toBeLessThanOrEqual(after)
|
|
65
|
+
}
|
|
66
|
+
expect(events).toEqual([
|
|
67
|
+
{ type: 'ownership_claimed', filePath: path.resolve('/tmp/file.txt'), owner: runId(1) },
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('is idempotent when the same owner re-claims — no re-emit', () => {
|
|
72
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
73
|
+
events.length = 0
|
|
74
|
+
|
|
75
|
+
const result = tracker.claim('/tmp/file.txt', runId(1))
|
|
76
|
+
expect(result.claimed).toBe(true)
|
|
77
|
+
expect(events).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('denies a claim by a different owner, emits ownership_denied', () => {
|
|
81
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
82
|
+
events.length = 0
|
|
83
|
+
|
|
84
|
+
const result = tracker.claim('/tmp/file.txt', runId(2))
|
|
85
|
+
expect(result.claimed).toBe(false)
|
|
86
|
+
if (!result.claimed) {
|
|
87
|
+
expect(result.currentOwner).toBe(runId(1))
|
|
88
|
+
expect(result.filePath).toBe(path.resolve('/tmp/file.txt'))
|
|
89
|
+
}
|
|
90
|
+
expect(events).toEqual([
|
|
91
|
+
{
|
|
92
|
+
type: 'ownership_denied',
|
|
93
|
+
filePath: path.resolve('/tmp/file.txt'),
|
|
94
|
+
requester: runId(2),
|
|
95
|
+
currentOwner: runId(1),
|
|
96
|
+
},
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('normalises file paths — equivalent paths collide', () => {
|
|
101
|
+
tracker.claim('/tmp/foo/../file.txt', runId(1))
|
|
102
|
+
const result = tracker.claim('/tmp/file.txt', runId(2))
|
|
103
|
+
expect(result.claimed).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('release', () => {
|
|
108
|
+
it('releases an owned file, emits ownership_released, returns true', () => {
|
|
109
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
110
|
+
events.length = 0
|
|
111
|
+
|
|
112
|
+
const ok = tracker.release('/tmp/file.txt', runId(1))
|
|
113
|
+
expect(ok).toBe(true)
|
|
114
|
+
expect(tracker.getOwner('/tmp/file.txt')).toBeUndefined()
|
|
115
|
+
expect(events).toEqual([
|
|
116
|
+
{
|
|
117
|
+
type: 'ownership_released',
|
|
118
|
+
filePath: path.resolve('/tmp/file.txt'),
|
|
119
|
+
previousOwner: runId(1),
|
|
120
|
+
},
|
|
121
|
+
])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns false when no ownership exists — no emit', () => {
|
|
125
|
+
const ok = tracker.release('/tmp/never-claimed.txt', runId(1))
|
|
126
|
+
expect(ok).toBe(false)
|
|
127
|
+
expect(events).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns false when owner mismatches — no emit', () => {
|
|
131
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
132
|
+
events.length = 0
|
|
133
|
+
|
|
134
|
+
const ok = tracker.release('/tmp/file.txt', runId(2))
|
|
135
|
+
expect(ok).toBe(false)
|
|
136
|
+
expect(tracker.getOwner('/tmp/file.txt')).toBe(runId(1))
|
|
137
|
+
expect(events).toEqual([])
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('transfer', () => {
|
|
142
|
+
it('transfers ownership from current owner to another — atomic, single event', () => {
|
|
143
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
144
|
+
events.length = 0
|
|
145
|
+
|
|
146
|
+
const ok = tracker.transfer('/tmp/file.txt', runId(1), runId(2))
|
|
147
|
+
expect(ok).toBe(true)
|
|
148
|
+
expect(tracker.getOwner('/tmp/file.txt')).toBe(runId(2))
|
|
149
|
+
expect(events).toEqual([
|
|
150
|
+
{
|
|
151
|
+
type: 'ownership_transferred',
|
|
152
|
+
filePath: path.resolve('/tmp/file.txt'),
|
|
153
|
+
from: runId(1),
|
|
154
|
+
to: runId(2),
|
|
155
|
+
},
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns false when the `from` argument is not the current owner — no emit', () => {
|
|
160
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
161
|
+
events.length = 0
|
|
162
|
+
|
|
163
|
+
const ok = tracker.transfer('/tmp/file.txt', runId(99), runId(2))
|
|
164
|
+
expect(ok).toBe(false)
|
|
165
|
+
expect(tracker.getOwner('/tmp/file.txt')).toBe(runId(1))
|
|
166
|
+
expect(events).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns false when no ownership exists — no emit', () => {
|
|
170
|
+
const ok = tracker.transfer('/tmp/file.txt', runId(1), runId(2))
|
|
171
|
+
expect(ok).toBe(false)
|
|
172
|
+
expect(events).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('refreshes claimedAt on successful transfer', async () => {
|
|
176
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
177
|
+
const t0 = tracker.getOwner('/tmp/file.txt')
|
|
178
|
+
expect(t0).toBe(runId(1))
|
|
179
|
+
|
|
180
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
181
|
+
tracker.transfer('/tmp/file.txt', runId(1), runId(2))
|
|
182
|
+
const list = tracker.listByOwner(runId(2))
|
|
183
|
+
expect(list).toHaveLength(1)
|
|
184
|
+
const after = list[0]
|
|
185
|
+
expect(after?.claimedAt).toBeGreaterThan(0)
|
|
186
|
+
expect(after?.owner).toBe(runId(2))
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('releaseAll', () => {
|
|
191
|
+
it('releases every ownership for the owner, returns count, emits per-entry', () => {
|
|
192
|
+
tracker.claim('/tmp/a.txt', runId(1))
|
|
193
|
+
tracker.claim('/tmp/b.txt', runId(1))
|
|
194
|
+
tracker.claim('/tmp/c.txt', runId(2))
|
|
195
|
+
events.length = 0
|
|
196
|
+
|
|
197
|
+
const count = tracker.releaseAll(runId(1))
|
|
198
|
+
expect(count).toBe(2)
|
|
199
|
+
expect(tracker.getOwner('/tmp/a.txt')).toBeUndefined()
|
|
200
|
+
expect(tracker.getOwner('/tmp/b.txt')).toBeUndefined()
|
|
201
|
+
expect(tracker.getOwner('/tmp/c.txt')).toBe(runId(2))
|
|
202
|
+
|
|
203
|
+
const released = events.filter((e) => e.type === 'ownership_released')
|
|
204
|
+
expect(released.length).toBe(2)
|
|
205
|
+
for (const e of released) {
|
|
206
|
+
if (e.type === 'ownership_released') {
|
|
207
|
+
expect(e.previousOwner).toBe(runId(1))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('returns 0 when the owner has no entries', () => {
|
|
213
|
+
const count = tracker.releaseAll(runId(99))
|
|
214
|
+
expect(count).toBe(0)
|
|
215
|
+
expect(events).toEqual([])
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('read helpers', () => {
|
|
220
|
+
it('getOwner returns undefined for unclaimed paths', () => {
|
|
221
|
+
expect(tracker.getOwner('/tmp/anything.txt')).toBeUndefined()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('listByOwner returns all ownerships for a given owner', () => {
|
|
225
|
+
tracker.claim('/tmp/a.txt', runId(1))
|
|
226
|
+
tracker.claim('/tmp/b.txt', runId(1))
|
|
227
|
+
tracker.claim('/tmp/c.txt', runId(2))
|
|
228
|
+
|
|
229
|
+
const list = tracker.listByOwner(runId(1))
|
|
230
|
+
expect(list.length).toBe(2)
|
|
231
|
+
expect(new Set(list.map((o) => o.filePath))).toEqual(
|
|
232
|
+
new Set([path.resolve('/tmp/a.txt'), path.resolve('/tmp/b.txt')]),
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('checkConflict returns the current owner iff a different owner holds the file', () => {
|
|
237
|
+
tracker.claim('/tmp/file.txt', runId(1))
|
|
238
|
+
expect(tracker.checkConflict('/tmp/file.txt', runId(1))).toBeUndefined()
|
|
239
|
+
expect(tracker.checkConflict('/tmp/file.txt', runId(2))).toBe(runId(1))
|
|
240
|
+
expect(tracker.checkConflict('/tmp/other.txt', runId(2))).toBeUndefined()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current-code invariants asserted (2026-04-21, ses_006 Phase 5):
|
|
3
|
+
*
|
|
4
|
+
* - `BaseConnector` is abstract. Each concrete subclass sets id /
|
|
5
|
+
* name / description / connectionType / configSchema / methods
|
|
6
|
+
* and implements connect / disconnect / healthCheck / execute.
|
|
7
|
+
* - `toDefinition()` projects the abstract readonly fields into a
|
|
8
|
+
* `ConnectorDefinition<TConfig>` — used to register the connector
|
|
9
|
+
* with the `ConnectorRegistry`.
|
|
10
|
+
* - `findMethod(name)` returns the method by name or undefined.
|
|
11
|
+
* - `requireMethod(name)` throws with the method name + available
|
|
12
|
+
* names when not found.
|
|
13
|
+
* - `validateInput(method, input)`: zod.safeParse; on failure
|
|
14
|
+
* throws `Invalid input for method "<name>": <joined issues>`.
|
|
15
|
+
* - `measureExecution(fn)` returns `{result, durationMs}` with
|
|
16
|
+
* durationMs rounded.
|
|
17
|
+
* - Auth resolution lives in subclasses (see HttpConnector tests),
|
|
18
|
+
* not in the base class.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from 'vitest'
|
|
22
|
+
import { z } from 'zod'
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
AuthConfig,
|
|
26
|
+
ConnectionType,
|
|
27
|
+
ConnectorExecuteResult,
|
|
28
|
+
ConnectorMethod,
|
|
29
|
+
} from '../types/connector/index.js'
|
|
30
|
+
import type { ConnectorId } from '../types/ids/index.js'
|
|
31
|
+
|
|
32
|
+
import { BaseConnector } from './BaseConnector.js'
|
|
33
|
+
|
|
34
|
+
class TestConnector extends BaseConnector<{ base: string }> {
|
|
35
|
+
readonly id = 'conn_test' as ConnectorId
|
|
36
|
+
readonly name = 'Test'
|
|
37
|
+
readonly description = 'Test connector'
|
|
38
|
+
readonly connectionType: ConnectionType = 'custom'
|
|
39
|
+
readonly configSchema = z.object({ base: z.string() })
|
|
40
|
+
readonly methods: ConnectorMethod[] = [
|
|
41
|
+
{
|
|
42
|
+
name: 'echo',
|
|
43
|
+
description: 'echo',
|
|
44
|
+
inputSchema: z.object({ value: z.string() }),
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
async connect(): Promise<void> {}
|
|
49
|
+
async disconnect(): Promise<void> {}
|
|
50
|
+
async healthCheck(): Promise<boolean> {
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
async execute(): Promise<ConnectorExecuteResult> {
|
|
54
|
+
return { success: true, output: 'ok', durationMs: 0 }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Expose protected methods for direct tests:
|
|
58
|
+
publicRequireMethod(name: string): ConnectorMethod {
|
|
59
|
+
return this.requireMethod(name)
|
|
60
|
+
}
|
|
61
|
+
publicValidateInput(method: ConnectorMethod, input: unknown): unknown {
|
|
62
|
+
return this.validateInput(method, input)
|
|
63
|
+
}
|
|
64
|
+
publicMeasureExecution<T>(fn: () => Promise<T>) {
|
|
65
|
+
return this.measureExecution(fn)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Exposed internal auth resolver on a subclass is a sibling concern —
|
|
69
|
+
// `BaseConnector` does not define any auth handling; the field is
|
|
70
|
+
// just stored.
|
|
71
|
+
getStoredAuth(): AuthConfig | undefined {
|
|
72
|
+
return this.auth
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('BaseConnector', () => {
|
|
77
|
+
it('toDefinition projects abstract readonly fields', () => {
|
|
78
|
+
const c = new TestConnector()
|
|
79
|
+
const def = c.toDefinition()
|
|
80
|
+
expect(def.id).toBe('conn_test')
|
|
81
|
+
expect(def.name).toBe('Test')
|
|
82
|
+
expect(def.description).toBe('Test connector')
|
|
83
|
+
expect(def.connectionType).toBe('custom')
|
|
84
|
+
expect(def.methods).toHaveLength(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('findMethod returns undefined for an unknown name', () => {
|
|
88
|
+
const c = new TestConnector()
|
|
89
|
+
expect(c.publicRequireMethod).toBeDefined()
|
|
90
|
+
// findMethod is invoked indirectly via requireMethod — we cover the
|
|
91
|
+
// positive + negative paths below.
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('requireMethod returns the method when present', () => {
|
|
95
|
+
const c = new TestConnector()
|
|
96
|
+
const method = c.publicRequireMethod('echo')
|
|
97
|
+
expect(method.name).toBe('echo')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('requireMethod throws naming the missing method + available names', () => {
|
|
101
|
+
const c = new TestConnector()
|
|
102
|
+
expect(() => c.publicRequireMethod('nope')).toThrow(/Method "nope" not found/)
|
|
103
|
+
expect(() => c.publicRequireMethod('nope')).toThrow(/Available: echo/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('validateInput passes through parsed data on success', () => {
|
|
107
|
+
const c = new TestConnector()
|
|
108
|
+
const method = c.publicRequireMethod('echo')
|
|
109
|
+
expect(c.publicValidateInput(method, { value: 'hi' })).toEqual({ value: 'hi' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('validateInput throws with joined issue messages on failure', () => {
|
|
113
|
+
const c = new TestConnector()
|
|
114
|
+
const method = c.publicRequireMethod('echo')
|
|
115
|
+
expect(() => c.publicValidateInput(method, { value: 123 })).toThrow(
|
|
116
|
+
/Invalid input for method "echo"/,
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('measureExecution returns the result + rounded durationMs', async () => {
|
|
121
|
+
const c = new TestConnector()
|
|
122
|
+
const { result, durationMs } = await c.publicMeasureExecution(async () => {
|
|
123
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
124
|
+
return 42
|
|
125
|
+
})
|
|
126
|
+
expect(result).toBe(42)
|
|
127
|
+
expect(durationMs).toBeGreaterThanOrEqual(0)
|
|
128
|
+
expect(Number.isInteger(durationMs)).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
})
|