Skip to content

Commit 8f6046d

Browse files
committed
feat: add concurrent retry tests, CI coverage reporting, and OPENCODE_VERSION env override
Signed-off-by: leocavalcante <leo@cavalcante.dev>
1 parent 985a1f8 commit 8f6046d

File tree

4 files changed

+286
-11
lines changed

4 files changed

+286
-11
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ jobs:
3939
- name: Install dependencies
4040
run: bun install --ignore-scripts
4141

42-
- name: Run tests
43-
run: bun test
42+
- name: Run tests with coverage
43+
run: bun test --coverage
4444

4545
test-postinstall:
4646
runs-on: ubuntu-latest

src/paths.d.mts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ export {
1111
} from "./semver.mjs"
1212

1313
/**
14-
* Mock OpenCode version for compatibility checking.
14+
* OpenCode version used for compatibility checking.
1515
*
1616
* This version string is used to check agent compatibility requirements
17-
* during installation. In production, this would ideally be obtained
18-
* from the OpenCode CLI or environment, but for now it serves as a
19-
* fallback/default version.
17+
* during installation. It can be overridden by setting the `OPENCODE_VERSION`
18+
* environment variable, which is useful for:
19+
* - Testing agents against different OpenCode versions
20+
* - Development environments with pre-release OpenCode builds
21+
* - CI/CD pipelines that need to simulate specific versions
2022
*
2123
* Format: Semantic versioning (MAJOR.MINOR.PATCH)
2224
*
@@ -25,6 +27,11 @@ export {
2527
*
2628
* // Check if an agent requiring ">=0.1.0" is compatible
2729
* const isCompatible = checkVersionCompatibility(">=0.1.0", OPENCODE_VERSION)
30+
*
31+
* @example
32+
* // Override via environment variable
33+
* // OPENCODE_VERSION=1.0.0 node postinstall.mjs
34+
* // Now OPENCODE_VERSION will be "1.0.0" instead of the default
2835
*/
2936
export declare const OPENCODE_VERSION: string
3037

src/paths.mjs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,24 @@ export {
2121
} from "./semver.mjs"
2222

2323
/**
24-
* Mock OpenCode version for compatibility checking.
24+
* Default OpenCode version for compatibility checking.
2525
*
2626
* This version string is used to check agent compatibility requirements
27-
* during installation. In production, this would ideally be obtained
28-
* from the OpenCode CLI or environment, but for now it serves as a
29-
* fallback/default version.
27+
* during installation when no override is provided.
28+
*
29+
* Format: Semantic versioning (MAJOR.MINOR.PATCH)
30+
*/
31+
const DEFAULT_OPENCODE_VERSION = "0.1.0"
32+
33+
/**
34+
* OpenCode version used for compatibility checking.
35+
*
36+
* This version string is used to check agent compatibility requirements
37+
* during installation. It can be overridden by setting the `OPENCODE_VERSION`
38+
* environment variable, which is useful for:
39+
* - Testing agents against different OpenCode versions
40+
* - Development environments with pre-release OpenCode builds
41+
* - CI/CD pipelines that need to simulate specific versions
3042
*
3143
* Format: Semantic versioning (MAJOR.MINOR.PATCH)
3244
*
@@ -35,8 +47,13 @@ export {
3547
*
3648
* // Check if an agent requiring ">=0.1.0" is compatible
3749
* const isCompatible = checkVersionCompatibility(">=0.1.0", OPENCODE_VERSION)
50+
*
51+
* @example
52+
* // Override via environment variable
53+
* // OPENCODE_VERSION=1.0.0 node postinstall.mjs
54+
* // Now OPENCODE_VERSION will be "1.0.0" instead of the default
3855
*/
39-
export const OPENCODE_VERSION = "0.1.0"
56+
export const OPENCODE_VERSION = process.env.OPENCODE_VERSION || DEFAULT_OPENCODE_VERSION
4057

