# Grafana k6 Browser

# Grafana k6 Browser current version

k6 Browser 在設計時, 大部分 API 跟名詞都遵循 [Playwright](https://playwright.dev/docs/api/class-playwright) 的 API 設計, 這樣的設計使得開發者降低從 Playwright 遷移腳本的認知負擔, 還可直接重用現有的元素定位策略. 以及未來會支援 [Playwright RPC server](https://github.com/playwright-community/playwright-go) .

瀏覽器的部分現在只支援`Chromium`, 未來才會陸續支援 `Firefox` 和 `WebKit-based` 的瀏覽器.

所以 k6 Browser 若是有看不懂或描述不清楚的文件, 前往 [Playwright 官網](https://playwright.dev/docs/api/class-playwright)查看會比較快又豐富.

# k6 Browser 常用 API

k6 Browser 提供了很多 API 模組，其中許多都跟 Playwright 的操作方式兼容。以下是一些基本一定會用到的 API 模組。

### Browser context

[**BrowserContext**](https://grafana.com/docs/k6/latest/javascript-api/k6-browser/browsercontext/) 在 Playwright 中是一個重要的概念，它代表了一個獨立的瀏覽器 session，類似於 private 模式，可以隔離 cookies 和 Local storage 等，使得每個測試案例在獨立的環境中運行，避免相互幹擾。

常見測試目的像是`多帳號測試場景`、`不同裝置或效能測試場景的模擬`等。

```javascript
import { browser } from "k6/browser";  

const iphoneX = devices['iPhone X'];
const context = await browser.newContext(iphoneX);
```

### Page

在 Playwright 和 k6 中，\*\*Page\*\*代表一個瀏覽器標籤頁或頁面，使用者透過Page物件與頁面內容交互，例如導覽、元素操作等。需要區分不同框架中的實作差異，例如 k6 可能更注重效能測試，而 Playwright 則更著重功能測試。同一個 BrowserContext 底下能開啟很多 Page 來測試。

```javascript
// 多頁面管理範例
const context = browser.newContext();
const page1 = await context.newPage();
const page2 = await context.newPage();

// 頁面關係示意
Browser Instance
└── BrowserContext
    ├── Page 1 (Main Frame)
    │   └── Sub Frame (iframe)
    └── Page 2 (SPA)
```

如果有多個頁面，我們能實踐\*\*跨 Page 通訊\*\*的測試互動

```javascript
// 主頁面
const [popup] = await Promise.all([
  page.waitForEvent('popup'),
  page.click('#open-window')
]);

// 新頁面操作
await popup.fill('#email', 'test@example.com');
```

但絕大多數我們幾乎都是在單個 Page 上操作的，畢竟現在大部份網站都是 SPA 架構了。

`goto` 與 `waitForNavigation` 是 page 蠻常會使用的 API。

[**goto(url\[, options\])**](https://grafana.com/docs/k6/latest/javascript-api/k6-browser/page/goto/)

將該頁導航至對應的 URL。通常測試腳本一開始就會執行這句。

```javascript
await page.goto(`https://otel-demo.field-eng.grafana.net/`);
```

**waitForNavigation(\[options\])**

在很多範例的測試腳本都會看到 `page.waitForNavigation()`，用來等待頁面導航至新的 URL 或重新載入。當您執行會間接導致頁面導航的程式碼時，此方法非常有用。

如果不知道這隻 API，大家肯定會用 `sleep(t)` 或者使用 `waitForTimeout(t)`，但這個會是個反模式。最顯著的問題是 life cycle，sleep 本身就是blocking的操作，所以 sleep的時長就會被納入 k6 的統計中。其次是可能會使得測試不夠穩定，也可能 sleep 過了，頁面都還沒載入。而`waitForTimeout` 跟 sleep 其實是一樣的東西。

```javascript
 page.waitForNavigation()
```

#### **頁面載入狀態事件**

當一個頁面開始載入時，有三個狀態我們需要了解的。`domcontentloaded`、`load` 和 `networkidle`，接下來，我需要分別解釋​​這三個事件：

**domcontentloaded**：當DOMContentLoaded事件觸發時完成操作。這個事件在HTML文件被完全載入和解析時觸發，無需等待 CSS、image 和框架的載入。適用於需要快速與DOM互動的情況，例如測試頁面結構是否已載入完成。

**load（預設值）**：當頁面所有資源（如圖片、CSS）載入完成後觸發 load 事件。這會等待更長時間，適合需要所有資源就緒的場景，例如測試頁面完全載入後的功能。

**networkidle**：網路空閒至少500毫秒時認為完成。但用戶指出這在某些情況下可能不可靠，特別是對於持續有網路請求的網站（如聊天應用程式）。 k6 browser可能同樣存在這個問題，因此建議使用明確的斷言來確認頁面狀態。

**事件狀態比較表**

| 事件類型 | 觸發時機 | 適用場景 | 潛在風險 |
| --- | --- | --- | --- |
| `domcontentloaded` | HTML 解析完成，DOM 樹構建完畢 | 表單驗證、DOM 操作測試 | 樣式未載入可能影響佈局判斷 |
| `load` | 所有資源(圖片/CSS/JS)載入完成 | 完整頁面渲染驗證 | 第三方資源延遲導致超時 |
| `networkidle` | 連續 500ms 無網絡活動 | 傳統 SPA 應用 | 長輪詢/WebSocket 導致失效 |

```mermaid
sequenceDiagram
    participant 瀏覽器
    participant 測試腳本

    瀏覽器->>瀏覽器: 解析 HTML (DOMContentLoaded)
    瀏覽器->>瀏覽器: 載入外部資源 (load)
    瀏覽器->>瀏覽器: 監控網絡活動 (networkidle)
    瀏覽器->>測試腳本: 觸發 ready 狀態
```

**screenshot(\[options\])**

將該頁面給截圖並且儲存在指定路徑，這在 debug 很好用。

```javascript
await page.screenshot({ path: `./screenshots/order.png` });
```

### Evaluate

evaluate允許在瀏覽器上下文中執行 JavaScript程式碼，通常用於取得或操作頁面元素，或執行特定的客戶端邏輯，如操作 DOM 元素、獲取頁面資料並操作、執行複雜計算「跟頁面中的 JavaScript 互動，甚至能注入 JavaScript 程式等，並且把結果返回 k6 中。且 evaluate 能處理 sync 和 async 函數，並向這些函數傳遞參數。

像是透過 evaluate 執行 [window.performance.mark](https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark) 或是 [window.performance.measure](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure)，用於精確測量程式碼區塊的效能，幫助開發者分析執行時間和最佳化效能。但其實 k6 browser metrics 有幫忙監測這些數值。

```javascript
const result = await page.evaluate("([x, y]) => Promise.resolve(x * y)", [7,8]);
console.log(`Result: ${result}`);

await page.evaluate(() => window.performance.mark('page-visit'));

// 假設頁面中有 greet(content) 的 JavaScript 函數
result = await page.evaluate('greet("k6 demo")');
```

### Locator

Locator 絕對是這裡面最需要搞懂的模組，它與`document.querySelector`ㄧ 樣就是找到需要操作的元素。

`locator(selector)` 唯一的參數就是 selector，這有常在使用 jQuery 的開發者應該不陌生了 XD

關於 select 的選擇官方有給一份最佳實踐。

1. 盡可能優先選擇頁面裡唯一的屬性。
    
2. 穩定性，抗變更的能力
    
3. 語意性，團隊中的規範
    

```mermaid
graph TD
    A[選擇器品質] --> B[唯一性]
    A --> C[穩定性]
    A --> D[語意性]
    B --> E[DOM 層級唯一驗證]
    C --> F[架構抗變更能力]
    D --> G[團隊溝通效率]
```

還給了評分表當作參考 *❌ = 0, ⚠️= 0.5, ✅ = 1 ，選用的 selector 類型分數越高越好。*

|  | **Unique to the page** | **Value is stable** | **Conveys intent** | **Total** |
| --- | --- | --- | --- | --- |
| **autocapitalize** | ❌ | ❌ | ❌ | 0 |
| **class** | ❌ | ⚠️ | ⚠️ | 1 |
| id | ✅ | ⚠️ | ⚠️ | 2 |
| name | ✅ | ⚠️ | ✅ | 2.5 |
| **placeholder** | ✅ | ⚠️ | ✅ | 2.5 |
| **data-testid** | ✅ | ✅ | ✅ | 3 |

```javascript
// 危險範例
page.locator('.css-8tk2dk-input-input') // 由CSS-in-JS生成
```

```mermaid
pie
    title Class選擇器失敗原因
    "非唯一性" : 45
    "編譯工具修改" : 30
    "重構被刪除" : 20
    "語意不明" : 5
```

```javascript
// React產生的易變ID
page.locator('#:r0:'); // 使用useId()生成
```

```javascript
// 多語言情境下的問題
page.locator('[placeholder="email or username"]')
```

selector 若選用 placeholder 的話可能會有以下問題︰

* 需要與i18n系統整合
    
* 內容團隊可能修改文案
    
* 無法區分相似元素（如多個email輸入框）
    

`data-testid` 這我其實不知道是什麼，我看公司平台的前端內容是有 data-pc-section、data-pc-nam，就加減用了。真的沒招的話，最差就是 `XPath` 了。

```javascript
page.locator(`//h2[text()="Product Title"]`)
```

### Check

若是以前有再寫 k6 API 測試的開發者，一定對 check 不陌生。以前的話是這樣寫

`check( val, sets, [tags] )`，set 則是對 val 做斷言檢查的部份。

```javascript
import { check } from 'k6';
import http from 'k6/http';

export default function () {
  const res = http.get('http://test.k6.io/');
  check(res, {
    'is status 200': (r) => r.status === 200,
  });
}
```

But ! 瀏覽器的元件都是 **Asynchronous** 的。原本 k6 內建的 check 沒辦法處理。因此需要 [k6 util](https://grafana.com/docs/k6/latest/javascript-api/jslib/utils/check/) 提供的 check 來處理非同步事件。將 locator 取得後，來斷言其內容或狀態。

```javascript
import { check } from 'https://jslib.k6.io/k6-utils/1.6.0/index.js';

export default async function () {
  const page = await browser.newPage();

  try {
    await page.goto('https://test.k6.io/my_messages.php');

    await check(page.locator('h2'), {
      'header': async lo => await lo.textContent() == 'Welcome, admin!'
    });
  } finally {
    await page.close();
  }
}
```

有 check 的話，都會在 options 的 thresholds 搭配使用 checks 來檢查失敗比例需要高於多少。以下範例就是全部 check 的成功率需要超過 9 成。rate 的範圍值在 \[0.0, 1.0\] 之間，`1.0` 等於就是 100% check 都成功。

```javascript
export const options = {
  thresholds: {
    // the rate of successful checks should be higher than 90%
    checks: ['rate>0.9'],
  },
};
```

```mermaid
graph TD
    A[測試執行] --> B[進行檢查]
    B --> C{檢查結果}
    C -->|成功| D[成功計數+1]
    C -->|失敗| E[失敗計數+1]
    D & E --> F[計算成功率]
    F --> G[成功率 >= 1.0 ?]
    G -->|是| H[標記為通過]
    G -->|否| I[標記為失敗]
```

# Executor ︰Shared-iterations

k6 其實提供很多 [Executor](https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/)，但 UI 測試幾乎都選用 Shared-iterations 居多，我們先理解 shared-iterations 是什麼。Executor 的選擇主要會根據我們執行測試腳本時，對於設定好的 VU 數量，想要怎麼調度的策略。k6 目前提供了 [7 種VU調度策略](https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/)。有興趣能自己研究。

shared-iterations executor 主要策略是讓設定好的 VU數量去共享迭代次數。以下範例就是 10 個 VU 去共享200次的腳本迭代，但因為執行時間不是穩定的，每個 VU 實際跑到的次數不一定的。

```javascript
export const options = {
  scenarios: {
    contacts: {
      executor: 'shared-iterations',
      vus: 10,
      iterations: 200,
    },
  },
};
```

而在 UI 的測試中，其實就只會設定一個 VU 跑一次迭代而已。除非有必要才會設定其他的參數。

# OTel Demo 電商專案 Example

```javascript
import { browser } from "k6/browser";
import { check } from "https://jslib.k6.io/k6-utils/1.6.0/index.js";
import { sleep, fail } from "k6";

const LESS_IMPORTANT = `info`;
const TWO_SECONDS = 2000;

export const options = {
  scenarios: {
    ui: {
      executor: "shared-iterations",
      options: {
        browser: {
          type: "chromium",
        },
      },
    },
  },
  thresholds: {
    'browser_web_vital_lcp': ['p(90) < 1500'],
    'browser_web_vital_inp': ['p(90) < 1500'],
    //'browser_web_vital_inp{url:https://otel-demo.field-eng.grafana.net/}': ['p(90) < 1500'],
    'browser_http_req_failed': ['rate < 0.3'],
    checks: ['rate==1.0'],
  },
};

export default async function () {
  const context = await browser.newContext();
  const page = await context.newPage();

  const result = await page.evaluate("([x, y]) => Promise.resolve(x * y)", [7,8]);
  console.log(`Result: ${result}`);
 // await page.waitForTimeout(5000);

  await page.goto(`https://otel-demo.field-eng.grafana.net/`);
 // await page.evaluate(() => window.performance.mark('page-visit'));

  // Check homepage title
  check(await page.title(), {
    "Homepage title is correct": (title) => title.includes("OTel demo"),
  });

  await page.locator(`//*[text()="Go Shopping"]`).click();

  // Check product listing page
  check(await page.url(), {
    "URL changed to products page": (url) => url.includes("/#hot-products"),
  });

  await Promise.all([
    page
      .locator(`//*[text()="Starsense Explorer Refractor Telescope"]`)
      .click(),

    // sleep (3000);
    page.waitForNavigation(),
  ]);

   // Check product details page
   check(await page.url(), {
    'URL changed to product detail page': (url) => url.includes('/product/')
  });

   // Check product price is present
   const priceElement = await page.locator('[data-cy="product-price"]');
   check(await priceElement.isVisible(), {
     'Product price is visible': (isVisible) => isVisible === true
   });

  // less important check
  await checkForRecommendedProducts(page, `Product page`);

  await Promise.all([
    page.locator(`//*[text()=" Add To Cart"]`).click(),
    page.waitForNavigation(),
  ]);

    // Check cart page
    check(await page.url(), {
      'URL changed to cart page': (url) => url.includes('/cart')
    });

  await page.locator("#email").fill("nathan@demo.com");
  await page.screenshot({ path: `./screenshots/order.png` });

  await Promise.all([
    page.locator(`//*[text()="Place Order"]`).click(),
    page.waitForNavigation(),
  ]);

    // Check order checkout page
    check(await page.url(), {
      'URL changed to order checkout page': (url) => url.includes('/checkout')
    });

  // less important check
  await checkForRecommendedProducts(page, `Order confirmation`);

  await page.close();
}

async function checkForRecommendedProducts(page, step) {
  try {
    await page
      .locator(
        `[data-cy="recommendation-list"] [data-cy="product-card"]:first-of-type`
      )
      .waitFor({ timeout: TWO_SECONDS });
  } catch (e) {
    await page.screenshot({ path: `./screenshots/${step}.png` });
    fail(
      `Failed to find recommended products on ${step} page within ${TWO_SECONDS}ms timeout`
    );
  } finally {
    const cards = await page.$$(`[data-cy="product-card"]`);
    console.log(step, cards.length);

    check(
      cards.length,
      {
        "4 recommended products are displayed": (length) => length === 4,
      },
      {
        importance: LESS_IMPORTANT,
      }
    );
  }
}
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1739716788777/99798b95-ffb3-49bb-a1d0-458fa3d05da1.gif align="center")

# Browser Metrics

```bash
> K6_BROWSER_HEADLESS=false k6 run demo1.js

         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 

     execution: local
        script: demo1.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):
              * ui: 1 iterations shared among 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)

INFO[0000] Result: 56                                    source=console
INFO[0004] Product page 4                                source=console
INFO[0006] Order confirmation 4                          source=console

     ✓ Homepage title is correct
     ✓ URL changed to products page
     ✓ URL changed to product detail page
     ✓ Product price is visible
     ✓ 4 recommended products are displayed
     ✓ URL changed to cart page
     ✓ URL changed to order checkout page

     browser_data_received.......: 12 MB   1.9 MB/s
     browser_data_sent...........: 158 kB  26 kB/s
     browser_http_req_duration...: avg=216.35ms min=49.64ms  med=209.6ms  max=392.39ms p(90)=242.57ms p(95)=300.16ms
   ✓ browser_http_req_failed.....: 2.94%   2 out of 68
     browser_web_vital_cls.......: avg=0.027803 min=0.027803 med=0.027803 max=0.027803 p(90)=0.027803 p(95)=0.027803
     browser_web_vital_fcp.......: avg=563ms    min=563ms    med=563ms    max=563ms    p(90)=563ms    p(95)=563ms   
     browser_web_vital_fid.......: avg=199.99µs min=199.99µs med=199.99µs max=199.99µs p(90)=199.99µs p(95)=199.99µs
   ✓ browser_web_vital_inp.......: avg=136ms    min=136ms    med=136ms    max=136ms    p(90)=136ms    p(95)=136ms   
   ✓ browser_web_vital_lcp.......: avg=1.16s    min=1.16s    med=1.16s    max=1.16s    p(90)=1.16s    p(95)=1.16s   
     browser_web_vital_ttfb......: avg=301.5ms  min=301.5ms  med=301.5ms  max=301.5ms  p(90)=301.5ms  p(95)=301.5ms 
   ✓ checks......................: 100.00% 8 out of 8
     data_received...............: 0 B     0 B/s
     data_sent...................: 0 B     0 B/s
     iteration_duration..........: avg=5.81s    min=5.81s    med=5.81s    max=5.81s    p(90)=5.81s    p(95)=5.81s   
     iterations..................: 1       0.164587/s
     vus.........................: 1       min=1       max=1
     vus_max.....................: 1       min=1       max=1


running (00m06.1s), 0/1 VUs, 1 complete and 0 interrupted iterations
ui   ✓ [======================================] 1 VUs  00m06.1s/10m0s  1/1 shared iters
```

[Google Core Web Vitals](https://developers.google.com/search/docs/appearance/core-web-vitals?hl=zh-tw)，包括 CLS、FID、LCP等。這些其實我也沒很懂。  
LCP（Largest Contentful Paint）大概是說主要內容加載，然後使用者多快能看到頁面主題。

FID（First Input Delay），使用者首次能互動的時間（能點擊/輸入）。

CLS （Cumulative Layout Shift）頁面視覺穩定性…我不懂。

INP（Interaction to Next Paint）複雜互動的回應速度。

FCP（First Contentful Paint）跟TTDB（Time to First Byte）

我個人主要是檢查 `browser_http_req_failed` 跟 `checks failed rate`。Core Web Vitals 我也不怎懂 XD

此外，k6 的這些指標也能通過 [OpenTelemetry](https://grafana.com/docs/k6/latest/results-output/real-time/opentelemetry/) 輸出到遠端的服務裡來儲存分析。

```bash
K6_OTEL_GRPC_EXPORTER_INSECURE=true K6_OTEL_METRIC_PREFIX=k6_ k6 run -o experimental-opentelemetry demo1.js
```

或是參考小弟出版的[OpenTelemetry 入門指南︰建立全面可觀測性架構](https://www.tenlong.com.tw/products/9786263338739?list_name=p-r-zh_tw) Ch 13，透過 Prometheus remote write 寫進 Prometheus 中。

# Hybrid Testing

![Hybrid Performance Testing for Websites | k6](https://k6.io/static/a41222c73a41b1c6a0e3c7eeb2d7515c/0fccf/hybrid-performance-testing-meta.png align="left")

有用過 k6 scenarios 的開發者就知道，其實我們能再 `scenarios` 中定義多個 scenario 然後指定 `exec` 的函數。所以也能應用這樣的方式，同時對 UI 和 API 做不同目的的測試。這裡就用到另一種 constant-vus executor，所以你執行後會看到同時有3個瀏覽氣在執行。

```javascript
import { browser } from "k6/browser"
import http from "k6/http"
import { check } from "https://jslib.k6.io/k6-utils/1.6.0/index.js"

const PRODUCT_IDS = __ENV.PRODUCT_IDS

const HAS_SOME_LEEWAY = `warn`
const SUPER_IMPORTANT_CHECK = `critical`
const LESS_IMPORTANT = `info`

export const options = {
  scenarios: {
    ui: {
      executor: "constant-vus",
      duration: "1m",
      vus: 3,
      options: {
        browser: {
          type: "chromium",
        },
      },
      exec: "checkoutCompletion",
    },
    "spike-api": {
      executor: "ramping-vus",
      startVUs: 0,
      stages: [
        { duration: "10s", target: 10 },
        { duration: "40s", target: 30 },
        { duration: "10s", target: 10 },
      ],
      gracefulRampDown: "10s",
      exec: "spikeApi",
    },
  },
  thresholds: {
    [`checks{importance:${SUPER_IMPORTANT_CHECK}}`]: ["rate==1.0"],
    [`checks{importance:${HAS_SOME_LEEWAY}}`]: ["rate>=0.95"],
    [`checks{importance:${LESS_IMPORTANT}}`]: ["rate>=0.9"],
  },
}

export function spikeApi() {
  const randomProduct =
    PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)]
  const res = http.get(`https://otel-demo.field-eng.grafana.net/api/recommendations?productIds=${randomProduct}`)

  check(
    res,
    {
      "status code is 200": (r) => r.status === 200,
    },
    { importance: HAS_SOME_LEEWAY }
  )
}

export async function checkoutCompletion() {
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto(`https://otel-demo.field-eng.grafana.net/`)
  await page.locator(`//*[text()="Go Shopping"]`).click()

  await Promise.all([
    page
      .locator(`//*[text()="Starsense Explorer Refractor Telescope"]`)
      .click(),
    page.waitForNavigation(),
  ])

  // less important check
  await checkForRecommendedProducts(page, `Product page`)

  await Promise.all([
    page.locator(`//*[text()=" Add To Cart"]`).click(),
    page.waitForNavigation(),
  ])

  // less important check
  await checkForRecommendedProducts(page, `Shipping form`)

  await Promise.all([
    page.locator(`//*[text()="Place Order"]`).click(),
    page.waitForNavigation(),
  ])

  // Super important check
  await check(
    page.locator(`h1`),
    {
      "Place order page was reached": async (lo) =>
        (await lo.textContent()) === "Your order is complete!",
    },
    { important: SUPER_IMPORTANT_CHECK }
  )

  // less important check
  await checkForRecommendedProducts(page, `Order confirmation`)
  await page.close()
}

const TWO_SECONDS = 2000

async function checkForRecommendedProducts(page, step) {
  try {
    await page
      .locator(
        `[data-cy="recommendation-list"] [data-cy="product-card"]:first-of-type`
      )
      .waitFor({ timeout: TWO_SECONDS })
  } catch (e) {
    await page.screenshot({ path: `./screenshots/${step}.png` })
  } finally {
    const cards = await page.$$(`[data-cy="product-card"]`)
    console.log(step, cards.length)

    check(
      cards.length,
      {
        "4 recommended products are displayed": (length) => length === 4,
      },
      {
        importance: LESS_IMPORTANT,
      }
    )
  }
}
```

能看到以下測試結果，我測試到一半時, server 其實就異常了。但無礙，因為是 hybrid testing，所以 Browser metrics 與 Http metrics 都有被計算出來了。這就是 Playwright 無法提供的，畢竟 k6 能提供很完整且強大的 metrics 資料以及各種負載測試類型的測試策略。

```bash
> K6_BROWSER_HEADLESS=false k6 run demo2.js -e PRODUCT_IDS=10

         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 

     execution: local
        script: demo5.js
        output: -

     scenarios: (100.00%) 2 scenarios, 33 max VUs, 1m30s max duration (incl. graceful stop):
              * spike-api: Up to 30 looping VUs for 1m0s over 3 stages (gracefulRampDown: 10s, exec: spikeApi, gracefulStop: 30s)
              * ui: 3 looping VUs for 1m0s (exec: checkoutCompletion, gracefulStop: 30s)

INFO[0002] Product page 4                                source=console
INFO[0003] Shipping form 4                               source=console
INFO[0003] Order confirmation 4                          source=console
INFO[0004] Product page 4                                source=console
INFO[0004] Product page 4                                source=console
INFO[0005] Shipping form 4                               source=console
INFO[0005] Shipping form 4                               source=console
INFO[0005] Order confirmation 4                          source=console
INFO[0006] Order confirmation 4                          source=console
INFO[0008] Product page 4                                source=console
INFO[0008] Product page 4                                source=console
INFO[0010] Product page 0                                source=console
INFO[0011] Shipping form 4                               source=console
INFO[0012] Shipping form 4                               source=console
INFO[0012] Order confirmation 4                          source=console
INFO[0013] Order confirmation 4                          source=console
INFO[0015] Product page 4                                source=console
INFO[0016] Shipping form 4                               source=console
INFO[0016] Product page 4                                source=console
INFO[0017] Order confirmation 4                          source=console
INFO[0017] Shipping form 4                               source=console
INFO[0017] Order confirmation 4                          source=console
INFO[0021] Product page 4                                source=console
INFO[0022] Product page 4                                source=console
INFO[0022] Shipping form 4                               source=console
INFO[0022] Shipping form 4                               source=console
INFO[0022] Order confirmation 4                          source=console
INFO[0023] Order confirmation 4                          source=console
INFO[0026] Product page 4                                source=console
INFO[0026] Shipping form 4                               source=console
INFO[0027] Product page 4                                source=console
INFO[0027] Order confirmation 4                          source=console
INFO[0027] Shipping form 4                               source=console
INFO[0028] Order confirmation 4                          source=console
INFO[0029] Product page 4                                source=console
INFO[0030] Shipping form 4                               source=console
INFO[0031] Order confirmation 4                          source=console
INFO[0033] Product page 4                                source=console
INFO[0033] Shipping form 4                               source=console
INFO[0035] Order confirmation 4                          source=console
INFO[0035] Product page 4                                source=console
INFO[0035] Shipping form 4                               source=console
INFO[0036] Order confirmation 4                          source=console
ERRO[0038] Uncaught (in promise) waiting for navigation: timed out after 30s  executor=constant-vus scenario=ui
INFO[0041] Product page 0                                source=console
INFO[0041] Product page 0                                source=console
WARN[0069] Unexpected DevTools server error: context canceled  category="ExecutionContext:eval" elapsed="0 ms" source=browser
ERRO[0069] Uncaught (in promise) clicking on "//*[text()=\"Go Shopping\"]": timed out after 30s  executor=constant-vus scenario=ui
WARN[0071] Unexpected DevTools server error: context canceled  category="ExecutionContext:eval" elapsed="0 ms" source=browser
WARN[0071] Unexpected DevTools server error: context canceled  category="ExecutionContext:eval" elapsed="0 ms" source=browser
ERRO[0071] Uncaught (in promise) clicking on "//*[text()=\" Add To Cart\"]": timed out after 30s  executor=constant-vus scenario=ui
ERRO[0071] Uncaught (in promise) clicking on "//*[text()=\" Add To Cart\"]": timed out after 30s  executor=constant-vus scenario=ui

     ✗ status code is 200
      ↳  75% — ✓ 2059 / ✗ 671
     ✗ 4 recommended products are displayed
      ↳  93% — ✓ 42 / ✗ 3
     ✗ Place order page was reached
      ↳  78% — ✓ 11 / ✗ 3

     browser_data_received..........: 162 MB 2.3 MB/s
     browser_data_sent..............: 2.6 MB 36 kB/s
     browser_http_req_duration......: avg=243.14ms min=50µs     med=218.64ms max=2.5s     p(90)=308.08ms p(95)=390.47ms
     browser_http_req_failed........: 4.31%  48 out of 1113
     browser_web_vital_cls..........: avg=0.052306 min=0.026286 med=0.027803 max=0.23987  p(90)=0.11228  p(95)=0.23917 
     browser_web_vital_fcp..........: avg=592.86ms min=244ms    med=522.5ms  max=2.45s    p(90)=589.2ms  p(95)=663.5ms 
     browser_web_vital_fid..........: avg=1.78ms   min=199.99µs med=1.59ms   max=3.59ms   p(90)=3.43ms   p(95)=3.51ms  
     browser_web_vital_inp..........: avg=107.29ms min=48ms     med=96ms     max=216ms    p(90)=144ms    p(95)=177.59ms
     browser_web_vital_lcp..........: avg=1.15s    min=658.29ms med=1.2s     max=2.45s    p(90)=1.31s    p(95)=1.49s   
     browser_web_vital_ttfb.........: avg=349.74ms min=200.6ms  med=238.59ms max=2.41s    p(90)=293.4ms  p(95)=306.4ms 
     checks.........................: 75.72% 2112 out of 2789
     ✓ { importance:critical }......: 0.00%  0 out of 0
     ✓ { importance:info }..........: 93.33% 42 out of 45
     ✗ { importance:warn }..........: 75.42% 2059 out of 2730
     data_received..................: 5.9 MB 83 kB/s
     data_sent......................: 258 kB 3.6 kB/s
     http_req_blocked...............: avg=117.56µs min=200ns    med=491ns    max=18.19ms  p(90)=671ns    p(95)=772ns   
     http_req_connecting............: avg=32.99µs  min=0s       med=0s       max=3.6ms    p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=385.08ms min=196.1ms  med=246.84ms max=3.3s     p(90)=703.54ms p(95)=991.39ms
       { expected_response:true }...: avg=343.25ms min=208.15ms med=254.68ms max=2.42s    p(90)=536.31ms p(95)=709.43ms
     http_req_failed................: 24.57% 671 out of 2730
     http_req_receiving.............: avg=158.17µs min=11.23µs  med=137.2µs  max=8.76ms   p(90)=238.37µs p(95)=283.66µs
     http_req_sending...............: avg=40.71µs  min=11.82µs  med=38.86µs  max=163.39µs p(90)=56.8µs   p(95)=64.05µs 
     http_req_tls_handshaking.......: avg=79.32µs  min=0s       med=0s       max=14.87ms  p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=384.88ms min=195.99ms med=246.56ms max=3.3s     p(90)=703.43ms p(95)=991.29ms
     http_reqs......................: 2730   38.299487/s
     iteration_duration.............: avg=457.94ms min=196.26ms med=248.53ms max=36.02s   p(90)=725.73ms p(95)=1.18s   
     iterations.....................: 2748   38.552011/s
     vus............................: 2      min=2            max=32
     vus_max........................: 33     min=33           max=33


running (1m11.3s), 00/33 VUs, 2748 complete and 0 interrupted iterations
spike-api ✓ [======================================] 00/30 VUs  1m0s
ui        ✓ [======================================] 3 VUs      1m0s
ERRO[0071] thresholds on metrics 'checks{importance:warn}' have been crossed
```

# References

[k6 Browser examples](https://github.com/grafana/xk6-browser/tree/main/examples)

[k6 JsLib](https://jslib.k6.io/)

[Grafana Blog - 5 tips to write better browser tests for performance testing and synthetic monitoring](https://grafana.com/blog/2024/11/21/5-tips-to-write-better-browser-tests-for-performance-testing-and-synthetic-monitoring/)

[Grafana Blog - Frontend vs. backend: How to plan your performance testing strategy](https://grafana.com/blog/2023/04/03/frontend-vs.-backend-how-to-plan-your-performance-testing-strategy/)

[Grafana Blog - How to load test a website: A comprehensive guide](https://grafana.com/blog/2024/01/30/load-testing-websites/)

[Youtube k6 - Browser Testing with k6](https://youtu.be/N7VJ9X5yAKo)
