Tutorial - grafana/xk6 GitHub Wiki

This guide explains a step-by-step process for creating a k6 extension using a hypothetical scenario.

Prerequisites

The tutorial assumes the reader has a GitHub account. Having a GitHub account simplifies the process of developing k6 extensions, which the tutorial will cover. GitHub Codespaces provides a streamlined development experience for k6 extensions, reducing the need for local setup.

If you don't have a GitHub account, please follow Creating an account on GitHub.

This document assumes prior knowledge from the k6 Extension Development Quick Start Guide to avoid repetition and potential inconsistencies. Certain steps will be referenced instead of being fully detailed here.

Hypothetical scenario

A REST API under test uses ascii85 encoding, a feature not natively supported by k6. To address this, ascii85 functionality can be implemented in two ways: as a JavaScript/TypeScript module or as a Go-based k6 extension. This guide will focus on the latter approach, developing it as a k6 extension in Go.

Step by Step

Create a GitHub repository

Begin by creating a GitHub repository using the grafana/xk6-example template repository (as explained in the Quick Start Guide). Name the repository xk6-example-ascii85.

Open in Codespace (or VS Code)

Create a Codespaces and open it or clone the repository and open it using VS Code (as explained in the Quick Start Guide).

Remove example code (optional)

The xk6-example-ascii85 repository includes integration examples between Go and the k6 JavaScript runtime. While these materials are helpful for study, they are not necessary for the current task and should be removed. The provided .starter.patch file can be used with the git apply command to delete these files.

git apply .starter.patch
rm .starter.patch

API declaration

This step is optional but recommended. It is a good practice to document the API of the k6 extension before implementing it. Create a TypeScript declaration file index.d.ts that documents the API to be implemented.

/**
 * **Example ascii85 encoding for k6**
 *
 * @module example_ascii85
 */

export as namespace example_ascii85;

/**
 * ascii85encode returns the ASCII85 encoding of src.
 *
 * @param src The input to encode.
 */
export declare function encode(src: ArrayBuffer): string;

/**
 * ascii85decode returns the decoded bytes represented by the string str.
 *
 * @param str The string to decode.
 */
export declare function decode(str: string): ArrayBuffer;

The implementation

The encode() function's implementation is straightforward, as the k6 runtime handles all type conversions. The Go standard ascii85 package provides the ASCII85 encoding implementation, requiring only parameter passing for its use. Add the following function to the module.go file, the ascii85 package import will be added automatically by the IDE.

func (*module) encode(data []byte) string {
	dst := make([]byte, ascii85.MaxEncodedLen(len(data)))
	n := ascii85.Encode(dst, data)

	return string(dst[:n])
}

The decode() function should return an ArrayBuffer. To accomplish this, type conversion using the JavaScript runtime is necessary. The sobek.ArrayBuffer go struct corresponds to the JavaScript ArrayBuffer, so an instance of it must be returned. Refer to the sobek.Runtime#ExportTo() documentation for mapping details.

func (m *module) decode(str string) (sobek.ArrayBuffer, error) {
	dst := make([]byte, len(str))

	n, _, err := ascii85.Decode(dst, []byte(str), true)
	if err != nil {
		return sobek.ArrayBuffer{}, err
	}

	return m.vu.Runtime().NewArrayBuffer(dst[:n]), nil
}

To make the encode() and decode() functions usable within the JavaScript runtime, ensure they are exported. Add them to the exported symbols in the module.go file.

func (m *module) Exports() modules.Exports {
	return modules.Exports{
		Named: map[string]any{
			"encode": m.encode,
			"decode": m.decode,
		},
	}
}

The Go implementation of the extension is complete.

Build a custom k6

To use the xk6-example-ascii85 extension, a custom k6 build must be created using the xk6 build subcommand.

xk6 build --with github.com/user/xk6-example-ascii85=.

Substitute user with your GitHub username.

This command creates a custom k6 executable in the current folder.

Usage

To showcase the extension's functionality, create a JavaScript file named script.js.

import { encode } from "k6/x/example_ascii85";

export default function () {
  console.log(encode(new Uint8Array([72, 101, 108, 108, 111, 33]).buffer)) // 87cURD]o
}

The script outputs 87cURD]o to the console. This string is the ascii85 encoded representation of Hello!

./k6 run script.js

Additional steps

Create a smoke test

For initial verification before comprehensive integration tests, create a basic smoke test in test/smoke.test.js.

import { encode, decode } from "k6/x/example_ascii85";
import { check } from "k6"

export const options = {
  thresholds: {
    checks: ["rate==1"],
  },
}

