Add a test freshness page.
authordewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 14 Dec 2017 09:49:37 +0000 (09:49 +0000)
committerdewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 14 Dec 2017 09:49:37 +0000 (09:49 +0000)
https://bugs.webkit.org/show_bug.cgi?id=180126

Reviewed by Ryosuke Niwa.

Added a page to show freshness of a test.
The test freshness page reports on the same set of tests as the one shown in the summary page.
Use a logistic function to evaluate the freshness of the data points.
This function has the desired property which increase dramatically when it close to the center of the graph.
'acceptableLastDataPointDurationInHour' configs the center of the graph.

* public/include/manifest-generator.php:
* public/v3/components/freshness-indicator.js: Added.
(FreshnessIndicator): A cell of the test freshness table, color will transit from green to red.
(FreshnessIndicator.prototype.update): Update the the data point information and triggers
the cell to re-render if anything changes.
(FreshnessIndicator.prototype._renderIndicator): Re-render the indicator.
(FreshnessIndicator.prototype.render): Render the box color base on a logistic function.
(FreshnessIndicator.prototype._createIndicator):
(FreshnessIndicator.htmlTemplate):
(FreshnessIndicator.cssTemplate):
* public/v3/index.html:
* public/v3/main.js: Added test freshness page.
(main):
* public/v3/models/build-request.js: Refactored waitingTime function to make it reusable.
(BuildRequest.formatTimeInterval): Format time interval in million seconds to more user friendly text.
(BuildRequest.prototype.waitingTime):
* public/v3/pages/test-freshness-page.js: Added.
(TestFreshnessPage):
(TestFreshnessPage.prototype.name):
(TestFreshnessPage.prototype._loadConfig): Load config from summary page configurations.
(TestFreshnessPage.prototype.open):
(TestFreshnessPage.prototype._fetchTestResults):
(TestFreshnessPage.prototype.render):
(TestFreshnessPage.prototype._renderTable):
(TestFreshnessPage.prototype._isValidPlatformMetricCombination): Return whether a platform
and metric combination is valid.
(TestFreshnessPage.prototype._constructTableCell):
(TestFreshnessPage.cssTemplate):
(TestFreshnessPage.prototype.routeName):
* server-tests/api-manifest-tests.js: Added 'warningHourBaseline' so that we can config the
parameter of logistic funciton.
* unit-tests/build-request-tests.js: Added unit tests for formatTimeInterval.

git-svn-id: http://svn.webkit.org/repository/webkit/trunk@225898 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/include/manifest-generator.php
Websites/perf.webkit.org/public/v3/components/freshness-indicator.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/main.js
Websites/perf.webkit.org/public/v3/models/build-request.js
Websites/perf.webkit.org/public/v3/pages/test-freshness-page.js [new file with mode: 0644]
Websites/perf.webkit.org/server-tests/api-manifest-tests.js
Websites/perf.webkit.org/unit-tests/build-request-tests.js

index 201e4b46f5fbb96fd5a625f7885cadbe01545ad2..d13e27ad90b1bf69b5c660c4f091c6f4d574eb8e 100644 (file)
@@ -1,3 +1,49 @@
+2017-12-13  Dewei Zhu  <dewei_zhu@apple.com>
+
+        Add a test freshness page.
+        https://bugs.webkit.org/show_bug.cgi?id=180126
+
+        Reviewed by Ryosuke Niwa.
+
+        Added a page to show freshness of a test.
+        The test freshness page reports on the same set of tests as the one shown in the summary page.
+        Use a logistic function to evaluate the freshness of the data points.
+        This function has the desired property which increase dramatically when it close to the center of the graph.
+        'acceptableLastDataPointDurationInHour' configs the center of the graph.
+
+        * public/include/manifest-generator.php:
+        * public/v3/components/freshness-indicator.js: Added.
+        (FreshnessIndicator): A cell of the test freshness table, color will transit from green to red.
+        (FreshnessIndicator.prototype.update): Update the the data point information and triggers
+        the cell to re-render if anything changes.
+        (FreshnessIndicator.prototype._renderIndicator): Re-render the indicator.
+        (FreshnessIndicator.prototype.render): Render the box color base on a logistic function.
+        (FreshnessIndicator.prototype._createIndicator):
+        (FreshnessIndicator.htmlTemplate):
+        (FreshnessIndicator.cssTemplate):
+        * public/v3/index.html:
+        * public/v3/main.js: Added test freshness page.
+        (main):
+        * public/v3/models/build-request.js: Refactored waitingTime function to make it reusable.
+        (BuildRequest.formatTimeInterval): Format time interval in million seconds to more user friendly text.
+        (BuildRequest.prototype.waitingTime):
+        * public/v3/pages/test-freshness-page.js: Added.
+        (TestFreshnessPage):
+        (TestFreshnessPage.prototype.name):
+        (TestFreshnessPage.prototype._loadConfig): Load config from summary page configurations.
+        (TestFreshnessPage.prototype.open):
+        (TestFreshnessPage.prototype._fetchTestResults):
+        (TestFreshnessPage.prototype.render):
+        (TestFreshnessPage.prototype._renderTable):
+        (TestFreshnessPage.prototype._isValidPlatformMetricCombination): Return whether a platform
+        and metric combination is valid.
+        (TestFreshnessPage.prototype._constructTableCell):
+        (TestFreshnessPage.cssTemplate):
+        (TestFreshnessPage.prototype.routeName):
+        * server-tests/api-manifest-tests.js: Added 'warningHourBaseline' so that we can config the
+        parameter of logistic funciton.
+        * unit-tests/build-request-tests.js: Added unit tests for formatTimeInterval.
+
 2017-11-02  Dewei Zhu  <dewei_zhu@apple.com>
 
         Add platform argument for syncing script.
