go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/impl/handler/start_test.go (about) 1 // Copyright 2021 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 handler 16 17 import ( 18 "fmt" 19 "testing" 20 "time" 21 22 "google.golang.org/protobuf/types/known/durationpb" 23 "google.golang.org/protobuf/types/known/timestamppb" 24 25 "go.chromium.org/luci/auth/identity" 26 bbpb "go.chromium.org/luci/buildbucket/proto" 27 bbutil "go.chromium.org/luci/buildbucket/protoutil" 28 "go.chromium.org/luci/common/clock" 29 . "go.chromium.org/luci/common/testing/assertions" 30 "go.chromium.org/luci/gae/service/datastore" 31 "go.chromium.org/luci/hardcoded/chromeinfra" 32 "go.chromium.org/luci/server/quota" 33 "go.chromium.org/luci/server/quota/quotapb" 34 35 cfgpb "go.chromium.org/luci/cv/api/config/v2" 36 "go.chromium.org/luci/cv/internal/changelist" 37 "go.chromium.org/luci/cv/internal/common" 38 "go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest" 39 "go.chromium.org/luci/cv/internal/cvtesting" 40 "go.chromium.org/luci/cv/internal/gerrit" 41 gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake" 42 "go.chromium.org/luci/cv/internal/metrics" 43 "go.chromium.org/luci/cv/internal/run" 44 "go.chromium.org/luci/cv/internal/run/eventpb" 45 "go.chromium.org/luci/cv/internal/run/impl/state" 46 "go.chromium.org/luci/cv/internal/run/runtest" 47 "go.chromium.org/luci/cv/internal/tryjob" 48 49 . "github.com/smartystreets/goconvey/convey" 50 ) 51 52 func TestStart(t *testing.T) { 53 t.Parallel() 54 55 Convey("StartRun", t, func() { 56 ct := cvtesting.Test{} 57 ctx, cancel := ct.SetUp(t) 58 defer cancel() 59 60 const ( 61 lProject = "chromium" 62 configGroupName = "combinable" 63 gerritHost = "chromium-review.googlesource.com" 64 committers = "committer-group" 65 dryRunners = "dry-runner-group" 66 stabilizationDelay = time.Minute 67 startLatency = 2 * time.Minute 68 ) 69 70 builder := &bbpb.BuilderID{ 71 Project: lProject, 72 Bucket: "try", 73 Builder: "cool_tester", 74 } 75 prjcfgtest.Create(ctx, lProject, &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{ 76 Name: configGroupName, 77 CombineCls: &cfgpb.CombineCLs{ 78 StabilizationDelay: durationpb.New(stabilizationDelay), 79 }, 80 Verifiers: &cfgpb.Verifiers{ 81 GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{ 82 CommitterList: []string{committers}, 83 DryRunAccessList: []string{dryRunners}, 84 }, 85 Tryjob: &cfgpb.Verifiers_Tryjob{ 86 Builders: []*cfgpb.Verifiers_Tryjob_Builder{ 87 { 88 Name: bbutil.FormatBuilderID(builder), 89 }, 90 }, 91 }, 92 }, 93 }}}) 94 95 makeIdentity := func(email string) identity.Identity { 96 id, err := identity.MakeIdentity(fmt.Sprintf("%s:%s", identity.User, email)) 97 So(err, ShouldBeNil) 98 return id 99 } 100 101 const tEmail = "t@example.org" 102 rs := &state.RunState{ 103 Run: run.Run{ 104 ID: lProject + "/1111111111111-deadbeef", 105 Status: run.Status_PENDING, 106 CreateTime: clock.Now(ctx).UTC().Add(-startLatency), 107 ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0], 108 Mode: run.DryRun, 109 CreatedBy: makeIdentity(tEmail), 110 BilledTo: makeIdentity(tEmail), 111 }, 112 } 113 h, deps := makeTestHandler(&ct) 114 115 var clid common.CLID 116 var accountID int64 117 addCL := func(triggerer, owner string) *changelist.CL { 118 clid++ 119 accountID++ 120 rs.CLs = append(rs.CLs, clid) 121 ci := gf.CI(100+int(clid), 122 gf.Owner(owner), 123 gf.CQ(+1, rs.CreateTime, gf.U(triggerer))) 124 cl := &changelist.CL{ 125 ID: clid, 126 ExternalID: changelist.MustGobID(gerritHost, ci.GetNumber()), 127 Snapshot: &changelist.Snapshot{ 128 Kind: &changelist.Snapshot_Gerrit{ 129 Gerrit: &changelist.Gerrit{ 130 Host: gerritHost, 131 Info: ci, 132 }, 133 }, 134 }, 135 } 136 rCL := &run.RunCL{ 137 ID: clid, 138 Run: datastore.MakeKey(ctx, common.RunKind, string(rs.ID)), 139 ExternalID: changelist.MustGobID(gerritHost, ci.GetNumber()), 140 Trigger: &run.Trigger{ 141 Email: gf.U(triggerer).Email, 142 Time: timestamppb.New(rs.CreateTime), 143 Mode: string(rs.Mode), 144 GerritAccountId: accountID, 145 }, 146 } 147 So(datastore.Put(ctx, cl, rCL), ShouldBeNil) 148 return cl 149 } 150 151 const ( 152 owner = "user-1" 153 triggerer = owner 154 ) 155 cl := addCL(triggerer, owner) 156 ct.AddMember(owner, dryRunners) 157 ct.AddMember(owner, committers) 158 159 Convey("Starts when Run is PENDING", func() { 160 deps.qm.runQuotaOp = "apb.OpResult{ 161 Status: quotapb.OpResult_SUCCESS, 162 NewBalance: 5, 163 PreviousBalance: 4, 164 } 165 166 res, err := h.Start(ctx, rs) 167 So(err, ShouldBeNil) 168 So(res.PreserveEvents, ShouldBeFalse) 169 So(deps.qm.debitRunQuotaCalls, ShouldEqual, 1) 170 171 So(res.State.Status, ShouldEqual, run.Status_RUNNING) 172 So(res.State.StartTime, ShouldEqual, ct.Clock.Now().UTC()) 173 So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{ 174 Requirement: &tryjob.Requirement{ 175 Definitions: []*tryjob.Definition{ 176 { 177 Backend: &tryjob.Definition_Buildbucket_{ 178 Buildbucket: &tryjob.Definition_Buildbucket{ 179 Host: chromeinfra.BuildbucketHost, 180 Builder: builder, 181 }, 182 }, 183 Critical: true, 184 }, 185 }, 186 }, 187 RequirementVersion: 1, 188 RequirementComputedAt: timestamppb.New(ct.Clock.Now().UTC()), 189 }) 190 So(res.State.LogEntries, ShouldHaveLength, 2) 191 So(res.State.LogEntries[0].GetInfo().GetMessage(), ShouldEqual, "Run quota debited from t@example.org; balance: 5") 192 So(res.State.LogEntries[1].GetStarted(), ShouldNotBeNil) 193 194 So(res.State.NewLongOpIDs, ShouldHaveLength, 2) 195 So(res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]].GetExecuteTryjobs(), ShouldNotBeNil) 196 So(res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[1]].GetPostStartMessage(), ShouldBeTrue) 197 198 So(res.SideEffectFn, ShouldNotBeNil) 199 So(datastore.RunInTransaction(ctx, res.SideEffectFn, nil), ShouldBeNil) 200 So(ct.TSMonSentValue(ctx, metrics.Public.RunStarted, lProject, configGroupName, string(run.DryRun)), ShouldEqual, 1) 201 So(ct.TSMonSentDistr(ctx, metricPickupLatencyS, lProject).Sum(), 202 ShouldAlmostEqual, startLatency.Seconds()) 203 So(ct.TSMonSentDistr(ctx, metricPickupLatencyAdjustedS, lProject).Sum(), 204 ShouldAlmostEqual, (startLatency - stabilizationDelay).Seconds()) 205 }) 206 207 Convey("Does not proceed if run quota is not available", func() { 208 deps.qm.runQuotaErr = quota.ErrQuotaApply 209 deps.qm.runQuotaOp = "apb.OpResult{ 210 Status: quotapb.OpResult_ERR_UNDERFLOW, 211 } 212 deps.qm.userLimit = &cfgpb.UserLimit{ 213 Run: &cfgpb.UserLimit_Run{ 214 ReachLimitMsg: "foo bar.", 215 }, 216 } 217 218 res, err := h.Start(ctx, rs) 219 So(err, ShouldBeNil) 220 So(res.PreserveEvents, ShouldBeTrue) 221 So(res.SideEffectFn, ShouldBeNil) 222 So(res.State.Status, ShouldEqual, run.Status_PENDING) 223 ops := res.State.OngoingLongOps.GetOps() 224 So(len(ops), ShouldEqual, 1) 225 So(ops, ShouldContainKey, "1-1") 226 So(ops["1-1"].GetPostGerritMessage(), ShouldResembleProto, &run.OngoingLongOps_Op_PostGerritMessage{ 227 Message: fmt.Sprintf("User %s has exhausted their run quota. This run will start once the quota balance has recovered.\n\nfoo bar.", rs.Run.BilledTo.Email()), 228 }) 229 So(ct.TSMonSentValue( 230 ctx, 231 metrics.Public.RunQuotaRejection, 232 lProject, 233 "combinable", 234 "chromium/1", 235 ), ShouldEqual, 1) 236 237 Convey("Enqueue pending message only once when quota is exhausted", func() { 238 res, err := h.Start(ctx, rs) 239 So(err, ShouldBeNil) 240 ops := res.State.OngoingLongOps.GetOps() 241 So(ops, ShouldContainKey, "1-1") 242 So(ops["1-1"].GetPostGerritMessage(), ShouldResembleProto, &run.OngoingLongOps_Op_PostGerritMessage{ 243 Message: fmt.Sprintf("User %s has exhausted their run quota. This run will start once the quota balance has recovered.\n\nfoo bar.", rs.Run.BilledTo.Email()), 244 }) 245 }) 246 }) 247 248 Convey("Throws error when quota manager fails with an unexpected error", func() { 249 deps.qm.runQuotaErr = quota.ErrQuotaApply 250 deps.qm.runQuotaOp = "apb.OpResult{ 251 Status: quotapb.OpResult_ERR_UNKNOWN, 252 } 253 254 res, err := h.Start(ctx, rs) 255 So(res, ShouldBeNil) 256 So(err, ShouldErrLike, "QM.DebitRunQuota: unexpected quotaOp Status ERR_UNKNOWN") 257 }) 258 259 Convey("Does not proceed if any parent RUN is still PENDING", func() { 260 const parentRun = common.RunID("parent/1-cow") 261 So(datastore.Put(ctx, 262 &run.Run{ 263 ID: parentRun, 264 Status: run.Status_PENDING, 265 }, 266 ), ShouldBeNil) 267 rs.DepRuns = common.RunIDs{parentRun} 268 res, err := h.Start(ctx, rs) 269 So(err, ShouldBeNil) 270 So(res.SideEffectFn, ShouldBeNil) 271 So(res.State.Status, ShouldEqual, run.Status_PENDING) 272 ops := res.State.OngoingLongOps.GetOps() 273 So(len(ops), ShouldEqual, 0) 274 }) 275 276 Convey("Does not proceed if parent RUN is CANCELLED/FAILED", func() { 277 const parentRun = common.RunID("parent/1-cow") 278 So(datastore.Put(ctx, 279 &run.Run{ 280 ID: parentRun, 281 Status: run.Status_CANCELLED, 282 }, 283 ), ShouldBeNil) 284 rs.DepRuns = common.RunIDs{parentRun} 285 res, err := h.Start(ctx, rs) 286 So(err, ShouldBeNil) 287 So(res.SideEffectFn, ShouldBeNil) 288 So(res.State.Status, ShouldEqual, run.Status_PENDING) 289 }) 290 291 Convey("Emits Start events for PENDING children", func() { 292 const child1 = common.RunID("child/1-cow") 293 So(datastore.Put( 294 ctx, 295 &run.Run{ 296 ID: child1, 297 Status: run.Status_PENDING, 298 DepRuns: common.RunIDs{rs.ID}, 299 }, 300 ), ShouldBeNil) 301 res, err := h.Start(ctx, rs) 302 So(err, ShouldBeNil) 303 So(res.SideEffectFn, ShouldNotBeNil) 304 So(datastore.RunInTransaction(ctx, res.SideEffectFn, nil), ShouldBeNil) 305 runtest.AssertReceivedStart(ctx, child1) 306 }) 307 308 Convey("Fail the Run if tryjob computation fails", func() { 309 if rs.Options == nil { 310 rs.Options = &run.Options{} 311 } 312 // included a builder that doesn't exist 313 rs.Options.IncludedTryjobs = append(rs.Options.IncludedTryjobs, "fooproj/ci:bar_builder") 314 anotherCL := addCL(triggerer, owner) 315 rs.CLs = common.CLIDs{cl.ID, anotherCL.ID} 316 Convey("Reset triggers on all CLs", func() { 317 res, err := h.Start(ctx, rs) 318 So(err, ShouldBeNil) 319 So(res.SideEffectFn, ShouldBeNil) 320 So(res.PreserveEvents, ShouldBeFalse) 321 So(res.State.Status, ShouldEqual, run.Status_PENDING) 322 So(res.State.Tryjobs, ShouldBeNil) 323 So(res.State.NewLongOpIDs, ShouldHaveLength, 1) 324 op := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]] 325 So(op.GetResetTriggers(), ShouldNotBeNil) 326 So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED) 327 resetCLs := common.CLIDs{} 328 for _, req := range op.GetResetTriggers().GetRequests() { 329 resetCLs = append(resetCLs, common.CLID(req.Clid)) 330 } 331 So(resetCLs, ShouldResemble, res.State.CLs) 332 So(res.State.LogEntries, ShouldHaveLength, 1) 333 So(res.State.LogEntries[0].GetInfo(), ShouldResembleProto, &run.LogEntry_Info{ 334 Label: "Tryjob Requirement Computation", 335 Message: "Failed to compute tryjob requirement. Reason: builder \"fooproj/ci/bar_builder\" is included but not defined in the LUCI project", 336 }) 337 }) 338 Convey("Only reset trigger on root CL", func() { 339 rs.RootCL = cl.ID 340 res, err := h.Start(ctx, rs) 341 So(err, ShouldBeNil) 342 So(res.SideEffectFn, ShouldBeNil) 343 So(res.State.NewLongOpIDs, ShouldHaveLength, 1) 344 op := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]] 345 So(op.GetResetTriggers(), ShouldNotBeNil) 346 So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED) 347 resetCLs := common.CLIDs{} 348 for _, req := range op.GetResetTriggers().GetRequests() { 349 resetCLs = append(resetCLs, common.CLID(req.Clid)) 350 } 351 So(resetCLs, ShouldResemble, common.CLIDs{rs.RootCL}) 352 }) 353 }) 354 355 Convey("Fail the Run if acls.CheckRunCreate fails", func() { 356 ct.ResetMockedAuthDB(ctx) 357 res, err := h.Start(ctx, rs) 358 So(err, ShouldBeNil) 359 So(res.SideEffectFn, ShouldBeNil) 360 So(res.PreserveEvents, ShouldBeFalse) 361 362 So(res.State.Status, ShouldEqual, run.Status_PENDING) 363 So(res.State.LogEntries, ShouldHaveLength, 1) 364 So(res.State.LogEntries[0].GetInfo(), ShouldResembleProto, &run.LogEntry_Info{ 365 Label: "Run failed", 366 Message: "" + 367 "the Run does not pass eligibility checks. See reasons at:" + 368 "\n * " + cl.ExternalID.MustURL(), 369 }) 370 371 So(res.State.NewLongOpIDs, ShouldHaveLength, 1) 372 longOp := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]] 373 resetOp := longOp.GetResetTriggers() 374 So(resetOp.Requests, ShouldHaveLength, 1) 375 So(resetOp.Requests[0], ShouldResembleProto, 376 &run.OngoingLongOps_Op_ResetTriggers_Request{ 377 Clid: int64(cl.ID), 378 Message: fmt.Sprintf( 379 "CV cannot start a Run for `%s` because the user is not a dry-runner.", gf.U(triggerer).Email, 380 ), 381 Notify: gerrit.Whoms{ 382 gerrit.Whom_OWNER, 383 gerrit.Whom_CQ_VOTERS, 384 }, 385 AddToAttention: gerrit.Whoms{ 386 gerrit.Whom_OWNER, 387 gerrit.Whom_CQ_VOTERS, 388 }, 389 AddToAttentionReason: "CQ/CV Run failed", 390 }, 391 ) 392 So(resetOp.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED) 393 394 Convey("Only reset trigger on root CL", func() { 395 anotherCL := addCL(triggerer, owner) 396 rs.CLs = common.CLIDs{cl.ID, anotherCL.ID} 397 rs.RootCL = cl.ID 398 res, err := h.Start(ctx, rs) 399 So(err, ShouldBeNil) 400 So(res.SideEffectFn, ShouldBeNil) 401 So(res.State.NewLongOpIDs, ShouldHaveLength, 1) 402 op := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]] 403 So(op.GetResetTriggers(), ShouldNotBeNil) 404 So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED) 405 resetCLs := common.CLIDs{} 406 for _, req := range op.GetResetTriggers().GetRequests() { 407 resetCLs = append(resetCLs, common.CLID(req.Clid)) 408 } 409 So(resetCLs, ShouldResemble, common.CLIDs{rs.RootCL}) 410 }) 411 }) 412 413 statuses := []run.Status{ 414 run.Status_RUNNING, 415 run.Status_WAITING_FOR_SUBMISSION, 416 run.Status_SUBMITTING, 417 run.Status_SUCCEEDED, 418 run.Status_FAILED, 419 run.Status_CANCELLED, 420 } 421 for _, status := range statuses { 422 Convey(fmt.Sprintf("Noop when Run is %s", status), func() { 423 rs.Status = status 424 res, err := h.Start(ctx, rs) 425 So(err, ShouldBeNil) 426 So(res.State, ShouldEqual, rs) 427 So(res.SideEffectFn, ShouldBeNil) 428 So(res.PreserveEvents, ShouldBeFalse) 429 }) 430 } 431 }) 432 } 433 434 func TestOnCompletedPostStartMessage(t *testing.T) { 435 t.Parallel() 436 437 Convey("onCompletedPostStartMessage works", t, func() { 438 ct := cvtesting.Test{} 439 ctx, cancel := ct.SetUp(t) 440 defer cancel() 441 442 const ( 443 lProject = "chromium" 444 opID = "1-1" 445 ) 446 447 prjcfgtest.Create(ctx, lProject, &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{Name: "single"}}}) 448 449 rs := &state.RunState{ 450 Run: run.Run{ 451 ID: lProject + "/1111111111111-1-deadbeef", 452 Status: run.Status_RUNNING, 453 Mode: run.DryRun, 454 ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0], 455 OngoingLongOps: &run.OngoingLongOps{ 456 Ops: map[string]*run.OngoingLongOps_Op{ 457 opID: { 458 Work: &run.OngoingLongOps_Op_PostStartMessage{ 459 PostStartMessage: true, 460 }, 461 }, 462 }, 463 }, 464 }, 465 } 466 result := &eventpb.LongOpCompleted{ 467 OperationId: opID, 468 } 469 h, _ := makeTestHandler(&ct) 470 471 Convey("if Run isn't RUNNING, just cleans up the operation", func() { 472 // NOTE: This should be rare. And since posting the starting message isn't 473 // a critical operation, it's OK to ignore its failures if the Run is 474 // already submitting the CL. 475 rs.Run.Status = run.Status_SUBMITTING 476 result.Status = eventpb.LongOpCompleted_FAILED 477 res, err := h.OnLongOpCompleted(ctx, rs, result) 478 So(err, ShouldBeNil) 479 So(res.State.Status, ShouldEqual, run.Status_SUBMITTING) 480 So(res.State.OngoingLongOps, ShouldBeNil) 481 So(res.SideEffectFn, ShouldBeNil) 482 So(res.PreserveEvents, ShouldBeFalse) 483 }) 484 485 Convey("on cancellation, cleans up Run's state", func() { 486 // NOTE: as of this writing (Oct 2021), the only time posting start 487 // message is cancelled is if the Run was already finalized. Therefore, 488 // Run can't be in RUNNING state any more. 489 // However, this test aims to cover possible future logic change in CV. 490 result.Status = eventpb.LongOpCompleted_CANCELLED 491 res, err := h.OnLongOpCompleted(ctx, rs, result) 492 So(err, ShouldBeNil) 493 So(res.State.Status, ShouldEqual, run.Status_RUNNING) 494 So(res.State.OngoingLongOps, ShouldBeNil) 495 So(res.SideEffectFn, ShouldBeNil) 496 So(res.PreserveEvents, ShouldBeFalse) 497 }) 498 499 Convey("on success, cleans Run's state", func() { 500 result.Status = eventpb.LongOpCompleted_SUCCEEDED 501 postedAt := ct.Clock.Now().Add(-time.Second) 502 result.Result = &eventpb.LongOpCompleted_PostStartMessage_{ 503 PostStartMessage: &eventpb.LongOpCompleted_PostStartMessage{ 504 Time: timestamppb.New(postedAt), 505 }, 506 } 507 res, err := h.OnLongOpCompleted(ctx, rs, result) 508 So(err, ShouldBeNil) 509 So(res.State.Status, ShouldEqual, run.Status_RUNNING) 510 So(res.State.OngoingLongOps, ShouldBeNil) 511 So(res.SideEffectFn, ShouldBeNil) 512 So(res.PreserveEvents, ShouldBeFalse) 513 So(res.State.LogEntries[0].GetTime().AsTime(), ShouldResemble, postedAt.UTC()) 514 }) 515 516 Convey("on failure, cleans Run's state and record reasons", func() { 517 result.Status = eventpb.LongOpCompleted_FAILED 518 res, err := h.OnLongOpCompleted(ctx, rs, result) 519 So(err, ShouldBeNil) 520 So(res.State.Status, ShouldEqual, run.Status_RUNNING) 521 So(res.State.OngoingLongOps, ShouldBeNil) 522 So(res.SideEffectFn, ShouldBeNil) 523 So(res.PreserveEvents, ShouldBeFalse) 524 So(res.State.LogEntries[0].GetInfo().GetMessage(), ShouldContainSubstring, "Failed to post the starting message") 525 }) 526 527 Convey("on expiration,cleans Run's state and record reasons", func() { 528 result.Status = eventpb.LongOpCompleted_EXPIRED 529 res, err := h.OnLongOpCompleted(ctx, rs, result) 530 So(err, ShouldBeNil) 531 So(res.State.Status, ShouldEqual, run.Status_RUNNING) 532 So(res.State.OngoingLongOps, ShouldBeNil) 533 So(res.SideEffectFn, ShouldBeNil) 534 So(res.PreserveEvents, ShouldBeFalse) 535 So(res.State.LogEntries[0].GetInfo().GetMessage(), ShouldContainSubstring, "Failed to post the starting message") 536 }) 537 }) 538 }