4158
/**
4259
* List of expected agent names (without .md extension).

tests/paths.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,44 @@ describe("paths.mjs exports", () => {
453453
expect(num).toBeGreaterThanOrEqual(0)
454454
}
455455
})
456+
457+
it("should support OPENCODE_VERSION environment variable override", async () => {
458+
// Spawn a subprocess that imports the module with a custom OPENCODE_VERSION
459+
const { spawnSync } = await import("node:child_process")
460+
const result = spawnSync(
461+
"node",
462+
[
463+
"--input-type=module",
464+
"-e",
465+
'import { OPENCODE_VERSION } from "./src/paths.mjs"; console.log(OPENCODE_VERSION);',
466+
],
467+
{
468+
encoding: "utf-8",
469+
env: { ...process.env, OPENCODE_VERSION: "2.0.0" },
470+
cwd: import.meta.dirname.replace("/tests", ""),
471+
},
472+
)
473+
expect(result.stdout.trim()).toBe("2.0.0")
474+
})
475+
476+
it("should use default when OPENCODE_VERSION env is empty", async () => {
477+
// Spawn a subprocess without the env var set
478+
const { spawnSync } = await import("node:child_process")
479+
const result = spawnSync(
480+
"node",
481+
[
482+
"--input-type=module",
483+
"-e",
484+
'import { OPENCODE_VERSION } from "./src/paths.mjs"; console.log(OPENCODE_VERSION);',
485+
],
486+
{
487+
encoding: "utf-8",
488+
env: { ...process.env, OPENCODE_VERSION: "" },
489+
cwd: import.meta.dirname.replace("/tests", ""),
490+
},
491+
)
492+
expect(result.stdout.trim()).toBe("0.1.0")
493+
})
456494
})
457495

458496
it("should export MIN_CONTENT_LENGTH as a number", () => {
@@ -1250,6 +1288,219 @@ This is a test agent that handles various tasks for you.
12501288
expect(callCount).toBe(1)
12511289
})
12521290
})
1291+
1292+
describe("concurrent operations", () => {
1293+
it("should handle multiple concurrent retryOnTransientError calls independently", async () => {
1294+
const counters = { c0: 0, c1: 0, c2: 0 }
1295+
const results = await Promise.all([
1296+
retryOnTransientError(
1297+
() => {
1298+
counters.c0++
1299+
if (counters.c0 < 2) {
1300+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
1301+
throw err
1302+
}
1303+
return "result-0"
1304+
},
1305+
{ retries: 3, initialDelayMs: 10 },
1306+
),
1307+
retryOnTransientError(
1308+
() => {
1309+
counters.c1++
1310+
if (counters.c1 < 3) {
1311+
const err = Object.assign(new Error("EBUSY"), { code: "EBUSY" })
1312+
throw err
1313+
}
1314+
return "result-1"
1315+
},
1316+
{ retries: 3, initialDelayMs: 10 },
1317+
),
1318+
retryOnTransientError(
1319+
() => {
1320+
counters.c2++
1321+
return "result-2" // Succeeds immediately
1322+
},
1323+
{ retries: 3, initialDelayMs: 10 },
1324+
),
1325+
])
1326+
1327+
expect(results).toEqual(["result-0", "result-1", "result-2"])
1328+
expect(counters.c0).toBe(2) // 1 failure + 1 success
1329+
expect(counters.c1).toBe(3) // 2 failures + 1 success
1330+
expect(counters.c2).toBe(1) // Immediate success
1331+
})
1332+
1333+
it("should isolate retry state between concurrent calls", async () => {
1334+
const executionOrder: string[] = []
1335+
1336+
const results = await Promise.all([
1337+
retryOnTransientError(
1338+
async () => {
1339+
executionOrder.push("A-start")
1340+
await new Promise((r) => setTimeout(r, 5))
1341+
executionOrder.push("A-end")
1342+
return "A"
1343+
},
1344+
{ retries: 1, initialDelayMs: 10 },
1345+
),
1346+
retryOnTransientError(
1347+
async () => {
1348+
executionOrder.push("B-start")
1349+
await new Promise((r) => setTimeout(r, 5))
1350+
executionOrder.push("B-end")
1351+
return "B"
1352+
},
1353+
{ retries: 1, initialDelayMs: 10 },
1354+
),
1355+
])
1356+
1357+
expect(results).toEqual(["A", "B"])
1358+
// Both should start before either ends (concurrent execution)
1359+
expect(executionOrder.indexOf("A-start")).toBeLessThan(executionOrder.indexOf("A-end"))
1360+
expect(executionOrder.indexOf("B-start")).toBeLessThan(executionOrder.indexOf("B-end"))
1361+
})
1362+
1363+
it("should handle mixed success and failure in concurrent calls", async () => {
1364+
const results = await Promise.allSettled([
1365+
retryOnTransientError(
1366+
() => {
1367+
return "success"
1368+
},
1369+
{ retries: 1, initialDelayMs: 10 },
1370+
),
1371+
retryOnTransientError(
1372+
() => {
1373+
const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" })
1374+
throw err // Non-transient error, fails immediately
1375+
},
1376+
{ retries: 3, initialDelayMs: 10 },
1377+
),
1378+
retryOnTransientError(
1379+
() => {
1380+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
1381+
throw err // Transient error, exhausts all retries
1382+
},
1383+
{ retries: 1, initialDelayMs: 10 },
1384+
),
1385+
])
1386+
1387+
expect(results[0]).toEqual({ status: "fulfilled", value: "success" })
1388+
expect(results[1].status).toBe("rejected")
1389+
expect((results[1] as PromiseRejectedResult).reason.code).toBe("ENOENT")
1390+
expect(results[2].status).toBe("rejected")
1391+
expect((results[2] as PromiseRejectedResult).reason.code).toBe("EAGAIN")
1392+
})
1393+
1394+
it("should maintain exponential backoff timing independently for each concurrent call", async () => {
1395+
const timestamps: { id: string; time: number }[] = []
1396+
const start = Date.now()
1397+
1398+
await Promise.all([
1399+
retryOnTransientError(
1400+
() => {
1401+
timestamps.push({ id: "A", time: Date.now() - start })
1402+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
1403+
throw err
1404+
},
1405+
{ retries: 2, initialDelayMs: 20 }, // Delays: 20ms, 40ms
1406+
).catch(() => {}),
1407+
retryOnTransientError(
1408+
() => {
1409+
timestamps.push({ id: "B", time: Date.now() - start })
1410+
const err = Object.assign(new Error("EBUSY"), { code: "EBUSY" })
1411+
throw err
1412+
},
1413+
{ retries: 2, initialDelayMs: 30 }, // Delays: 30ms, 60ms
1414+
).catch(() => {}),
1415+
])
1416+
1417+
// A should have 3 attempts with ~60ms total delay (20 + 40)
1418+
// B should have 3 attempts with ~90ms total delay (30 + 60)
1419+
const aAttempts = timestamps.filter((t) => t.id === "A")
1420+
const bAttempts = timestamps.filter((t) => t.id === "B")
1421+
1422+
expect(aAttempts).toHaveLength(3)
1423+
expect(bAttempts).toHaveLength(3)
1424+
1425+
// Verify A's backoff timing - calculate delays between consecutive attempts
1426+
// biome-ignore lint/style/noNonNullAssertion: length verified above
1427+
const aDiffs = aAttempts.slice(1).map((curr, i) => curr.time - aAttempts[i]!.time)
1428+
expect(aDiffs[0]).toBeGreaterThanOrEqual(15) // ~20ms first delay
1429+
expect(aDiffs[1]).toBeGreaterThanOrEqual(30) // ~40ms second delay
1430+
1431+
// Verify B's backoff timing - calculate delays between consecutive attempts
1432+
// biome-ignore lint/style/noNonNullAssertion: length verified above
1433+
const bDiffs = bAttempts.slice(1).map((curr, i) => curr.time - bAttempts[i]!.time)
1434+
expect(bDiffs[0]).toBeGreaterThanOrEqual(25) // ~30ms first delay
1435+
expect(bDiffs[1]).toBeGreaterThanOrEqual(50) // ~60ms second delay
1436+
})
1437+
1438+
it("should handle high concurrency without interference", async () => {
1439+
const concurrentCount = 10
1440+
const callCounts = new Array(concurrentCount).fill(0)
1441+
1442+
const promises = Array.from({ length: concurrentCount }, (_, i) =>
1443+
retryOnTransientError(
1444+
() => {
1445+
callCounts[i]++
1446+
if (callCounts[i] < 2) {
1447+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
1448+
throw err
1449+
}
1450+
return `result-${i}`
1451+
},
1452+
{ retries: 2, initialDelayMs: 5 },
1453+
),
1454+
)
1455+
1456+
const results = await Promise.all(promises)
1457+
1458+
// All should succeed after one retry
1459+
expect(results).toHaveLength(concurrentCount)
1460+
for (let i = 0; i < concurrentCount; i++) {
1461+
expect(results[i]).toBe(`result-${i}`)
1462+
expect(callCounts[i]).toBe(2)
1463+
}
1464+
})
1465+
1466+
it("should not share retry count state between concurrent calls", async () => {
1467+
let sharedCounter = 0
1468+
const individualCounts = { a: 0, b: 0 }
1469+
1470+
await Promise.all([
1471+
retryOnTransientError(
1472+
() => {
1473+
sharedCounter++
1474+
individualCounts.a++
1475+
if (individualCounts.a <= 2) {
1476+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
1477+
throw err
1478+
}
1479+
return "A"
1480+
},
1481+
{ retries: 3, initialDelayMs: 5 },
1482+
),
1483+
retryOnTransientError(
1484+
() => {
1485+
sharedCounter++
1486+
individualCounts.b++
1487+
if (individualCounts.b <= 2) {
1488+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
1489+
throw err
1490+
}
1491+
return "B"
1492+
},
1493+
{ retries: 3, initialDelayMs: 5 },
1494+
),
1495+
])
1496+
1497+
// Each call should have its own retry count (3 attempts each)
1498+
expect(individualCounts.a).toBe(3)
1499+
expect(individualCounts.b).toBe(3)
1500+
// Total shared counter should be sum of both
1501+
expect(sharedCounter).toBe(6)
1502+
})
1503+
})
12531504
})
12541505

12551506
describe("parseFrontmatter", () => {

0 commit comments

Comments
 (0)