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