Skip to content

Commit 3f8dcbe

Browse files
Add Bun lockfile tactic tests
Comprehensive test suite for Bun lockfile parsing and graph building: - Parsing tests for simple, workspace, and devdeps lockfiles - JSONC trailing comma support tests - Graph building tests for direct/transitive dependencies - Environment labeling tests (dev vs production) - Workspace package filtering tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent bc79d1d commit 3f8dcbe

6 files changed

Lines changed: 414 additions & 0 deletions

File tree

test/Bun/BunLockSpec.hs

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
{-# LANGUAGE TemplateHaskell #-}
2+
3+
module Bun.BunLockSpec (
4+
spec,
5+
) where
6+
7+
import Data.Aeson (eitherDecodeStrict)
8+
import Data.Map.Strict qualified as Map
9+
import Data.Set qualified as Set
10+
import Data.String.Conversion qualified
11+
import Data.Text (Text)
12+
import Data.Text qualified as Text
13+
import DepTypes (
14+
DepEnvironment (EnvDevelopment, EnvProduction),
15+
DepType (NodeJSType),
16+
Dependency (..),
17+
VerConstraint (CEq),
18+
)
19+
import GraphUtil (
20+
expectDirect,
21+
expectEdge,
22+
)
23+
import Graphing (Graphing)
24+
import Graphing qualified
25+
import Path (Abs, File, Path, fromAbsFile, mkRelFile, (</>))
26+
import Path.IO (getCurrentDir)
27+
import Strategy.Node.Bun.BunLock (
28+
BunLockFile (..),
29+
BunPackage (..),
30+
BunWorkspace (..),
31+
buildGraph,
32+
parseBunLock,
33+
)
34+
import Test.Effect (it', shouldBe')
35+
import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, runIO, shouldBe)
36+
37+
spec :: Spec
38+
spec = do
39+
currentDir <- runIO getCurrentDir
40+
let simpleBunLockPath = currentDir </> $(mkRelFile "test/Bun/testdata/simple-bun.lock")
41+
let workspaceBunLockPath = currentDir </> $(mkRelFile "test/Bun/testdata/workspace-bun.lock")
42+
let devdepsBunLockPath = currentDir </> $(mkRelFile "test/Bun/testdata/devdeps-bun.lock")
43+
let trailingCommasBunLockPath = currentDir </> $(mkRelFile "test/Bun/testdata/trailing-commas-bun.lock")
44+
45+
describe "bun.lock simple" $ do
46+
it' "should parse simple bun.lock" $ do
47+
lockFile <- parseBunLock simpleBunLockPath
48+
lockfileVersion lockFile `shouldBe'` 1
49+
50+
-- Check workspaces
51+
let ws = workspaces lockFile
52+
Map.size ws `shouldBe'` 1
53+
let rootWs = ws Map.! ""
54+
name rootWs `shouldBe'` "simple-project"
55+
dependencies rootWs `shouldBe'` Map.fromList [("lodash", "^4.17.21")]
56+
devDependencies rootWs `shouldBe'` mempty
57+
58+
-- Check packages
59+
let pkgs = packages lockFile
60+
Map.size pkgs `shouldBe'` 1
61+
let lodashPkg = pkgs Map.! "lodash"
62+
resolution lodashPkg `shouldBe'` "lodash@4.17.21"
63+
64+
describe "bun.lock with workspaces" $ do
65+
it' "should parse workspace bun.lock" $ do
66+
lockFile <- parseBunLock workspaceBunLockPath
67+
lockfileVersion lockFile `shouldBe'` 1
68+
69+
-- Check workspaces
70+
let ws = workspaces lockFile
71+
Map.size ws `shouldBe'` 3
72+
73+
-- Root workspace
74+
let rootWs = ws Map.! ""
75+
name rootWs `shouldBe'` "root-workspace"
76+
dependencies rootWs `shouldBe'` Map.fromList [("express", "^4.18.0")]
77+
78+
-- API workspace
79+
let apiWs = ws Map.! "packages/api"
80+
name apiWs `shouldBe'` "api-package"
81+
dependencies apiWs `shouldBe'` Map.fromList [("axios", "^1.4.0")]
82+
83+
-- Web workspace
84+
let webWs = ws Map.! "packages/web"
85+
name webWs `shouldBe'` "web-package"
86+
dependencies webWs `shouldBe'` Map.fromList [("react", "^18.2.0")]
87+
88+
-- Check packages
89+
let pkgs = packages lockFile
90+
Map.size pkgs `shouldBe'` 3
91+
resolution (pkgs Map.! "express") `shouldBe'` "express@4.18.2"
92+
resolution (pkgs Map.! "axios") `shouldBe'` "axios@1.4.0"
93+
resolution (pkgs Map.! "react") `shouldBe'` "react@18.2.0"
94+
95+
describe "bun.lock with dev dependencies" $ do
96+
it' "should parse devdeps bun.lock" $ do
97+
lockFile <- parseBunLock devdepsBunLockPath
98+
lockfileVersion lockFile `shouldBe'` 1
99+
100+
-- Check workspaces
101+
let ws = workspaces lockFile
102+
Map.size ws `shouldBe'` 1
103+
let rootWs = ws Map.! ""
104+
name rootWs `shouldBe'` "devdeps-project"
105+
dependencies rootWs `shouldBe'` Map.fromList [("lodash", "^4.17.21")]
106+
devDependencies rootWs `shouldBe'` Map.fromList [("typescript", "^5.0.0"), ("jest", "^29.5.0")]
107+
108+
-- Check packages
109+
let pkgs = packages lockFile
110+
Map.size pkgs `shouldBe'` 3
111+
resolution (pkgs Map.! "lodash") `shouldBe'` "lodash@4.17.21"
112+
resolution (pkgs Map.! "typescript") `shouldBe'` "typescript@5.3.3"
113+
resolution (pkgs Map.! "jest") `shouldBe'` "jest@29.5.0"
114+
115+
describe "bun.lock with trailing commas (JSONC)" $ do
116+
it' "should parse bun.lock with trailing commas" $ do
117+
lockFile <- parseBunLock trailingCommasBunLockPath
118+
lockfileVersion lockFile `shouldBe'` 1
119+
120+
-- Check workspaces
121+
let ws = workspaces lockFile
122+
Map.size ws `shouldBe'` 1
123+
let rootWs = ws Map.! ""
124+
name rootWs `shouldBe'` "test-project"
125+
Map.size (dependencies rootWs) `shouldBe'` 2
126+
Map.size (devDependencies rootWs) `shouldBe'` 1
127+
128+
-- Check packages
129+
let pkgs = packages lockFile
130+
Map.size pkgs `shouldBe'` 3
131+
132+
-- Graph building tests
133+
let transitiveBunLockPath = currentDir </> $(mkRelFile "test/Bun/testdata/transitive-bun.lock")
134+
135+
-- Use checkGraph pattern like PnpmLockSpec
136+
checkGraph simpleBunLockPath simpleGraphSpec
137+
checkGraph workspaceBunLockPath workspaceGraphSpec
138+
checkGraph devdepsBunLockPath devdepsGraphSpec
139+
checkGraph transitiveBunLockPath transitiveGraphSpec
140+
141+
-- | Load a bun.lock file and run graph specs on it
142+
checkGraph :: Path Abs File -> (Graphing Dependency -> Spec) -> Spec
143+
checkGraph pathToFixture buildGraphSpec = do
144+
eitherLockFile <- runIO $ parseBunLockIO pathToFixture
145+
case eitherLockFile of
146+
Left err ->
147+
describe "bun.lock" $
148+
it "should parse lockfile" (expectationFailure err)
149+
Right lockFile -> buildGraphSpec (buildGraph lockFile)
150+
151+
-- | Parse a bun.lock file using IO (for tests)
152+
parseBunLockIO :: Path Abs File -> IO (Either String BunLockFile)
153+
parseBunLockIO path = do
154+
contents <- readFile (fromAbsFile path)
155+
let stripped = stripJsoncComments (Text.pack contents)
156+
bs = Data.String.Conversion.encodeUtf8 stripped
157+
pure $ eitherDecodeStrict bs
158+
159+
-- | Convert JSONC to valid JSON (copy from BunLock.hs for testing)
160+
-- JSONC allows: // comments and trailing commas
161+
stripJsoncComments :: Text -> Text
162+
stripJsoncComments input = removeTrailingCommas $ Text.unlines $ map processLine $ Text.lines input
163+
where
164+
processLine :: Text -> Text
165+
processLine line =
166+
let stripped = Text.stripStart line
167+
in if "//" `Text.isPrefixOf` stripped
168+
then ""
169+
else stripInlineComment line
170+
171+
stripInlineComment :: Text -> Text
172+
stripInlineComment = go False
173+
where
174+
go :: Bool -> Text -> Text
175+
go _ t | Text.null t = t
176+
go inString t =
177+
case Text.uncons t of
178+
Nothing -> t
179+
Just ('"', rest)
180+
| not inString -> "\"" <> go True rest
181+
| otherwise -> "\"" <> go False rest
182+
Just ('\\', rest)
183+
| inString ->
184+
case Text.uncons rest of
185+
Just (c, rest') -> "\\" <> Text.singleton c <> go True rest'
186+
Nothing -> "\\"
187+
| otherwise -> "\\" <> go inString rest
188+
Just ('/', rest)
189+
| not inString ->
190+
case Text.uncons rest of
191+
Just ('/', _) -> ""
192+
_ -> "/" <> go inString rest
193+
| otherwise -> "/" <> go inString rest
194+
Just (c, rest) -> Text.singleton c <> go inString rest
195+
196+
removeTrailingCommas :: Text -> Text
197+
removeTrailingCommas = go False
198+
where
199+
go :: Bool -> Text -> Text
200+
go _ t | Text.null t = t
201+
go inString t =
202+
case Text.uncons t of
203+
Nothing -> t
204+
Just ('"', rest)
205+
| not inString -> "\"" <> go True rest
206+
| otherwise -> "\"" <> go False rest
207+
Just ('\\', rest)
208+
| inString ->
209+
case Text.uncons rest of
210+
Just (c, rest') -> "\\" <> Text.singleton c <> go True rest'
211+
Nothing -> "\\"
212+
| otherwise -> "\\" <> go inString rest
213+
Just (',', rest)
214+
| not inString ->
215+
let afterWs = Text.dropWhile (`elem` [' ', '\t', '\n', '\r']) rest
216+
in case Text.uncons afterWs of
217+
Just ('}', _) -> go False rest
218+
Just (']', _) -> go False rest
219+
_ -> "," <> go False rest
220+
| otherwise -> "," <> go inString rest
221+
Just (c, rest) -> Text.singleton c <> go inString rest
222+
223+
simpleGraphSpec :: Graphing Dependency -> Spec
224+
simpleGraphSpec graph = do
225+
describe "simple bun.lock graph" $ do
226+
it "marks direct dependencies from workspaces" $ do
227+
expectDirect [mkProdDep "lodash@4.17.21"] graph
228+
229+
workspaceGraphSpec :: Graphing Dependency -> Spec
230+
workspaceGraphSpec graph = do
231+
describe "workspace bun.lock graph" $ do
232+
it "marks direct dependencies from all workspaces" $ do
233+
expectDirect
234+
[ mkProdDep "express@4.18.2"
235+
, mkProdDep "axios@1.4.0"
236+
, mkProdDep "react@18.2.0"
237+
]
238+
graph
239+
240+
it "excludes workspace packages from graph nodes" $ do
241+
-- Workspace packages should not appear in the graph
242+
-- The workspace names are: "root-workspace", "api-package", "web-package"
243+
-- These should be filtered out from the final graph
244+
let vertices = Graphing.vertexList graph
245+
vertexNames = map dependencyName vertices
246+
vertexNames `shouldBe` ["express", "axios", "react"]
247+
248+
devdepsGraphSpec :: Graphing Dependency -> Spec
249+
devdepsGraphSpec graph = do
250+
describe "devdeps bun.lock graph" $ do
251+
it "labels dev dependencies with EnvDevelopment" $ do
252+
expectDirect
253+
[ mkProdDep "lodash@4.17.21"
254+
, mkDevDep "typescript@5.3.3"
255+
, mkDevDep "jest@29.5.0"
256+
]
257+
graph
258+
259+
transitiveGraphSpec :: Graphing Dependency -> Spec
260+
transitiveGraphSpec graph = do
261+
let hasEdge :: Dependency -> Dependency -> Expectation
262+
hasEdge = expectEdge graph
263+
264+
describe "transitive bun.lock graph" $ do
265+
it "marks direct dependencies" $ do
266+
expectDirect
267+
[ mkProdDep "express@4.18.2"
268+
, mkDevDep "typescript@5.3.3"
269+
]
270+
graph
271+
272+
it "creates edges for transitive dependencies" $ do
273+
-- express -> accepts, body-parser
274+
hasEdge (mkProdDep "express@4.18.2") (mkProdDep "accepts@1.3.8")
275+
hasEdge (mkProdDep "express@4.18.2") (mkProdDep "body-parser@1.20.1")
276+
277+
-- accepts -> mime-types
278+
hasEdge (mkProdDep "accepts@1.3.8") (mkProdDep "mime-types@2.1.35")
279+
280+
-- | Helper to create a production dependency
281+
mkProdDep :: Text -> Dependency
282+
mkProdDep nameAtVersion = mkDep nameAtVersion (Just EnvProduction)
283+
284+
-- | Helper to create a dev dependency
285+
mkDevDep :: Text -> Dependency
286+
mkDevDep nameAtVersion = mkDep nameAtVersion (Just EnvDevelopment)
287+
288+
-- | Helper to create a dependency from "name@version" string
289+
mkDep :: Text -> Maybe DepEnvironment -> Dependency
290+
mkDep nameAtVersion env = do
291+
let (name, version) = parseNameVersion nameAtVersion
292+
Dependency
293+
NodeJSType
294+
name
295+
(CEq <$> Just version)
296+
mempty
297+
(maybe mempty Set.singleton env)
298+
mempty
299+
300+
-- | Parse "name@version" or "@scope/name@version" into (name, version)
301+
parseNameVersion :: Text -> (Text, Text)
302+
parseNameVersion t
303+
| "@" `Text.isPrefixOf` t =
304+
-- Scoped package: @scope/name@version
305+
let withoutAt = Text.drop 1 t
306+
(scopeAndName, rest) = Text.breakOn "@" withoutAt
307+
in ("@" <> scopeAndName, Text.drop 1 rest)
308+
| otherwise =
309+
-- Regular package: name@version
310+
let (name, rest) = Text.breakOn "@" t
311+
in (name, Text.drop 1 rest)

test/Bun/testdata/devdeps-bun.lock

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"lockfileVersion": 1,
3+
"workspaces": {
4+
"": {
5+
"name": "devdeps-project",
6+
"dependencies": {
7+
"lodash": "^4.17.21"
8+
},
9+
"devDependencies": {
10+
"typescript": "^5.0.0",
11+
"jest": "^29.5.0"
12+
}
13+
}
14+
},
15+
"packages": {
16+
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZfKzewUda7lEbFF7sBMudS3aMUvSCbiNgcvJ5NiynzQ=="],
17+
"typescript": ["typescript@5.3.3", "", {"bin": {"tsc": "bin/tsc", "tsserver": "bin/tsserver"}}, "sha512-P5QFLNDH3NKVQWQ7mT8rUI9HDarfua0VSKIDQ+jsQBzSsnVjcccgNeiWGe74XsbqRLuxV5mS1ct6nGkLDTwsQ=="],
18+
"jest": ["jest@29.5.0", "", {"bin": {"jest": "bin/jest.js"}}, "sha512-juAQQXNETCNmngM5KSwtSB56LmCQWfp4+kHA9ZScOH+FmWkQRAoNS59BY59PEnF4CJIkCMXWol1nKlstrDXSQ=="]
19+
}
20+
}

test/Bun/testdata/simple-bun.lock

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"lockfileVersion": 1,
3+
"workspaces": {
4+
"": {
5+
"name": "simple-project",
6+
"dependencies": {
7+
"lodash": "^4.17.21"
8+
}
9+
}
10+
},
11+
"packages": {
12+
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZfKzewUda7lEbFF7sBMudS3aMUvSCbiNgcvJ5NiynzQ=="]
13+
}
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"lockfileVersion": 1,
3+
"workspaces": {
4+
"": {
5+
"name": "test-project",
6+
"dependencies": {
7+
"chalk": "^5.6.2",
8+
"lodash": "^4.17.23",
9+
},
10+
"devDependencies": {
11+
"@types/bun": "latest",
12+
},
13+
},
14+
},
15+
"packages": {
16+
"@types/bun": ["@types/bun@1.3.8", "", {}, "sha512-xxx"],
17+
"chalk": ["chalk@5.6.2", "", {}, "sha512-yyy"],
18+
"lodash": ["lodash@4.17.23", "", {}, "sha512-zzz"],
19+
},
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"lockfileVersion": 1,
3+
"workspaces": {
4+
"": {
5+
"name": "transitive-project",
6+
"dependencies": {
7+
"express": "^4.18.0"
8+
},
9+
"devDependencies": {
10+
"typescript": "^5.0.0"
11+
}
12+
}
13+
},
14+
"packages": {
15+
"express": ["express@4.18.2", "", {"dependencies": {"accepts": "~1.3.8", "body-parser": "1.20.1"}}, "sha512-express-hash"],
16+
"accepts": ["accepts@1.3.8", "", {"dependencies": {"mime-types": "~2.1.34"}}, "sha512-accepts-hash"],
17+
"body-parser": ["body-parser@1.20.1", "", {}, "sha512-body-parser-hash"],
18+
"mime-types": ["mime-types@2.1.35", "", {}, "sha512-mime-types-hash"],
19+
"typescript": ["typescript@5.3.3", "", {"bin": {"tsc": "bin/tsc"}}, "sha512-typescript-hash"]
20+
}
21+
}

0 commit comments

Comments
 (0)