index 80a622e45a7d31760d13db6ecfce1e7f4df9f511..e771419982a738952e76287b76ff087dae9d858d 100644 (file)
@@ -44,6 +44,7 @@ class ManifestGenerator {
             'dashboards' => (object)config('dashboards'),
             'summaryPages' => config('summaryPages'),
             'fileUploadSizeLimit' => config('uploadFileLimitInMB', 0) * 1024 * 1024,
+            'testAgeToleranceInHours' => config('testAgeToleranceInHours'),
         );
 
         $this->manifest['elapsedTime'] = (microtime(true) - $start_time) * 1000;
diff --git a/Websites/perf.webkit.org/public/v3/components/freshness-indicator.js b/Websites/perf.webkit.org/public/v3/components/freshness-indicator.js
new file mode 100644 (file)
index 0000000..40dc264
--- /dev/null
@@ -0,0 +1,79 @@
+class FreshnessIndicator extends ComponentBase {
+    constructor(lastDataPointDuration, testAgeTolerance, summary, url)
+    {
+        super('freshness-indicator');
+        this._lastDataPointDuration = lastDataPointDuration;
+        this._summary = summary;
+        this._testAgeTolerance = testAgeTolerance;
+        this._url = url;
+
+        this._renderIndicatorLazily = new LazilyEvaluatedFunction(this._renderIndicator.bind(this));
+    }
+
+    update(lastDataPointDuration, testAgeTolerance, summary, url)
+    {
+        this._lastDataPointDuration = lastDataPointDuration;
+        this._summary = summary;
+        this._testAgeTolerance = testAgeTolerance;
+        this._url = url;
+        this.enqueueToRender();
+    }
+
+    render()
+    {
+        super.render();
+        this._renderIndicatorLazily.evaluate(this._lastDataPointDuration, this._testAgeTolerance, this._summary, this._url);
+
+    }
+
+    _renderIndicator(lastDataPointDuration, testAgeTolerance, summary, url)
+    {
+        const element = ComponentBase.createElement;
+        if (!lastDataPointDuration) {
+            this.renderReplace(this.content('container'), new SpinnerIcon);
+            return;
+        }
+
+        const hoursSinceLastDataPoint = this._lastDataPointDuration / 3600 / 1000;
+        const testAgeToleranceInHours = testAgeTolerance / 3600 / 1000;
+        const rating = 1 / (1 + Math.exp(Math.log(1.2) * (hoursSinceLastDataPoint - testAgeToleranceInHours)));
+        const hue = Math.round(120 * rating);
+        const brightness = Math.round(30 + 50 * rating);
+        const indicator = element('a', {id: 'cell', title: summary, href: url});
+
+        indicator.style.backgroundColor = `hsl(${hue}, 100%, ${brightness}%)`;
+        this.renderReplace(this.content('container'), indicator);
+    }
+
+    static htmlTemplate()
+    {
+        return `<div id='container'></div>`;
+    }
+
+    static  cssTemplate()
+    {
+        return `
+            div {
+                height: 1.8rem;
+                width: 1.8rem;
+                padding-top: 0.1rem;
+            }
+            a {
+                display: block;
+                height:1.6rem;
+                width:1.6rem;
+                margin: 0.1rem;
+                padding: 0;
+            }
+
+            a:hover {
+                height: 1.8rem;
+                width: 1.8rem;
+                margin: 0rem;
+                padding: 0;
+            }`;
+    }
+}
+
+
+ComponentBase.defineElement('freshness-indicator', FreshnessIndicator);
\ No newline at end of file
index 46e6adc44e22754908175024f09dc590ad2daad2..1a21c0f657993396e397af02fca770e39a147dd9 100644 (file)
@@ -98,6 +98,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/custom-analysis-task-configurator.js"></script>
         <script src="components/custom-configuration-test-group-form.js"></script>
         <script src="components/instant-file-uploader.js"></script>
