Repository: ai/size-limit (js)

Issue:
Add customisation for improving performance culture
I recently thought about this and maybe others agree on this. Why not add customisation to the messages shown in the CLI and CI.

For example. I updated line 156 of [create-report.js](https://github.com/ai/size-limit/blob/main/packages/size-limit/create-reporter.js#L156) to:

```
if (typeof check.loadTime !== 'undefined') {
 rows.push(['Loading time', formatTime(check.loadTime), 'for ~1000 users per month'])
}
```

Can't find a way to modify the loading time. Currently it's set to Slow 3G (400kb/s & 2000ms latency). I would like to update it to the speed of our users.

This issue is not about solving this particular wish, it's about others as well. When possible to customise those messages, it will help to create awareness for the engineers.

<img width="695" alt="image" src="https://github.com/ai/size-limit/assets/34123512/c86934ce-8c65-49f4-9c4d-0ef5e15a7c49">

Could elaborate on this and dive deeper into it, but I first want to see if people agree on this.

Possibly add it to the package.json. Just a rough draft:

```
"size-limit": [
  {  
     "path": "./clients/dashboard/build/static/js/vendorsMain.*.bundle.js",
     "customConfig": {
         "downloadSpeed": "1200 kb",
         "latency": "800 ms",
         "audienceMessage": "for ~600 users"
  }
],
```

Pull request description:
feat: add time options to set loading network speed, latency and message
This PR introduces a new time field for check configuration to customize `@size-limit/time` plugin loading time with such options as network speed, latency, and message.

Resolves #325

Further enhancement:
- extend time options with customization of running time calculation.

Patch:
diff --git a/README.md b/README.md
index e98ba67..5b03d58 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,8 @@ if the cost exceeds the limit.
 * Can calculate **the time** it would take a browser
   to download and **execute** your JS. Time is a much more accurate
   and understandable metric compared to the size in bytes.
+  Additionally, you can [customize time plugin] via config
+  for every check with network speed, latency and so on.
 * Calculations include **all dependencies and polyfills**
   used in your JS.
 
@@ -50,6 +52,7 @@ We are using [Statoscope] for this analysis.
 [Statoscope]:    https://github.com/statoscope/statoscope
 [cult-img]:      http://cultofmartians.com/assets/badges/badge.svg
 [cult]:          http://cultofmartians.com/tasks/size-limit-config.html
+[customize time plugin]: https://github.com/ai/size-limit/packages/time#customize-network-speed
 
 ## Who Uses Size Limit
 
diff --git a/fixtures/time-latency/index.js b/fixtures/time-latency/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/fixtures/time-latency/package.json b/fixtures/time-latency/package.json
new file mode 100644
index 0000000..fa0b148
--- /dev/null
+++ b/fixtures/time-latency/package.json
@@ -0,0 +1,21 @@
+{
+  "private": true,
+  "name": "time-latency",
+  "devDependencies": {
+    "@size-limit/time": ">= 0.0.0",
+    "size-limit": ">= 0.0.0"
+  },
+  "size-limit": [
+    {
+      "path": "index.js",
+      "time": {
+        "latency": "200 ms"
+      }
+    },
+    {
+      "time": {
+        "latency": "2.35 s"
+      }
+    }
+  ]
+}
diff --git a/fixtures/time-network-speed/index.js b/fixtures/time-network-speed/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/fixtures/time-network-speed/package.json b/fixtures/time-network-speed/package.json
new file mode 100644
index 0000000..0689bdf
--- /dev/null
+++ b/fixtures/time-network-speed/package.json
@@ -0,0 +1,21 @@
+{
+  "private": true,
+  "name": "time-network-speed",
+  "devDependencies": {
+    "@size-limit/time": ">= 0.0.0",
+    "size-limit": ">= 0.0.0"
+  },
+  "size-limit": [
+    {
+      "path": "index.js",
+      "time": {
+        "networkSpeed": "20 B"
+      }
+    },
+    {
+      "time": {
+        "networkSpeed": "20 kB"
+      }
+    }
+  ]
+}
diff --git a/packages/size-limit/calc.js b/packages/size-limit/calc.js
index 9834335..5a6987a 100644
--- a/packages/size-limit/calc.js
+++ b/packages/size-limit/calc.js
@@ -46,7 +46,7 @@ export default async function calc(plugins, config, createSpinner) {
       check.passed = check.sizeLimit >= check.size
     }
     if (typeof check.timeLimit !== 'undefined') {
-      check.passed = check.timeLimit >= check.time
+      check.passed = check.timeLimit >= check.totalTime
     }
     if (check.files && !check.files.length && check.path) {
       check.missed = true
diff --git a/packages/size-limit/create-reporter.js b/packages/size-limit/create-reporter.js
index acd7124..afbf95f 100644
--- a/packages/size-limit/create-reporter.js
+++ b/packages/size-limit/create-reporter.js
@@ -153,12 +153,14 @@ function createHumanReporter(process, isSilentMode = false) {
           rows.push(['Size', sizeString, sizeNote])
         }
         if (typeof check.loadTime !== 'undefined') {
-          rows.push(['Loading time', formatTime(check.loadTime), 'on slow 3G'])
+          let description =
+            (check.time && check.time.loadingMessage) || 'on slow 3G'
+          rows.push(['Loading time', formatTime(check.loadTime), description])
         }
         if (typeof check.runTime !== 'undefined') {
           rows.push(
             ['Running time', formatTime(check.runTime), 'on Snapdragon 410'],
-            ['Total time', formatTime(check.time)]
+            ['Total time', formatTime(check.totalTime)]
           )
         }
 
diff --git a/packages/size-limit/get-config.js b/packages/size-limit/get-config.js
index 22c8415..4ad7207 100644
--- a/packages/size-limit/get-config.js
+++ b/packages/size-limit/get-config.js
@@ -28,6 +28,7 @@ let OPTIONS = {
   name: true,
   path: true,
   running: 'time',
+  time: 'time',
   uiReports: 'webpack',
   webpack: 'webpack'
 }
@@ -42,6 +43,14 @@ function isStringsOrUndefined(value) {
   return type === 'undefined' || type === 'string' || isStrings(value)
 }
 
+function endsWithMs(value) {
+  return / ?ms/i.test(value)
+}
+
+function endsWithS(value) {
+  return / ?s/i.test(value)
+}
+
 function checkChecks(plugins, checks) {
   if (!Array.isArray(checks)) {
     throw new SizeLimitError('noArrayConfig')
@@ -183,9 +192,9 @@ export default async function getConfig(plugins, process, args, pkg) {
     if (!check.name) check.name = toName(check.entry || check.files, config.cwd)
     if (args.limit) check.limit = args.limit
     if (check.limit) {
-      if (/ ?ms/i.test(check.limit)) {
+      if (endsWithMs(check.limit)) {
         check.timeLimit = parseFloat(check.limit) / 1000
-      } else if (/ ?s/i.test(check.limit)) {
+      } else if (endsWithS(check.limit)) {
         check.timeLimit = parseFloat(check.limit)
       } else {
         check.sizeLimit = bytes.parse(check.limit)
@@ -217,6 +226,19 @@ export default async function getConfig(plugins, process, args, pkg) {
       }
       check.import = imports
     }
+    if (check.time) {
+      let { latency, networkSpeed } = check.time
+      if (latency) {
+        if (endsWithMs(latency)) {
+          check.time.latency = parseFloat(latency) / 1000
+        } else {
+          check.time.latency = parseFloat(latency) || 0
+        }
+      }
+      if (networkSpeed) {
+        check.time.networkSpeed = bytes.parse(networkSpeed)
+      }
+    }
   }
 
   return config
diff --git a/packages/size-limit/index.d.ts b/packages/size-limit/index.d.ts
index 7c1bf9f..3a86553 100644
--- a/packages/size-limit/index.d.ts
+++ b/packages/size-limit/index.d.ts
@@ -96,6 +96,38 @@ export interface Check {
    * With `false` it will disable webpack.
    */
   webpack?: boolean
+
+  /**
+   * Options for `@size-limit/time` plugin.
+   */
+  time?: TimeOptions
+}
+
+/**
+ * Represents the options for the size-limit check time property to customize `@size-limit/time` plugin.
+ */
+export interface TimeOptions {
+  /**
+   * A network speed to calculate loading time of files.
+   * It should be a string with a number and unit, separated by a space.
+   * Format: `100 B`, `10 kB`.
+   * @default "50 kB"
+   */
+  networkSpeed?: string
+
+  /**
+   * Delay for calculating loading time that simulates network latency
+   * It should be a string with a number and unit, separated by a space.
+   * Format: `500 ms`, `1 s`.
+   * @default: "0"
+   */
+  latency: string
+
+  /**
+   * A message for loading time details
+   * @default "on slow 3G"
+   */
+  loadingMessage?: string
 }
 
 export type SizeLimitConfig = Check[]
diff --git a/packages/time/README.md b/packages/time/README.md
index 0324efe..f9b5a09 100644
--- a/packages/time/README.md
+++ b/packages/time/README.md
@@ -3,7 +3,46 @@
 The plugin for [Size Limit] to track JS download and execution time by [estimo]
 and Puppeter.
 
-See Size Limit docs for more details.
+## Customize Network Speed
+
+By default, Size Limit measures the loading time of your files using a slow 3G
+network (50 kB/s) without latency. You can customize these settings for each
+check by modifying your Size Limit configuration:
+
+1. Install the preset:
+
+```sh
+npm install --save-dev size-limit @size-limit/file @size-limit/time
+```
+
+2. Add the size-limit [config](https://github.com/ai/size-limit?tab=readme-ov-file#limits-config):
+
+```js
+// .size-limit.js
+export default [
+  {
+    path: 'index.js',
+    time: {
+      networkSpeed: '5 MB', // Custom network speed for loading files
+      latency: '800 ms', // Custom network latency
+      loadingMessage: 'on fast 4G' // Custom message in output
+    }
+  }
+]
+```
+
+3. After configuring, run Size Limit to check the customized loading time:
+
+```sh
+    $ npm run size-limit
+
+      Package size: 998.6 kB
+      Loading time: 200 ms   on fast 4G
+      Running time: 214 ms   on Snapdragon 410
+      Total time:   1.3 s
+```
+
+See [Size Limit] docs for more details.
 
 [Size Limit]: https://github.com/ai/size-limit/
 [estimo]: https://github.com/mbalabash/estimo
diff --git a/packages/time/index.js b/packages/time/index.js
index fe91dfe..e1e3ff2 100644
--- a/packages/time/index.js
+++ b/packages/time/index.js
@@ -8,9 +8,9 @@ async function sum(array, fn) {
   return (await Promise.all(array.map(fn))).reduce((all, i) => all + i, 0)
 }
 
-function getLoadingTime(size) {
+function getLoadingTime(size, networkSpeed) {
   if (size === 0) return 0
-  let time = size / SLOW_3G
+  let time = size / networkSpeed
   if (time < 0.01) time = 0.01
   return time
 }
@@ -22,13 +22,15 @@ export default [
       if (typeof check.size === 'undefined') {
         throw new SizeLimitError('missedPlugin', 'file')
       }
-      check.loadTime = getLoadingTime(check.size)
+      let networkSpeed = (check.time && check.time.networkSpeed) || SLOW_3G
+      let latency = (check.time && check.time.latency) || 0
+      check.loadTime = getLoadingTime(check.size, networkSpeed) + latency
       if (check.running !== false) {
         let files = check.bundles || check.files
         check.runTime = await sum(files, i => getRunningTime(i))
-        check.time = check.runTime + check.loadTime
+        check.totalTime = check.runTime + check.loadTime
       } else {
-        check.time = check.loadTime
+        check.totalTime = check.loadTime
       }
     },
     wait80: 'Running JS in headless Chrome'


Test patch:
diff --git a/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap b/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap
index 958d7f0..ecd7eee 100644
--- a/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap
+++ b/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap
@@ -137,6 +137,22 @@ exports[`renders list of success checks in silent mode 1`] = `
 [1A[2K"
 `;
 
+exports[`renders loading time with custom message from time options for every check 1`] = `
+"  
+  [1mloading message 1[22m
+  Size:         [32m[1m10 B[22m[39m
+  Loading time: [32m[1m200 ms[22m[39m [90mfor ~1000 users per month[39m
+  Running time: [32m[1m400 ms[22m[39m [90mon Snapdragon 410[39m
+  Total time:   [32m[1m1.4 s[22m[39m
+  
+  [1mloading message 2[22m
+  Loading time: [32m[1m200 ms[22m[39m [90mon slow 3G[39m
+  Running time: [32m[1m300 ms[22m[39m [90mon Snapdragon 410[39m
+  Total time:   [32m[1mNaN ms[22m[39m
+  
+"
+`;
+
 exports[`renders result for file with gzip 1`] = `
 "  
   Size limit: [32m[1m99 B[22m[39m
diff --git a/packages/size-limit/test/__snapshots__/run.test.js.snap b/packages/size-limit/test/__snapshots__/run.test.js.snap
index 08cc728..3a77fa1 100644
--- a/packages/size-limit/test/__snapshots__/run.test.js.snap
+++ b/packages/size-limit/test/__snapshots__/run.test.js.snap
@@ -90,7 +90,7 @@ exports[`shows debug 1`] = `
       "size": 123,
       "loadTime": 0.01,
       "runTime": 1,
-      "time": 1.01,
+      "totalTime": 1.01,
       "passed": true
     }
   ],
diff --git a/packages/size-limit/test/create-reporter.test.js b/packages/size-limit/test/create-reporter.test.js
index 9e04926..5fb18ec 100644
--- a/packages/size-limit/test/create-reporter.test.js
+++ b/packages/size-limit/test/create-reporter.test.js
@@ -31,7 +31,7 @@ it('renders results', () => {
           name: 'limitless',
           runTime: 0.5,
           size: 10,
-          time: 0.6
+          totalTime: 0.6
         },
         {
           loadTime: 1,
@@ -40,7 +40,7 @@ it('renders results', () => {
           runTime: 2,
           size: 102400,
           sizeLimit: 102400,
-          time: 3
+          totalTime: 3
         },
         {
           gzip: false,
@@ -49,8 +49,8 @@ it('renders results', () => {
           passed: true,
           runTime: 2,
           size: 102400,
-          time: 3,
-          timeLimit: 4
+          timeLimit: 4,
+          totalTime: 3
         }
       ]
     })
@@ -69,7 +69,7 @@ it('renders list of success checks in silent mode', () => {
             name: 'limitless',
             runTime: 0.5,
             size: 10,
-            time: 0.6
+            totalTime: 0.6
           },
           {
             loadTime: 1,
@@ -78,7 +78,7 @@ it('renders list of success checks in silent mode', () => {
             runTime: 2,
             size: 102400,
             sizeLimit: 102400,
-            time: 3
+            totalTime: 3
           }
         ]
       },
@@ -132,7 +132,7 @@ it('renders list of failed and success checks in silent mode', () => {
             name: 'limitless',
             runTime: 0.5,
             size: 10,
-            time: 0.6
+            totalTime: 0.6
           },
           {
             loadTime: 1,
@@ -141,7 +141,7 @@ it('renders list of failed and success checks in silent mode', () => {
             runTime: 2,
             size: 102400,
             sizeLimit: 102400,
-            time: 3
+            totalTime: 3
           },
           {
             name: 'big fail',
@@ -291,8 +291,8 @@ it('renders config-less result', () => {
           passed: false,
           runTime: 0.3,
           size: 1000,
-          time: 0.5,
-          timeLimit: 0.5
+          timeLimit: 0.5,
+          totalTime: 0.5
         }
       ],
       failed: true
@@ -317,8 +317,8 @@ it('renders JSON results', () => {
             path: '/b',
             runTime: 0.3,
             size: 1000,
-            time: 0.5,
-            timeLimit: 10
+            timeLimit: 10,
+            totalTime: 0.5
           }
         ],
         failed: true
@@ -372,3 +372,28 @@ it('renders Webpack stats help message', () => {
     })
   ).toMatchSnapshot()
 })
+
+it('renders loading time with custom message from time options for every check', () => {
+  expect(
+    results(['time'], {
+      checks: [
+        {
+          loadTime: 0.2,
+          name: 'loading message 1',
+          passed: true,
+          runTime: 0.4,
+          size: 10,
+          time: { loadingMessage: 'for ~1000 users per month' },
+          totalTime: 1.4
+        },
+        {
+          loadTime: 0.2,
+          name: 'loading message 2',
+          passed: true,
+          runTime: 0.3,
+          time: { loadingMessage: '' }
+        }
+      ]
+    })
+  ).toMatchSnapshot()
+})
diff --git a/packages/size-limit/test/get-config.test.js b/packages/size-limit/test/get-config.test.js
index 43a9182..a94a743 100644
--- a/packages/size-limit/test/get-config.test.js
+++ b/packages/size-limit/test/get-config.test.js
@@ -429,6 +429,48 @@ it('normalizes import', async () => {
   })
 })
 
+it('normalizes networkSpeed option for time plugin', async () => {
+  let cwd = 'time-network-speed'
+  expect(await check(cwd)).toEqual({
+    checks: [
+      {
+        files: [fixture(cwd, 'index.js')],
+        name: 'index.js',
+        path: 'index.js',
+        time: { networkSpeed: 20 }
+      },
+      {
+        files: [fixture(cwd, 'index.js')],
+        name: 'index.js',
+        time: { networkSpeed: 20000 }
+      }
+    ],
+    configPath: 'package.json',
+    cwd: fixture(cwd)
+  })
+})
+
+it('normalizes latency option for time plugin', async () => {
+  let cwd = 'time-latency'
+  expect(await check(cwd)).toEqual({
+    checks: [
+      {
+        files: [fixture(cwd, 'index.js')],
+        name: 'index.js',
+        path: 'index.js',
+        time: { latency: 0.2 }
+      },
+      {
+        files: [fixture(cwd, 'index.js')],
+        name: 'index.js',
+        time: { latency: 2.35 }
+      }
+    ],
+    configPath: 'package.json',
+    cwd: fixture(cwd)
+  })
+})
+    
 it('takes config from CLI config argument', async () => {
   let cwd = 'config-file-from-arg'
   let configPath = 'src/configs/my-size-limit.config.js'
diff --git a/packages/time/test/index.test.js b/packages/time/test/index.test.js
index f159b55..65e991f 100644
--- a/packages/time/test/index.test.js
+++ b/packages/time/test/index.test.js
@@ -33,7 +33,7 @@ it('calculates time to download and run', async () => {
         loadTime: 20.48,
         runTime: 10,
         size: 1024 * 1024,
-        time: 30.48
+        totalTime: 30.48
       },
       { files: ['/b'], size: 1024 * 1024 }
     ]
@@ -53,7 +53,7 @@ it('avoids run on request', async () => {
     loadTime: 20.48,
     running: false,
     size: 1024 * 1024,
-    time: 20.48
+    totalTime: 20.48
   })
 })
 
@@ -94,3 +94,50 @@ it('throws an error on missed size', async () => {
   }
   expect(err).toEqual(new SizeLimitError('missedPlugin', 'file'))
 })
+
+it('uses provided network speed for calculating loading time', async () => {
+  let config = {
+    checks: [
+      { running: false, size: 1024 * 1024, time: { networkSpeed: 100 * 1024 } }
+    ]
+  }
+
+  await time.step80(config, config.checks[0])
+  expect(config.checks[0].loadTime).toBe(
+    config.checks[0].size / config.checks[0].time.networkSpeed
+  )
+})
+
+it('uses provided latency to loading time', async () => {
+  let size = 1024 * 1024
+  let config = {
+    checks: [
+      { running: false, size },
+      { running: false, size, time: { latency: 8 } }
+    ]
+  }
+
+  await time.step80(config, config.checks[0])
+  await time.step80(config, config.checks[1])
+  expect(config.checks[1].loadTime).toBe(
+    config.checks[0].loadTime + config.checks[1].time.latency
+  )
+})
+
+it('uses provided network speed and latency for calculating loading time', async () => {
+  let config = {
+    checks: [
+      {
+        running: false,
+        size: 1024 * 1024,
+        time: { latency: 0.1984, networkSpeed: 100 * 1024 }
+      }
+    ]
+  }
+
+  await time.step80(config, config.checks[0])
+  expect(config.checks[0].loadTime).toBe(
+    config.checks[0].size / config.checks[0].time.networkSpeed +
+      config.checks[0].time.latency
+  )
+})