@@ -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