+        <script src="components/freshness-indicator.js"></script>
 
         <script src="pages/page.js"></script>
         <script src="pages/page-router.js"></script>
@@ -117,6 +118,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="pages/create-analysis-task-page.js"></script>
         <script src="pages/build-request-queue-page.js"></script>
         <script src="pages/summary-page.js"></script>
+        <script src="pages/test-freshness-page.js"></script>
 
         <script src="main.js"></script>
     </template>
index 4075c9ceaac9a10b8fc8011841642939ca27f142..a545d7ac80cac65f97679d7153ffc371efa2a712 100644 (file)
@@ -47,8 +47,10 @@ function main() {
         var buildRequestQueuePage = new BuildRequestQueuePage();
         buildRequestQueuePage.setParentPage(analysisCategoryPage);
 
+        const testHealthPage = new TestFreshnessPage(manifest.summaryPages, manifest.testAgeToleranceInHours);
+
         var heading = new Heading(manifest.siteTitle);
-        heading.addPageGroup(summaryPages.concat([chartsPage, analysisCategoryPage]));
+        heading.addPageGroup(summaryPages.concat([chartsPage, analysisCategoryPage, testHealthPage]));
 
         heading.setTitle(manifest.siteTitle);
         heading.addPageGroup(dashboardPages);
@@ -61,6 +63,7 @@ function main() {
         router.addPage(analysisTaskPage);
         router.addPage(buildRequestQueuePage);
         router.addPage(analysisCategoryPage);
+        router.addPage(testHealthPage);
         for (var page of dashboardPages)
             router.addPage(page);
 
index 06e92d8c545739d9c96bc138ad54b7223a77a7f6..cc8609b6625e3d99478a8416e9d6aefd827e0c5b 100644 (file)
@@ -84,43 +84,47 @@ class BuildRequest extends DataModelObject {
     buildId() { return this._buildId; }
     createdAt() { return this._createdAt; }
 
-    waitingTime(referenceTime)
-    {
-        var units = [
+    static formatTimeInterval(intervalInMillionSeconds) {
+        let intervalInSeconds = intervalInMillionSeconds / 1000;
+        const units = [
             {unit: 'week', length: 7 * 24 * 3600},
             {unit: 'day', length: 24 * 3600},
             {unit: 'hour', length: 3600},
             {unit: 'minute', length: 60},
         ];
 
-        var diff = (referenceTime - this.createdAt()) / 1000;
 
-        var indexOfFirstSmallEnoughUnit = units.length - 1;
-        for (var i = 0; i < units.length; i++) {
-            if (diff > 1.5 * units[i].length) {
+        let indexOfFirstSmallEnoughUnit = units.length - 1;
+        for (let i = 0; i < units.length; i++) {
+            if (intervalInSeconds > 1.5 * units[i].length) {
                 indexOfFirstSmallEnoughUnit = i;
                 break;
             }
         }
 
-        var label = '';
-        var lastUnit = false;
-        for (var i = indexOfFirstSmallEnoughUnit; !lastUnit; i++) {
+        let label = '';
+        let lastUnit = false;
+        for (let i = indexOfFirstSmallEnoughUnit; !lastUnit; i++) {
             lastUnit = i == indexOfFirstSmallEnoughUnit + 1 || i == units.length - 1;
-            var length = units[i].length;
-            var valueForUnit = lastUnit ? Math.round(diff / length) : Math.floor(diff / length);
+            const length = units[i].length;
+            const valueForUnit = lastUnit ? Math.round(intervalInSeconds / length) : Math.floor(intervalInSeconds / length);
 
-            var unit = units[i].unit + (valueForUnit == 1 ? '' : 's');
+            const unit = units[i].unit + (valueForUnit == 1 ? '' : 's');
             if (label)
                 label += ' ';
             label += `${valueForUnit} ${unit}`;
 
-            diff = diff - valueForUnit * length;
+            intervalInSeconds = intervalInSeconds - valueForUnit * length;
         }
 
         return label;
     }
 
+    waitingTime(referenceTime)
+    {
+        return BuildRequest.formatTimeInterval(referenceTime - this.createdAt());
+    }
+
     static fetchForTriggerable(triggerable)
     {
         return RemoteAPI.getJSONWithStatus('/api/build-requests/' + triggerable).then(function (data) {
diff --git a/Websites/perf.webkit.org/public/v3/pages/test-freshness-page.js b/Websites/perf.webkit.org/public/v3/pages/test-freshness-page.js
new file mode 100644 (file)
index 0000000..781c3f2
--- /dev/null
@@ -0,0 +1,230 @@
+
+class TestFreshnessPage extends PageWithHeading {
+    constructor(summaryPageConfiguration, testAgeToleranceInHours)
+    {
+        super('test-freshness', null);
+        this._testAgeTolerance = (testAgeToleranceInHours || 24) * 3600 * 1000;
+        this._timeDuration = this._testAgeTolerance * 2;
+        this._excludedConfigurations = {};
+        this._lastDataPointByConfiguration = null;
+        this._indicatorByConfiguration = null;
+        this._renderTableLazily = new LazilyEvaluatedFunction(this._renderTable.bind(this));
+
+        this._loadConfig(summaryPageConfiguration);
+    }
+
+    name() { return 'Test-Freshness'; }
+
+    _loadConfig(summaryPageConfiguration)
+    {
+        const platformIdSet = new Set;
+        const metricIdSet = new Set;
+
+        for (const config of summaryPageConfiguration) {
+            for (const platformGroup of config.platformGroups) {
+                for (const platformId of platformGroup.platforms)
+                    platformIdSet.add(platformId);
+            }
+
+            for (const metricGroup of config.metricGroups) {
+                for (const subgroup of metricGroup.subgroups) {
+                    for (const metricId of subgroup.metrics)
+                        metricIdSet.add(metricId);
+                }
+            }
+
+            const excludedConfigs = config.excludedConfigurations;
+            for (const platform in excludedConfigs) {
+                if (platform in this._excludedConfigurations)
+                    this._excludedConfigurations[platform] = this._excludedConfigurations[platform].concat(excludedConfigs[platform]);
+                else
+                    this._excludedConfigurations[platform] = excludedConfigs[platform];
+            }
+        }
+        this._platforms = [...platformIdSet].map((platformId) => Platform.findById(platformId));
+        this._metrics = [...metricIdSet].map((metricId) => Metric.findById(metricId));
+    }
+
+    open(state)
+    {
+        this._fetchTestResults();
+        super.open(state);
+    }
+
+    _fetchTestResults()
+    {
+        this._measurementSetFetchTime = Date.now();
+        this._lastDataPointByConfiguration = new Map;
+
+        const startTime = this._measurementSetFetchTime - this._timeDuration;
+
+        for (const platform of this._platforms) {
+            const lastDataPointByMetric = new Map;
+            this._lastDataPointByConfiguration.set(platform, lastDataPointByMetric);
+
+            for (const metric of this._metrics) {
+                if (!this._isValidPlatformMetricCombination(platform, metric, this._excludedConfigurations))
+                    continue;
+
+                const measurementSet = MeasurementSet.findSet(platform.id(), metric.id(), platform.lastModified(metric));
+                measurementSet.fetchBetween(startTime, this._measurementSetFetchTime).then(() => {
+                    const currentTimeSeries = measurementSet.fetchedTimeSeries('current', false, false);
+
+                    let timeForLastDataPoint = startTime;
+                    if (currentTimeSeries.lastPoint())
+                        timeForLastDataPoint = currentTimeSeries.lastPoint().time;
+
+                    lastDataPointByMetric.set(metric, {time: timeForLastDataPoint, hasCurrentDataPoint: !!currentTimeSeries.lastPoint()});
+                    this.enqueueToRender();
+                });
+            }
+        }
+    }
+
+    render()
+    {
+        super.render();
+
+        this._renderTableLazily.evaluate(this._platforms, this._metrics);
+
+        for (const [platform, lastDataPointByMetric] of this._lastDataPointByConfiguration.entries()) {
+            for (const [metric, lastDataPoint] of lastDataPointByMetric.entries()) {
+                const timeDuration = this._measurementSetFetchTime - lastDataPoint.time;
+                const timeDurationSummaryPrefix = lastDataPoint.hasCurrentDataPoint ? '' : 'More than ';
+                const timeDurationSummary = BuildRequest.formatTimeInterval(timeDuration);
+                const testLabel = `"${metric.test().fullName()}" for "${platform.name()}"`;
+                const summary = `${timeDurationSummaryPrefix}${timeDurationSummary} since last data point on ${testLabel}`;
+                const url = this._router.url('charts', ChartsPage.createStateForDashboardItem(platform.id(), metric.id(),
+                    this._measurementSetFetchTime - this._timeDuration));
+
+                const indicator = this._indicatorByConfiguration.get(platform).get(metric);
+                indicator.update(timeDuration, this._testAgeTolerance, summary, url);
+            }
+        }
+    }
+
+    _renderTable(platforms, metrics)
+    {
+        const element = ComponentBase.createElement;
+        const tableBodyElement = [];
+        const tableHeadElements = [element('th',  {class: 'table-corner'}, 'Platform \\ Test')];
+
+        for (const metric of metrics)
+            tableHeadElements.push(element('th', {class: 'diagonal-header'}, element('div', metric.test().fullName())));
+
+        this._indicatorByConfiguration = new Map;
+        for (const platform of platforms) {
+            const indicatorByMetric = new Map;
+            this._indicatorByConfiguration.set(platform, indicatorByMetric);
+            tableBodyElement.push(element('tr',
+                [element('th', platform.label()), ...metrics.map((metric) => this._constructTableCell(platform, metric, indicatorByMetric))]));
+        }
+
+        this.renderReplace(this.content('test-health'), [element('thead', tableHeadElements), element('tbody', tableBodyElement)]);
+    }
+
+    _isValidPlatformMetricCombination(platform, metric)
+    {
+        return !(this._excludedConfigurations && this._excludedConfigurations[platform.id()]
+            && this._excludedConfigurations[platform.id()].some((metricId) => metricId == metric.id()))
+            && platform.hasMetric(metric);
+    }
+
+    _constructTableCell(platform, metric, indicatorByMetric)
+    {
+        const element = ComponentBase.createElement;
+
+        if (!this._isValidPlatformMetricCombination(platform, metric))
+            return element('td', {class: 'blank-cell'}, element('div'));
+
+        const indicator = new FreshnessIndicator;
+        indicatorByMetric.set(metric, indicator);
+        return element('td', {class: 'status-cell'}, indicator);
+    }
+
+    static htmlTemplate()
+    {
+        return `<section class="page-with-heading"><table id="test-health"></table></section>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            .page-with-heading {
+                display: flex;
+                justify-content: center;
+            }
+            #test-health {
+                font-size: 1rem;
+            }
+            #test-health th.table-corner {
+                text-align: right;
+                vertical-align: bottom;
+            }
+            #test-health th {
+                text-align: left;
+                border-bottom: 0.1rem solid #ccc;
+                font-weight: normal;
+            }
+            #test-health th.diagonal-header {
+                white-space: nowrap;
+                height: 16rem;
+                border-bottom: 0rem;
+            }
+            #test-health th.diagonal-header > div {
+                transform: translate(1rem, 7rem) rotate(315deg);
+                width: 2rem;
+                border: 0rem;
+            }
+            #test-health td.status-cell {
+                margin: 0;
+                padding: 0;
+                max-width: 2.2rem;
+                max-height: 2.2rem;
+                min-width: 2.2rem;
+                min-height: 2.2rem;
+            }
+            #test-health td.blank-cell {
+                margin: 0;
+                padding: 0;
+                max-width: 2.2rem;
+                max-height: 2.2rem;
+                min-width: 2.2rem;
+                min-height: 2.2rem;
+            }
+            #test-health td.blank-cell > div  {
+                background-color: #F9F9F9;
+                height: 1.6rem;
+                width: 1.6rem;
+                margin: 0.1rem;
+                padding: 0;
+                position: relative;
+                overflow: hidden;
+            }
+            #test-health td.blank-cell > div::before {
+              content: "";
+              position: absolute;
+              top: -1px;
+              left: -1px;
+              display: block;
+              width: 0px;
+              height: 0px;
+              border-right: calc(1.6rem + 1px) solid #ddd;
+              border-top: calc(1.6rem + 1px) solid transparent;
+            }
+            #test-health td.blank-cell > div::after {
+              content: "";
+              display: block;
+              position: absolute;
+              top: 1px;
+              left: 1px;
+              width: 0px;
+              height: 0px;
+              border-right: calc(1.6rem - 1px) solid #F9F9F9;
+              border-top: calc(1.6rem - 1px) solid transparent;
+            }
+        `;
+    }
+
+    routeName() { return 'test-freshness'; }
+}
\ No newline at end of file
index f0687e392a7e33acc4790b1a1f561b79afdd0011..20af3812a8cb2425b1e7a9d3ba55bca14d334aeb 100644 (file)
@@ -14,7 +14,7 @@ describe('/api/manifest', function () {
     it("should generate an empty manifest when database is empty", () => {
         return TestServer.remoteAPI().getJSON('/api/manifest').then((manifest) => {
             assert.deepEqual(Object.keys(manifest).sort(), ['all', 'bugTrackers', 'builders', 'dashboard', 'dashboards',
-                'elapsedTime', 'fileUploadSizeLimit', 'metrics', 'repositories', 'siteTitle', 'status', 'summaryPages', 'tests', 'triggerables']);
+                'elapsedTime', 'fileUploadSizeLimit', 'metrics', 'repositories', 'siteTitle', 'status', 'summaryPages', 'testAgeToleranceInHours', 'tests', 'triggerables']);
 
             assert.equal(typeof(manifest.elapsedTime), 'number');
             delete manifest.elapsedTime;
@@ -29,6 +29,7 @@ describe('/api/manifest', function () {
                 fileUploadSizeLimit: 2097152, // 2MB during testing.
                 metrics: {},
                 repositories: {},
+                testAgeToleranceInHours: null,
                 tests: {},
                 triggerables: {},
                 summaryPages: [],
index 56cd807b65f8a5ee90513423760f49d82791007a..f092955ba352a7a8a76ef80b0682b427e584cbf1 100644 (file)
@@ -158,4 +158,54 @@ describe('BuildRequest', function () {
 
     });
 
+    describe('formatTimeInterval', () => {
+        it('should return "0 minutes" when formatting for 0 second in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(0), '0 minutes');
+        });
+
+        it('should return "1 minute" when formatting for 60 seconds in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(60 * 1000), '1 minute');
+        });
+
+        it('should return "1 minute" when formatting for  75 seconds in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(75 * 1000), '1 minute');
+        });
+
+        it('should return "2 minutes" when formatting for 118 seconds in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(118 * 1000), '2 minutes');
+        });
+
+        it('should return "75 minutes" when formatting for 75 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(75 * 60 * 1000), '75 minutes');
+        });
+
+        it('should return "1 hour 58 minutes" when formatting for 118 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(118 * 60 * 1000), '1 hour 58 minutes');
+        });
+
+        it('should return "3 hours 2 minutes" when formatting for 182 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(182 * 60 * 1000), '3 hours 2 minutes');
+        });
+
+        it('should return "27 hours 14 minutes" when formatting for 27 hours 14 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((27 * 3600 + 14 * 60) * 1000), '27 hours 14 minutes');
+        });
+
+        it('should return "2 days 3 hours" when formatting for 51 hours 14 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((51 * 3600 + 14 * 60) * 1000), '2 days 3 hours');
+        });
+
+        it('should return "2 days 0 hours" when formatting for 48 hours 1 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((48 * 3600 + 1 * 60) * 1000), '2 days 0 hours');
+        });
+
+        it('should return "2 days 2 hours" when formatting for 49 hours 59 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((49 * 3600 + 59 * 60) * 1000), '2 days 2 hours');
+        });
+
+        it('should return "2 weeks 6 days" when formatting for 20 days 5 hours 21 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(((20 * 24 + 5) * 3600 + 21 * 60) * 1000), '2 weeks 6 days');
+        });
+    });
+
 });
\ No newline at end of file