export default function () {
  const bytes = new Uint8Array([72, 101, 108, 108, 111, 33]).buffer;

  check(encode(bytes), {
    encoded: (str) => str == "87cURD]o",
    reverse: (str) => equal(bytes, decode(str)),
  });
}

const equal = (a, b) => new Uint8Array(a).toString() === new Uint8Array(b).toString()

This test ensures the correctness of ascii85 encoding and decoding. It uses a fixed Hello! string as a test case for both encoding and decoding processes.

Create Go module tests

Go tests offer the quickest method for verifying extension implementations. Standard unit testing practices apply. For a module-level integration test example, refer to the module_test.go file. This setup facilitates comprehensive integration testing between the Go implementation and the JavaScript runtime.

package example_ascii85

import (
	_ "embed"
	"testing"

	"github.com/stretchr/testify/require"
	"go.k6.io/k6/js/modulestest"
)

func Test_module(t *testing.T) { //nolint:tparallel
	t.Parallel()

	runtime := modulestest.NewRuntime(t)
	err := runtime.SetupModuleSystem(map[string]any{importPath: new(rootModule)}, nil, nil)
	require.NoError(t, err)

	_, err = runtime.RunOnEventLoop(`let mod = require("` + importPath + `")`)
	require.NoError(t, err)

	tests := []struct {
		name  string
		check string
	}{
		// Add your test cases here
		// Example: {name: "myFunc()", check: `mod.myFunc() == expectedValue`},
		{
			name:  "encode()",
			check: `mod.encode(new Uint8Array([72, 101, 108, 108, 111, 33]).buffer) == "87cURD]o"`,
		},
	}
	for _, tt := range tests { //nolint:paralleltest
		t.Run(tt.name, func(t *testing.T) {
			got, err := runtime.RunOnEventLoop(tt.check)

			require.NoError(t, err)
			require.True(t, got.ToBoolean())
		})
	}
}

The provided test code creates an extension instance and integrates it into the JavaScript runtime, accessible as mod. The JavaScript code defining the test is then executed within the JavaScript runtime's event loop.

Generate API documentation

You can generate HTML API documentation from the index.d.ts API declaration file using TypeDoc. To do this, run the following command that creates the extension API documentation from the index.d.ts file and saves it in the build/docs directory.

bun x typedoc --out build/docs

Security and vulnerability

Ensure the Go source code of your k6 extension is checked for security vulnerabilities using the gosec tool. Like any Go project, security scanning is crucial for your extension's codebase.

gosec -quiet ./...

Generally, extensions rely on external Go module dependencies. It is advisable to use the govulncheck tool to identify potential vulnerabilities within these dependencies.

govulncheck ./...

Security and vulnerability checks are a requirement for registering the extension in the k6 Extension Registry.

Static analysis

Analyzing the Go source code of your k6 extension statically can proactively identify subtle bugs. golangci-lint is a popular static code analysis tool that even can be used without configuration.

golangci-lint run ./...

Additional information

The complete Go source code (module.go) for the extension implementation is provided for reference.

// Package example_ascii85 contains the xk6-example-ascii85 extension.
package example_ascii85

import (
	"encoding/ascii85"

	"github.com/grafana/sobek"
	"go.k6.io/k6/js/modules"
)

type rootModule struct{}

func (*rootModule) NewModuleInstance(vu modules.VU) modules.Instance {
	return &module{vu}
}

type module struct {
	vu modules.VU
}

func (m *module) Exports() modules.Exports {
	return modules.Exports{
		Named: map[string]any{
			"encode": m.encode,
			"decode": m.decode,
		},
	}
}

func (*module) encode(data []byte) string {
	dst := make([]byte, ascii85.MaxEncodedLen(len(data)))
	n := ascii85.Encode(dst, data)

	return string(dst[:n])
}

func (m *module) decode(str string) (sobek.ArrayBuffer, error) {
	dst := make([]byte, len(str))

	n, _, err := ascii85.Decode(dst, []byte(str), true)
	if err != nil {
		return sobek.ArrayBuffer{}, err
	}

	return m.vu.Runtime().NewArrayBuffer(dst[:n]), nil
}

var _ modules.Module = (*rootModule)(nil)

In addition, register.go contains the registration of the extension with the k6 runtime.

package example_ascii85

import "go.k6.io/k6/js/modules"

const importPath = "k6/x/example_ascii85"

func init() {
	modules.Register(importPath, new(rootModule))
}

Reference to JavaScript runtime

In the k6 runtime, each VU (data structure representing a virtual user) has a dedicated JavaScript runtime instance, which can be accessed with the Runtime() function.

m.vu.Runtime()

Further reading