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  });