# Grafana xk6

最近因為任務，需要對公司的平台做 `smoke testing` 與一些基本的 `load testing`。但因為我們是 Web3 的產品有點麻煩，登入要 OTP 驗證，還有很多簽章的流程需要處理。剛好又是做借貸撮合的平台，需要開多個瀏覽器登入不同角色跟帳號進行操作。剛好 k6 有提供 `xk6` 來建置出 k6 的插件。

除此之外這測試腳本還是能跟原本的 k6 http load testing 腳本混合執行。同時能執行瀏覽器的測試以及 API 的壓測的 solution 不算多，k6 算是其中之一。

但這篇先來寫 **xk6** .

---

![Using k6 browser | Grafana k6 documentation](https://img.youtube.com/vi/N7VJ9X5yAKo/maxresdefault.jpg align="left")

# Grafana k6 Browser previos version

**k6 Browser** 本來也是 k6 的插件之一，也是用 xk6 來編譯使用的，但到了 k6 `0.56` 版本後，就被合併到 k6 的主要程式庫當中了。剛好在寫文章當下也是最新版本也是 `0.56`。

> Starting from k6 version v0.56, this [codebase](https://github.com/grafana/xk6-browser/tree/main) has been merged and is now part of the [main k6 repository.](https://github.com/grafana/k6)

但我們能看一下它原本是怎麼透過 xk6 編譯的。根目錄中的 [Makefile](https://github.com/grafana/xk6-browser/blob/main/Makefile) 中的 build job.

```yaml
go install go.k6.io/xk6/cmd/xk6@latest && 
xk6 build --output xk6-browser --with github.com/grafana/xk6-browser=.
```

# xk6

[https://github.com/grafana/xk6](https://github.com/grafana/xk6) xk6 主要是利用 Go 語言的 package 管理器來進行套件的下載與編譯出 k6 套件的, 所以套件開發者需要安裝 Go 才方便編譯 xk6 撰寫的套件.

\*\* 安裝 xk6 \*\*

```go
# Install xk6
go install go.k6.io/xk6/cmd/xk6@latest
```

然後用 Go 指令新建一個專案, 這裡我用簡單的 OTP 產生器為例子. 以下是檔案目錄.

```go
├── otp
│   ├── go.mod
│   ├── go.sum
│   └── otp.go
```

接著需要在專案中安裝k6套件.

```bash
go get go.k6.io/k6
```

最重要的是`modules.Register("k6/x/otp", new(OtpGenerator))`, 這裡第一個參數一定要事 `k6/x/` 來作為k6模組的名稱前綴. `x` 我不知道是`experimental` 還是 `xk6`的 x 就是了.

\*\* 模組架構設計 (Go 語言部分)\*\*

1. 模組註冊與生命週期
    

```go
// 全局模組 root 實例 (每個測試程序只會存在一個)
type RootModule struct{} 

// 模組實例 (每個 VU 虛擬用戶獨立一個實例)
type ModuleInstance struct {
    vu modules.VU  // 持有 VU 上下文
}

// 必須實現的 interface 驗證
var (
    _ modules.Module   = &RootModule{}
    _ modules.Instance = &ModuleInstance{}
)
```

2. 模組初始化流程
    

```go
func init() {
    // 註冊模組到 k6 系統 (強制前綴 k6/x/)
    modules.Register("k6/x/otp", new(RootModule))
}

// 創建 VU 級實例
func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance {
    return &ModuleInstance{vu: vu}
}

// 匯出 JS 可調用的構建函數
func (m *ModuleInstance) Exports() modules.Exports {
    return modules.Exports{
        Named: map[string]interface{}{
            "OtpGenerator": m.newOtpGenerator, // JS 中 new OtpGenerator()
        },
    }
}
```

**OTP 產生器實現細節** 3. 核心類結構

```go
type OtpGenerator struct {
    vu       modules.VU  // 必備的 VU 引用
    secret   string      //  密鑰
    passcode string      // 最後生成的驗證碼
}
```

4. JavaScript 綁定 (構建函數)
    

```go
func (m *ModuleInstance) newOtpGenerator(c sobek.ConstructorCall) *sobek.Object {
    rt := m.vu.Runtime()
    
    // 參數驗證邏輯
    if len(c.Arguments) != 1 {
        common.Throw(rt, fmt.Errorf("需要 1 個參數 (secret)"))
    }
    secret := c.Argument(0).String()
    if secret == "" || len(secret) < 16 {
        common.Throw(rt, fmt.Errorf("無效密鑰"))
    }

    // 創建 Go 實例
    generator := &OtpGenerator{
        vu:     m.vu,
        secret: secret,
    }

    // 創建 JS 對象並綁定方法
    obj := rt.NewObject()
    obj.Set("generate", generator.generate)       // 綁定產生 OTP 方法
    obj.Set("getPasscode", generator.getPasscode) // 綁定取值方法
    return obj
}
```

**xk6 編譯與使用**

```bash
cd otp && xk6 build \
	--with github.com/$(basename $(pwd))=. \
	--output ../k6
```

檢查編譯出來的 k6 執行檔其包含的模組資訊. 我這裡出現兩個是因為我編譯時 `--with` 有用到另一個模組.

```bash
./k6 version

k6 v0.56.0 (go1.22.1, darwin/arm64)
Extensions:
  github.com (devel), k6/x/kv [js]
  github.com (devel), k6/x/otp [js]
```

多模組同時編譯的指令

```bash
cd otp && xk6 build \
	--with github.com/$(basename $(pwd))=. \
	--with github.com/oleiade/xk6-kv \
	--output ../k6
```

也能透過 [docker image](https://hub.docker.com/r/grafana/xk6) 來協助編譯. 其中`grafana/xk6 build v0.56`, 是用來指定用 v0.56版本的 k6 來編譯.

```bash
docker run --rm -it -e GOOS=darwin  -u "$(id -u):$(id -g)" \
	-v "${PWD}:/xk6" \
	grafana/xk6 build v0.56 \
	--with github.com/demo/otp=./otp \
	--with github.com/oleiade/xk6-kv
```

以下是完整程式

```go
package otp

import (
	"fmt"
	"time"

	"github.com/grafana/sobek"
	"github.com/pquerna/otp"
	"github.com/pquerna/otp/totp"
	"go.k6.io/k6/js/common"
	"go.k6.io/k6/js/modules"
)

// Initialize module registration
func init() {
	modules.Register("k6/x/otp", new(RootModule))
}

// RootModule implements the global module instance
type RootModule struct{}

// ModuleInstance represents a per-VU module instance
type ModuleInstance struct {
	vu modules.VU
}

// Ensure interfaces are implemented
var (
	_ modules.Module   = &RootModule{}
	_ modules.Instance = &ModuleInstance{}
)

// NewModuleInstance creates a new module instance for each VU
func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance {
	return &ModuleInstance{
		vu: vu,
	}
}

// Exposes constructor functions for JavaScript binding
func (m *ModuleInstance) Exports() modules.Exports {
	return modules.Exports{
		Named: map[string]interface{}{
			"OtpGenerator": m.newOtpGenerator,
		},
	}
}

// OtpGenerator handles TOTP generation operations
type OtpGenerator struct {
	vu       modules.VU
	secret   string // Base32 encoded secret key
	passcode string // Last generated passcode
}

// newOtpGenerator constructor creates JS-bound OTP generator object
func (m *ModuleInstance) newOtpGenerator(c sobek.ConstructorCall) *sobek.Object {
	rt := m.vu.Runtime()

	// Validate constructor arguments
	if len(c.Arguments) != 1 {
        // 統一使用 common.Throw 拋出異常
		common.Throw(rt, fmt.Errorf("OtpGenerator requires 1 argument (secret)"))
	}
	secret := c.Argument(0).String()
	if secret == "" {
		common.Throw(rt, fmt.Errorf("secret cannot be empty"))
	}
	if len(secret) < 16 {
		common.Throw(rt, fmt.Errorf("secret too short"))
	}

	// Initialize generator instance
	generator := &OtpGenerator{
		vu:     m.vu,
		secret: secret,
	}

	// Create JS object and bind methods
	obj := rt.NewObject()

	// Bind generate method
	if err := obj.Set("generate", generator.generate); err != nil {
		common.Throw(rt, err)
	}

	// Bind passcode getter (optional)
	if err := obj.Set("getPasscode", func() string {
		return generator.passcode
	}); err != nil {
		common.Throw(rt, err)
	}

	return obj
}

// generate handles TOTP code generation with optional timestamp parameter
// Returns generated passcode directly to JS
func (g *OtpGenerator) generate(c sobek.FunctionCall) sobek.Value {
	rt := g.vu.Runtime()

	// Handle timestamp parameter
	var now time.Time
	if len(c.Arguments) > 0 {
        // JavaScript -> Go 參數接收
		ms := c.Argument(0).ToInteger()
		now = time.UnixMilli(ms)
	} else {
		now = time.Now()
	}

	// Generate TOTP with fixed parameters
	passcode, err := totp.GenerateCodeCustom(g.secret, now, totp.ValidateOpts{
		Period:    30, // 30-second intervals
		Skew:      1,  // Allow 1 interval before/after
		Digits:    otp.DigitsSix,
		Algorithm: otp.AlgorithmSHA1,
	})

	if err != nil {
		common.Throw(rt, err)
	}

	// Store and return the generated code
	g.passcode = passcode

   // Go -> JavaScript 值轉換
	return rt.ToValue(passcode)
}
```

\*\* k6 客戶端使用\*\*

```javascript
import { OtpGenerator } from 'k6/x/otp';

const generator = new OtpGenerator(otpSecret);
const code = generator.generate();
```

# 總結

在公司專案中使用 k6 時, 基本上有很大機會會透過 xk6 來編譯出客製化的 k6 套件. 偏偏官網這部分講的不是太豐富. 就把應用到的部分簡單整理.

[下一篇再來分享 k6 Browser.](https://ganhua.wang/grafana-k6-browser)
