go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/store/build_state/build_state.test.ts (about) 1 // Copyright 2022 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 import { render } from 'lit'; 16 import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 17 import { DateTime } from 'luxon'; 18 import { action, computed, makeAutoObservable } from 'mobx'; 19 import { destroy } from 'mobx-state-tree'; 20 21 import { Build, BuildbucketStatus, Step } from '@/common/services/buildbucket'; 22 import { renderMarkdown } from '@/common/tools/markdown/utils'; 23 24 import { 25 BuildState, 26 BuildStateInstance, 27 clusterBuildSteps, 28 StepExt, 29 } from './build_state'; 30 31 describe('StepExt', () => { 32 function createStep( 33 index: number, 34 name: string, 35 status = BuildbucketStatus.Success, 36 summaryMarkdown = '', 37 children: StepExt[] = [], 38 ) { 39 const nameSegs = name.split('|'); 40 const step = new StepExt({ 41 step: { 42 name, 43 startTime: '2020-11-01T21:43:03.351951Z', 44 status, 45 summaryMarkdown, 46 }, 47 listNumber: '1.1', 48 selfName: nameSegs.pop()!, 49 depth: nameSegs.length, 50 index, 51 }); 52 step.children.push(...children); 53 return step; 54 } 55 56 describe('succeededRecursively/failed', () => { 57 test('succeeded step with no children', async () => { 58 const step = createStep(0, 'parent', BuildbucketStatus.Success); 59 expect(step.succeededRecursively).toBeTruthy(); 60 expect(step.failed).toBeFalsy(); 61 }); 62 63 test('failed step with no children', async () => { 64 const step = createStep(0, 'parent', BuildbucketStatus.Failure); 65 expect(step.succeededRecursively).toBeFalsy(); 66 expect(step.failed).toBeTruthy(); 67 }); 68 69 test('infra-failed step with no children', async () => { 70 const step = createStep(0, 'parent', BuildbucketStatus.InfraFailure); 71 expect(step.succeededRecursively).toBeFalsy(); 72 expect(step.failed).toBeTruthy(); 73 }); 74 75 test('non-(infra-)failed step with no children', async () => { 76 const step = createStep(0, 'parent', BuildbucketStatus.Canceled); 77 expect(step.succeededRecursively).toBeFalsy(); 78 expect(step.failed).toBeFalsy(); 79 }); 80 81 test('succeeded step with only succeeded children', async () => { 82 const step = createStep(0, 'parent', BuildbucketStatus.Success, '', [ 83 createStep(0, 'parent|child1', BuildbucketStatus.Success), 84 createStep(1, 'parent|child2', BuildbucketStatus.Success), 85 ]); 86 expect(step.succeededRecursively).toBeTruthy(); 87 expect(step.failed).toBeFalsy(); 88 }); 89 90 test('succeeded step with failed child', async () => { 91 const step = createStep(0, 'parent', BuildbucketStatus.Success, '', [ 92 createStep(0, 'parent|child1', BuildbucketStatus.Success), 93 createStep(1, 'parent|child2', BuildbucketStatus.Failure), 94 ]); 95 expect(step.succeededRecursively).toBeFalsy(); 96 expect(step.failed).toBeTruthy(); 97 }); 98 99 test('succeeded step with non-succeeded child', async () => { 100 const step = createStep(0, 'parent', BuildbucketStatus.Success, '', [ 101 createStep(0, 'parent|child1', BuildbucketStatus.Success), 102 createStep(1, 'parent|child2', BuildbucketStatus.Started), 103 ]); 104 expect(step.succeededRecursively).toBeFalsy(); 105 expect(step.failed).toBeFalsy(); 106 }); 107 108 test('failed step with succeeded children', async () => { 109 const step = createStep(0, 'parent', BuildbucketStatus.Failure, '', [ 110 createStep(0, 'parent|child1', BuildbucketStatus.Success), 111 createStep(1, 'parent|child2', BuildbucketStatus.Success), 112 ]); 113 expect(step.succeededRecursively).toBeFalsy(); 114 expect(step.failed).toBeTruthy(); 115 }); 116 117 test('infra-failed step with succeeded children', async () => { 118 const step = createStep(0, 'parent', BuildbucketStatus.InfraFailure, '', [ 119 createStep(0, 'parent|child1', BuildbucketStatus.Success), 120 createStep(1, 'parent|child2', BuildbucketStatus.Success), 121 ]); 122 expect(step.succeededRecursively).toBeFalsy(); 123 expect(step.failed).toBeTruthy(); 124 }); 125 }); 126 127 describe('summary should be rendered properly', () => { 128 function getExpectedHTML(markdownBody: string): string { 129 const container = document.createElement('div'); 130 render(unsafeHTML(renderMarkdown(markdownBody)), container); 131 return container.innerHTML; 132 } 133 134 test('for no summary', async () => { 135 const step = createStep(0, 'step', BuildbucketStatus.Success, undefined); 136 expect(step.summary).toBeNull(); 137 }); 138 139 test('for empty summary', async () => { 140 const step = createStep(0, 'step', BuildbucketStatus.Success, ''); 141 expect(step.summary).toBeNull(); 142 }); 143 144 test('for text summary', async () => { 145 const step = createStep( 146 0, 147 'step', 148 BuildbucketStatus.Success, 149 'this is some text', 150 ); 151 expect(step.summary?.innerHTML).toStrictEqual( 152 getExpectedHTML('this is some text'), 153 ); 154 }); 155 156 test('for summary with a link', async () => { 157 const step = createStep( 158 0, 159 'step', 160 BuildbucketStatus.Success, 161 '<a href="http://google.com">Link</a><br/>content', 162 ); 163 expect(step.summary?.innerHTML).toStrictEqual( 164 getExpectedHTML('<a href="http://google.com">Link</a><br/>content'), 165 ); 166 }); 167 }); 168 }); 169 170 describe('clusterBuildSteps', () => { 171 function createStep(id: number, isCritical: boolean) { 172 return { 173 id, 174 isCritical, 175 } as Partial<StepExt> as StepExt; 176 } 177 178 test('should cluster build steps correctly', () => { 179 const clusteredSteps = clusterBuildSteps([ 180 createStep(1, false), 181 createStep(2, false), 182 createStep(3, false), 183 createStep(4, true), 184 createStep(5, false), 185 createStep(6, false), 186 createStep(7, true), 187 createStep(8, true), 188 createStep(9, false), 189 createStep(10, true), 190 createStep(11, true), 191 ]); 192 expect(clusteredSteps).toEqual([ 193 [createStep(1, false), createStep(2, false), createStep(3, false)], 194 [createStep(4, true)], 195 [createStep(5, false), createStep(6, false)], 196 [createStep(7, true), createStep(8, true)], 197 [createStep(9, false)], 198 [createStep(10, true), createStep(11, true)], 199 ]); 200 }); 201 202 test("should cluster build steps correctly when there're no steps", () => { 203 const clusteredSteps = clusterBuildSteps([]); 204 expect(clusteredSteps).toEqual([]); 205 }); 206 207 test("should cluster build steps correctly when there's a single step", () => { 208 const clusteredSteps = clusterBuildSteps([createStep(1, false)]); 209 expect(clusteredSteps).toEqual([[createStep(1, false)]]); 210 }); 211 212 test('should not re-cluster steps when the criticality is updated', () => { 213 const step1 = makeAutoObservable(createStep(1, false)); 214 const step2 = makeAutoObservable(createStep(2, false)); 215 const step3 = makeAutoObservable(createStep(3, false)); 216 217 const computedCluster = computed( 218 () => clusterBuildSteps([step1, step2, step3]), 219 { keepAlive: true }, 220 ); 221 222 const clustersBeforeUpdate = clusterBuildSteps([step1, step2, step3]); 223 expect(clustersBeforeUpdate).toEqual([[step1, step2, step3]]); 224 expect(computedCluster.get()).toEqual(clustersBeforeUpdate); 225 226 action(() => ((step2 as Mutable<typeof step2>).isCritical = true))(); 227 const clustersAfterUpdate = clusterBuildSteps([step1, step2, step3]); 228 expect(clustersAfterUpdate).toEqual([[step1], [step2], [step3]]); 229 230 expect(computedCluster.get()).toEqual(clustersBeforeUpdate); 231 }); 232 }); 233 234 describe('BuildState', () => { 235 let build: BuildStateInstance; 236 afterEach(() => { 237 destroy(build); 238 }); 239 240 test('should build step-tree correctly', async () => { 241 const time = '2020-11-01T21:43:03.351951Z'; 242 build = BuildState.create({ 243 data: { 244 steps: [ 245 { name: 'root1', startTime: time } as Step, 246 { name: 'root2', startTime: time }, 247 { name: 'root2|parent1', startTime: time }, 248 { name: 'root3', startTime: time }, 249 { name: 'root2|parent1|child1', startTime: time }, 250 { name: 'root3|parent1', startTime: time }, 251 { name: 'root2|parent1|child2', startTime: time }, 252 { name: 'root3|parent2', startTime: time }, 253 { name: 'root3|parent2|child1', startTime: time }, 254 { name: 'root3|parent2|child2', startTime: time }, 255 ] as readonly Step[], 256 } as Build, 257 }); 258 259 expect(build.rootSteps).toMatchObject([ 260 { 261 name: 'root1', 262 selfName: 'root1', 263 listNumber: '1.', 264 depth: 0, 265 index: 0, 266 children: [], 267 } as Partial<StepExt>, 268 { 269 name: 'root2', 270 selfName: 'root2', 271 listNumber: '2.', 272 depth: 0, 273 index: 1, 274 children: [ 275 { 276 name: 'root2|parent1', 277 selfName: 'parent1', 278 listNumber: '2.1.', 279 depth: 1, 280 index: 0, 281 children: [ 282 { 283 name: 'root2|parent1|child1', 284 selfName: 'child1', 285 listNumber: '2.1.1.', 286 depth: 2, 287 index: 0, 288 children: [], 289 }, 290 { 291 name: 'root2|parent1|child2', 292 selfName: 'child2', 293 listNumber: '2.1.2.', 294 depth: 2, 295 index: 1, 296 children: [], 297 }, 298 ], 299 }, 300 ], 301 }, 302 { 303 name: 'root3', 304 selfName: 'root3', 305 listNumber: '3.', 306 depth: 0, 307 index: 2, 308 children: [ 309 { 310 name: 'root3|parent1', 311 selfName: 'parent1', 312 listNumber: '3.1.', 313 depth: 1, 314 index: 0, 315 children: [], 316 }, 317 { 318 name: 'root3|parent2', 319 selfName: 'parent2', 320 listNumber: '3.2.', 321 depth: 1, 322 index: 1, 323 children: [ 324 { 325 name: 'root3|parent2|child1', 326 selfName: 'child1', 327 listNumber: '3.2.1.', 328 depth: 2, 329 index: 0, 330 children: [], 331 }, 332 { 333 name: 'root3|parent2|child2', 334 selfName: 'child2', 335 listNumber: '3.2.2.', 336 depth: 2, 337 index: 1, 338 children: [], 339 }, 340 ], 341 }, 342 ], 343 }, 344 ]); 345 }); 346 347 describe('should calculate pending/execution time/status correctly', () => { 348 beforeAll(() => { 349 jest.useFakeTimers(); 350 }); 351 afterAll(() => { 352 jest.useRealTimers(); 353 }); 354 355 test("when the build hasn't started", () => { 356 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:20Z').toMillis()); 357 build = BuildState.create({ 358 data: { 359 status: BuildbucketStatus.Scheduled, 360 createTime: '2020-01-01T00:00:10Z', 361 schedulingTimeout: '20s', 362 executionTimeout: '20s', 363 } as Build, 364 }); 365 366 expect(build.pendingDuration.toISO()).toStrictEqual('PT10S'); 367 expect(build.isPending).toBeTruthy(); 368 expect(build.exceededSchedulingTimeout).toBeFalsy(); 369 370 expect(build.executionDuration).toBeNull(); 371 expect(build.isExecuting).toBeFalsy(); 372 expect(build.exceededExecutionTimeout).toBeFalsy(); 373 }); 374 375 test('when the build was canceled before exceeding the scheduling timeout', () => { 376 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:50Z').toMillis()); 377 build = BuildState.create({ 378 data: { 379 status: BuildbucketStatus.Canceled, 380 createTime: '2020-01-01T00:00:10Z', 381 endTime: '2020-01-01T00:00:20Z', 382 schedulingTimeout: '20s', 383 executionTimeout: '20s', 384 } as Build, 385 }); 386 387 expect(build.pendingDuration.toISO()).toStrictEqual('PT10S'); 388 expect(build.isPending).toBeFalsy(); 389 expect(build.exceededSchedulingTimeout).toBeFalsy(); 390 391 expect(build.executionDuration).toBeNull(); 392 expect(build.isExecuting).toBeFalsy(); 393 expect(build.exceededExecutionTimeout).toBeFalsy(); 394 }); 395 396 test('when the build was canceled after exceeding the scheduling timeout', () => { 397 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:50Z').toMillis()); 398 build = BuildState.create({ 399 data: { 400 status: BuildbucketStatus.Canceled, 401 createTime: '2020-01-01T00:00:10Z', 402 endTime: '2020-01-01T00:00:30Z', 403 schedulingTimeout: '20s', 404 executionTimeout: '20s', 405 } as Build, 406 }); 407 408 expect(build.pendingDuration.toISO()).toStrictEqual('PT20S'); 409 expect(build.isPending).toBeFalsy(); 410 expect(build.exceededSchedulingTimeout).toBeTruthy(); 411 412 expect(build.executionDuration).toBeNull(); 413 expect(build.isExecuting).toBeFalsy(); 414 expect(build.exceededExecutionTimeout).toBeFalsy(); 415 }); 416 417 test('when the build was started', () => { 418 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:30Z').toMillis()); 419 build = BuildState.create({ 420 data: { 421 status: BuildbucketStatus.Started, 422 createTime: '2020-01-01T00:00:10Z', 423 startTime: '2020-01-01T00:00:20Z', 424 schedulingTimeout: '20s', 425 executionTimeout: '20s', 426 } as Build, 427 }); 428 429 expect(build.pendingDuration.toISO()).toStrictEqual('PT10S'); 430 expect(build.isPending).toBeFalsy(); 431 expect(build.exceededSchedulingTimeout).toBeFalsy(); 432 433 expect(build.executionDuration?.toISO()).toStrictEqual('PT10S'); 434 expect(build.isExecuting).toBeTruthy(); 435 expect(build.exceededExecutionTimeout).toBeFalsy(); 436 }); 437 438 test('when the build was started and canceled before exceeding the execution timeout', () => { 439 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:40Z').toMillis()); 440 build = BuildState.create({ 441 data: { 442 status: BuildbucketStatus.Canceled, 443 createTime: '2020-01-01T00:00:10Z', 444 startTime: '2020-01-01T00:00:20Z', 445 endTime: '2020-01-01T00:00:30Z', 446 schedulingTimeout: '20s', 447 executionTimeout: '20s', 448 } as Build, 449 }); 450 451 expect(build.pendingDuration.toISO()).toStrictEqual('PT10S'); 452 expect(build.isPending).toBeFalsy(); 453 expect(build.exceededSchedulingTimeout).toBeFalsy(); 454 455 expect(build.executionDuration?.toISO()).toStrictEqual('PT10S'); 456 expect(build.isExecuting).toBeFalsy(); 457 expect(build.exceededExecutionTimeout).toBeFalsy(); 458 }); 459 460 test('when the build started and ended after exceeding the execution timeout', () => { 461 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:50Z').toMillis()); 462 build = BuildState.create({ 463 data: { 464 status: BuildbucketStatus.Canceled, 465 createTime: '2020-01-01T00:00:10Z', 466 startTime: '2020-01-01T00:00:20Z', 467 endTime: '2020-01-01T00:00:40Z', 468 schedulingTimeout: '20s', 469 executionTimeout: '20s', 470 } as Build, 471 }); 472 473 expect(build.pendingDuration.toISO()).toStrictEqual('PT10S'); 474 expect(build.isPending).toBeFalsy(); 475 expect(build.exceededSchedulingTimeout).toBeFalsy(); 476 477 expect(build.executionDuration?.toISO()).toStrictEqual('PT20S'); 478 expect(build.isExecuting).toBeFalsy(); 479 expect(build.exceededExecutionTimeout).toBeTruthy(); 480 }); 481 482 test("when the build wasn't started or canceled after the scheduling timeout", () => { 483 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:50Z').toMillis()); 484 build = BuildState.create({ 485 data: { 486 status: BuildbucketStatus.Scheduled, 487 createTime: '2020-01-01T00:00:10Z', 488 schedulingTimeout: '20s', 489 executionTimeout: '20s', 490 } as Build, 491 }); 492 493 expect(build.pendingDuration.toISO()).toStrictEqual('PT40S'); 494 expect(build.isPending).toBeTruthy(); 495 expect(build.exceededSchedulingTimeout).toBeFalsy(); 496 497 expect(build.executionDuration).toBeNull(); 498 expect(build.isExecuting).toBeFalsy(); 499 expect(build.exceededExecutionTimeout).toBeFalsy(); 500 }); 501 502 test('when the build was started after the scheduling timeout', () => { 503 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:00:50Z').toMillis()); 504 build = BuildState.create({ 505 data: { 506 status: BuildbucketStatus.Started, 507 createTime: '2020-01-01T00:00:10Z', 508 startTime: '2020-01-01T00:00:40Z', 509 schedulingTimeout: '20s', 510 executionTimeout: '20s', 511 } as Build, 512 }); 513 514 expect(build.pendingDuration.toISO()).toStrictEqual('PT30S'); 515 expect(build.isPending).toBeFalsy(); 516 expect(build.exceededSchedulingTimeout).toBeFalsy(); 517 518 expect(build.executionDuration?.toISO()).toStrictEqual('PT10S'); 519 expect(build.isExecuting).toBeTruthy(); 520 expect(build.exceededExecutionTimeout).toBeFalsy(); 521 }); 522 523 test('when the build was not canceled after the execution timeout', () => { 524 jest.setSystemTime(DateTime.fromISO('2020-01-01T00:01:10Z').toMillis()); 525 build = BuildState.create({ 526 data: { 527 status: BuildbucketStatus.Success, 528 createTime: '2020-01-01T00:00:10Z', 529 startTime: '2020-01-01T00:00:40Z', 530 endTime: '2020-01-01T00:01:10Z', 531 schedulingTimeout: '20s', 532 executionTimeout: '20s', 533 } as Build, 534 }); 535 536 expect(build.pendingDuration.toISO()).toStrictEqual('PT30S'); 537 expect(build.isPending).toBeFalsy(); 538 expect(build.exceededSchedulingTimeout).toBeFalsy(); 539 540 expect(build.executionDuration?.toISO()).toStrictEqual('PT30S'); 541 expect(build.isExecuting).toBeFalsy(); 542 expect(build.exceededExecutionTimeout).toBeFalsy(); 543 }); 544 }); 545 });