1# Copyright 2024 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Tests for the pw_build.bazel_to_gn module.""" 15 16import json 17import unittest 18 19from io import StringIO 20from pathlib import PurePath 21 22from unittest import mock 23 24from pw_build.bazel_to_gn import BazelToGnConverter 25 26# Test fixtures. 27 28PW_ROOT = '/path/to/pigweed' 29FOO_SOURCE_DIR = '/path/to/foo' 30BAR_SOURCE_DIR = '../relative/path/to/bar' 31BAZ_SOURCE_DIR = '/path/to/baz' 32 33# Simulated out/args.gn file contents. 34ARGS_GN = f'''dir_pw_third_party_foo = "{FOO_SOURCE_DIR}" 35pw_log_BACKEND = "$dir_pw_log_basic" 36pw_unit_test_MAIN = "$dir_pw_unit_test:logging_main" 37dir_pw_third_party_bar = "{BAR_SOURCE_DIR}" 38pw_unit_test_GOOGLETEST_BACKEND == "$dir_pw_third_party/googletest" 39pw_unit_test_MAIN == "$dir_pw_third_party/googletest:gmock_main 40dir_pw_third_party_baz = "{BAZ_SOURCE_DIR}"''' 41 42# Simulated Bazel repo names. 43FOO_REPO = 'dev_pigweed_foo' 44BAR_REPO = 'dev_pigweed_bar' 45BAZ_REPO = 'dev_pigweed_baz' 46 47# Simulated //third_party.../bazel_to_gn.json file contents. 48FOO_B2G_JSON = f'''{{ 49 "repo": "{FOO_REPO}", 50 "targets": [ "//package:target" ] 51}}''' 52BAR_B2G_JSON = f'''{{ 53 "repo": "{BAR_REPO}", 54 "options": {{ 55 "//package:my_flag": true 56 }}, 57 "targets": [ "//bar/pkg:bar_target1" ] 58}}''' 59BAZ_B2G_JSON = f'''{{ 60 "repo": "{BAZ_REPO}", 61 "generate": false 62}}''' 63 64# Simulated 'bazel cquery ...' results. 65FOO_RULE_JSON = f'''{{ 66 "results": [ 67 {{ 68 "target": {{ 69 "rule": {{ 70 "name": "//package:target", 71 "ruleClass": "cc_library", 72 "attribute": [ 73 {{ 74 "explicitlySpecified": true, 75 "name": "hdrs", 76 "type": "label_list", 77 "stringListValue": [ "//include:foo.h" ] 78 }}, 79 {{ 80 "explicitlySpecified": true, 81 "name": "srcs", 82 "type": "label_list", 83 "stringListValue": [ "//src:foo.cc" ] 84 }}, 85 {{ 86 "explicitlySpecified": true, 87 "name": "additional_linker_inputs", 88 "type": "label_list", 89 "stringListValue": [ "//data:input" ] 90 }}, 91 {{ 92 "explicitlySpecified": true, 93 "name": "includes", 94 "type": "string_list", 95 "stringListValue": [ "include" ] 96 }}, 97 {{ 98 "explicitlySpecified": true, 99 "name": "copts", 100 "type": "string_list", 101 "stringListValue": [ "-cflag" ] 102 }}, 103 {{ 104 "explicitlySpecified": true, 105 "name": "linkopts", 106 "type": "string_list", 107 "stringListValue": [ "-ldflag" ] 108 }}, 109 {{ 110 "explicitlySpecified": true, 111 "name": "defines", 112 "type": "string_list", 113 "stringListValue": [ "DEFINE" ] 114 }}, 115 {{ 116 "explicitlySpecified": true, 117 "name": "local_defines", 118 "type": "string_list", 119 "stringListValue": [ "LOCAL_DEFINE" ] 120 }}, 121 {{ 122 "explicitlySpecified": true, 123 "name": "deps", 124 "type": "label_list", 125 "stringListValue": [ 126 "@{BAR_REPO}//bar/pkg:bar_target1", 127 "@{BAR_REPO}//bar/pkg:bar_target2" 128 ] 129 }}, 130 {{ 131 "explicitlySpecified": true, 132 "name": "implementation_deps", 133 "type": "label_list", 134 "stringListValue": [ "@{BAZ_REPO}//baz/pkg:baz_target" ] 135 }} 136 ] 137 }} 138 }} 139 }} 140 ] 141}} 142''' 143BAR_RULE_JSON = ''' 144{ 145 "results": [ 146 { 147 "target": { 148 "rule": { 149 "name": "//bar/pkg:bar_target1", 150 "ruleClass": "cc_library", 151 "attribute": [ 152 { 153 "explicitlySpecified": true, 154 "name": "defines", 155 "type": "string_list", 156 "stringListValue": [ "FILTERED", "KEPT" ] 157 } 158 ] 159 } 160 } 161 } 162 ] 163} 164''' 165 166# Simulated Bazel WORKSPACE file for Pigweed. 167# Keep this in sync with PW_EXTERNAL_DEPS below. 168PW_WORKSPACE = f''' 169http_archive( 170 name = "{FOO_REPO}", 171 strip_prefix = "foo-feedface", 172 url = "http://localhost:9000/feedface.tgz", 173) 174 175http_archive( 176 name = "{BAR_REPO}", 177 strip_prefix = "bar-v1.0", 178 urls = ["http://localhost:9000/bar/v1.0.tgz"], 179) 180 181http_archive( 182 name = "{BAZ_REPO}", 183 strip_prefix = "baz-v1.5", 184 url = "http://localhost:9000/baz/v1.5.zip", 185) 186 187http_archive( 188 name = "other", 189 strip_prefix = "other-v2.0", 190 url = "http://localhost:9000/other/v2.0.zip", 191) 192 193another_rule( 194 # aribtrary contents 195) 196''' 197 198# Simulated 'bazel query //external:*' results for com_google_pigweed. 199# Keep this in sync with PW_WORKSPACE above. 200PW_EXTERNAL_DEPS = '\n'.join( 201 [ 202 json.dumps( 203 { 204 'type': 'RULE', 205 'rule': { 206 'name': f'//external:{FOO_REPO}', 207 'ruleClass': 'http_archive', 208 'attribute': [ 209 { 210 'name': 'strip_prefix', 211 'explicitlySpecified': True, 212 "type": "string", 213 'stringValue': 'foo-feedface', 214 }, 215 { 216 'name': 'url', 217 'explicitlySpecified': True, 218 "type": "string", 219 'stringValue': 'http://localhost:9000/feedface.tgz', 220 }, 221 ], 222 }, 223 } 224 ), 225 json.dumps( 226 { 227 'type': 'RULE', 228 'rule': { 229 'name': f'//external:{BAR_REPO}', 230 'ruleClass': 'http_archive', 231 'attribute': [ 232 { 233 'name': 'strip_prefix', 234 'explicitlySpecified': True, 235 "type": "string", 236 'stringValue': 'bar-v1.0', 237 }, 238 { 239 'name': 'urls', 240 'explicitlySpecified': True, 241 "type": "string_list", 242 'stringListValue': [ 243 'http://localhost:9000/bar/v1.0.tgz' 244 ], 245 }, 246 ], 247 }, 248 } 249 ), 250 ] 251) 252# Simulated 'bazel query //external:*' results for dev_pigweed_foo. 253FOO_EXTERNAL_DEPS = '\n'.join( 254 [ 255 json.dumps( 256 { 257 'type': 'RULE', 258 'rule': { 259 'name': f'//external:{BAR_REPO}', 260 'ruleClass': 'http_archive', 261 'attribute': [ 262 { 263 'name': 'strip_prefix', 264 'explicitlySpecified': True, 265 "type": "string", 266 'stringValue': 'bar-v2.0', 267 }, 268 { 269 'name': 'urls', 270 'explicitlySpecified': True, 271 "type": "string_list", 272 'stringListValue': [ 273 'http://localhost:9000/bar/v2.0.tgz' 274 ], 275 }, 276 ], 277 }, 278 } 279 ), 280 json.dumps( 281 { 282 'type': 'RULE', 283 'rule': { 284 'name': f'//external:{BAZ_REPO}', 285 'ruleClass': 'http_archive', 286 'attribute': [ 287 { 288 'name': 'url', 289 'explicitlySpecified': True, 290 "type": "string", 291 'stringValue': 'http://localhost:9000/baz/v1.5.tgz', 292 } 293 ], 294 }, 295 } 296 ), 297 ] 298) 299# Unit tests. 300 301 302class TestBazelToGnConverter(unittest.TestCase): 303 """Tests for bazel_to_gn.BazelToGnConverter.""" 304 305 def test_parse_args_gn(self): 306 """Tests parsing args.gn.""" 307 b2g = BazelToGnConverter(PW_ROOT) 308 b2g.parse_args_gn(StringIO(ARGS_GN)) 309 self.assertEqual(b2g.get_source_dir('foo'), PurePath(FOO_SOURCE_DIR)) 310 self.assertEqual(b2g.get_source_dir('bar'), PurePath(BAR_SOURCE_DIR)) 311 self.assertEqual(b2g.get_source_dir('baz'), PurePath(BAZ_SOURCE_DIR)) 312 313 @mock.patch('subprocess.run') 314 def test_load_workspace(self, _): 315 """Tests loading a workspace from a bazel_to_gn.json file.""" 316 b2g = BazelToGnConverter(PW_ROOT) 317 b2g.parse_args_gn(StringIO(ARGS_GN)) 318 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 319 b2g.load_workspace('bar', StringIO(BAR_B2G_JSON)) 320 b2g.load_workspace('baz', StringIO(BAZ_B2G_JSON)) 321 self.assertEqual(b2g.get_name(repo=FOO_REPO), 'foo') 322 self.assertEqual(b2g.get_name(repo=BAR_REPO), 'bar') 323 self.assertEqual(b2g.get_name(repo=BAZ_REPO), 'baz') 324 325 @mock.patch('subprocess.run') 326 def test_get_initial_targets(self, _): 327 """Tests adding initial targets to the pending queue.""" 328 b2g = BazelToGnConverter(PW_ROOT) 329 b2g.parse_args_gn(StringIO(ARGS_GN)) 330 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 331 targets = b2g.get_initial_targets('foo') 332 json_targets = json.loads(FOO_B2G_JSON)['targets'] 333 self.assertEqual(len(targets), len(json_targets)) 334 self.assertEqual(b2g.num_loaded(), 1) 335 336 @mock.patch('subprocess.run') 337 def test_load_rules(self, mock_run): 338 """Tests loading a rule from a Bazel workspace.""" 339 mock_run.side_effect = [ 340 mock.MagicMock(**retval) 341 for retval in [ 342 {'stdout.decode.return_value': ''}, # foo: git fetch 343 {'stdout.decode.return_value': FOO_RULE_JSON}, 344 ] 345 ] 346 b2g = BazelToGnConverter(PW_ROOT) 347 b2g.parse_args_gn(StringIO(ARGS_GN)) 348 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 349 labels = b2g.get_initial_targets('foo') 350 rule = list(b2g.load_rules(labels))[0] 351 self.assertEqual( 352 rule.get_list('deps'), 353 [ 354 f'@{BAR_REPO}//bar/pkg:bar_target1', 355 f'@{BAR_REPO}//bar/pkg:bar_target2', 356 ], 357 ) 358 self.assertEqual( 359 rule.get_list('implementation_deps'), 360 [ 361 f'@{BAZ_REPO}//baz/pkg:baz_target', 362 ], 363 ) 364 self.assertEqual(b2g.num_loaded(), 1) 365 366 @mock.patch('subprocess.run') 367 def test_convert_rule(self, mock_run): 368 """Tests converting a Bazel rule into a GN target.""" 369 mock_run.side_effect = [ 370 mock.MagicMock(**retval) 371 for retval in [ 372 {'stdout.decode.return_value': ''}, # foo: git fetch 373 {'stdout.decode.return_value': ''}, # bar: git fetch 374 {'stdout.decode.return_value': ''}, # baz: git fetch 375 {'stdout.decode.return_value': FOO_RULE_JSON}, 376 ] 377 ] 378 b2g = BazelToGnConverter(PW_ROOT) 379 b2g.parse_args_gn(StringIO(ARGS_GN)) 380 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 381 b2g.load_workspace('bar', StringIO(BAR_B2G_JSON)) 382 b2g.load_workspace('baz', StringIO(BAZ_B2G_JSON)) 383 labels = b2g.get_initial_targets('foo') 384 rule = list(b2g.load_rules(labels))[0] 385 gn_target = b2g.convert_rule(rule) 386 self.assertEqual(gn_target.attrs['cflags'], ['-cflag']) 387 self.assertEqual(gn_target.attrs['defines'], ['LOCAL_DEFINE']) 388 self.assertEqual( 389 gn_target.attrs['deps'], 390 ['$dir_pw_third_party/baz/baz/pkg:baz_target'], 391 ) 392 self.assertEqual( 393 gn_target.attrs['include_dirs'], ['$dir_pw_third_party_foo/include'] 394 ) 395 self.assertEqual( 396 gn_target.attrs['inputs'], ['$dir_pw_third_party_foo/data/input'] 397 ) 398 self.assertEqual(gn_target.attrs['ldflags'], ['-ldflag']) 399 self.assertEqual( 400 gn_target.attrs['public'], ['$dir_pw_third_party_foo/include/foo.h'] 401 ) 402 self.assertEqual(gn_target.attrs['public_defines'], ['DEFINE']) 403 self.assertEqual( 404 gn_target.attrs['public_deps'], 405 [ 406 '$dir_pw_third_party/bar/bar/pkg:bar_target1', 407 '$dir_pw_third_party/bar/bar/pkg:bar_target2', 408 ], 409 ) 410 self.assertEqual( 411 gn_target.attrs['sources'], ['$dir_pw_third_party_foo/src/foo.cc'] 412 ) 413 414 @mock.patch('subprocess.run') 415 def test_update_pw_package(self, mock_run): 416 """Tests updating the pw_package file.""" 417 mock_run.side_effect = [ 418 mock.MagicMock(**retval) 419 for retval in [ 420 {'stdout.decode.return_value': ''}, # foo: git fetch 421 {'stdout.decode.return_value': 'some-tag'}, 422 {'stdout.decode.return_value': '2024-01-01 00:00:00'}, 423 {'stdout.decode.return_value': '2024-01-01 00:00:01'}, 424 ] 425 ] 426 b2g = BazelToGnConverter(PW_ROOT) 427 b2g.parse_args_gn(StringIO(ARGS_GN)) 428 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 429 contents = '''some_python_call( 430 name='foo', 431 commit='cafef00d', 432 **kwargs, 433) 434''' 435 inputs = contents.split('\n') 436 outputs = list(b2g.update_pw_package('foo', inputs)) 437 self.assertEqual(outputs[0:2], inputs[0:2]) 438 self.assertEqual(outputs[3], inputs[3].replace('cafef00d', 'some-tag')) 439 self.assertEqual(outputs[4:-1], inputs[4:]) 440 441 @mock.patch('subprocess.run') 442 def test_get_imports(self, mock_run): 443 """Tests getting the GNI files needed for a GN target.""" 444 mock_run.side_effect = [ 445 mock.MagicMock(**retval) 446 for retval in [ 447 {'stdout.decode.return_value': ''}, # foo: git fetch 448 {'stdout.decode.return_value': ''}, # bar: git fetch 449 {'stdout.decode.return_value': ''}, # baz: git fetch 450 {'stdout.decode.return_value': FOO_RULE_JSON}, 451 ] 452 ] 453 b2g = BazelToGnConverter(PW_ROOT) 454 b2g.parse_args_gn(StringIO(ARGS_GN)) 455 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 456 b2g.load_workspace('bar', StringIO(BAR_B2G_JSON)) 457 b2g.load_workspace('baz', StringIO(BAZ_B2G_JSON)) 458 labels = b2g.get_initial_targets('foo') 459 rule = list(b2g.load_rules(labels))[0] 460 gn_target = b2g.convert_rule(rule) 461 imports = set(b2g.get_imports(gn_target)) 462 self.assertEqual(imports, {'$dir_pw_third_party/foo/foo.gni'}) 463 464 @mock.patch('subprocess.run') 465 def test_update_doc_rst(self, mock_run): 466 """Tests updating the git revision in the docs.""" 467 mock_run.side_effect = [ 468 mock.MagicMock(**retval) 469 for retval in [ 470 {'stdout.decode.return_value': ''}, # foo: git fetch 471 {'stdout.decode.return_value': 'http://src/foo.git'}, 472 {'stdout.decode.return_value': 'deadbeeffeedface'}, 473 ] 474 ] 475 b2g = BazelToGnConverter(PW_ROOT) 476 b2g.parse_args_gn(StringIO(ARGS_GN)) 477 b2g.load_workspace('foo', StringIO(FOO_B2G_JSON)) 478 inputs = ( 479 [f'preserved {i}' for i in range(10)] 480 + ['.. DO NOT EDIT BELOW THIS LINE. Generated section.'] 481 + [f'overwritten {i}' for i in range(10)] 482 ) 483 outputs = list(b2g.update_doc_rst('foo', inputs)) 484 self.assertEqual(len(outputs), 18) 485 self.assertEqual(outputs[:11], inputs[:11]) 486 self.assertEqual(outputs[11], '') 487 self.assertEqual(outputs[12], 'Version') 488 self.assertEqual(outputs[13], '=======') 489 self.assertEqual( 490 outputs[14], 491 'The update script was last run for revision `deadbeef`_.', 492 ) 493 self.assertEqual(outputs[15], '') 494 self.assertEqual( 495 outputs[16], 496 '.. _deadbeef: http://src/foo/tree/deadbeeffeedface', 497 ) 498 self.assertEqual(outputs[17], '') 499 500 501if __name__ == '__main__': 502 unittest.main() 503