go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luciexe/host/buildmerge/agent_test.go (about)

     1  // Copyright 2019 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://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,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package buildmerge
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"testing"
    21  
    22  	"github.com/golang/protobuf/ptypes"
    23  	"google.golang.org/protobuf/proto"
    24  	"google.golang.org/protobuf/types/known/structpb"
    25  
    26  	bbpb "go.chromium.org/luci/buildbucket/proto"
    27  	"go.chromium.org/luci/common/clock/testclock"
    28  	"go.chromium.org/luci/common/proto/reflectutil"
    29  	"go.chromium.org/luci/logdog/api/logpb"
    30  	"go.chromium.org/luci/logdog/common/types"
    31  	"go.chromium.org/luci/luciexe"
    32  
    33  	. "github.com/smartystreets/goconvey/convey"
    34  
    35  	. "go.chromium.org/luci/common/testing/assertions"
    36  )
    37  
    38  func mkDesc(name string) *logpb.LogStreamDescriptor {
    39  	return &logpb.LogStreamDescriptor{
    40  		Name:        name,
    41  		StreamType:  logpb.StreamType_DATAGRAM,
    42  		ContentType: luciexe.BuildProtoContentType,
    43  	}
    44  }
    45  
    46  func TestAgent(t *testing.T) {
    47  	t.Parallel()
    48  
    49  	Convey(`buildState`, t, func() {
    50  		now, err := ptypes.TimestampProto(testclock.TestRecentTimeLocal)
    51  		So(err, ShouldBeNil)
    52  		ctx, _ := testclock.UseTime(context.Background(), testclock.TestRecentTimeLocal)
    53  		ctx, cancel := context.WithCancel(ctx)
    54  
    55  		baseProps, err := structpb.NewStruct(map[string]any{
    56  			"test": "value",
    57  		})
    58  		So(err, ShouldBeNil)
    59  
    60  		base := &bbpb.Build{
    61  			Input: &bbpb.Build_Input{
    62  				Properties: baseProps,
    63  			},
    64  			Output: &bbpb.Build_Output{
    65  				Logs: []*bbpb.Log{
    66  					{Name: "stdout", Url: "stdout"},
    67  				},
    68  			},
    69  		}
    70  		// we omit view url here to keep tests simpler
    71  		merger, err := New(ctx, "u/", base, func(ns, stream types.StreamName) (url, viewURL string) {
    72  			return fmt.Sprintf("url://%s%s", ns, stream), ""
    73  		})
    74  		So(err, ShouldBeNil)
    75  		defer merger.Close()
    76  		defer cancel()
    77  
    78  		getFinal := func() (lastBuild *bbpb.Build) {
    79  			for build := range merger.MergedBuildC {
    80  				lastBuild = build
    81  			}
    82  			return
    83  		}
    84  
    85  		Convey(`can close without any data`, func() {
    86  			merger.Close()
    87  			build := <-merger.MergedBuildC
    88  
    89  			base.Output.Logs[0].Url = "url://u/stdout"
    90  
    91  			So(build, ShouldResembleProto, base)
    92  		})
    93  
    94  		Convey(`bad stream type`, func() {
    95  			cb := merger.onNewStream(&logpb.LogStreamDescriptor{
    96  				Name:        "u/build.proto",
    97  				StreamType:  logpb.StreamType_TEXT, // should be DATAGRAM
    98  				ContentType: luciexe.BuildProtoContentType,
    99  			})
   100  			So(cb, ShouldBeNil)
   101  			// NOTE: here and below we do ShouldBeTrue on `ok` instead of using
   102  			// ShouldNotBeNil on `tracker`. This is because ShouldNotBeNil is
   103  			// currently (as of Sep'19) implemented in terms of ShouldBeNil, which
   104  			// ends up traversing the entire `tracker` struct with `reflect`. This
   105  			// causes the race detector to claim that we're reading the contents of
   106  			// the atomic.Value in tracker without a lock (which is true).
   107  			tracker, ok := merger.states["url://u/build.proto"]
   108  			So(ok, ShouldBeTrue)
   109  
   110  			So(tracker.getLatestBuild(), ShouldResembleProto, &bbpb.Build{
   111  				EndTime:         now,
   112  				UpdateTime:      now,
   113  				Status:          bbpb.Status_INFRA_FAILURE,
   114  				SummaryMarkdown: "\n\nError in build protocol: build proto stream \"u/build.proto\" has type \"TEXT\", expected \"DATAGRAM\"",
   115  				Output: &bbpb.Build_Output{
   116  					Status:          bbpb.Status_INFRA_FAILURE,
   117  					SummaryMarkdown: "\n\nError in build protocol: build proto stream \"u/build.proto\" has type \"TEXT\", expected \"DATAGRAM\"",
   118  				},
   119  			})
   120  		})
   121  
   122  		Convey(`bad content type`, func() {
   123  			cb := merger.onNewStream(&logpb.LogStreamDescriptor{
   124  				Name:        "u/build.proto",
   125  				StreamType:  logpb.StreamType_DATAGRAM,
   126  				ContentType: "i r bad",
   127  			})
   128  			So(cb, ShouldBeNil)
   129  			tracker, ok := merger.states["url://u/build.proto"]
   130  			So(ok, ShouldBeTrue)
   131  
   132  			So(tracker.getLatestBuild(), ShouldResembleProto, &bbpb.Build{
   133  				EndTime:         now,
   134  				UpdateTime:      now,
   135  				Status:          bbpb.Status_INFRA_FAILURE,
   136  				SummaryMarkdown: fmt.Sprintf("\n\nError in build protocol: stream \"u/build.proto\" has content type \"i r bad\", expected one of %v", []string{luciexe.BuildProtoContentType, luciexe.BuildProtoZlibContentType}),
   137  				Output: &bbpb.Build_Output{
   138  					Status:          bbpb.Status_INFRA_FAILURE,
   139  					SummaryMarkdown: fmt.Sprintf("\n\nError in build protocol: stream \"u/build.proto\" has content type \"i r bad\", expected one of %v", []string{luciexe.BuildProtoContentType, luciexe.BuildProtoZlibContentType}),
   140  				},
   141  			})
   142  		})
   143  
   144  		Convey(`build.proto suffix but bad stream type and content type `, func() {
   145  			cb := merger.onNewStream(&logpb.LogStreamDescriptor{
   146  				Name:        "u/build.proto",
   147  				StreamType:  logpb.StreamType_TEXT,
   148  				ContentType: "i r bad",
   149  			})
   150  			So(cb, ShouldBeNil)
   151  			tracker, ok := merger.states["url://u/build.proto"]
   152  			So(ok, ShouldBeTrue)
   153  
   154  			So(tracker.getLatestBuild(), ShouldResembleProto, &bbpb.Build{
   155  				EndTime:         now,
   156  				UpdateTime:      now,
   157  				Status:          bbpb.Status_INFRA_FAILURE,
   158  				SummaryMarkdown: fmt.Sprintf("\n\nError in build protocol: build.proto stream \"u/build.proto\" has stream type \"TEXT\" and content type \"i r bad\", expected \"DATAGRAM\" and one of %v", []string{luciexe.BuildProtoContentType, luciexe.BuildProtoZlibContentType}),
   159  				Output: &bbpb.Build_Output{
   160  					Status:          bbpb.Status_INFRA_FAILURE,
   161  					SummaryMarkdown: fmt.Sprintf("\n\nError in build protocol: build.proto stream \"u/build.proto\" has stream type \"TEXT\" and content type \"i r bad\", expected \"DATAGRAM\" and one of %v", []string{luciexe.BuildProtoContentType, luciexe.BuildProtoZlibContentType}),
   162  				},
   163  			})
   164  		})
   165  
   166  		Convey(`ignores out-of-namespace streams`, func() {
   167  			So(merger.onNewStream(&logpb.LogStreamDescriptor{Name: "uprefix"}), ShouldBeNil)
   168  			So(merger.onNewStream(&logpb.LogStreamDescriptor{Name: "nope/something"}), ShouldBeNil)
   169  			So(merger.states, ShouldBeEmpty)
   170  		})
   171  
   172  		Convey(`ignores new registrations on closure`, func() {
   173  			merger.Close()
   174  			merger.onNewStream(mkDesc("u/build.proto"))
   175  			So(merger.states, ShouldBeEmpty)
   176  		})
   177  
   178  		Convey(`will merge+relay root proto only`, func() {
   179  			cb := merger.onNewStream(mkDesc("u/build.proto"))
   180  			So(cb, ShouldNotBeNil)
   181  			tracker, ok := merger.states["url://u/build.proto"]
   182  			So(ok, ShouldBeTrue)
   183  
   184  			tracker.handleNewData(mkDgram(&bbpb.Build{
   185  				Steps: []*bbpb.Step{
   186  					{Name: "Hello"},
   187  				},
   188  			}))
   189  
   190  			mergedBuild := <-merger.MergedBuildC
   191  			expect := reflectutil.ShallowCopy(base).(*bbpb.Build)
   192  			expect.Steps = append(expect.Steps, &bbpb.Step{Name: "Hello"})
   193  			expect.UpdateTime = now
   194  			expect.Output.Logs[0].Url = "url://u/stdout"
   195  			So(mergedBuild, ShouldResembleProto, expect)
   196  
   197  			merger.Close()
   198  			<-merger.MergedBuildC // final build
   199  		})
   200  
   201  		Convey(`can emit changes for merge steps`, func() {
   202  			merger.onNewStream(mkDesc("u/build.proto"))
   203  			merger.onNewStream(mkDesc("u/sub/build.proto"))
   204  
   205  			rootTrack, ok := merger.states["url://u/build.proto"]
   206  			So(ok, ShouldBeTrue)
   207  			subTrack, ok := merger.states["url://u/sub/build.proto"]
   208  			So(ok, ShouldBeTrue)
   209  
   210  			// No merge step yet
   211  			rootTrack.handleNewData(mkDgram(&bbpb.Build{
   212  				Steps: []*bbpb.Step{
   213  					{Name: "Hello"},
   214  				},
   215  			}))
   216  			expect := reflectutil.ShallowCopy(base).(*bbpb.Build)
   217  			expect.Steps = append(expect.Steps, &bbpb.Step{Name: "Hello"})
   218  			expect.UpdateTime = now
   219  			expect.Output.Logs[0].Url = "url://u/stdout"
   220  			So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   221  
   222  			// order of updates doesn't matter, so we'll update the sub build first
   223  			subTrack.handleNewData(mkDgram(&bbpb.Build{
   224  				Steps: []*bbpb.Step{
   225  					{Name: "SubStep"},
   226  				},
   227  			}))
   228  			// the root stream doesn't have the merge step yet, so it doesn't show up.
   229  			So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   230  
   231  			// Ok, now add the merge step
   232  			rootTrack.handleNewData(mkDgram(&bbpb.Build{
   233  				Steps: []*bbpb.Step{
   234  					{Name: "Hello"},
   235  					{Name: "Merge",
   236  						MergeBuild: &bbpb.Step_MergeBuild{
   237  							FromLogdogStream: "sub/build.proto",
   238  						}},
   239  				},
   240  			}))
   241  			expect.Steps = append(expect.Steps, &bbpb.Step{
   242  				Name: "Merge",
   243  				MergeBuild: &bbpb.Step_MergeBuild{
   244  					FromLogdogStream: "url://u/sub/build.proto",
   245  				},
   246  			})
   247  			expect.Steps = append(expect.Steps, &bbpb.Step{Name: "Merge|SubStep"})
   248  			expect.UpdateTime = now
   249  			So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   250  
   251  			Convey(`and shut down`, func() {
   252  				merger.Close()
   253  				expect.EndTime = now
   254  				expect.Status = bbpb.Status_INFRA_FAILURE
   255  				expect.Output.Status = bbpb.Status_INFRA_FAILURE
   256  				expect.SummaryMarkdown = "\n\nError in build protocol: Expected a terminal build status, got STATUS_UNSPECIFIED, while top level status is STATUS_UNSPECIFIED."
   257  				expect.Output.SummaryMarkdown = expect.SummaryMarkdown
   258  				for _, step := range expect.Steps {
   259  					step.EndTime = now
   260  					if step.Name != "Merge" {
   261  						step.Status = bbpb.Status_CANCELED
   262  						step.SummaryMarkdown = "step was never finalized; did the build crash?"
   263  					} else {
   264  						step.Status = bbpb.Status_INFRA_FAILURE
   265  						step.SummaryMarkdown = "\n\nError in build protocol: Expected a terminal build status, got STATUS_UNSPECIFIED, while top level status is STATUS_UNSPECIFIED."
   266  					}
   267  				}
   268  				So(getFinal(), ShouldResembleProto, expect)
   269  			})
   270  
   271  			Convey(`can handle recursive merge steps`, func() {
   272  				merger.onNewStream(mkDesc("u/sub/super_deep/build.proto"))
   273  				superTrack, ok := merger.states["url://u/sub/super_deep/build.proto"]
   274  				So(ok, ShouldBeTrue)
   275  
   276  				subTrack.handleNewData(mkDgram(&bbpb.Build{
   277  					Steps: []*bbpb.Step{
   278  						{Name: "SubStep"},
   279  						{Name: "SuperDeep",
   280  							MergeBuild: &bbpb.Step_MergeBuild{
   281  								FromLogdogStream: "super_deep/build.proto",
   282  							}},
   283  					},
   284  				}))
   285  				<-merger.MergedBuildC // digest subTrack update
   286  				superTrack.handleNewData(mkDgram(&bbpb.Build{
   287  					Steps: []*bbpb.Step{
   288  						{Name: "Hi!"},
   289  					},
   290  				}))
   291  				expect.Steps = append(expect.Steps,
   292  					&bbpb.Step{
   293  						Name: "Merge|SuperDeep",
   294  						MergeBuild: &bbpb.Step_MergeBuild{
   295  							FromLogdogStream: "url://u/sub/super_deep/build.proto",
   296  						},
   297  					},
   298  					&bbpb.Step{
   299  						Name: "Merge|SuperDeep|Hi!",
   300  					},
   301  				)
   302  				So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   303  
   304  				Convey(`and shut down`, func() {
   305  					merger.Close()
   306  
   307  					expect.EndTime = now
   308  					expect.Status = bbpb.Status_INFRA_FAILURE
   309  					expect.Output.Status = bbpb.Status_INFRA_FAILURE
   310  					expect.SummaryMarkdown = "\n\nError in build protocol: Expected a terminal build status, got STATUS_UNSPECIFIED, while top level status is STATUS_UNSPECIFIED."
   311  					expect.Output.SummaryMarkdown = expect.SummaryMarkdown
   312  					for _, step := range expect.Steps {
   313  						step.EndTime = now
   314  						switch step.Name {
   315  						case "Merge", "Merge|SuperDeep":
   316  							step.Status = bbpb.Status_INFRA_FAILURE
   317  							step.SummaryMarkdown = "\n\nError in build protocol: Expected a terminal build status, got STATUS_UNSPECIFIED, while top level status is STATUS_UNSPECIFIED."
   318  						default:
   319  							step.Status = bbpb.Status_CANCELED
   320  							step.SummaryMarkdown = "step was never finalized; did the build crash?"
   321  						}
   322  					}
   323  					So(getFinal(), ShouldResembleProto, expect)
   324  				})
   325  			})
   326  
   327  			Convey(`and merge sub-build successfully as it becomes invalid`, func() {
   328  				// added an invalid step to sub build
   329  				subTrack.handleNewData(mkDgram(&bbpb.Build{
   330  					Steps: []*bbpb.Step{
   331  						{Name: "SubStep"},
   332  						{
   333  							Name: "Invalid_SubStep",
   334  							Logs: []*bbpb.Log{
   335  								{Url: "emoji 💩 is not a valid url"},
   336  							},
   337  						},
   338  					},
   339  				}))
   340  
   341  				Convey(`and shut down`, func() {
   342  					merger.Close()
   343  
   344  					expect.EndTime = now
   345  					expect.Status = bbpb.Status_INFRA_FAILURE
   346  					expect.Output.Status = bbpb.Status_INFRA_FAILURE
   347  					expect.SummaryMarkdown = "\n\nError in build protocol: Expected a terminal build status, got STATUS_UNSPECIFIED, while top level status is STATUS_UNSPECIFIED."
   348  					expect.Output.SummaryMarkdown = expect.SummaryMarkdown
   349  					expect.Steps = nil
   350  					expect.Steps = append(expect.Steps,
   351  						&bbpb.Step{
   352  							Name:            "Hello",
   353  							EndTime:         now,
   354  							Status:          bbpb.Status_CANCELED,
   355  							SummaryMarkdown: "step was never finalized; did the build crash?",
   356  						},
   357  						&bbpb.Step{
   358  							Name:    "Merge",
   359  							Status:  bbpb.Status_INFRA_FAILURE,
   360  							EndTime: now,
   361  							MergeBuild: &bbpb.Step_MergeBuild{
   362  								FromLogdogStream: "url://u/sub/build.proto",
   363  							},
   364  							SummaryMarkdown: "\n\nError in build protocol: step[\"Invalid_SubStep\"].logs[\"\"]: bad log url \"emoji 💩 is not a valid url\": illegal character ( ) at index 5",
   365  						},
   366  						&bbpb.Step{
   367  							Name:            "Merge|SubStep",
   368  							EndTime:         now,
   369  							Status:          bbpb.Status_CANCELED,
   370  							SummaryMarkdown: "step was never finalized; did the build crash?",
   371  						},
   372  						&bbpb.Step{
   373  							Name:    "Merge|Invalid_SubStep",
   374  							Status:  bbpb.Status_INFRA_FAILURE,
   375  							EndTime: now,
   376  							Logs: []*bbpb.Log{
   377  								{Url: "emoji 💩 is not a valid url"},
   378  							},
   379  							SummaryMarkdown: "bad log url \"emoji 💩 is not a valid url\": illegal character ( ) at index 5",
   380  						},
   381  					)
   382  					So(getFinal(), ShouldResembleProto, expect)
   383  				})
   384  			})
   385  		})
   386  
   387  		Convey(`can merge sub-build`, func() {
   388  			merger.onNewStream(mkDesc("u/build.proto"))
   389  			rootTrack, ok := merger.states["url://u/build.proto"]
   390  			So(ok, ShouldBeTrue)
   391  
   392  			rootTrack.handleNewData(mkDgram(&bbpb.Build{
   393  				Steps: []*bbpb.Step{
   394  					{
   395  						Name:   "Merge",
   396  						Status: bbpb.Status_STARTED,
   397  						MergeBuild: &bbpb.Step_MergeBuild{
   398  							FromLogdogStream: "sub/build.proto",
   399  						},
   400  					},
   401  				},
   402  			}))
   403  
   404  			expect := proto.Clone(base).(*bbpb.Build)
   405  			expect.Steps = nil
   406  			expect.UpdateTime = now
   407  			expect.Output.Logs[0].Url = "url://u/stdout"
   408  
   409  			Convey(`when sub-build stream has not been registered yet`, func() {
   410  				expect.Steps = []*bbpb.Step{
   411  					{
   412  						Name:   "Merge",
   413  						Status: bbpb.Status_STARTED,
   414  						MergeBuild: &bbpb.Step_MergeBuild{
   415  							FromLogdogStream: "url://u/sub/build.proto",
   416  						},
   417  						SummaryMarkdown: "build.proto stream: \"url://u/sub/build.proto\" is not registered",
   418  					},
   419  				}
   420  				So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   421  
   422  				Convey(`Append existing SummaryMarkdown`, func() {
   423  					rootTrack.handleNewData(mkDgram(&bbpb.Build{
   424  						Steps: []*bbpb.Step{
   425  							{
   426  								Name:            "Merge",
   427  								Status:          bbpb.Status_STARTED,
   428  								SummaryMarkdown: "existing summary",
   429  								MergeBuild: &bbpb.Step_MergeBuild{
   430  									FromLogdogStream: "sub/build.proto",
   431  								},
   432  							},
   433  						},
   434  					}))
   435  
   436  					expect.Steps = []*bbpb.Step{
   437  						{
   438  							Name:   "Merge",
   439  							Status: bbpb.Status_STARTED,
   440  							MergeBuild: &bbpb.Step_MergeBuild{
   441  								FromLogdogStream: "url://u/sub/build.proto",
   442  							},
   443  							SummaryMarkdown: "existing summary\n\nbuild.proto stream: \"url://u/sub/build.proto\" is not registered",
   444  						},
   445  					}
   446  					So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   447  				})
   448  
   449  				Convey(`then registered but stream is empty`, func() {
   450  					merger.onNewStream(mkDesc("u/sub/build.proto"))
   451  					subTrack, ok := merger.states["url://u/sub/build.proto"]
   452  					So(ok, ShouldBeTrue)
   453  					expect.Steps = []*bbpb.Step{
   454  						{
   455  							Name:   "Merge",
   456  							Status: bbpb.Status_STARTED,
   457  							MergeBuild: &bbpb.Step_MergeBuild{
   458  								FromLogdogStream: "url://u/sub/build.proto",
   459  							},
   460  							SummaryMarkdown: "build.proto stream: \"url://u/sub/build.proto\" is empty",
   461  						},
   462  					}
   463  					// send something random to kick off a merge.
   464  					merger.onNewStream(mkDesc("u/unknown/build.proto"))(mkDgram(&bbpb.Build{}))
   465  					So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   466  
   467  					Convey(`finally merge properly when sub-build stream is present`, func() {
   468  						subTrack.handleNewData(mkDgram(&bbpb.Build{
   469  							Status: bbpb.Status_SUCCESS,
   470  							Output: &bbpb.Build_Output{
   471  								Status: bbpb.Status_SUCCESS,
   472  							},
   473  							Steps: []*bbpb.Step{
   474  								{Name: "SubStep"},
   475  							},
   476  						}))
   477  						expect.Steps = []*bbpb.Step{
   478  							{
   479  								Name:   "Merge",
   480  								Status: bbpb.Status_SUCCESS,
   481  								MergeBuild: &bbpb.Step_MergeBuild{
   482  									FromLogdogStream: "url://u/sub/build.proto",
   483  								},
   484  							},
   485  							{Name: "Merge|SubStep"},
   486  						}
   487  						So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   488  					})
   489  
   490  				})
   491  			})
   492  		})
   493  
   494  		Convey(`can merge sub-build into global namespace`, func() {
   495  			merger.onNewStream(mkDesc("u/build.proto"))
   496  			rootTrack, ok := merger.states["url://u/build.proto"]
   497  			So(ok, ShouldBeTrue)
   498  
   499  			baseProps, err := structpb.NewStruct(map[string]any{
   500  				"something": "value",
   501  			})
   502  			So(err, ShouldBeNil)
   503  
   504  			rootTrack.handleNewData(mkDgram(&bbpb.Build{
   505  				Output: &bbpb.Build_Output{
   506  					Properties: baseProps,
   507  				},
   508  				SummaryMarkdown: "some words",
   509  				Steps: []*bbpb.Step{
   510  					{
   511  						Name:   "Merge",
   512  						Status: bbpb.Status_STARTED,
   513  						MergeBuild: &bbpb.Step_MergeBuild{
   514  							FromLogdogStream:      "sub/build.proto",
   515  							LegacyGlobalNamespace: true,
   516  						},
   517  					},
   518  				},
   519  			}))
   520  			// make sure to pull this through to avoid races
   521  			<-merger.MergedBuildC
   522  
   523  			expect := proto.Clone(base).(*bbpb.Build)
   524  			expect.Steps = nil
   525  			expect.UpdateTime = now
   526  			expect.SummaryMarkdown = "some words"
   527  			expect.Output.Logs[0].Url = "url://u/stdout"
   528  			expect.Output.Properties, _ = structpb.NewStruct(map[string]any{
   529  				"something": "value",
   530  			})
   531  			expect.Steps = []*bbpb.Step{
   532  				{
   533  					Name:   "Merge",
   534  					Status: bbpb.Status_STARTED,
   535  					MergeBuild: &bbpb.Step_MergeBuild{
   536  						FromLogdogStream:      "url://u/sub/build.proto",
   537  						LegacyGlobalNamespace: true,
   538  					},
   539  					SummaryMarkdown: "build.proto stream: \"url://u/sub/build.proto\" is empty",
   540  				},
   541  			}
   542  
   543  			merger.onNewStream(mkDesc("u/sub/build.proto"))
   544  			subTrack, ok := merger.states["url://u/sub/build.proto"]
   545  			So(ok, ShouldBeTrue)
   546  
   547  			Convey(`Overwrites properties`, func() {
   548  				subProps, err := structpb.NewStruct(map[string]any{
   549  					"new":       "prop",
   550  					"something": "overwrite",
   551  				})
   552  				So(err, ShouldBeNil)
   553  				subTrack.handleNewData(mkDgram(&bbpb.Build{
   554  					Output: &bbpb.Build_Output{
   555  						Properties: subProps,
   556  						Status:     bbpb.Status_STARTED,
   557  					},
   558  					Status: bbpb.Status_STARTED,
   559  					Steps: []*bbpb.Step{
   560  						{Name: "SubStep"},
   561  					},
   562  				}))
   563  				expect.Steps = append(expect.Steps, &bbpb.Step{Name: "SubStep"})
   564  				expect.Output.Properties.Fields["new"] = structpb.NewStringValue("prop")
   565  				expect.Output.Properties.Fields["something"] = structpb.NewStringValue("overwrite")
   566  				expect.Steps[0].SummaryMarkdown = ""
   567  				So(<-merger.MergedBuildC, ShouldResembleProto, expect)
   568  			})
   569  
   570  		})
   571  	})
   572  }