diff --git a/.github/workflows/browser_e2e.yml b/.github/workflows/browser_e2e.yml new file mode 100644 index 00000000000..02fed479a0f --- /dev/null +++ b/.github/workflows/browser_e2e.yml @@ -0,0 +1,70 @@ +name: E2E +on: + # Enable manually triggering this workflow via the API or web UI + workflow_dispatch: + push: + branches: + - main + pull_request: + schedule: + # At 06:00 AM UTC from Monday through Friday + - cron: '0 6 * * 1-5' + +defaults: + run: + shell: bash + +jobs: + test: + strategy: + matrix: + go: [stable, tip] + platform: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout code + if: matrix.go != 'tip' || matrix.platform != 'windows-latest' + uses: actions/checkout@v4 + - name: Install Go + if: matrix.go != 'tip' || matrix.platform != 'windows-latest' + uses: actions/setup-go@v5 + with: + go-version: 1.x + - name: Install Go tip + if: matrix.go == 'tip' && matrix.platform != 'windows-latest' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download ${{ matrix.platform }} --repo grafana/gotip --pattern 'go.zip' + unzip go.zip -d $HOME/sdk + echo "GOROOT=$HOME/sdk/gotip" >> "$GITHUB_ENV" + echo "GOPATH=$HOME/go" >> "$GITHUB_ENV" + echo "$HOME/go/bin" >> "$GITHUB_PATH" + echo "$HOME/sdk/gotip/bin" >> "$GITHUB_PATH" + - name: Build k6 + if: matrix.go != 'tip' || matrix.platform != 'windows-latest' + run: | + which go + go version + + go build . + ./k6 version + - name: Run E2E tests + if: matrix.go != 'tip' || matrix.platform != 'windows-latest' + run: | + set -x + if [ "$RUNNER_OS" == "Linux" ]; then + export K6_BROWSER_EXECUTABLE_PATH=/usr/bin/google-chrome + fi + export K6_BROWSER_HEADLESS=true + for f in examples/browser/*.js; do + if [ "$f" == "examples/browser/hosts.js" ] && [ "$RUNNER_OS" == "Windows" ]; then + echo "skipping $f on Windows" + continue + fi + ./k6 run -q "$f" + done + - name: Check screenshot + if: matrix.go != 'tip' || matrix.platform != 'windows-latest' + # TODO: Do something more sophisticated? + run: test -s screenshot.png diff --git a/.golangci.yml b/.golangci.yml index 15b979e43f6..76bad2652ca 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,6 +26,10 @@ issues: - funlen - lll - forcetypeassert + - path: js\/modules\/k6\/browser\/.*\.go + linters: + - revive + - contextcheck - path: js\/modules\/k6\/html\/.*\.go text: "exported: exported " linters: diff --git a/examples/browser/colorscheme.js b/examples/browser/colorscheme.js new file mode 100644 index 00000000000..1a31ea8786d --- /dev/null +++ b/examples/browser/colorscheme.js @@ -0,0 +1,39 @@ +import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +export default async function() { + const context = await browser.newContext({ + // valid values are "light", "dark" or "no-preference" + colorScheme: 'dark', + }); + const page = await context.newPage(); + + try { + await page.goto( + 'https://test.k6.io', + { waitUntil: 'load' }, + ) + await check(page, { + 'isDarkColorScheme': + p => p.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches) + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/cookies.js b/examples/browser/cookies.js new file mode 100644 index 00000000000..48f588b6076 --- /dev/null +++ b/examples/browser/cookies.js @@ -0,0 +1,126 @@ +import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +}; + +export default async function () { + const page = await browser.newPage(); + const context = page.context(); + + try { + // get cookies from the browser context + await check(await context.cookies(), { + 'initial number of cookies should be zero': c => c.length === 0, + }); + + // add some cookies to the browser context + const unixTimeSinceEpoch = Math.round(new Date() / 1000); + const day = 60*60*24; + const dayAfter = unixTimeSinceEpoch+day; + const dayBefore = unixTimeSinceEpoch-day; + await context.addCookies([ + // this cookie expires at the end of the session + { + name: 'testcookie', + value: '1', + sameSite: 'Strict', + domain: '127.0.0.1', + path: '/', + httpOnly: true, + secure: true, + }, + // this cookie expires in a day + { + name: 'testcookie2', + value: '2', + sameSite: 'Lax', + domain: '127.0.0.1', + path: '/', + expires: dayAfter, + }, + // this cookie expires in the past, so it will be removed. + { + name: 'testcookie3', + value: '3', + sameSite: 'Lax', + domain: '127.0.0.1', + path: '/', + expires: dayBefore + } + ]); + let cookies = await context.cookies(); + await check(cookies.length, { + 'number of cookies should be 2': n => n === 2, + }); + await check(cookies[0], { + 'cookie 1 name should be testcookie': c => c.name === 'testcookie', + 'cookie 1 value should be 1': c => c.value === '1', + 'cookie 1 should be session cookie': c => c.expires === -1, + 'cookie 1 should have domain': c => c.domain === '127.0.0.1', + 'cookie 1 should have path': c => c.path === '/', + 'cookie 1 should have sameSite': c => c.sameSite == 'Strict', + 'cookie 1 should be httpOnly': c => c.httpOnly === true, + 'cookie 1 should be secure': c => c.secure === true, + }); + await check(cookies[1], { + 'cookie 2 name should be testcookie2': c => c.name === 'testcookie2', + 'cookie 2 value should be 2': c => c.value === '2', + }); + + // let's add more cookies to filter by urls. + await context.addCookies([ + { + name: "foo", + value: "42", + sameSite: "Strict", + url: "http://foo.com", + }, + { + name: "bar", + value: "43", + sameSite: "Lax", + url: "https://bar.com", + }, + { + name: "baz", + value: "44", + sameSite: "Lax", + url: "https://baz.com", + }, + ]); + cookies = await context.cookies("http://foo.com", "https://baz.com"); + await check(cookies.length, { + 'number of filtered cookies should be 2': n => n === 2, + }); + await check(cookies[0], { + 'the first filtered cookie name should be foo': c => c.name === 'foo', + 'the first filtered cookie value should be 42': c => c.value === '42', + }); + await check(cookies[1], { + 'the second filtered cookie name should be baz': c => c.name === 'baz', + 'the second filtered cookie value should be 44': c => c.value === '44', + }); + + // clear cookies + await context.clearCookies(); + await check(await context.cookies(), { + 'number of cookies should be zero': c => c.length === 0, + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/device_emulation.js b/examples/browser/device_emulation.js new file mode 100644 index 00000000000..2c892f08e49 --- /dev/null +++ b/examples/browser/device_emulation.js @@ -0,0 +1,51 @@ +import { browser, devices } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +export default async function() { + const device = devices['iPhone X']; + // The spread operator is currently unsupported by k6's Babel, so use + // Object.assign instead to merge browser context and device options. + // See https://github.com/grafana/k6/issues/2296 + const options = Object.assign({ locale: 'es-ES' }, device); + const context = await browser.newContext(options); + const page = await context.newPage(); + + try { + await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); + const dimensions = await page.evaluate(() => { + return { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + deviceScaleFactor: window.devicePixelRatio + }; + }); + + await check(dimensions, { + 'width': d => d.width === device.viewport.width, + 'height': d => d.height === device.viewport.height, + 'scale': d => d.deviceScaleFactor === device.deviceScaleFactor, + }); + + if (!__ENV.K6_BROWSER_HEADLESS) { + await page.waitForTimeout(10000); + } + } finally { + await page.close(); + } +} diff --git a/examples/browser/dispatch.js b/examples/browser/dispatch.js new file mode 100644 index 00000000000..32816d2af17 --- /dev/null +++ b/examples/browser/dispatch.js @@ -0,0 +1,39 @@ +import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +export default async function() { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); + + const contacts = page.locator('a[href="/contacts.php"]'); + await contacts.dispatchEvent("click"); + + await check(page.locator('h3'), { + 'header': async lo => { + const text = await lo.textContent(); + return text == 'Contact us'; + } + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/elementstate.js b/examples/browser/elementstate.js new file mode 100644 index 00000000000..4b4c5165090 --- /dev/null +++ b/examples/browser/elementstate.js @@ -0,0 +1,82 @@ +import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +export default async function() { + const context = await browser.newContext(); + const page = await context.newPage(); + + // Inject page content + await page.setContent(` +
Shadow DOM
'; + document.body.appendChild(shadowRoot); + }); + + await check(page.locator('#shadow-dom'), { + 'shadow element exists': e => e !== null, + 'shadow element text is correct': async e => { + return await e.innerText() === 'Shadow DOM'; + } + }); + + await page.close(); +} diff --git a/examples/browser/throttle.js b/examples/browser/throttle.js new file mode 100644 index 00000000000..4a93c51702d --- /dev/null +++ b/examples/browser/throttle.js @@ -0,0 +1,76 @@ +import { browser, networkProfiles } from 'k6/browser'; + +export const options = { + scenarios: { + normal: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + exec: 'normal', + }, + networkThrottled: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + exec: 'networkThrottled', + }, + cpuThrottled: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + exec: 'cpuThrottled', + }, + }, + thresholds: { + 'browser_http_req_duration{scenario:normal}': ['p(99)<3000'], + 'browser_http_req_duration{scenario:networkThrottled}': ['p(99)<6000'], + 'iteration_duration{scenario:normal}': ['p(99)<5000'], + 'iteration_duration{scenario:cpuThrottled}': ['p(99)<10000'], + }, +} + +export async function normal() { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); + } finally { + await page.close(); + } +} + +export async function networkThrottled() { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.throttleNetwork(networkProfiles["Slow 3G"]); + + await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); + } finally { + await page.close(); + } +} + +export async function cpuThrottled() { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.throttleCPU({ rate: 4 }); + + await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/touchscreen.js b/examples/browser/touchscreen.js new file mode 100644 index 00000000000..75981b37df3 --- /dev/null +++ b/examples/browser/touchscreen.js @@ -0,0 +1,34 @@ +import { browser } from 'k6/browser'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + } +} + +export default async function () { + const page = await browser.newPage(); + + await page.goto("https://test.k6.io/", { waitUntil: "networkidle" }); + + // Obtain ElementHandle for news link and navigate to it + // by tapping in the 'a' element's bounding box + const newsLinkBox = await page.$('a[href="/news.php"]').then((e) => e.boundingBox()); + + // Wait until the navigation is done before closing the page. + // Otherwise, there will be a race condition between the page closing + // and the navigation. + await Promise.all([ + page.waitForNavigation(), + page.touchscreen.tap(newsLinkBox.x + newsLinkBox.width / 2, newsLinkBox.y), + ]); + + await page.close(); +} diff --git a/examples/browser/useragent.js b/examples/browser/useragent.js new file mode 100644 index 00000000000..ee5d3f26d95 --- /dev/null +++ b/examples/browser/useragent.js @@ -0,0 +1,49 @@ +import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +export default async function() { + let context = await browser.newContext({ + userAgent: 'k6 test user agent', + }) + let page = await context.newPage(); + await check(page, { + 'user agent is set': async p => { + const userAgent = await p.evaluate(() => navigator.userAgent); + return userAgent.includes('k6 test user agent'); + } + }); + await page.close(); + await context.close(); + + context = await browser.newContext(); + check(context.browser(), { + 'user agent does not contain headless': b => { + return b.userAgent().includes('Headless') === false; + } + }); + + page = await context.newPage(); + await check(page, { + 'chromium user agent does not contain headless': async p => { + const userAgent = await p.evaluate(() => navigator.userAgent); + return userAgent.includes('Headless') === false; + } + }); + await page.close(); +} diff --git a/examples/browser/waitForEvent.js b/examples/browser/waitForEvent.js new file mode 100644 index 00000000000..d8e99ffe0b4 --- /dev/null +++ b/examples/browser/waitForEvent.js @@ -0,0 +1,39 @@ +import { browser } from 'k6/browser'; + +export const options = { + scenarios: { + browser: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, +} + +export default async function() { + const context = await browser.newContext() + + // We want to wait for two page creations before carrying on. + var counter = 0 + const promise = context.waitForEvent("page", { predicate: page => { + if (++counter == 2) { + return true + } + return false + } }) + + // Now we create two pages. + const page = await context.newPage(); + const page2 = await context.newPage(); + + // We await for the page creation events to be processed and the predicate + // to pass. + await promise + console.log('predicate passed') + + await page.close() + await page2.close(); +}; diff --git a/examples/browser/waitforfunction.js b/examples/browser/waitforfunction.js new file mode 100644 index 00000000000..ea939b6d990 --- /dev/null +++ b/examples/browser/waitforfunction.js @@ -0,0 +1,47 @@ +import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +export default async function() { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.evaluate(() => { + setTimeout(() => { + const el = document.createElement('h1'); + el.innerHTML = 'Hello'; + document.body.appendChild(el); + }, 1000); + }); + + const e = await page.waitForFunction( + "document.querySelector('h1')", { + polling: 'mutation', + timeout: 2000 + } + ); + await check(e, { + 'waitForFunction successfully resolved': async e => { + return await e.innerHTML() === 'Hello'; + } + }); + } finally { + await page.close(); + } +} diff --git a/examples/experimental/browser.js b/examples/experimental/browser.js deleted file mode 100644 index d96518677d4..00000000000 --- a/examples/experimental/browser.js +++ /dev/null @@ -1,48 +0,0 @@ -import { check } from 'k6'; -import { browser } from 'k6/browser'; - -export const options = { - scenarios: { - ui: { - executor: 'shared-iterations', - options: { - browser: { - type: 'chromium', - }, - }, - }, - }, - thresholds: { - checks: ["rate==1.0"] - } -} - -export default async function() { - const context = await browser.newContext(); - const page = await context.newPage(); - - try { - // Goto front page, find login link and click it - await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); - await Promise.all([ - page.waitForNavigation(), - page.locator('a[href="/my_messages.php"]').click(), - ]); - // Enter login credentials and login - await page.locator('input[name="login"]').type("admin"); - await page.locator('input[name="password"]').type("123"); - // We expect the form submission to trigger a navigation, so to prevent a - // race condition, setup a waiter concurrently while waiting for the click - // to resolve. - await Promise.all([ - page.waitForNavigation(), - page.locator('input[type="submit"]').click(), - ]); - const content = await page.locator("h2").textContent(); - check(content, { - 'header': content => content == 'Welcome, admin!', - }); - } finally { - await page.close(); - } -} diff --git a/go.mod b/go.mod index dd922cf5f04..e64052dee1f 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/go-sourcemap/sourcemap v2.1.4+incompatible github.com/golang/protobuf v1.5.4 github.com/gorilla/websocket v1.5.3 - github.com/grafana/xk6-browser v1.10.0 github.com/grafana/xk6-dashboard v0.7.5 github.com/grafana/xk6-output-opentelemetry v0.3.0 github.com/grafana/xk6-output-prometheus-remote v0.5.0 @@ -66,7 +65,7 @@ require ( github.com/bufbuild/protocompile v0.14.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f // indirect + github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f github.com/chromedp/sysutil v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -96,7 +95,7 @@ require ( go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.28.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/sync v0.10.0 // indirect + golang.org/x/sync v0.10.0 golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect diff --git a/go.sum b/go.sum index 1fa6ae22efc..d2b982b63bc 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,6 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/sobek v0.0.0-20241024150027-d91f02b05e9b h1:hzfIt1lf19Zx1jIYdeHvuWS266W+jL+7dxbpvH2PZMQ= github.com/grafana/sobek v0.0.0-20241024150027-d91f02b05e9b/go.mod h1:FmcutBFPLiGgroH42I4/HBahv7GxVjODcVWFTw1ISes= -github.com/grafana/xk6-browser v1.10.0 h1:nj7CdQRtfEnBC8UpiFJVqfoXeWktMO+v5FV1ST44aFw= -github.com/grafana/xk6-browser v1.10.0/go.mod h1:fCdTCgN4x154XOj5bRajxsCjvMA3dShkGQa59Br34xI= github.com/grafana/xk6-dashboard v0.7.5 h1:TcILyffT/Ea/XD7xG1jMA5lwtusOPRbEQsQDHmO30Mk= github.com/grafana/xk6-dashboard v0.7.5/go.mod h1:Y75F8xmgCraKT+pBzFH6me9AyH5PkXD+Bxo1dm6Il/M= github.com/grafana/xk6-output-opentelemetry v0.3.0 h1:dmclGBFtFVRJijqLncpu2dKVIIvx1GS3y6sQGg4Khl8= diff --git a/js/jsmodules.go b/js/jsmodules.go index e6f96eb2882..5c464d9f48c 100644 --- a/js/jsmodules.go +++ b/js/jsmodules.go @@ -8,6 +8,7 @@ import ( "go.k6.io/k6/js/common" "go.k6.io/k6/js/modules" "go.k6.io/k6/js/modules/k6" + "go.k6.io/k6/js/modules/k6/browser/browser" "go.k6.io/k6/js/modules/k6/crypto" "go.k6.io/k6/js/modules/k6/crypto/x509" "go.k6.io/k6/js/modules/k6/data" @@ -23,7 +24,6 @@ import ( "go.k6.io/k6/js/modules/k6/timers" "go.k6.io/k6/js/modules/k6/ws" - "github.com/grafana/xk6-browser/browser" "github.com/grafana/xk6-redis/redis" "github.com/grafana/xk6-webcrypto/webcrypto" expws "github.com/grafana/xk6-websockets/websockets" diff --git a/vendor/github.com/grafana/xk6-browser/browser/browser_context_mapping.go b/js/modules/k6/browser/browser/browser_context_mapping.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/browser_context_mapping.go rename to js/modules/k6/browser/browser/browser_context_mapping.go index f4b9abd3646..68161a74df5 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/browser_context_mapping.go +++ b/js/modules/k6/browser/browser/browser_context_mapping.go @@ -8,9 +8,9 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6error" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6error" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapBrowserContext to the JS module. diff --git a/js/modules/k6/browser/browser/browser_context_options_test.go b/js/modules/k6/browser/browser/browser_context_options_test.go new file mode 100644 index 00000000000..5b33ee4686b --- /dev/null +++ b/js/modules/k6/browser/browser/browser_context_options_test.go @@ -0,0 +1,140 @@ +package browser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" +) + +func TestBrowserContextOptionsPermissions(t *testing.T) { + t.Parallel() + vu := k6test.NewVU(t) + + opts, err := parseBrowserContextOptions(vu.Runtime(), vu.ToSobekValue((struct { + Permissions []any `js:"permissions"` + }{ + Permissions: []any{"camera", "microphone"}, + }))) + assert.NoError(t, err) + assert.Len(t, opts.Permissions, 2) + assert.Equal(t, opts.Permissions, []string{"camera", "microphone"}) +} + +func TestBrowserContextSetGeolocation(t *testing.T) { + t.Parallel() + vu := k6test.NewVU(t) + + opts, err := parseBrowserContextOptions(vu.Runtime(), vu.ToSobekValue((struct { + GeoLocation *common.Geolocation `js:"geolocation"` + }{ + GeoLocation: &common.Geolocation{ + Latitude: 1.0, + Longitude: 2.0, + Accuracy: 3.0, + }, + }))) + assert.NoError(t, err) + assert.NotNil(t, opts) + assert.Equal(t, 1.0, opts.Geolocation.Latitude) + assert.Equal(t, 2.0, opts.Geolocation.Longitude) + assert.Equal(t, 3.0, opts.Geolocation.Accuracy) +} + +func TestBrowserContextDefaultOptions(t *testing.T) { + t.Parallel() + vu := k6test.NewVU(t) + + defaults := common.DefaultBrowserContextOptions() + + // gets the default options by default + opts, err := parseBrowserContextOptions(vu.Runtime(), nil) + require.NoError(t, err) + assert.Equal(t, defaults, opts) + + // merges with the default options + opts, err = parseBrowserContextOptions(vu.Runtime(), vu.ToSobekValue((struct { + DeviceScaleFactor float64 `js:"deviceScaleFactor"` // just to test a different field + }{ + DeviceScaleFactor: defaults.DeviceScaleFactor + 1, + }))) + require.NoError(t, err) + assert.NotEqual(t, defaults.DeviceScaleFactor, opts.DeviceScaleFactor) + assert.Equal(t, defaults.Locale, opts.Locale) // should remain as default +} + +func TestBrowserContextAllOptions(t *testing.T) { + t.Parallel() + vu := k6test.NewVU(t) + opts, err := vu.Runtime().RunString(`const opts = { + acceptDownloads: true, + downloadsPath: '/tmp', + bypassCSP: true, + colorScheme: 'dark', + deviceScaleFactor: 1, + extraHTTPHeaders: { + 'X-Header': 'value', + }, + geolocation: { latitude: 51.509865, longitude: -0.118092, accuracy: 1 }, + hasTouch: true, + httpCredentials: { username: 'admin', password: 'password' }, + ignoreHTTPSErrors: true, + isMobile: true, + javaScriptEnabled: true, + locale: 'fr-FR', + offline: true, + permissions: ['camera', 'microphone'], + reducedMotion: 'no-preference', + screen: { width: 800, height: 600 }, + timezoneID: 'Europe/Paris', + userAgent: 'my agent', + viewport: { width: 800, height: 600 }, + }; + opts; + `) + require.NoError(t, err) + + parsedOpts, err := parseBrowserContextOptions(vu.Runtime(), opts) + require.NoError(t, err) + + assert.Equal(t, &common.BrowserContextOptions{ + AcceptDownloads: true, + DownloadsPath: "/tmp", + BypassCSP: true, + ColorScheme: common.ColorSchemeDark, + DeviceScaleFactor: 1, + ExtraHTTPHeaders: map[string]string{ + "X-Header": "value", + }, + Geolocation: &common.Geolocation{ + Latitude: 51.509865, + Longitude: -0.118092, + Accuracy: 1, + }, + HasTouch: true, + HTTPCredentials: common.Credentials{ + Username: "admin", + Password: "password", + }, + IgnoreHTTPSErrors: true, + IsMobile: true, + JavaScriptEnabled: true, + Locale: "fr-FR", + Offline: true, + Permissions: []string{"camera", "microphone"}, + ReducedMotion: common.ReducedMotionNoPreference, + Screen: common.Screen{ + Width: 800, + Height: 600, + }, + TimezoneID: "Europe/Paris", + UserAgent: "my agent", + Viewport: common.Viewport{ + Width: 800, + Height: 600, + }, + }, parsedOpts) +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/browser_mapping.go b/js/modules/k6/browser/browser/browser_mapping.go similarity index 97% rename from vendor/github.com/grafana/xk6-browser/browser/browser_mapping.go rename to js/modules/k6/browser/browser/browser_mapping.go index 10051209565..d615faed9c2 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/browser_mapping.go +++ b/js/modules/k6/browser/browser/browser_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapBrowser to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/console_message_mapping.go b/js/modules/k6/browser/browser/console_message_mapping.go similarity index 93% rename from vendor/github.com/grafana/xk6-browser/browser/console_message_mapping.go rename to js/modules/k6/browser/browser/console_message_mapping.go index 839ad3af2bc..93bf21189c7 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/console_message_mapping.go +++ b/js/modules/k6/browser/browser/console_message_mapping.go @@ -1,7 +1,7 @@ package browser import ( - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // mapConsoleMessage to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/element_handle_mapping.go b/js/modules/k6/browser/browser/element_handle_mapping.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/element_handle_mapping.go rename to js/modules/k6/browser/browser/element_handle_mapping.go index 2ce19d416f8..856834f70ba 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/element_handle_mapping.go +++ b/js/modules/k6/browser/browser/element_handle_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapElementHandle to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/file_persister.go b/js/modules/k6/browser/browser/file_persister.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/browser/file_persister.go rename to js/modules/k6/browser/browser/file_persister.go index 93df6609dae..92a1fad824f 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/file_persister.go +++ b/js/modules/k6/browser/browser/file_persister.go @@ -6,8 +6,8 @@ import ( "net/url" "strings" - "github.com/grafana/xk6-browser/env" - "github.com/grafana/xk6-browser/storage" + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/storage" ) type presignedURLConfig struct { diff --git a/js/modules/k6/browser/browser/file_persister_test.go b/js/modules/k6/browser/browser/file_persister_test.go new file mode 100644 index 00000000000..1fe40ad6111 --- /dev/null +++ b/js/modules/k6/browser/browser/file_persister_test.go @@ -0,0 +1,174 @@ +package browser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/storage" +) + +func Test_newScreenshotPersister(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + envLookup env.LookupFunc + wantType filePersister + wantErr bool + }{ + { + name: "local_no_env_var", + envLookup: env.EmptyLookup, + wantType: &storage.LocalFilePersister{}, + }, + { + name: "local_empty_env_var", + envLookup: env.ConstLookup( + env.ScreenshotsOutput, + "", + ), + wantType: &storage.LocalFilePersister{}, + }, + { + name: "remote", + envLookup: env.ConstLookup( + env.ScreenshotsOutput, + "url=https://127.0.0.1/,basePath=/screenshots,header.1=a", + ), + wantType: &storage.RemoteFilePersister{}, + }, + { + name: "remote_parse_failed", + envLookup: env.ConstLookup( + env.ScreenshotsOutput, + "basePath=/screenshots,header.1=a", + ), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotType, err := newScreenshotPersister(tt.envLookup) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.IsType(t, tt.wantType, gotType) + }) + } +} + +func Test_parsePresignedURLEnvVar(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + envVarValue string + want presignedURLConfig + wantErr string + }{ + { + name: "url_headers_basePath", + envVarValue: "url=https://127.0.0.1/,basePath=/screenshots,header.1=a,header.2=b", + want: presignedURLConfig{ + getterURL: "https://127.0.0.1/", + basePath: "/screenshots", + headers: map[string]string{ + "1": "a", + "2": "b", + }, + }, + }, + { + name: "url_headers", + envVarValue: "url=https://127.0.0.1/,header.1=a,header.2=b", + want: presignedURLConfig{ + getterURL: "https://127.0.0.1/", + headers: map[string]string{ + "1": "a", + "2": "b", + }, + }, + }, + { + name: "url", + envVarValue: "url=https://127.0.0.1/", + want: presignedURLConfig{ + getterURL: "https://127.0.0.1/", + headers: map[string]string{}, + }, + }, + { + name: "url_basePath", + envVarValue: "url=https://127.0.0.1/,basePath=/screenshots", + want: presignedURLConfig{ + getterURL: "https://127.0.0.1/", + basePath: "/screenshots", + headers: map[string]string{}, + }, + }, + { + name: "empty_basePath", + envVarValue: "url=https://127.0.0.1/,basePath=", + want: presignedURLConfig{ + getterURL: "https://127.0.0.1/", + basePath: "", + headers: map[string]string{}, + }, + }, + { + name: "empty", + envVarValue: "", + wantErr: `format of value must be k=v, received ""`, + }, + { + name: "missing_url", + envVarValue: "basePath=/screenshots,header.1=a,header.2=b", + wantErr: "missing required url", + }, + { + name: "invalid_option", + envVarValue: "ulr=https://127.0.0.1/", + wantErr: "invalid option", + }, + { + name: "empty_header_key", + envVarValue: "url=https://127.0.0.1/,header.=a", + wantErr: "empty header key", + }, + { + name: "invalid_format", + envVarValue: "url==https://127.0.0.1/", + wantErr: "format of value must be k=v", + }, + { + name: "invalid_header_format", + envVarValue: "url=https://127.0.0.1/,header..asd=a", + wantErr: "format of header must be header.k=v", + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parsePresignedURLEnvVar(tt.envVarValue) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/frame_mapping.go b/js/modules/k6/browser/browser/frame_mapping.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/browser/frame_mapping.go rename to js/modules/k6/browser/browser/frame_mapping.go index 1bb7293cc0b..c67051358ac 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/frame_mapping.go +++ b/js/modules/k6/browser/browser/frame_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapFrame to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/helpers.go b/js/modules/k6/browser/browser/helpers.go similarity index 95% rename from vendor/github.com/grafana/xk6-browser/browser/helpers.go rename to js/modules/k6/browser/browser/helpers.go index 4b79e14ba43..3d2056735ff 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/helpers.go +++ b/js/modules/k6/browser/browser/helpers.go @@ -7,8 +7,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6error" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6error" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) func panicIfFatalError(ctx context.Context, err error) { diff --git a/js/modules/k6/browser/browser/helpers_test.go b/js/modules/k6/browser/browser/helpers_test.go new file mode 100644 index 00000000000..b7f1ecd06ca --- /dev/null +++ b/js/modules/k6/browser/browser/helpers_test.go @@ -0,0 +1,26 @@ +package browser + +import ( + "testing" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/require" +) + +func TestSobekEmptyString(t *testing.T) { + t.Parallel() + // SobekEmpty string should return true if the argument + // is an empty string or not defined in the Sobek runtime. + rt := sobek.New() + require.NoError(t, rt.Set("sobekEmptyString", sobekEmptyString)) + for _, s := range []string{"() => true", "'() => false'"} { // not empty + v, err := rt.RunString(`sobekEmptyString(` + s + `)`) + require.NoError(t, err) + require.Falsef(t, v.ToBoolean(), "got: true, want: false for %q", s) + } + for _, s := range []string{"", " ", "null", "undefined"} { // empty + v, err := rt.RunString(`sobekEmptyString(` + s + `)`) + require.NoError(t, err) + require.Truef(t, v.ToBoolean(), "got: false, want: true for %q", s) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/js_handle_mapping.go b/js/modules/k6/browser/browser/js_handle_mapping.go similarity index 95% rename from vendor/github.com/grafana/xk6-browser/browser/js_handle_mapping.go rename to js/modules/k6/browser/browser/js_handle_mapping.go index ad32bee8de7..e66864a57de 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/js_handle_mapping.go +++ b/js/modules/k6/browser/browser/js_handle_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapJSHandle to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/keyboard_mapping.go b/js/modules/k6/browser/browser/keyboard_mapping.go similarity index 93% rename from vendor/github.com/grafana/xk6-browser/browser/keyboard_mapping.go rename to js/modules/k6/browser/browser/keyboard_mapping.go index a634fe40f84..dbbc8176413 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/keyboard_mapping.go +++ b/js/modules/k6/browser/browser/keyboard_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) func mapKeyboard(vu moduleVU, kb *common.Keyboard) mapping { diff --git a/vendor/github.com/grafana/xk6-browser/browser/locator_mapping.go b/js/modules/k6/browser/browser/locator_mapping.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/locator_mapping.go rename to js/modules/k6/browser/browser/locator_mapping.go index bc014da1cc9..6204c044344 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/locator_mapping.go +++ b/js/modules/k6/browser/browser/locator_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapLocator API to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/mapping.go b/js/modules/k6/browser/browser/mapping.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/browser/mapping.go rename to js/modules/k6/browser/browser/mapping.go index 80cc7cbfaa2..166f51380c3 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/mapping.go +++ b/js/modules/k6/browser/browser/mapping.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" k6common "go.k6.io/k6/js/common" ) diff --git a/js/modules/k6/browser/browser/mapping_test.go b/js/modules/k6/browser/browser/mapping_test.go new file mode 100644 index 00000000000..46f4ffa3f81 --- /dev/null +++ b/js/modules/k6/browser/browser/mapping_test.go @@ -0,0 +1,574 @@ +package browser + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" + + k6common "go.k6.io/k6/js/common" + k6modulestest "go.k6.io/k6/js/modulestest" + k6lib "go.k6.io/k6/lib" + k6metrics "go.k6.io/k6/metrics" +) + +// customMappings is a list of custom mappings for our module API. +// Some of them are wildcards, such as query to $ mapping; and +// others are for publicly accessible fields, such as mapping +// of page.keyboard to Page.getKeyboard. +func customMappings() map[string]string { + return map[string]string{ + // wildcards + "pageAPI.query": "$", + "pageAPI.queryAll": "$$", + "frameAPI.query": "$", + "frameAPI.queryAll": "$$", + "elementHandleAPI.query": "$", + "elementHandleAPI.queryAll": "$$", + // getters + "pageAPI.getKeyboard": "keyboard", + "pageAPI.getMouse": "mouse", + "pageAPI.getTouchscreen": "touchscreen", + // internal methods + "elementHandleAPI.objectID": "", + "frameAPI.id": "", + "frameAPI.loaderID": "", + "JSHandleAPI.objectID": "", + "browserAPI.close": "", + "frameAPI.evaluateWithContext": "", + // TODO: browser.on method is unexposed until more event + // types other than 'disconnect' are supported. + // See: https://go.k6.io/k6/js/modules/k6/browser/issues/913 + "browserAPI.on": "", + } +} + +// TestMappings tests that all the methods of the API (api/) are +// to the module. This is to ensure that we don't forget to map +// a new method to the module. +func TestMappings(t *testing.T) { + t.Parallel() + + type test struct { + apiInterface any + mapp func() mapping + } + + var ( + vu = &k6modulestest.VU{ + RuntimeField: sobek.New(), + InitEnvField: &k6common.InitEnvironment{ + TestPreInitState: &k6lib.TestPreInitState{ + Registry: k6metrics.NewRegistry(), + }, + }, + } + customMappings = customMappings() + ) + + // testMapping tests that all the methods of an API are mapped + // to the module. And wildcards are mapped correctly and their + // methods are not mapped. + testMapping := func(t *testing.T, tt test) { + t.Helper() + + var ( + typ = reflect.TypeOf(tt.apiInterface).Elem() + mapped = tt.mapp() + tested = make(map[string]bool) + ) + for i := 0; i < typ.NumMethod(); i++ { + method := typ.Method(i) + require.NotNil(t, method) + + // sobek uses methods that starts with lowercase. + // so we need to convert the first letter to lowercase. + m := toFirstLetterLower(method.Name) + + cm, cmok := isCustomMapping(customMappings, typ.Name(), m) + // if the method is a custom mapping, it should not be + // mapped to the module. so we should not find it in + // the mapped methods. + if _, ok := mapped[m]; cmok && ok { + t.Errorf("method %q should not be mapped", m) + } + // a custom mapping with an empty string means that + // the method should not exist on the API. + if cmok && cm == "" { + continue + } + // change the method name if it is mapped to a custom + // method. these custom methods are not exist on our + // API. so we need to use the mapped method instead. + if cmok { + m = cm + } + if _, ok := mapped[m]; !ok { + t.Errorf("method %q not found", m) + } + // to detect if a method is redundantly mapped. + tested[m] = true + } + // detect redundant mappings. + for m := range mapped { + if !tested[m] { + t.Errorf("method %q is redundant", m) + } + } + } + + for name, tt := range map[string]test{ + "browser": { + apiInterface: (*browserAPI)(nil), + mapp: func() mapping { + return mapBrowser(moduleVU{VU: vu}) + }, + }, + "browserContext": { + apiInterface: (*browserContextAPI)(nil), + mapp: func() mapping { + return mapBrowserContext(moduleVU{VU: vu}, &common.BrowserContext{}) + }, + }, + "page": { + apiInterface: (*pageAPI)(nil), + mapp: func() mapping { + return mapPage(moduleVU{VU: vu}, &common.Page{ + Keyboard: &common.Keyboard{}, + Mouse: &common.Mouse{}, + Touchscreen: &common.Touchscreen{}, + }) + }, + }, + "elementHandle": { + apiInterface: (*elementHandleAPI)(nil), + mapp: func() mapping { + return mapElementHandle(moduleVU{VU: vu}, &common.ElementHandle{}) + }, + }, + "jsHandle": { + apiInterface: (*common.JSHandleAPI)(nil), + mapp: func() mapping { + return mapJSHandle(moduleVU{VU: vu}, &common.BaseJSHandle{}) + }, + }, + "frame": { + apiInterface: (*frameAPI)(nil), + mapp: func() mapping { + return mapFrame(moduleVU{VU: vu}, &common.Frame{}) + }, + }, + "mapRequest": { + apiInterface: (*requestAPI)(nil), + mapp: func() mapping { + return mapRequest(moduleVU{VU: vu}, &common.Request{}) + }, + }, + "mapResponse": { + apiInterface: (*responseAPI)(nil), + mapp: func() mapping { + return mapResponse(moduleVU{VU: vu}, &common.Response{}) + }, + }, + "mapWorker": { + apiInterface: (*workerAPI)(nil), + mapp: func() mapping { + return mapWorker(moduleVU{VU: vu}, &common.Worker{}) + }, + }, + "mapLocator": { + apiInterface: (*locatorAPI)(nil), + mapp: func() mapping { + return mapLocator(moduleVU{VU: vu}, &common.Locator{}) + }, + }, + "mapConsoleMessage": { + apiInterface: (*consoleMessageAPI)(nil), + mapp: func() mapping { + return mapConsoleMessage(moduleVU{VU: vu}, common.PageOnEvent{ + ConsoleMessage: &common.ConsoleMessage{}, + }) + }, + }, + "mapMetricEvent": { + apiInterface: (*metricEventAPI)(nil), + mapp: func() mapping { + return mapMetricEvent(moduleVU{VU: vu}, common.PageOnEvent{ + Metric: &common.MetricEvent{}, + }) + }, + }, + "mapTouchscreen": { + apiInterface: (*touchscreenAPI)(nil), + mapp: func() mapping { + return mapTouchscreen(moduleVU{VU: vu}, &common.Touchscreen{}) + }, + }, + "mapKeyboard": { + apiInterface: (*keyboardAPI)(nil), + mapp: func() mapping { + return mapKeyboard(moduleVU{VU: vu}, &common.Keyboard{}) + }, + }, + "mapMouse": { + apiInterface: (*mouseAPI)(nil), + mapp: func() mapping { + return mapMouse(moduleVU{VU: vu}, &common.Mouse{}) + }, + }, + } { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + testMapping(t, tt) + }) + } +} + +// toFirstLetterLower converts the first letter of the string to lower case. +func toFirstLetterLower(s string) string { + // Special cases. + // Instead of loading up an acronyms list, just do this. + // Good enough for our purposes. + special := map[string]string{ + "ID": "id", + "JSON": "json", + "JSONValue": "jsonValue", + "URL": "url", + } + if v, ok := special[s]; ok { + return v + } + if s == "" { + return "" + } + + return strings.ToLower(s[:1]) + s[1:] +} + +// isCustomMapping returns true if the method is a custom mapping +// and returns the name of the method to be called instead of the +// original one. +func isCustomMapping(customMappings map[string]string, typ, method string) (string, bool) { + name := typ + "." + method + + if s, ok := customMappings[name]; ok { + return s, ok + } + + return "", false +} + +// ---------------------------------------------------------------------------- +// JavaScript API definitions. +// ---------------------------------------------------------------------------- + +// browserAPI is the public interface of a CDP browser. +type browserAPI interface { + Close() + Context() *common.BrowserContext + CloseContext() + IsConnected() bool + NewContext(opts *common.BrowserContextOptions) (*common.BrowserContext, error) + NewPage(opts *common.BrowserContextOptions) (*common.Page, error) + On(string) (bool, error) + UserAgent() string + Version() string +} + +// browserContextAPI is the public interface of a CDP browser context. +type browserContextAPI interface { //nolint:interfacebloat + AddCookies(cookies []*common.Cookie) error + AddInitScript(script sobek.Value, arg sobek.Value) error + Browser() *common.Browser + ClearCookies() error + ClearPermissions() error + Close() error + Cookies(urls ...string) ([]*common.Cookie, error) + GrantPermissions(permissions []string, opts sobek.Value) error + NewPage() (*common.Page, error) + Pages() []*common.Page + SetDefaultNavigationTimeout(timeout int64) + SetDefaultTimeout(timeout int64) + SetGeolocation(geolocation *common.Geolocation) error + SetHTTPCredentials(httpCredentials common.Credentials) error + SetOffline(offline bool) error + WaitForEvent(event string, optsOrPredicate sobek.Value) (any, error) +} + +// pageAPI is the interface of a single browser tab. +type pageAPI interface { //nolint:interfacebloat + BringToFront() error + Check(selector string, opts sobek.Value) error + Click(selector string, opts sobek.Value) error + Close(opts sobek.Value) error + Content() (string, error) + Context() *common.BrowserContext + Dblclick(selector string, opts sobek.Value) error + DispatchEvent(selector string, typ string, eventInit sobek.Value, opts sobek.Value) + EmulateMedia(opts sobek.Value) error + EmulateVisionDeficiency(typ string) error + Evaluate(pageFunc sobek.Value, arg ...sobek.Value) (any, error) + EvaluateHandle(pageFunc sobek.Value, arg ...sobek.Value) (common.JSHandleAPI, error) + Fill(selector string, value string, opts sobek.Value) error + Focus(selector string, opts sobek.Value) error + Frames() []*common.Frame + GetAttribute(selector string, name string, opts sobek.Value) (string, bool, error) + GetKeyboard() *common.Keyboard + GetMouse() *common.Mouse + GetTouchscreen() *common.Touchscreen + Goto(url string, opts sobek.Value) (*common.Response, error) + Hover(selector string, opts sobek.Value) error + InnerHTML(selector string, opts sobek.Value) (string, error) + InnerText(selector string, opts sobek.Value) (string, error) + InputValue(selector string, opts sobek.Value) (string, error) + IsChecked(selector string, opts sobek.Value) (bool, error) + IsClosed() bool + IsDisabled(selector string, opts sobek.Value) (bool, error) + IsEditable(selector string, opts sobek.Value) (bool, error) + IsEnabled(selector string, opts sobek.Value) (bool, error) + IsHidden(selector string, opts sobek.Value) (bool, error) + IsVisible(selector string, opts sobek.Value) (bool, error) + Locator(selector string, opts sobek.Value) *common.Locator + MainFrame() *common.Frame + On(event common.PageOnEventName, handler func(common.PageOnEvent) error) error + Opener() pageAPI + Press(selector string, key string, opts sobek.Value) error + Query(selector string) (*common.ElementHandle, error) + QueryAll(selector string) ([]*common.ElementHandle, error) + Reload(opts sobek.Value) *common.Response + Screenshot(opts sobek.Value) ([]byte, error) + SelectOption(selector string, values sobek.Value, opts sobek.Value) ([]string, error) + SetChecked(selector string, checked bool, opts sobek.Value) error + SetContent(html string, opts sobek.Value) error + SetDefaultNavigationTimeout(timeout int64) + SetDefaultTimeout(timeout int64) + SetExtraHTTPHeaders(headers map[string]string) error + SetInputFiles(selector string, files sobek.Value, opts sobek.Value) error + SetViewportSize(viewportSize sobek.Value) error + Tap(selector string, opts sobek.Value) error + TextContent(selector string, opts sobek.Value) (string, bool, error) + ThrottleCPU(common.CPUProfile) error + ThrottleNetwork(common.NetworkProfile) error + Title() (string, error) + Type(selector string, text string, opts sobek.Value) error + Uncheck(selector string, opts sobek.Value) error + URL() (string, error) + ViewportSize() map[string]float64 + WaitForFunction(fn, opts sobek.Value, args ...sobek.Value) (any, error) + WaitForLoadState(state string, opts sobek.Value) error + WaitForNavigation(opts sobek.Value) (*common.Response, error) + WaitForSelector(selector string, opts sobek.Value) (*common.ElementHandle, error) + WaitForTimeout(timeout int64) + Workers() []*common.Worker +} + +// consoleMessageAPI is the interface of a console message. +type consoleMessageAPI interface { + Args() []common.JSHandleAPI + Page() *common.Page + Text() string + Type() string +} + +// metricEventAPI is the interface of a metric event. +type metricEventAPI interface { + Tag(matchesRegex common.K6BrowserCheckRegEx, patterns common.TagMatches) error +} + +// frameAPI is the interface of a CDP target frame. +type frameAPI interface { //nolint:interfacebloat + Check(selector string, opts sobek.Value) error + ChildFrames() []*common.Frame + Click(selector string, opts sobek.Value) error + Content() (string, error) + Dblclick(selector string, opts sobek.Value) error + DispatchEvent(selector string, typ string, eventInit sobek.Value, opts sobek.Value) error + // EvaluateWithContext for internal use only + EvaluateWithContext(ctx context.Context, pageFunc sobek.Value, args ...sobek.Value) (any, error) + Evaluate(pageFunc sobek.Value, args ...sobek.Value) (any, error) + EvaluateHandle(pageFunc sobek.Value, args ...sobek.Value) (common.JSHandleAPI, error) + Fill(selector string, value string, opts sobek.Value) error + Focus(selector string, opts sobek.Value) error + FrameElement() (*common.ElementHandle, error) + GetAttribute(selector string, name string, opts sobek.Value) (string, bool, error) + Goto(url string, opts sobek.Value) (*common.Response, error) + Hover(selector string, opts sobek.Value) error + InnerHTML(selector string, opts sobek.Value) (string, error) + InnerText(selector string, opts sobek.Value) (string, error) + InputValue(selector string, opts sobek.Value) (string, error) + IsChecked(selector string, opts sobek.Value) (bool, error) + IsDetached() bool + IsDisabled(selector string, opts sobek.Value) (bool, error) + IsEditable(selector string, opts sobek.Value) (bool, error) + IsEnabled(selector string, opts sobek.Value) (bool, error) + IsHidden(selector string, opts sobek.Value) (bool, error) + IsVisible(selector string, opts sobek.Value) (bool, error) + ID() string + LoaderID() string + Locator(selector string, opts sobek.Value) *common.Locator + Name() string + Query(selector string) (*common.ElementHandle, error) + QueryAll(selector string) ([]*common.ElementHandle, error) + Page() *common.Page + ParentFrame() *common.Frame + Press(selector string, key string, opts sobek.Value) error + SelectOption(selector string, values sobek.Value, opts sobek.Value) ([]string, error) + SetChecked(selector string, checked bool, opts sobek.Value) error + SetContent(html string, opts sobek.Value) error + SetInputFiles(selector string, files sobek.Value, opts sobek.Value) + Tap(selector string, opts sobek.Value) error + TextContent(selector string, opts sobek.Value) (string, bool, error) + Title() string + Type(selector string, text string, opts sobek.Value) error + Uncheck(selector string, opts sobek.Value) error + URL() string + WaitForFunction(pageFunc, opts sobek.Value, args ...sobek.Value) (any, error) + WaitForLoadState(state string, opts sobek.Value) error + WaitForNavigation(opts sobek.Value) (*common.Response, error) + WaitForSelector(selector string, opts sobek.Value) (*common.ElementHandle, error) + WaitForTimeout(timeout int64) +} + +// elementHandleAPI is the interface of an in-page DOM element. +type elementHandleAPI interface { //nolint:interfacebloat + common.JSHandleAPI + + BoundingBox() (*common.Rect, error) + Check(opts sobek.Value) error + Click(opts sobek.Value) error + ContentFrame() (*common.Frame, error) + Dblclick(opts sobek.Value) error + DispatchEvent(typ string, props sobek.Value) error + Fill(value string, opts sobek.Value) error + Focus() error + GetAttribute(name string) (string, bool, error) + Hover(opts sobek.Value) error + InnerHTML() (string, error) + InnerText() (string, error) + InputValue(opts sobek.Value) (string, error) + IsChecked() (bool, error) + IsDisabled() (bool, error) + IsEditable() (bool, error) + IsEnabled() (bool, error) + IsHidden() (bool, error) + IsVisible() (bool, error) + OwnerFrame() (*common.Frame, error) + Press(key string, opts sobek.Value) error + Query(selector string) (*common.ElementHandle, error) + QueryAll(selector string) ([]*common.ElementHandle, error) + Screenshot(opts sobek.Value) (sobek.ArrayBuffer, error) + ScrollIntoViewIfNeeded(opts sobek.Value) error + SelectOption(values sobek.Value, opts sobek.Value) ([]string, error) + SelectText(opts sobek.Value) error + SetChecked(checked bool, opts sobek.Value) error + SetInputFiles(files sobek.Value, opts sobek.Value) error + Tap(opts sobek.Value) error + TextContent() (string, bool, error) + Type(text string, opts sobek.Value) error + Uncheck(opts sobek.Value) error + WaitForElementState(state string, opts sobek.Value) error + WaitForSelector(selector string, opts sobek.Value) (*common.ElementHandle, error) +} + +// requestAPI is the interface of an HTTP request. +type requestAPI interface { //nolint:interfacebloat + AllHeaders() map[string]string + Frame() *common.Frame + HeaderValue(string) sobek.Value + Headers() map[string]string + HeadersArray() []common.HTTPHeader + IsNavigationRequest() bool + Method() string + PostData() string + PostDataBuffer() sobek.ArrayBuffer + ResourceType() string + Response() *common.Response + Size() common.HTTPMessageSize + Timing() sobek.Value + URL() string +} + +// responseAPI is the interface of an HTTP response. +type responseAPI interface { //nolint:interfacebloat + AllHeaders() map[string]string + Body() ([]byte, error) + Frame() *common.Frame + HeaderValue(string) (string, bool) + HeaderValues(string) []string + Headers() map[string]string + HeadersArray() []common.HTTPHeader + JSON() (any, error) + Ok() bool + Request() *common.Request + SecurityDetails() *common.SecurityDetails + ServerAddr() *common.RemoteAddress + Size() common.HTTPMessageSize + Status() int64 + StatusText() string + URL() string + Text() (string, error) +} + +// locatorAPI represents a way to find element(s) on a page at any moment. +type locatorAPI interface { //nolint:interfacebloat + Clear(opts *common.FrameFillOptions) error + Click(opts sobek.Value) error + Dblclick(opts sobek.Value) error + SetChecked(checked bool, opts sobek.Value) error + Check(opts sobek.Value) error + Uncheck(opts sobek.Value) error + IsChecked(opts sobek.Value) (bool, error) + IsEditable(opts sobek.Value) (bool, error) + IsEnabled(opts sobek.Value) (bool, error) + IsDisabled(opts sobek.Value) (bool, error) + IsVisible(opts sobek.Value) (bool, error) + IsHidden(opts sobek.Value) (bool, error) + Fill(value string, opts sobek.Value) error + Focus(opts sobek.Value) error + GetAttribute(name string, opts sobek.Value) (string, bool, error) + InnerHTML(opts sobek.Value) (string, error) + InnerText(opts sobek.Value) (string, error) + TextContent(opts sobek.Value) (string, bool, error) + InputValue(opts sobek.Value) (string, error) + SelectOption(values sobek.Value, opts sobek.Value) ([]string, error) + Press(key string, opts sobek.Value) error + Type(text string, opts sobek.Value) error + Hover(opts sobek.Value) error + Tap(opts sobek.Value) error + DispatchEvent(typ string, eventInit, opts sobek.Value) + WaitFor(opts sobek.Value) error +} + +// keyboardAPI is the interface of a keyboard input device. +type keyboardAPI interface { + Down(key string) error + Up(key string) error + InsertText(char string) error + Press(key string, opts sobek.Value) error + Type(text string, opts sobek.Value) error +} + +// touchscreenAPI is the interface of a touchscreen. +type touchscreenAPI interface { + Tap(x float64, y float64) error +} + +// mouseAPI is the interface of a mouse input device. +type mouseAPI interface { + Click(x float64, y float64, opts sobek.Value) error + DblClick(x float64, y float64, opts sobek.Value) error + Down(opts sobek.Value) error + Up(opts sobek.Value) error + Move(x float64, y float64, opts sobek.Value) error +} + +// workerAPI is the interface of a web worker. +type workerAPI interface { + URL() string +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/metric_event_mapping.go b/js/modules/k6/browser/browser/metric_event_mapping.go similarity index 93% rename from vendor/github.com/grafana/xk6-browser/browser/metric_event_mapping.go rename to js/modules/k6/browser/browser/metric_event_mapping.go index e4e0756262a..b8c66ea5ff4 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/metric_event_mapping.go +++ b/js/modules/k6/browser/browser/metric_event_mapping.go @@ -3,7 +3,7 @@ package browser import ( "fmt" - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // mapMetricEvent to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/module.go b/js/modules/k6/browser/browser/module.go similarity index 97% rename from vendor/github.com/grafana/xk6-browser/browser/module.go rename to js/modules/k6/browser/browser/module.go index 57bc62c349c..b947e6b0640 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/module.go +++ b/js/modules/k6/browser/browser/module.go @@ -16,9 +16,9 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/env" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext" k6modules "go.k6.io/k6/js/modules" ) diff --git a/js/modules/k6/browser/browser/module_test.go b/js/modules/k6/browser/browser/module_test.go new file mode 100644 index 00000000000..2eec055aead --- /dev/null +++ b/js/modules/k6/browser/browser/module_test.go @@ -0,0 +1,24 @@ +package browser + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" +) + +// TestModuleNew tests registering the module. +// It doesn't test the module's remaining functionality as it is +// already tested in the tests/ integration tests. +func TestModuleNew(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + m, ok := New().NewModuleInstance(vu).(*ModuleInstance) + require.True(t, ok, "NewModuleInstance should return a ModuleInstance") + require.NotNil(t, m.mod, "Module should be set") + require.NotNil(t, m.mod.Browser, "Browser should be set") + require.NotNil(t, m.mod.Devices, "Devices should be set") + require.NotNil(t, m.mod.NetworkProfiles, "Profiles should be set") +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/modulevu.go b/js/modules/k6/browser/browser/modulevu.go similarity index 90% rename from vendor/github.com/grafana/xk6-browser/browser/modulevu.go rename to js/modules/k6/browser/browser/modulevu.go index ea65979858a..4ec2b6d3912 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/modulevu.go +++ b/js/modules/k6/browser/browser/modulevu.go @@ -3,8 +3,8 @@ package browser import ( "context" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" k6modules "go.k6.io/k6/js/modules" ) diff --git a/vendor/github.com/grafana/xk6-browser/browser/mouse_mapping.go b/js/modules/k6/browser/browser/mouse_mapping.go similarity index 92% rename from vendor/github.com/grafana/xk6-browser/browser/mouse_mapping.go rename to js/modules/k6/browser/browser/mouse_mapping.go index ee5f61b4d3d..5fd24b22851 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/mouse_mapping.go +++ b/js/modules/k6/browser/browser/mouse_mapping.go @@ -3,8 +3,8 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) func mapMouse(vu moduleVU, m *common.Mouse) mapping { diff --git a/vendor/github.com/grafana/xk6-browser/browser/page_mapping.go b/js/modules/k6/browser/browser/page_mapping.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/browser/page_mapping.go rename to js/modules/k6/browser/browser/page_mapping.go index ce0e82fe723..0d2e70f3dba 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/page_mapping.go +++ b/js/modules/k6/browser/browser/page_mapping.go @@ -8,8 +8,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapPage to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/registry.go b/js/modules/k6/browser/browser/registry.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/registry.go rename to js/modules/k6/browser/browser/registry.go index b0f37a95428..255a22a0c22 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/registry.go +++ b/js/modules/k6/browser/browser/registry.go @@ -16,11 +16,11 @@ import ( "go.opentelemetry.io/otel/attribute" oteltrace "go.opentelemetry.io/otel/trace" - "github.com/grafana/xk6-browser/chromium" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/env" - "github.com/grafana/xk6-browser/k6ext" - browsertrace "github.com/grafana/xk6-browser/trace" + "go.k6.io/k6/js/modules/k6/browser/chromium" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + browsertrace "go.k6.io/k6/js/modules/k6/browser/trace" k6event "go.k6.io/k6/event" k6modules "go.k6.io/k6/js/modules" diff --git a/js/modules/k6/browser/browser/registry_test.go b/js/modules/k6/browser/browser/registry_test.go new file mode 100644 index 00000000000..16afd4d8e56 --- /dev/null +++ b/js/modules/k6/browser/browser/registry_test.go @@ -0,0 +1,405 @@ +package browser + +import ( + "context" + "errors" + "strconv" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + + k6event "go.k6.io/k6/event" +) + +func TestPidRegistry(t *testing.T) { + t.Parallel() + + p := &pidRegistry{} + + var wg sync.WaitGroup + expected := []int{} + iteration := 100 + wg.Add(iteration) + for i := 0; i < iteration; i++ { + go func(i int) { + p.registerPid(i) + wg.Done() + }(i) + expected = append(expected, i) + } + + wg.Wait() + + got := p.Pids() + + assert.ElementsMatch(t, expected, got) +} + +func TestIsRemoteBrowser(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + envVarName, envVarValue string + expIsRemote bool + expValidWSURLs []string + expErr error + }{ + { + name: "browser is not remote", + envVarName: "FOO", + envVarValue: "BAR", + expIsRemote: false, + }, + { + name: "single WS URL", + envVarName: env.WebSocketURLs, + envVarValue: "WS_URL", + expIsRemote: true, + expValidWSURLs: []string{"WS_URL"}, + }, + { + name: "multiple WS URL", + envVarName: env.WebSocketURLs, + envVarValue: "WS_URL_1,WS_URL_2,WS_URL_3", + expIsRemote: true, + expValidWSURLs: []string{"WS_URL_1", "WS_URL_2", "WS_URL_3"}, + }, + { + name: "ending comma is handled", + envVarName: env.WebSocketURLs, + envVarValue: "WS_URL_1,WS_URL_2,", + expIsRemote: true, + expValidWSURLs: []string{"WS_URL_1", "WS_URL_2"}, + }, + { + name: "void string does not panic", + envVarName: env.WebSocketURLs, + envVarValue: "", + expIsRemote: true, + expValidWSURLs: []string{""}, + }, + { + name: "comma does not panic", + envVarName: env.WebSocketURLs, + envVarValue: ",", + expIsRemote: true, + expValidWSURLs: []string{""}, + }, + { + name: "read a single scenario with a single ws url", + envVarName: env.InstanceScenarios, + envVarValue: `[{"id": "one","browsers": [{ "handle": "WS_URL_1" }]}]`, + expIsRemote: true, + expValidWSURLs: []string{"WS_URL_1"}, + }, + { + name: "read a single scenario with a two ws urls", + envVarName: env.InstanceScenarios, + envVarValue: `[{"id": "one","browsers": [{"handle": "WS_URL_1"}, {"handle": "WS_URL_2"}]}]`, + expIsRemote: true, + expValidWSURLs: []string{"WS_URL_1", "WS_URL_2"}, + }, + { + name: "read two scenarios with multiple ws urls", + envVarName: env.InstanceScenarios, + envVarValue: `[ + {"id": "one","browsers": [{"handle": "WS_URL_1"}, {"handle": "WS_URL_2"}]}, + {"id": "two","browsers": [{"handle": "WS_URL_3"}, {"handle": "WS_URL_4"}]} + ]`, + expIsRemote: true, + expValidWSURLs: []string{"WS_URL_1", "WS_URL_2", "WS_URL_3", "WS_URL_4"}, + }, + { + name: "read scenarios without any ws urls", + envVarName: env.InstanceScenarios, + envVarValue: `[{"id": "one","browsers": [{}]}]`, + expIsRemote: false, + expValidWSURLs: []string{""}, + }, + { + name: "read scenarios without any browser objects", + envVarName: env.InstanceScenarios, + envVarValue: `[{"id": "one"}]`, + expIsRemote: false, + expValidWSURLs: []string{""}, + }, + { + name: "read empty scenarios", + envVarName: env.InstanceScenarios, + envVarValue: ``, + expErr: errors.New("parsing K6_INSTANCE_SCENARIOS: unexpected end of JSON input"), + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + lookup := func(key string) (string, bool) { + v := tc.envVarValue + if tc.envVarName == "K6_INSTANCE_SCENARIOS" { + v = strconv.Quote(v) + } + if key == tc.envVarName { + return v, true + } + return "", false + } + + rr, err := newRemoteRegistry(lookup) + if tc.expErr != nil { + assert.Error(t, tc.expErr, err) + return + } + assert.NoError(t, err) + + wsURL, isRemote := rr.isRemoteBrowser() + require.Equal(t, tc.expIsRemote, isRemote) + if isRemote { + require.Contains(t, tc.expValidWSURLs, wsURL) + } + }) + } + + t.Run("K6_INSTANCE_SCENARIOS should override K6_BROWSER_WS_URL", func(t *testing.T) { + t.Parallel() + + lookup := func(key string) (string, bool) { + switch key { + case env.WebSocketURLs: + return "WS_URL_1", true + case env.InstanceScenarios: + return strconv.Quote(`[{"id": "one","browsers": [{ "handle": "WS_URL_2" }]}]`), true + default: + return "", false + } + } + + rr, err := newRemoteRegistry(lookup) + assert.NoError(t, err) + + wsURL, isRemote := rr.isRemoteBrowser() + + require.Equal(t, true, isRemote) + require.Equal(t, "WS_URL_2", wsURL) + }) +} + +func TestBrowserRegistry(t *testing.T) { + t.Parallel() + + remoteRegistry, err := newRemoteRegistry(func(key string) (string, bool) { + // No env vars + return "", false + }) + require.NoError(t, err) + + t.Run("init_and_close_browsers_on_iter_events", func(t *testing.T) { + t.Parallel() + + var ( + vu = k6test.NewVU(t) + browserRegistry = newBrowserRegistry(context.Background(), vu, remoteRegistry, &pidRegistry{}, nil) + ) + + vu.ActivateVU() + + // Send a few IterStart events + vu.StartIteration(t, k6test.WithIteration(0)) + vu.StartIteration(t, k6test.WithIteration(1)) + vu.StartIteration(t, k6test.WithIteration(2)) + + // Verify browsers are initialized + assert.Equal(t, 3, browserRegistry.browserCount()) + + // Verify iteration traces are started + assert.Equal(t, 3, browserRegistry.tr.iterationTracesCount()) + + // Send IterEnd events + vu.EndIteration(t, k6test.WithIteration(0)) + vu.EndIteration(t, k6test.WithIteration(1)) + vu.EndIteration(t, k6test.WithIteration(2)) + + // Verify there are no browsers left + assert.Equal(t, 0, browserRegistry.browserCount()) + + // Verify iteration traces have been ended + assert.Equal(t, 0, browserRegistry.tr.iterationTracesCount()) + }) + + t.Run("close_browsers_on_exit_event", func(t *testing.T) { + t.Parallel() + + var ( + vu = k6test.NewVU(t) + browserRegistry = newBrowserRegistry(context.Background(), vu, remoteRegistry, &pidRegistry{}, nil) + ) + + vu.ActivateVU() + + // Send a few IterStart events + vu.StartIteration(t, k6test.WithIteration(0)) + vu.StartIteration(t, k6test.WithIteration(1)) + vu.StartIteration(t, k6test.WithIteration(2)) + + // Verify browsers are initialized + assert.Equal(t, 3, browserRegistry.browserCount()) + + // Send Exit event + events, ok := vu.EventsField.Global.(*k6event.System) + require.True(t, ok, "want *k6event.System; got %T", events) + waitDone := events.Emit(&k6event.Event{ + Type: k6event.Exit, + }) + require.NoError(t, waitDone(context.Background()), "error waiting on Exit done") + + // Verify there are no browsers left + assert.Equal(t, 0, browserRegistry.browserCount()) + }) + + t.Run("unsubscribe_on_non_browser_vu", func(t *testing.T) { + t.Parallel() + + var ( + vu = k6test.NewVU(t) + browserRegistry = newBrowserRegistry(context.Background(), vu, remoteRegistry, &pidRegistry{}, nil) + ) + + vu.ActivateVU() + + // Unset browser type option in scenario options in order to represent that VU is not + // a browser test VU + delete(vu.StateField.Options.Scenarios["default"].GetScenarioOptions().Browser, "type") + + vu.StartIteration(t) + + assert.True(t, browserRegistry.stopped.Load()) + }) + + // This test ensures that the chromium browser's lifecycle is not controlled + // by the vu context. + t.Run("dont_close_browser_on_vu_context_close", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + var cancel context.CancelFunc + vu.CtxField, cancel = context.WithCancel(vu.CtxField) //nolint:fatcontext + browserRegistry := newBrowserRegistry(context.Background(), vu, remoteRegistry, &pidRegistry{}, nil) + + vu.ActivateVU() + + // Send a few IterStart events + vu.StartIteration(t, k6test.WithIteration(0)) + + // Verify browsers are initialized + assert.Equal(t, 1, browserRegistry.browserCount()) + + // Cancel the "iteration" by closing the context. + cancel() + + // Verify browsers are still alive + assert.Equal(t, 1, browserRegistry.browserCount()) + + // Do cleanup by sending the Exit event + events, ok := vu.EventsField.Global.(*k6event.System) + require.True(t, ok, "want *k6event.System; got %T", events) + waitDone := events.Emit(&k6event.Event{ + Type: k6event.Exit, + }) + require.NoError(t, waitDone(context.Background()), "error waiting on Exit done") + + // Verify there are no browsers left + assert.Equal(t, 0, browserRegistry.browserCount()) + }) +} + +func TestParseTracesMetadata(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + env map[string]string + expMetadata map[string]string + expErrMssg string + }{ + { + name: "no metadata", + env: make(map[string]string), + expMetadata: make(map[string]string), + }, + { + name: "one metadata field", + env: map[string]string{ + "K6_BROWSER_TRACES_METADATA": "meta=value", + }, + expMetadata: map[string]string{ + "meta": "value", + }, + }, + { + name: "one metadata field finishing in comma", + env: map[string]string{ + "K6_BROWSER_TRACES_METADATA": "meta=value,", + }, + expMetadata: map[string]string{ + "meta": "value", + }, + }, + { + name: "multiple metadata fields", + env: map[string]string{ + "K6_BROWSER_TRACES_METADATA": "meta1=value1,meta2=value2", + }, + expMetadata: map[string]string{ + "meta1": "value1", + "meta2": "value2", + }, + }, + { + name: "multiple metadata fields finishing in comma", + env: map[string]string{ + "K6_BROWSER_TRACES_METADATA": "meta1=value1,meta2=value2,", + }, + expMetadata: map[string]string{ + "meta1": "value1", + "meta2": "value2", + }, + }, + { + name: "invalid metadata", + env: map[string]string{ + "K6_BROWSER_TRACES_METADATA": "thisIsInvalid", + }, + expErrMssg: "is not a valid key=value metadata", + }, + { + name: "invalid metadata void", + env: map[string]string{ + "K6_BROWSER_TRACES_METADATA": "", + }, + expErrMssg: "is not a valid key=value metadata", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lookup := func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + } + metadata, err := parseTracesMetadata(lookup) + if err != nil { + assert.ErrorContains(t, err, tc.expErrMssg) + return + } + assert.Equal(t, tc.expMetadata, metadata) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/browser/request_mapping.go b/js/modules/k6/browser/browser/request_mapping.go similarity index 94% rename from vendor/github.com/grafana/xk6-browser/browser/request_mapping.go rename to js/modules/k6/browser/browser/request_mapping.go index cca7479ed68..e4f50ddb629 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/request_mapping.go +++ b/js/modules/k6/browser/browser/request_mapping.go @@ -3,8 +3,8 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapRequest to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/response_mapping.go b/js/modules/k6/browser/browser/response_mapping.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/browser/response_mapping.go rename to js/modules/k6/browser/browser/response_mapping.go index cd74675a15f..68aa48136cb 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/response_mapping.go +++ b/js/modules/k6/browser/browser/response_mapping.go @@ -3,8 +3,8 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapResponse to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_browser_context_mapping.go b/js/modules/k6/browser/browser/sync_browser_context_mapping.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/browser/sync_browser_context_mapping.go rename to js/modules/k6/browser/browser/sync_browser_context_mapping.go index 7a737c9f1b6..29ef9613a3f 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_browser_context_mapping.go +++ b/js/modules/k6/browser/browser/sync_browser_context_mapping.go @@ -6,9 +6,9 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6error" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6error" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // syncMapBrowserContext is like mapBrowserContext but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_browser_mapping.go b/js/modules/k6/browser/browser/sync_browser_mapping.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/browser/sync_browser_mapping.go rename to js/modules/k6/browser/browser/sync_browser_mapping.go diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_console_message_mapping.go b/js/modules/k6/browser/browser/sync_console_message_mapping.go similarity index 94% rename from vendor/github.com/grafana/xk6-browser/browser/sync_console_message_mapping.go rename to js/modules/k6/browser/browser/sync_console_message_mapping.go index 6884c2a0873..ee872d59c47 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_console_message_mapping.go +++ b/js/modules/k6/browser/browser/sync_console_message_mapping.go @@ -3,7 +3,7 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // syncMapConsoleMessage is like mapConsoleMessage but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_element_handle_mapping.go b/js/modules/k6/browser/browser/sync_element_handle_mapping.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/sync_element_handle_mapping.go rename to js/modules/k6/browser/browser/sync_element_handle_mapping.go index 924ae372942..21d346dc5b7 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_element_handle_mapping.go +++ b/js/modules/k6/browser/browser/sync_element_handle_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // syncMapElementHandle is like mapElementHandle but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_frame_mapping.go b/js/modules/k6/browser/browser/sync_frame_mapping.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/sync_frame_mapping.go rename to js/modules/k6/browser/browser/sync_frame_mapping.go index d2a70227242..6ccbd5c7f8c 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_frame_mapping.go +++ b/js/modules/k6/browser/browser/sync_frame_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // syncMapFrame is like mapFrame but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_js_handle_mapping.go b/js/modules/k6/browser/browser/sync_js_handle_mapping.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/browser/sync_js_handle_mapping.go rename to js/modules/k6/browser/browser/sync_js_handle_mapping.go index dda134547d5..5004dcaa500 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_js_handle_mapping.go +++ b/js/modules/k6/browser/browser/sync_js_handle_mapping.go @@ -3,7 +3,7 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // syncMapJSHandle is like mapJSHandle but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_keyboard_mapping.go b/js/modules/k6/browser/browser/sync_keyboard_mapping.go similarity index 94% rename from vendor/github.com/grafana/xk6-browser/browser/sync_keyboard_mapping.go rename to js/modules/k6/browser/browser/sync_keyboard_mapping.go index 3d6161de207..5dbad37486e 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_keyboard_mapping.go +++ b/js/modules/k6/browser/browser/sync_keyboard_mapping.go @@ -5,7 +5,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) func syncMapKeyboard(vu moduleVU, kb *common.Keyboard) mapping { diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_locator_mapping.go b/js/modules/k6/browser/browser/sync_locator_mapping.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/browser/sync_locator_mapping.go rename to js/modules/k6/browser/browser/sync_locator_mapping.go index 309f4758208..720b5863f80 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_locator_mapping.go +++ b/js/modules/k6/browser/browser/sync_locator_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // syncMapLocator is like mapLocator but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_mapping.go b/js/modules/k6/browser/browser/sync_mapping.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/browser/sync_mapping.go rename to js/modules/k6/browser/browser/sync_mapping.go diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_page_mapping.go b/js/modules/k6/browser/browser/sync_page_mapping.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/browser/sync_page_mapping.go rename to js/modules/k6/browser/browser/sync_page_mapping.go index f386a2c6446..e63e2dd52d1 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_page_mapping.go +++ b/js/modules/k6/browser/browser/sync_page_mapping.go @@ -5,8 +5,8 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // syncMapPage is like mapPage but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_request_mapping.go b/js/modules/k6/browser/browser/sync_request_mapping.go similarity index 95% rename from vendor/github.com/grafana/xk6-browser/browser/sync_request_mapping.go rename to js/modules/k6/browser/browser/sync_request_mapping.go index d5295e78aca..5d51a7a2c6e 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_request_mapping.go +++ b/js/modules/k6/browser/browser/sync_request_mapping.go @@ -1,7 +1,7 @@ package browser import ( - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // syncMapRequest is like mapRequest but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_response_mapping.go b/js/modules/k6/browser/browser/sync_response_mapping.go similarity index 95% rename from vendor/github.com/grafana/xk6-browser/browser/sync_response_mapping.go rename to js/modules/k6/browser/browser/sync_response_mapping.go index 3b564376e2c..7e83286cc14 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_response_mapping.go +++ b/js/modules/k6/browser/browser/sync_response_mapping.go @@ -1,7 +1,7 @@ package browser import ( - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // syncMapResponse is like mapResponse but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_touchscreen_mapping.go b/js/modules/k6/browser/browser/sync_touchscreen_mapping.go similarity index 82% rename from vendor/github.com/grafana/xk6-browser/browser/sync_touchscreen_mapping.go rename to js/modules/k6/browser/browser/sync_touchscreen_mapping.go index 88537dc3669..224abfcfac5 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_touchscreen_mapping.go +++ b/js/modules/k6/browser/browser/sync_touchscreen_mapping.go @@ -3,8 +3,8 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // syncMapTouchscreen is like mapTouchscreen but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/sync_worker_mapping.go b/js/modules/k6/browser/browser/sync_worker_mapping.go similarity index 81% rename from vendor/github.com/grafana/xk6-browser/browser/sync_worker_mapping.go rename to js/modules/k6/browser/browser/sync_worker_mapping.go index a2a6c005fd4..b1a49fef643 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/sync_worker_mapping.go +++ b/js/modules/k6/browser/browser/sync_worker_mapping.go @@ -1,7 +1,7 @@ package browser import ( - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // syncMapWorker is like mapWorker but returns synchronous functions. diff --git a/vendor/github.com/grafana/xk6-browser/browser/touchscreen_mapping.go b/js/modules/k6/browser/browser/touchscreen_mapping.go similarity index 80% rename from vendor/github.com/grafana/xk6-browser/browser/touchscreen_mapping.go rename to js/modules/k6/browser/browser/touchscreen_mapping.go index ea18f95dba8..f4804c88a73 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/touchscreen_mapping.go +++ b/js/modules/k6/browser/browser/touchscreen_mapping.go @@ -3,8 +3,8 @@ package browser import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // mapTouchscreen to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/browser/worker_mapping.go b/js/modules/k6/browser/browser/worker_mapping.go similarity index 77% rename from vendor/github.com/grafana/xk6-browser/browser/worker_mapping.go rename to js/modules/k6/browser/browser/worker_mapping.go index 0e944ae641c..2c6c06ed1a8 100644 --- a/vendor/github.com/grafana/xk6-browser/browser/worker_mapping.go +++ b/js/modules/k6/browser/browser/worker_mapping.go @@ -1,7 +1,7 @@ package browser import ( - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // mapWorker to the JS module. diff --git a/vendor/github.com/grafana/xk6-browser/chromium/browser.go b/js/modules/k6/browser/chromium/browser.go similarity index 87% rename from vendor/github.com/grafana/xk6-browser/chromium/browser.go rename to js/modules/k6/browser/chromium/browser.go index e2faa3d898a..b4a0e466cba 100644 --- a/vendor/github.com/grafana/xk6-browser/chromium/browser.go +++ b/js/modules/k6/browser/chromium/browser.go @@ -2,7 +2,7 @@ package chromium import ( - "github.com/grafana/xk6-browser/common" + "go.k6.io/k6/js/modules/k6/browser/common" ) // Browser is the public interface of a CDP browser. diff --git a/vendor/github.com/grafana/xk6-browser/chromium/browser_type.go b/js/modules/k6/browser/chromium/browser_type.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/chromium/browser_type.go rename to js/modules/k6/browser/chromium/browser_type.go index e2bdc1bd7b0..df6e5b67de5 100644 --- a/vendor/github.com/grafana/xk6-browser/chromium/browser_type.go +++ b/js/modules/k6/browser/chromium/browser_type.go @@ -12,11 +12,11 @@ import ( "strings" "time" - "github.com/grafana/xk6-browser/common" - "github.com/grafana/xk6-browser/env" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" - "github.com/grafana/xk6-browser/storage" + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" + "go.k6.io/k6/js/modules/k6/browser/storage" k6modules "go.k6.io/k6/js/modules" k6lib "go.k6.io/k6/lib" diff --git a/js/modules/k6/browser/chromium/browser_type_test.go b/js/modules/k6/browser/chromium/browser_type_test.go new file mode 100644 index 00000000000..dca92b307d4 --- /dev/null +++ b/js/modules/k6/browser/chromium/browser_type_test.go @@ -0,0 +1,336 @@ +package chromium + +import ( + "io/fs" + "net" + "path/filepath" + "sort" + "testing" + + "go.k6.io/k6/js/modules/k6/browser/common" + "go.k6.io/k6/js/modules/k6/browser/env" + + k6lib "go.k6.io/k6/lib" + k6types "go.k6.io/k6/lib/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBrowserTypePrepareFlags(t *testing.T) { + t.Parallel() + + // to be used by the tests below + host, err := k6types.NewHost(net.ParseIP("127.0.0.1"), "8000") + require.NoError(t, err, "failed to set up test host") + hosts, err := k6types.NewHosts(map[string]k6types.Host{ + "test.k6.io": *host, + "httpbin.test.k6.io": *host, + }) + require.NoError(t, err, "failed to set up test hosts") + + testCases := []struct { + flag string + changeOpts *common.BrowserOptions + changeK6Opts *k6lib.Options + expInitVal, expChangedVal any + post func(t *testing.T, flags map[string]any) + }{ + { + flag: "hide-scrollbars", + changeOpts: &common.BrowserOptions{IgnoreDefaultArgs: []string{"hide-scrollbars"}, Headless: true}, + }, + { + flag: "hide-scrollbars", + changeOpts: &common.BrowserOptions{Headless: true}, + expChangedVal: true, + }, + { + flag: "browser-arg", + expInitVal: nil, + changeOpts: &common.BrowserOptions{Args: []string{"browser-arg=value"}}, + expChangedVal: "value", + }, + { + flag: "browser-arg-flag", + expInitVal: nil, + changeOpts: &common.BrowserOptions{Args: []string{"browser-arg-flag"}}, + expChangedVal: "", + }, + { + flag: "browser-arg-trim-double-quote", + expInitVal: nil, + changeOpts: &common.BrowserOptions{Args: []string{ + ` browser-arg-trim-double-quote = "value " `, + }}, + expChangedVal: "value ", + }, + { + flag: "browser-arg-trim-single-quote", + expInitVal: nil, + changeOpts: &common.BrowserOptions{Args: []string{ + ` browser-arg-trim-single-quote=' value '`, + }}, + expChangedVal: " value ", + }, + { + flag: "browser-args", + expInitVal: nil, + changeOpts: &common.BrowserOptions{Args: []string{ + "browser-arg1='value1", "browser-arg2=''value2''", "browser-flag", + }}, + post: func(t *testing.T, flags map[string]any) { + t.Helper() + + assert.Equal(t, "'value1", flags["browser-arg1"]) + assert.Equal(t, "'value2'", flags["browser-arg2"]) + assert.Equal(t, "", flags["browser-flag"]) + }, + }, + { + flag: "host-resolver-rules", + expInitVal: nil, + changeOpts: &common.BrowserOptions{Args: []string{ + `host-resolver-rules="MAP * www.example.com, EXCLUDE *.youtube.*"`, + }}, + changeK6Opts: &k6lib.Options{ + Hosts: k6types.NullHosts{Trie: hosts, Valid: true}, + }, + expChangedVal: "MAP * www.example.com, EXCLUDE *.youtube.*," + + "MAP httpbin.test.k6.io 127.0.0.1:8000,MAP test.k6.io 127.0.0.1:8000", + }, + { + flag: "host-resolver-rules", + expInitVal: nil, + changeOpts: &common.BrowserOptions{}, + changeK6Opts: &k6lib.Options{}, + expChangedVal: nil, + }, + { + flag: "headless", + expInitVal: false, + changeOpts: &common.BrowserOptions{Headless: true}, + expChangedVal: true, + post: func(t *testing.T, flags map[string]any) { + t.Helper() + + extraFlags := []string{"hide-scrollbars", "mute-audio", "blink-settings"} + for _, f := range extraFlags { + assert.Contains(t, flags, f) + } + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.flag, func(t *testing.T) { + t.Parallel() + + flags, err := prepareFlags(&common.BrowserOptions{}, nil) + require.NoError(t, err, "failed to prepare flags") + + if tc.expInitVal != nil { + require.Contains(t, flags, tc.flag) + assert.Equal(t, tc.expInitVal, flags[tc.flag]) + } else { + require.NotContains(t, flags, tc.flag) + } + + if tc.changeOpts != nil || tc.changeK6Opts != nil { + flags, err = prepareFlags(tc.changeOpts, tc.changeK6Opts) + require.NoError(t, err, "failed to prepare flags") + if tc.expChangedVal != nil { + assert.Equal(t, tc.expChangedVal, flags[tc.flag]) + } else { + assert.NotContains(t, flags, tc.flag) + } + } + + if tc.post != nil { + tc.post(t, flags) + } + }) + } +} + +func TestExecutablePath(t *testing.T) { + t.Parallel() + + // we pick a random file name to look for in our tests + // this doesn't matter as long as it's in the paths we look for + // in ExecutablePath function. + const chromiumExecutable = "google-chrome" + + userProvidedPath := filepath.Join("path", "to", "chromium") + + fileNotExists := func(file string) (string, error) { + return "", fs.ErrNotExist + } + + tests := map[string]struct { + userProvidedPath string // user provided path + lookPath func(file string) (string, error) // determines if a file exists + userProfile env.LookupFunc // user profile folder lookup + + wantPath string + wantErr error + }{ + "without_chromium": { + userProvidedPath: "", + lookPath: fileNotExists, + userProfile: env.EmptyLookup, + wantPath: "", + wantErr: ErrChromeNotInstalled, + }, + "with_chromium": { + userProvidedPath: "", + lookPath: func(file string) (string, error) { + if file == chromiumExecutable { + return "", nil + } + return "", fs.ErrNotExist + }, + userProfile: env.EmptyLookup, + wantPath: chromiumExecutable, + wantErr: nil, + }, + "without_chromium_in_user_path": { + userProvidedPath: userProvidedPath, + lookPath: fileNotExists, + userProfile: env.EmptyLookup, + wantPath: "", + wantErr: ErrChromeNotFoundAtPath, + }, + "with_chromium_in_user_path": { + userProvidedPath: userProvidedPath, + lookPath: func(file string) (string, error) { + if file == userProvidedPath { + return "", nil + } + return "", fs.ErrNotExist + }, + userProfile: env.EmptyLookup, + wantPath: userProvidedPath, + wantErr: nil, + }, + "without_chromium_in_user_profile": { + userProvidedPath: "", + lookPath: fileNotExists, + userProfile: env.ConstLookup("USERPROFILE", `home`), + wantPath: "", + wantErr: ErrChromeNotInstalled, + }, + "with_chromium_in_user_profile": { + userProvidedPath: "", + lookPath: func(file string) (string, error) { // we look chrome.exe in the user profile + if file == filepath.Join("home", `AppData\Local\Google\Chrome\Application\chrome.exe`) { + return "", nil + } + return "", fs.ErrNotExist + }, + userProfile: env.ConstLookup("USERPROFILE", `home`), + wantPath: filepath.Join("home", `AppData\Local\Google\Chrome\Application\chrome.exe`), + wantErr: nil, + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + + path, err := executablePath(tt.userProvidedPath, tt.userProfile, tt.lookPath) + + assert.Equal(t, tt.wantPath, path) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + return + } + assert.NoError(t, err) + }) + } +} + +func TestParseArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags map[string]any + want []string + }{ + { + name: "string_flag_with_value", + flags: map[string]any{ + "flag1": "value1", + "flag2": "value2", + }, + want: []string{ + "--flag1=value1", + "--flag2=value2", + "--remote-debugging-port=0", + }, + }, + { + name: "string_flag_with_empty_value", + flags: map[string]any{ + "flag1": "", + "flag2": "value2", + }, + want: []string{ + "--flag1", + "--flag2=value2", + "--remote-debugging-port=0", + }, + }, + { + name: "bool_flag_true", + flags: map[string]any{ + "flag1": true, + "flag2": true, + }, + want: []string{ + "--flag1", + "--flag2", + "--remote-debugging-port=0", + }, + }, + { + name: "bool_flag_false", + flags: map[string]any{ + "flag1": false, + "flag2": true, + }, + want: []string{ + "--flag2", + "--remote-debugging-port=0", + }, + }, + { + name: "invalid_flag_type", + flags: map[string]any{ + "flag1": 123, + }, + want: nil, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseArgs(tt.flags) + + if tt.want == nil { + assert.Error(t, err) + return + } + require.NoError(t, err) + sort.StringSlice(tt.want).Sort() + sort.StringSlice(got).Sort() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/barrier.go b/js/modules/k6/browser/common/barrier.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/barrier.go rename to js/modules/k6/browser/common/barrier.go diff --git a/js/modules/k6/browser/common/barrier_test.go b/js/modules/k6/browser/common/barrier_test.go new file mode 100644 index 00000000000..41291056320 --- /dev/null +++ b/js/modules/k6/browser/common/barrier_test.go @@ -0,0 +1,30 @@ +package common + +import ( + "context" + "testing" + + "github.com/chromedp/cdproto/cdp" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/log" +) + +func TestBarrier(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + log := log.NewNullLogger() + + timeoutSettings := NewTimeoutSettings(nil) + frameManager := NewFrameManager(ctx, nil, nil, timeoutSettings, log) + frame := NewFrame(ctx, frameManager, nil, cdp.FrameID("frame_id_0123456789"), log) + + barrier := NewBarrier() + barrier.AddFrameNavigation(frame) + frame.emit(EventFrameNavigation, "some data") + + err := barrier.Wait(ctx) + require.Nil(t, err) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/browser.go b/js/modules/k6/browser/common/browser.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/browser.go rename to js/modules/k6/browser/common/browser.go index c8335e60166..83108b67075 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser.go +++ b/js/modules/k6/browser/common/browser.go @@ -15,8 +15,8 @@ import ( "github.com/chromedp/cdproto/target" "github.com/gorilla/websocket" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" ) const ( diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_context.go b/js/modules/k6/browser/common/browser_context.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/browser_context.go rename to js/modules/k6/browser/common/browser_context.go index 400c268e832..6e0b842f969 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_context.go +++ b/js/modules/k6/browser/common/browser_context.go @@ -14,10 +14,10 @@ import ( "github.com/chromedp/cdproto/storage" "github.com/chromedp/cdproto/target" - "github.com/grafana/xk6-browser/common/js" - "github.com/grafana/xk6-browser/k6error" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/common/js" + "go.k6.io/k6/js/modules/k6/browser/k6error" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" ) diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_context_options.go b/js/modules/k6/browser/common/browser_context_options.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/browser_context_options.go rename to js/modules/k6/browser/common/browser_context_options.go diff --git a/js/modules/k6/browser/common/browser_context_test.go b/js/modules/k6/browser/common/browser_context_test.go new file mode 100644 index 00000000000..a78ce2a78a7 --- /dev/null +++ b/js/modules/k6/browser/common/browser_context_test.go @@ -0,0 +1,327 @@ +package common + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common/js" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + "go.k6.io/k6/js/modules/k6/browser/log" +) + +func TestNewBrowserContext(t *testing.T) { + t.Parallel() + + t.Run("add_web_vital_js_scripts_to_context", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + logger := log.NewNullLogger() + b := newBrowser(context.Background(), ctx, cancel, nil, NewLocalBrowserOptions(), logger) + + vu := k6test.NewVU(t) + ctx = k6ext.WithVU(ctx, vu) + + bc, err := NewBrowserContext(ctx, b, "some-id", nil, nil) + require.NoError(t, err) + + webVitalIIFEScriptFound := false + webVitalInitScriptFound := false + for _, script := range bc.evaluateOnNewDocumentSources { + switch script { + case js.WebVitalIIFEScript: + webVitalIIFEScriptFound = true + case js.WebVitalInitScript: + webVitalInitScriptFound = true + default: + assert.Fail(t, "script is neither WebVitalIIFEScript, nor WebVitalInitScript") + } + } + + assert.True(t, webVitalIIFEScriptFound, "WebVitalIIFEScript was not initialized in the context") + assert.True(t, webVitalInitScriptFound, "WebVitalInitScript was not initialized in the context") + }) +} + +func TestSetDownloadsPath(t *testing.T) { + t.Parallel() + + t.Run("empty_path", func(t *testing.T) { + t.Parallel() + + var bc BrowserContext + require.NoError(t, bc.setDownloadsPath("")) + assert.NotEmpty(t, bc.DownloadsPath) + assert.Contains(t, bc.DownloadsPath, artifactsDirectory) + assert.DirExists(t, bc.DownloadsPath) + }) + t.Run("non_empty_path", func(t *testing.T) { + t.Parallel() + + var bc BrowserContext + path := "/my/directory" + require.NoError(t, bc.setDownloadsPath(path)) + assert.Equal(t, path, bc.DownloadsPath) + }) +} + +func TestFilterCookies(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + filterByURLs []string + cookies []*Cookie + wantCookies []*Cookie + wantErr bool + }{ + "no_cookies": { + filterByURLs: []string{"https://example.com"}, + cookies: nil, + wantCookies: nil, + }, + "filter_none": { + filterByURLs: nil, + cookies: []*Cookie{ + { + Domain: "foo.com", + }, + { + Domain: "bar.com", + }, + }, + wantCookies: []*Cookie{ + { + Domain: "foo.com", + }, + { + Domain: "bar.com", + }, + }, + }, + "filter_by_url": { + filterByURLs: []string{ + "https://foo.com", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + }, + { + Domain: "bar.com", + }, + { + Domain: "baz.com", + }, + }, + wantCookies: []*Cookie{ + { + Domain: "foo.com", + }, + }, + }, + "filter_by_urls": { + filterByURLs: []string{ + "https://foo.com", + "https://baz.com", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + }, + { + Domain: "bar.com", + }, + { + Domain: "baz.com", + }, + }, + wantCookies: []*Cookie{ + { + Domain: "foo.com", + }, + { + Domain: "baz.com", + }, + }, + }, + "filter_by_subdomain": { + filterByURLs: []string{ + "https://sub.foo.com", + }, + cookies: []*Cookie{ + { + Domain: "sub.foo.com", + }, + { + Domain: ".foo.com", + }, + }, + wantCookies: []*Cookie{ + { + Domain: "sub.foo.com", + }, + }, + }, + "filter_dot_prefixed_domains": { + filterByURLs: []string{ + "https://foo.com", + }, + cookies: []*Cookie{ + { + Domain: ".foo.com", + }, + }, + wantCookies: []*Cookie{ + { + Domain: ".foo.com", + }, + }, + }, + "filter_by_secure_cookies": { + filterByURLs: []string{ + "https://foo.com", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + Secure: true, + }, + }, + wantCookies: []*Cookie{ + { + Domain: "foo.com", + Secure: true, + }, + }, + }, + "filter_by_http_only_cookies": { + filterByURLs: []string{ + "https://foo.com", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + HTTPOnly: true, + }, + }, + wantCookies: []*Cookie{ + { + Domain: "foo.com", + HTTPOnly: true, + }, + }, + }, + "filter_by_path": { + filterByURLs: []string{ + "https://foo.com/bar", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + Path: "/bar", + }, + { + Domain: "foo.com", + Path: "/baz", + }, + }, + wantCookies: []*Cookie{ + { + Domain: "foo.com", + Path: "/bar", + }, + }, + }, + "allow_secure_cookie_on_localhost": { + filterByURLs: []string{ + "http://localhost", + }, + cookies: []*Cookie{ + { + Domain: "localhost", + Secure: true, + }, + }, + wantCookies: []*Cookie{ + { + Domain: "localhost", + Secure: true, + }, + }, + }, + "disallow_secure_cookie_on_http": { + filterByURLs: []string{ + "http://foo.com", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + Secure: true, + }, + }, + wantCookies: nil, + }, + "invalid_filter": { + filterByURLs: []string{ + "HELLO WORLD!", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + }, + }, + wantCookies: nil, + wantErr: true, + }, + "invalid_filter_empty": { + filterByURLs: []string{ + "", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + }, + }, + wantCookies: nil, + wantErr: true, + }, + "invalid_filter_multi": { + filterByURLs: []string{ + "https://foo.com", "", "HELLO WORLD", + }, + cookies: []*Cookie{ + { + Domain: "foo.com", + }, + }, + wantCookies: nil, + wantErr: true, + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + + cookies, err := filterCookies( + tt.cookies, + tt.filterByURLs..., + ) + if tt.wantErr { + assert.Nilf(t, cookies, "want no cookies after an error, but got %#v", cookies) + require.Errorf(t, err, "want an error, but got none") + return + } + require.NoError(t, err) + + assert.Equalf(t, + tt.wantCookies, cookies, + "incorrect cookies filtered by the filter %#v", tt.filterByURLs, + ) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_options.go b/js/modules/k6/browser/common/browser_options.go similarity index 96% rename from vendor/github.com/grafana/xk6-browser/common/browser_options.go rename to js/modules/k6/browser/common/browser_options.go index 0d9a226a2bb..805f231096c 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_options.go +++ b/js/modules/k6/browser/common/browser_options.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/grafana/xk6-browser/env" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/log" "go.k6.io/k6/lib/types" ) @@ -26,7 +26,7 @@ type BrowserOptions struct { IgnoreDefaultArgs []string LogCategoryFilter string // TODO: Do not expose slowMo option by now. - // See https://github.com/grafana/xk6-browser/issues/857. + // See https://go.k6.io/k6/js/modules/k6/browser/issues/857. SlowMo time.Duration Timeout time.Duration diff --git a/js/modules/k6/browser/common/browser_options_test.go b/js/modules/k6/browser/common/browser_options_test.go new file mode 100644 index 00000000000..99ee249f783 --- /dev/null +++ b/js/modules/k6/browser/common/browser_options_test.go @@ -0,0 +1,249 @@ +package common + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + "go.k6.io/k6/js/modules/k6/browser/log" +) + +func TestBrowserOptionsParse(t *testing.T) { + t.Parallel() + + defaultOptions := &BrowserOptions{ + Headless: true, + LogCategoryFilter: ".*", + Timeout: DefaultTimeout, + } + + for name, tt := range map[string]struct { + opts map[string]any + envLookupper env.LookupFunc + assert func(testing.TB, *BrowserOptions) + err string + isRemoteBrowser bool + }{ + "defaults": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.EmptyLookup, + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(t, defaultOptions, lo) + }, + }, + "defaults_nil": { // providing nil option returns default options + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.EmptyLookup, + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(t, defaultOptions, lo) + }, + }, + "defaults_remote_browser": { + opts: map[string]any{ + "type": "chromium", + }, + isRemoteBrowser: true, + envLookupper: func(k string) (string, bool) { + switch k { + // disallow changing the following opts + case env.BrowserArguments: + return "any", true + case env.BrowserExecutablePath: + return "something else", true + case env.BrowserHeadless: + return "false", true + case env.BrowserIgnoreDefaultArgs: + return "any", true + // allow changing the following opts + case env.BrowserEnableDebugging: + return "true", true + case env.LogCategoryFilter: + return "...", true + case env.BrowserGlobalTimeout: + return "1s", true + default: + return "", false + } + }, + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(t, &BrowserOptions{ + // disallowed: + Headless: true, + // allowed: + Debug: true, + LogCategoryFilter: "...", + Timeout: time.Second, + + isRemoteBrowser: true, + }, lo) + }, + }, + "nulls": { // don't override the defaults on `null` + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: func(k string) (string, bool) { + return "", true + }, + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(tb, &BrowserOptions{ + Headless: true, + LogCategoryFilter: ".*", + Timeout: DefaultTimeout, + }, lo) + }, + }, + "args": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserArguments, "browser-arg1='value1,browser-arg2=value2,browser-flag"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + require.Len(tb, lo.Args, 3) + assert.Equal(tb, "browser-arg1='value1", lo.Args[0]) + assert.Equal(tb, "browser-arg2=value2", lo.Args[1]) + assert.Equal(tb, "browser-flag", lo.Args[2]) + }, + }, + "debug": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserEnableDebugging, "true"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.True(t, lo.Debug) + }, + }, + "debug_err": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserEnableDebugging, "non-boolean"), + err: "K6_BROWSER_DEBUG should be a boolean", + }, + "executablePath": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserExecutablePath, "cmd/somewhere"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(t, "cmd/somewhere", lo.ExecutablePath) + }, + }, + "headless": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserHeadless, "false"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.False(t, lo.Headless) + }, + }, + "headless_err": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserHeadless, "non-boolean"), + err: "K6_BROWSER_HEADLESS should be a boolean", + }, + "ignoreDefaultArgs": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserIgnoreDefaultArgs, "--hide-scrollbars,--hide-something"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Len(t, lo.IgnoreDefaultArgs, 2) + assert.Equal(t, "--hide-scrollbars", lo.IgnoreDefaultArgs[0]) + assert.Equal(t, "--hide-something", lo.IgnoreDefaultArgs[1]) + }, + }, + "logCategoryFilter": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.LogCategoryFilter, "**"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(t, "**", lo.LogCategoryFilter) + }, + }, + "timeout": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserGlobalTimeout, "10s"), + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + assert.Equal(t, 10*time.Second, lo.Timeout) + }, + }, + "timeout_err": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.ConstLookup(env.BrowserGlobalTimeout, "ABC"), + err: "K6_BROWSER_TIMEOUT should be a time duration value", + }, + "browser_type": { + opts: map[string]any{ + "type": "chromium", + }, + envLookupper: env.EmptyLookup, + assert: func(tb testing.TB, lo *BrowserOptions) { + tb.Helper() + // Noop, just expect no error + }, + }, + "browser_type_err": { + opts: map[string]any{ + "type": "mybrowsertype", + }, + envLookupper: env.EmptyLookup, + err: "unsupported browser type: mybrowsertype", + }, + "browser_type_unset_err": { + envLookupper: env.EmptyLookup, + err: "browser type option must be set", + }, + } { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + var ( + vu = k6test.NewVU(t) + lo *BrowserOptions + ) + + if tt.isRemoteBrowser { + lo = NewRemoteBrowserOptions() + } else { + lo = NewLocalBrowserOptions() + } + + err := lo.Parse(vu.Context(), log.NewNullLogger(), tt.opts, tt.envLookupper) + if tt.err != "" { + require.ErrorContains(t, err, tt.err) + } else { + require.NoError(t, err) + tt.assert(t, lo) + } + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_process.go b/js/modules/k6/browser/common/browser_process.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/common/browser_process.go rename to js/modules/k6/browser/common/browser_process.go index a28bf672949..433f40b0543 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_process.go +++ b/js/modules/k6/browser/common/browser_process.go @@ -11,8 +11,8 @@ import ( "os/exec" "strings" - "github.com/grafana/xk6-browser/log" - "github.com/grafana/xk6-browser/storage" + "go.k6.io/k6/js/modules/k6/browser/log" + "go.k6.io/k6/js/modules/k6/browser/storage" ) type BrowserProcess struct { diff --git a/vendor/github.com/grafana/xk6-browser/common/browser_process_meta.go b/js/modules/k6/browser/common/browser_process_meta.go similarity index 97% rename from vendor/github.com/grafana/xk6-browser/common/browser_process_meta.go rename to js/modules/k6/browser/common/browser_process_meta.go index ce5c499f684..8c74b7b3e1c 100644 --- a/vendor/github.com/grafana/xk6-browser/common/browser_process_meta.go +++ b/js/modules/k6/browser/common/browser_process_meta.go @@ -3,7 +3,7 @@ package common import ( "os" - "github.com/grafana/xk6-browser/storage" + "go.k6.io/k6/js/modules/k6/browser/storage" ) const ( diff --git a/js/modules/k6/browser/common/browser_process_test.go b/js/modules/k6/browser/common/browser_process_test.go new file mode 100644 index 00000000000..32df231f088 --- /dev/null +++ b/js/modules/k6/browser/common/browser_process_test.go @@ -0,0 +1,187 @@ +package common + +import ( + "context" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockCommand struct { + *command + cancelFn context.CancelFunc +} + +type mockReader struct { + lines []string + hook func() + err error +} + +func (r *mockReader) Read(p []byte) (n int, err error) { + if r.hook != nil { + // Allow some time for the read to be processed + time.AfterFunc(100*time.Millisecond, r.hook) + r.hook = nil // Ensure the hook only runs once + } + if len(r.lines) == 0 { + return 0, io.EOF + } + n = copy(p, []byte(r.lines[0]+"\n")) + r.lines = r.lines[1:] + + return n, r.err +} + +func TestParseDevToolsURL(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + stderr []string + readErr error + readHook func(c *mockCommand) + assert func(t *testing.T, wsURL string, err error) + }{ + { + name: "ok/no_error", + stderr: []string{ + `DevTools listening on ws://127.0.0.1:41315/devtools/browser/d1d3f8eb-b362-4f12-9370-bd25778d0da7`, + }, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.NoError(t, err) + assert.Equal(t, "ws://127.0.0.1:41315/devtools/browser/d1d3f8eb-b362-4f12-9370-bd25778d0da7", wsURL) + }, + }, + { + name: "ok/non-fatal_error", + stderr: []string{ + `[23400:23418:1028/115455.877614:ERROR:bus.cc(399)] Failed to ` + + `connect to the bus: Could not parse server address: ` + + `Unknown address type (examples of valid types are "tcp" ` + + `and on UNIX "unix")`, + "", + `DevTools listening on ws://127.0.0.1:41315/devtools/browser/d1d3f8eb-b362-4f12-9370-bd25778d0da7`, + }, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.NoError(t, err) + assert.Equal(t, "ws://127.0.0.1:41315/devtools/browser/d1d3f8eb-b362-4f12-9370-bd25778d0da7", wsURL) + }, + }, + { + name: "err/fatal-eof", + stderr: []string{ + `[6497:6497:1013/103521.932979:ERROR:ozone_platform_x11` + + `.cc(247)] Missing X server or $DISPLAY` + "\n", + }, + readErr: io.ErrUnexpectedEOF, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.Empty(t, wsURL) + assert.EqualError(t, err, "Missing X server or $DISPLAY") + }, + }, + { + name: "err/fatal-eof-no_stderr", + stderr: []string{""}, + readErr: io.ErrUnexpectedEOF, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.Empty(t, wsURL) + assert.EqualError(t, err, "unexpected EOF") + }, + }, + { + // Ensure any error found on stderr is returned first. + name: "err/fatal-premature_cmd_done-stderr", + stderr: []string{ + `[6497:6497:1013/103521.932979:ERROR:ozone_platform_x11` + + `.cc(247)] Missing X server or $DISPLAY` + "\n", + }, + readHook: func(c *mockCommand) { close(c.done) }, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.Empty(t, wsURL) + assert.EqualError(t, err, "Missing X server or $DISPLAY") + }, + }, + { + // If there's no error on stderr, return a generic error. + name: "err/fatal-premature_cmd_done-no_stderr", + stderr: []string{""}, + readHook: func(c *mockCommand) { close(c.done) }, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.Empty(t, wsURL) + assert.EqualError(t, err, "browser process ended unexpectedly") + }, + }, + { + name: "err/fatal-premature_ctx_cancel-stderr", + stderr: []string{ + `[6497:6497:1013/103521.932979:ERROR:ozone_platform_x11` + + `.cc(247)] Missing X server or $DISPLAY` + "\n", + }, + readHook: func(c *mockCommand) { c.cancelFn() }, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.Empty(t, wsURL) + assert.EqualError(t, err, "Missing X server or $DISPLAY") + }, + }, + { + name: "err/fatal-premature_ctx_cancel-no_stderr", + stderr: []string{""}, + readHook: func(c *mockCommand) { c.cancelFn() }, + assert: func(t *testing.T, wsURL string, err error) { + t.Helper() + require.Empty(t, wsURL) + assert.EqualError(t, err, "context canceled") + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var cmd *mockCommand + mr := mockReader{lines: tc.stderr, err: tc.readErr} + if tc.readHook != nil { + mr.hook = func() { tc.readHook(cmd) } + } + cmd = &mockCommand{&command{done: make(chan struct{}), stderr: &mr}, cancel} + + timeout := time.Second + timer := time.NewTimer(timeout) + t.Cleanup(func() { _ = timer.Stop() }) + + var ( + done = make(chan struct{}) + wsURL string + err error + ) + + go func() { + wsURL, err = parseDevToolsURL(ctx, *cmd.command) + close(done) + }() + + select { + case <-done: + tc.assert(t, wsURL, err) + case <-timer.C: + t.Errorf("test timed out after %s", timeout) + } + }) + } +} diff --git a/js/modules/k6/browser/common/browser_test.go b/js/modules/k6/browser/common/browser_test.go new file mode 100644 index 00000000000..44042766061 --- /dev/null +++ b/js/modules/k6/browser/common/browser_test.go @@ -0,0 +1,194 @@ +package common + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/target" + "github.com/mailru/easyjson" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + "go.k6.io/k6/js/modules/k6/browser/log" +) + +func TestBrowserNewPageInContext(t *testing.T) { + t.Parallel() + + type testCase struct { + b *Browser + bc *BrowserContext + } + newTestCase := func(id cdp.BrowserContextID) *testCase { + ctx, cancel := context.WithCancel(context.Background()) + logger := log.NewNullLogger() + b := newBrowser(context.Background(), ctx, cancel, nil, NewLocalBrowserOptions(), logger) + // set a new browser context in the browser with `id`, so that newPageInContext can find it. + var err error + vu := k6test.NewVU(t) + ctx = k6ext.WithVU(ctx, vu) + b.context, err = NewBrowserContext(ctx, b, id, nil, nil) + require.NoError(t, err) + return &testCase{ + b: b, + bc: b.context, + } + } + + const ( + // default IDs to be used in tests. + browserContextID cdp.BrowserContextID = "42" + targetID target.ID = "84" + ) + + t.Run("happy_path", func(t *testing.T) { + t.Parallel() + + // newPageInContext will look for this browser context. + tc := newTestCase(browserContextID) + + // newPageInContext will return this page by searching it by its targetID in the wait event handler. + tc.b.pages[targetID] = &Page{targetID: targetID} + + tc.b.conn = fakeConn{ + execute: func( + ctx context.Context, method string, params easyjson.Marshaler, res easyjson.Unmarshaler, + ) error { + require.Equal(t, target.CommandCreateTarget, method) + require.IsType(t, params, &target.CreateTargetParams{}) + tp, _ := params.(*target.CreateTargetParams) + require.Equal(t, BlankPage, tp.URL) + require.Equal(t, browserContextID, tp.BrowserContextID) + + // newPageInContext event handler will catch this target ID, and compare it to + // the new page's target ID to detect whether the page + // is loaded. + require.IsType(t, res, &target.CreateTargetReturns{}) + v, _ := res.(*target.CreateTargetReturns) + v.TargetID = targetID + + // for the event handler to work, there needs to be an event called + // EventBrowserContextPage to be fired. this normally happens when the browser's + // onAttachedToTarget event is fired. here, we imitate as if the browser created a target for + // the page. + tc.bc.emit(EventBrowserContextPage, &Page{targetID: targetID}) + + return nil + }, + } + + page, err := tc.b.newPageInContext(browserContextID) + require.NoError(t, err) + require.NotNil(t, page) + require.Equal(t, targetID, page.targetID) + }) + + // should return an error if it cannot find a browser context. + t.Run("missing_browser_context", func(t *testing.T) { + t.Parallel() + + const missingBrowserContextID = "911" + + // set an existing browser context, + _, err := newTestCase(browserContextID). + // but look for a different one. + b.newPageInContext(missingBrowserContextID) + require.Error(t, err) + require.Contains(t, err.Error(), missingBrowserContextID, + "should have returned the missing browser context ID in the error message") + }) + + // should return the error returned from the executor. + t.Run("error_in_create_target_action", func(t *testing.T) { + t.Parallel() + + const wantErr = "anything" + + tc := newTestCase(browserContextID) + tc.b.conn = fakeConn{ + execute: func(context.Context, string, easyjson.Marshaler, easyjson.Unmarshaler) error { + return errors.New(wantErr) + }, + } + page, err := tc.b.newPageInContext(browserContextID) + + require.NotNil(t, err) + require.Contains(t, err.Error(), wantErr) + require.Nil(t, page) + }) + + t.Run("timeout", func(t *testing.T) { + t.Parallel() + + tc := newTestCase(browserContextID) + + // set a lower timeout for catching the timeout error. + const timeout = 100 * time.Millisecond + // set the timeout for the browser value. + tc.b.browserOpts.Timeout = timeout + tc.b.conn = fakeConn{ + execute: func(context.Context, string, easyjson.Marshaler, easyjson.Unmarshaler) error { + // executor takes more time than the timeout. + time.Sleep(2 * timeout) + return nil + }, + } + + var ( + page *Page + err error + + done = make(chan struct{}) + ) + go func() { + // it should timeout in 100ms because the executor will sleep double of the timeout time. + page, err = tc.b.newPageInContext(browserContextID) + done <- struct{}{} + }() + select { + case <-done: + require.Error(t, err) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, page) + case <-time.After(5 * timeout): + require.FailNow(t, "test timed out: expected newPageInContext to time out instead") + } + }) + + t.Run("context_done", func(t *testing.T) { + t.Parallel() + + tc := newTestCase(browserContextID) + + tc.b.conn = fakeConn{ + execute: func(context.Context, string, easyjson.Marshaler, easyjson.Unmarshaler) error { + return nil + }, + } + + var cancel func() + tc.b.vuCtx, cancel = context.WithCancel(tc.b.vuCtx) //nolint:fatcontext + // let newPageInContext return a context cancelation error by canceling the context before + // running the method. + cancel() + page, err := tc.b.newPageInContext(browserContextID) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, page) + }) +} + +type fakeConn struct { + connection + execute func(context.Context, string, easyjson.Marshaler, easyjson.Unmarshaler) error +} + +func (c fakeConn) Execute( + ctx context.Context, method string, params easyjson.Marshaler, res easyjson.Unmarshaler, +) error { + return c.execute(ctx, method, params, res) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/connection.go b/js/modules/k6/browser/common/connection.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/connection.go rename to js/modules/k6/browser/common/connection.go index f6eca67016f..94e5271e7a8 100644 --- a/vendor/github.com/grafana/xk6-browser/common/connection.go +++ b/js/modules/k6/browser/common/connection.go @@ -10,7 +10,7 @@ import ( "sync/atomic" "time" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" "github.com/chromedp/cdproto" "github.com/chromedp/cdproto/cdp" diff --git a/js/modules/k6/browser/common/connection_test.go b/js/modules/k6/browser/common/connection_test.go new file mode 100644 index 00000000000..1601612cb38 --- /dev/null +++ b/js/modules/k6/browser/common/connection_test.go @@ -0,0 +1,158 @@ +package common + +import ( + "context" + "fmt" + "net/url" + "testing" + + "go.k6.io/k6/js/modules/k6/browser/log" + "go.k6.io/k6/js/modules/k6/browser/tests/ws" + + "github.com/chromedp/cdproto" + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/target" + "github.com/gorilla/websocket" + "github.com/mailru/easyjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnection(t *testing.T) { + t.Parallel() + + server := ws.NewServer(t, ws.WithEchoHandler("/echo")) + + t.Run("connect", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + url, _ := url.Parse(server.ServerHTTP.URL) + wsURL := fmt.Sprintf("ws://%s/echo", url.Host) + conn, err := NewConnection(ctx, wsURL, log.NewNullLogger(), nil) + conn.Close() + + require.NoError(t, err) + }) +} + +func TestConnectionClosureAbnormal(t *testing.T) { + t.Parallel() + + server := ws.NewServer(t, ws.WithClosureAbnormalHandler("/closure-abnormal")) + + t.Run("closure abnormal", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + url, _ := url.Parse(server.ServerHTTP.URL) + wsURL := fmt.Sprintf("ws://%s/closure-abnormal", url.Host) + conn, err := NewConnection(ctx, wsURL, log.NewNullLogger(), nil) + + if assert.NoError(t, err) { + action := target.SetDiscoverTargets(true) + err := action.Do(cdp.WithExecutor(ctx, conn)) + require.ErrorContains(t, err, "websocket: close 1006 (abnormal closure): unexpected EOF") + } + }) +} + +func TestConnectionSendRecv(t *testing.T) { + t.Parallel() + + server := ws.NewServer(t, ws.WithCDPHandler("/cdp", ws.CDPDefaultHandler, nil)) + + t.Run("send command with empty reply", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + url, _ := url.Parse(server.ServerHTTP.URL) + wsURL := fmt.Sprintf("ws://%s/cdp", url.Host) + conn, err := NewConnection(ctx, wsURL, log.NewNullLogger(), nil) + + if assert.NoError(t, err) { + action := target.SetDiscoverTargets(true) + err := action.Do(cdp.WithExecutor(ctx, conn)) + require.NoError(t, err) + } + }) +} + +func TestConnectionCreateSession(t *testing.T) { + t.Parallel() + + cmdsReceived := make([]cdproto.MethodType, 0) + handler := func(conn *websocket.Conn, msg *cdproto.Message, writeCh chan cdproto.Message, done chan struct{}) { + if msg.SessionID == "" && msg.Method != "" { + switch msg.Method { + case cdproto.MethodType(cdproto.CommandTargetSetDiscoverTargets): + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + Result: easyjson.RawMessage([]byte("{}")), + } + case cdproto.MethodType(cdproto.CommandTargetAttachToTarget): + switch msg.Method { + case cdproto.MethodType(cdproto.CommandTargetSetDiscoverTargets): + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + Result: easyjson.RawMessage([]byte("{}")), + } + case cdproto.MethodType(cdproto.CommandTargetAttachToTarget): + writeCh <- cdproto.Message{ + Method: cdproto.EventTargetAttachedToTarget, + Params: easyjson.RawMessage([]byte(` + { + "sessionId": "0123456789", + "targetInfo": { + "targetId": "abcdef0123456789", + "type": "page", + "title": "", + "url": "about:blank", + "attached": true, + "browserContextId": "0123456789876543210" + }, + "waitingForDebugger": false + } + `)), + } + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + Result: easyjson.RawMessage([]byte(`{"sessionId":"0123456789"}`)), + } + } + } + } + } + + server := ws.NewServer(t, ws.WithCDPHandler("/cdp", handler, &cmdsReceived)) + + t.Run("create session for target", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + url, _ := url.Parse(server.ServerHTTP.URL) + wsURL := fmt.Sprintf("ws://%s/cdp", url.Host) + conn, err := NewConnection(ctx, wsURL, log.NewNullLogger(), nil) + + if assert.NoError(t, err) { + session, err := conn.createSession(&target.Info{ + TargetID: "abcdef0123456789", + Type: "page", + BrowserContextID: "0123456789876543210", + }) + + require.NoError(t, err) + require.NotNil(t, session) + require.NotEmpty(t, session.id) + require.NotEmpty(t, conn.sessions) + require.Len(t, conn.sessions, 1) + require.Equal(t, conn.sessions[session.id], session) + require.Equal(t, []cdproto.MethodType{ + cdproto.CommandTargetAttachToTarget, + }, cmdsReceived) + } + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/consts.go b/js/modules/k6/browser/common/consts.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/consts.go rename to js/modules/k6/browser/common/consts.go diff --git a/vendor/github.com/grafana/xk6-browser/common/context.go b/js/modules/k6/browser/common/context.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/context.go rename to js/modules/k6/browser/common/context.go diff --git a/js/modules/k6/browser/common/context_test.go b/js/modules/k6/browser/common/context_test.go new file mode 100644 index 00000000000..a972ffc2ce7 --- /dev/null +++ b/js/modules/k6/browser/common/context_test.go @@ -0,0 +1,22 @@ +package common + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestContextWithDoneChan(t *testing.T) { + t.Parallel() + + done := make(chan struct{}) + ctx := contextWithDoneChan(context.Background(), done) + close(done) + select { + case <-ctx.Done(): + case <-time.After(time.Millisecond * 100): + require.FailNow(t, "should cancel the context after closing the done chan") + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/device.go b/js/modules/k6/browser/common/device.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/device.go rename to js/modules/k6/browser/common/device.go diff --git a/vendor/github.com/grafana/xk6-browser/common/doc.go b/js/modules/k6/browser/common/doc.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/doc.go rename to js/modules/k6/browser/common/doc.go diff --git a/vendor/github.com/grafana/xk6-browser/common/element_handle.go b/js/modules/k6/browser/common/element_handle.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/element_handle.go rename to js/modules/k6/browser/common/element_handle.go index faf31404741..b3c53424237 100644 --- a/vendor/github.com/grafana/xk6-browser/common/element_handle.go +++ b/js/modules/k6/browser/common/element_handle.go @@ -15,8 +15,8 @@ import ( "github.com/grafana/sobek" "go.opentelemetry.io/otel/attribute" - "github.com/grafana/xk6-browser/common/js" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/common/js" + "go.k6.io/k6/js/modules/k6/browser/k6ext" k6common "go.k6.io/k6/js/common" ) diff --git a/vendor/github.com/grafana/xk6-browser/common/element_handle_options.go b/js/modules/k6/browser/common/element_handle_options.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/element_handle_options.go rename to js/modules/k6/browser/common/element_handle_options.go index fa1ef179a3b..cd803e3ba08 100644 --- a/vendor/github.com/grafana/xk6-browser/common/element_handle_options.go +++ b/js/modules/k6/browser/common/element_handle_options.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) type ElementHandleBaseOptions struct { diff --git a/js/modules/k6/browser/common/element_handle_test.go b/js/modules/k6/browser/common/element_handle_test.go new file mode 100644 index 00000000000..a0bb379600d --- /dev/null +++ b/js/modules/k6/browser/common/element_handle_test.go @@ -0,0 +1,209 @@ +package common + +import ( + "context" + "errors" + "testing" + + "go.k6.io/k6/js/modules/k6/browser/common/js" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrorFromDOMError(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + in string + sentinel bool // if it returns the same error value + want error + }{ + {in: "timed out", want: ErrTimedOut, sentinel: true}, + {in: "error:notconnected", want: errors.New("element is not attached to the DOM")}, + {in: "error:expectednode:anything", want: errors.New("expected node but got anything")}, + {in: "nonexistent error", want: errors.New("nonexistent error")}, + } { + got := errorFromDOMError(tc.in) + if tc.sentinel && !errors.Is(got, tc.want) { + assert.Failf(t, "not sentinel", "error value of %q should be sentinel", tc.in) + } else { + require.Error(t, got) + assert.EqualError(t, tc.want, got.Error()) + } + } +} + +func TestQueryAll(t *testing.T) { + t.Parallel() + + var ( + nilHandle = func() *ElementHandle { return nil } + nonNilHandle = func() *ElementHandle { return &ElementHandle{} } + ) + + for name, tt := range map[string]struct { + selector string + returnHandle func() any + returnErr error + wantErr bool + wantHandles, wantDisposeCalls int + }{ + "invalid_selector": { + selector: "*=*>>*=*", + returnHandle: func() any { return nil }, + returnErr: errors.New("any"), + wantErr: true, + }, + "cannot_evaluate": { + selector: "*", + returnHandle: func() any { return nil }, + returnErr: errors.New("any"), + wantErr: true, + }, + "nil_handles_no_err": { + selector: "*", + returnHandle: func() any { return nil }, + returnErr: nil, + wantErr: false, + }, + "invalid_js_handle": { + selector: "*", + returnHandle: func() any { return "an invalid handle" }, + wantErr: true, + }, + "disposes_main_handle": { + selector: "*", + returnHandle: func() any { return &jsHandleStub{} }, + wantDisposeCalls: 1, + }, + // disposes the main handle and all its nil children + "disposes_handles": { + selector: "*", + returnHandle: func() any { + handles := &jsHandleStub{ + asElementFn: nilHandle, + } + handles.getPropertiesFn = func() (map[string]JSHandleAPI, error) { + return map[string]JSHandleAPI{ + "1": handles, + "2": handles, + }, nil + } + + return handles + }, + wantDisposeCalls: 3, + }, + // only returns non-nil handles + "returns_elems": { + selector: "*", + returnHandle: func() any { + childHandles := map[string]JSHandleAPI{ + "1": &jsHandleStub{asElementFn: nonNilHandle}, + "2": &jsHandleStub{asElementFn: nonNilHandle}, + "3": &jsHandleStub{asElementFn: nilHandle}, + } + return &jsHandleStub{ + getPropertiesFn: func() (map[string]JSHandleAPI, error) { + return childHandles, nil + }, + } + }, + wantHandles: 2, + }, + "returns_err": { + selector: "*", + returnHandle: func() any { + return &jsHandleStub{ + getPropertiesFn: func() (map[string]JSHandleAPI, error) { + return nil, errors.New("any error") + }, + } + }, + wantHandles: 0, + wantErr: true, + }, + } { + name, tt := name, tt + + t.Run(name, func(t *testing.T) { + t.Parallel() + + var ( + returnHandle = tt.returnHandle() + evalFunc = func(_ context.Context, _ evalOptions, _ string, _ ...any) (any, error) { + return returnHandle, tt.returnErr + } + ) + + handles, err := (&ElementHandle{}).queryAll(tt.selector, evalFunc) + + if tt.wantErr { + require.Error(t, err) + require.Nil(t, handles) + } else { + require.NoError(t, err) + } + assert.Len(t, handles, tt.wantHandles) + + if want := tt.wantDisposeCalls; want > 0 { + stub, ok := returnHandle.(*jsHandleStub) + require.True(t, ok) + assert.Equal(t, want, stub.disposeCalls) + } + }) + } + + t.Run("eval_call", func(t *testing.T) { + t.Parallel() + + const selector = "body" + + _, _ = (&ElementHandle{}).queryAll( + selector, + func(_ context.Context, opts evalOptions, jsFunc string, args ...any) (any, error) { + assert.Equal(t, js.QueryAll, jsFunc) + + assert.Equal(t, opts.forceCallable, true) + assert.Equal(t, opts.returnByValue, false) + + assert.NotEmpty(t, args) + assert.IsType(t, args[0], &Selector{}) + sel, ok := args[0].(*Selector) + require.True(t, ok) + assert.Equal(t, sel.Selector, selector) + + return nil, nil //nolint:nilnil + }, + ) + }) +} + +type jsHandleStub struct { + JSHandleAPI + + disposeCalls int + + asElementFn func() *ElementHandle + getPropertiesFn func() (map[string]JSHandleAPI, error) +} + +func (s *jsHandleStub) AsElement() *ElementHandle { + if s.asElementFn == nil { + return nil + } + return s.asElementFn() +} + +func (s *jsHandleStub) Dispose() error { + s.disposeCalls++ + return nil +} + +func (s *jsHandleStub) GetProperties() (map[string]JSHandleAPI, error) { + if s.getPropertiesFn == nil { + return nil, nil //nolint:nilnil + } + return s.getPropertiesFn() +} diff --git a/vendor/github.com/grafana/xk6-browser/common/errors.go b/js/modules/k6/browser/common/errors.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/errors.go rename to js/modules/k6/browser/common/errors.go diff --git a/vendor/github.com/grafana/xk6-browser/common/event_emitter.go b/js/modules/k6/browser/common/event_emitter.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/event_emitter.go rename to js/modules/k6/browser/common/event_emitter.go diff --git a/js/modules/k6/browser/common/event_emitter_test.go b/js/modules/k6/browser/common/event_emitter_test.go new file mode 100644 index 00000000000..e474372a668 --- /dev/null +++ b/js/modules/k6/browser/common/event_emitter_test.go @@ -0,0 +1,304 @@ +package common + +import ( + "context" + "testing" + "time" + + "github.com/chromedp/cdproto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventEmitterSpecificEvent(t *testing.T) { + t.Parallel() + + t.Run("add event handler", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event) + + emitter.on(ctx, []string{cdproto.EventTargetTargetCreated}, ch) + emitter.sync(func() { + require.Len(t, emitter.handlers, 1) + require.Contains(t, emitter.handlers, cdproto.EventTargetTargetCreated) + require.Len(t, emitter.handlers[cdproto.EventTargetTargetCreated], 1) + require.Equal(t, ch, emitter.handlers[cdproto.EventTargetTargetCreated][0].ch) + require.Empty(t, emitter.handlersAll) + }) + }) + + t.Run("remove event handler", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cancelCtx, cancelFn := context.WithCancel(ctx) + emitter := NewBaseEventEmitter(cancelCtx) + ch := make(chan Event) + + emitter.on(cancelCtx, []string{cdproto.EventTargetTargetCreated}, ch) + cancelFn() + emitter.emit(cdproto.EventTargetTargetCreated, nil) // Event handlers are removed as part of event emission + + emitter.sync(func() { + require.Contains(t, emitter.handlers, cdproto.EventTargetTargetCreated) + require.Len(t, emitter.handlers[cdproto.EventTargetTargetCreated], 0) + }) + }) + + t.Run("emit event", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event, 1) + + emitter.on(ctx, []string{cdproto.EventTargetTargetCreated}, ch) + emitter.emit(cdproto.EventTargetTargetCreated, "hello world") + msg := <-ch + + emitter.sync(func() { + require.Equal(t, cdproto.EventTargetTargetCreated, msg.typ) + require.Equal(t, "hello world", msg.data) + }) + }) +} + +func TestEventEmitterAllEvents(t *testing.T) { + t.Parallel() + + t.Run("add catch-all event handler", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event) + + emitter.onAll(ctx, ch) + + emitter.sync(func() { + require.Len(t, emitter.handlersAll, 1) + require.Equal(t, ch, emitter.handlersAll[0].ch) + require.Empty(t, emitter.handlers) + }) + }) + + t.Run("remove catch-all event handler", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + emitter := NewBaseEventEmitter(ctx) + cancelCtx, cancelFn := context.WithCancel(ctx) + ch := make(chan Event) + + emitter.onAll(cancelCtx, ch) + cancelFn() + emitter.emit(cdproto.EventTargetTargetCreated, nil) // Event handlers are removed as part of event emission + + emitter.sync(func() { + require.Len(t, emitter.handlersAll, 0) + }) + }) + + t.Run("emit event", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event, 1) + + emitter.onAll(ctx, ch) + emitter.emit(cdproto.EventTargetTargetCreated, "hello world") + msg := <-ch + + emitter.sync(func() { + require.Equal(t, cdproto.EventTargetTargetCreated, msg.typ) + require.Equal(t, "hello world", msg.data) + }) + }) +} + +func TestBaseEventEmitter(t *testing.T) { + t.Parallel() + + t.Run("order of emitted events kept", func(t *testing.T) { + t.Parallel() + + // Test description + // + // 1. Emit many events from the emitWorker. + // 2. Handler receives the emitted events. + // + // Success criteria: Ensure that the ordering of events is + // received in the order they're emitted. + + eventName := "AtomicIntEvent" + maxInt := 100 + + ctx, cancel := context.WithCancel(context.Background()) + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event) + emitter.on(ctx, []string{eventName}, ch) + + var expectedI int + handler := func() { + defer cancel() + + for expectedI != maxInt { + e := <-ch + + i, ok := e.data.(int) + if !ok { + assert.FailNow(t, "unexpected type read from channel", e.data) + } + + assert.Equal(t, eventName, e.typ) + assert.Equal(t, expectedI, i) + + expectedI++ + } + + close(ch) + } + go handler() + + emitWorker := func() { + for i := 0; i < maxInt; i++ { + emitter.emit(eventName, i) + } + } + go emitWorker() + + select { + case <-ctx.Done(): + case <-time.After(time.Second * 2): + assert.FailNow(t, "test timed out, deadlock?") + } + }) + + t.Run("order of emitted different event types kept", func(t *testing.T) { + t.Parallel() + + // Test description + // + // 1. Emit many different event types from the emitWorker. + // 2. Handler receives the emitted events. + // + // Success criteria: Ensure that the ordering of events is + // received in the order they're emitted. + + eventName1 := "AtomicIntEvent1" + eventName2 := "AtomicIntEvent2" + eventName3 := "AtomicIntEvent3" + eventName4 := "AtomicIntEvent4" + maxInt := 100 + + ctx, cancel := context.WithCancel(context.Background()) + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event) + // Calling on twice to ensure that the same queue is used + // internally for the same channel and handler. + emitter.on(ctx, []string{eventName1, eventName2}, ch) + emitter.on(ctx, []string{eventName3, eventName4}, ch) + + var expectedI int + handler := func() { + defer cancel() + + for expectedI != maxInt { + e := <-ch + + i, ok := e.data.(int) + if !ok { + assert.FailNow(t, "unexpected type read from channel", e.data) + } + + assert.Equal(t, expectedI, i) + + expectedI++ + } + + close(ch) + } + go handler() + + emitWorker := func() { + for i := 0; i < maxInt; i += 4 { + emitter.emit(eventName1, i) + emitter.emit(eventName2, i+1) + emitter.emit(eventName3, i+2) + emitter.emit(eventName4, i+3) + } + } + go emitWorker() + + select { + case <-ctx.Done(): + case <-time.After(time.Second * 2): + assert.FailNow(t, "test timed out, deadlock?") + } + }) + + t.Run("handler can emit without deadlocking", func(t *testing.T) { + t.Parallel() + + // Test description + // + // 1. Emit many events from the emitWorker. + // 2. Handler receives emitted events (AtomicIntEvent1). + // 3. Handler emits event as AtomicIntEvent2. + // 4. Handler received emitted events again (AtomicIntEvent2). + // + // Success criteria: No deadlock should occur between receiving, + // emitting, and receiving of events. + + eventName1 := "AtomicIntEvent1" + eventName2 := "AtomicIntEvent2" + maxInt := 100 + + ctx, cancel := context.WithCancel(context.Background()) + emitter := NewBaseEventEmitter(ctx) + ch := make(chan Event) + emitter.on(ctx, []string{eventName1, eventName2}, ch) + + var expectedI2 int + handler := func() { + defer cancel() + + for expectedI2 != maxInt { + e := <-ch + + switch e.typ { + case eventName1: + i, ok := e.data.(int) + if !ok { + assert.FailNow(t, "unexpected type read from channel", e.data) + } + emitter.emit(eventName2, i) + case eventName2: + expectedI2++ + default: + assert.FailNow(t, "unexpected event type received") + } + } + + close(ch) + } + go handler() + + emitWorker := func() { + for i := 0; i < maxInt; i++ { + emitter.emit(eventName1, i) + } + } + go emitWorker() + + select { + case <-ctx.Done(): + case <-time.After(time.Second * 2): + assert.FailNow(t, "test timed out, deadlock?") + } + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/execution_context.go b/js/modules/k6/browser/common/execution_context.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/execution_context.go rename to js/modules/k6/browser/common/execution_context.go index 6e0f9b5f728..8e4f91f1c45 100644 --- a/vendor/github.com/grafana/xk6-browser/common/execution_context.go +++ b/js/modules/k6/browser/common/execution_context.go @@ -8,8 +8,8 @@ import ( "regexp" "sync" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" diff --git a/vendor/github.com/grafana/xk6-browser/common/frame.go b/js/modules/k6/browser/common/frame.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/frame.go rename to js/modules/k6/browser/common/frame.go index a627794a26c..b84a3454a14 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame.go +++ b/js/modules/k6/browser/common/frame.go @@ -15,8 +15,8 @@ import ( "github.com/chromedp/cdproto/runtime" "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" ) diff --git a/vendor/github.com/grafana/xk6-browser/common/frame_manager.go b/js/modules/k6/browser/common/frame_manager.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/frame_manager.go rename to js/modules/k6/browser/common/frame_manager.go index 9397839c3d5..1be55525ca8 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame_manager.go +++ b/js/modules/k6/browser/common/frame_manager.go @@ -7,8 +7,8 @@ import ( "sync" "sync/atomic" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" diff --git a/vendor/github.com/grafana/xk6-browser/common/frame_options.go b/js/modules/k6/browser/common/frame_options.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/frame_options.go rename to js/modules/k6/browser/common/frame_options.go index d1069151c35..f01278fef0f 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame_options.go +++ b/js/modules/k6/browser/common/frame_options.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) type FrameBaseOptions struct { diff --git a/js/modules/k6/browser/common/frame_options_test.go b/js/modules/k6/browser/common/frame_options_test.go new file mode 100644 index 00000000000..5497cc04964 --- /dev/null +++ b/js/modules/k6/browser/common/frame_options_test.go @@ -0,0 +1,121 @@ +package common + +import ( + "testing" + "time" + + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFrameGotoOptionsParse(t *testing.T) { + t.Parallel() + + t.Run("ok", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + opts := vu.ToSobekValue(map[string]any{ + "timeout": "1000", + "waitUntil": "networkidle", + }) + gotoOpts := NewFrameGotoOptions("https://example.com/", 0) + err := gotoOpts.Parse(vu.Context(), opts) + require.NoError(t, err) + + assert.Equal(t, "https://example.com/", gotoOpts.Referer) + assert.Equal(t, time.Second, gotoOpts.Timeout) + assert.Equal(t, LifecycleEventNetworkIdle, gotoOpts.WaitUntil) + }) + + t.Run("err/invalid_waitUntil", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + opts := vu.ToSobekValue(map[string]any{ + "waitUntil": "none", + }) + navOpts := NewFrameGotoOptions("", 0) + err := navOpts.Parse(vu.Context(), opts) + + assert.EqualError(t, err, + `parsing goto options: `+ + `invalid lifecycle event: "none"; must be one of: `+ + `load, domcontentloaded, networkidle`) + }) +} + +func TestFrameSetContentOptionsParse(t *testing.T) { + t.Parallel() + + t.Run("ok", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + opts := vu.ToSobekValue(map[string]any{ + "waitUntil": "networkidle", + }) + scOpts := NewFrameSetContentOptions(30 * time.Second) + err := scOpts.Parse(vu.Context(), opts) + require.NoError(t, err) + + assert.Equal(t, 30*time.Second, scOpts.Timeout) + assert.Equal(t, LifecycleEventNetworkIdle, scOpts.WaitUntil) + }) + + t.Run("err/invalid_waitUntil", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + opts := vu.ToSobekValue(map[string]any{ + "waitUntil": "none", + }) + navOpts := NewFrameSetContentOptions(0) + err := navOpts.Parse(vu.Context(), opts) + + assert.EqualError(t, err, + `parsing setContent options: `+ + `invalid lifecycle event: "none"; must be one of: `+ + `load, domcontentloaded, networkidle`) + }) +} + +func TestFrameWaitForNavigationOptionsParse(t *testing.T) { + t.Parallel() + + t.Run("ok", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + opts := vu.ToSobekValue(map[string]any{ + "url": "https://example.com/", + "timeout": "1000", + "waitUntil": "networkidle", + }) + navOpts := NewFrameWaitForNavigationOptions(0) + err := navOpts.Parse(vu.Context(), opts) + require.NoError(t, err) + + assert.Equal(t, "https://example.com/", navOpts.URL) + assert.Equal(t, time.Second, navOpts.Timeout) + assert.Equal(t, LifecycleEventNetworkIdle, navOpts.WaitUntil) + }) + + t.Run("err/invalid_waitUntil", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + opts := vu.ToSobekValue(map[string]any{ + "waitUntil": "none", + }) + navOpts := NewFrameWaitForNavigationOptions(0) + err := navOpts.Parse(vu.Context(), opts) + + assert.EqualError(t, err, + `parsing waitForNavigation options: `+ + `invalid lifecycle event: "none"; must be one of: `+ + `load, domcontentloaded, networkidle`) + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/frame_session.go b/js/modules/k6/browser/common/frame_session.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/frame_session.go rename to js/modules/k6/browser/common/frame_session.go index c4df0a6ff65..28eea5d8aa2 100644 --- a/vendor/github.com/grafana/xk6-browser/common/frame_session.go +++ b/js/modules/k6/browser/common/frame_session.go @@ -14,8 +14,8 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" k6metrics "go.k6.io/k6/metrics" diff --git a/js/modules/k6/browser/common/frame_test.go b/js/modules/k6/browser/common/frame_test.go new file mode 100644 index 00000000000..7c8d75a5367 --- /dev/null +++ b/js/modules/k6/browser/common/frame_test.go @@ -0,0 +1,112 @@ +package common + +import ( + "context" + "testing" + "time" + + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + "go.k6.io/k6/js/modules/k6/browser/log" + + "github.com/chromedp/cdproto/cdp" + "github.com/stretchr/testify/require" +) + +// Test calling Frame.document does not panic with a nil document. +// See: Issue #53 for details. +func TestFrameNilDocument(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + log := log.NewNullLogger() + + fm := NewFrameManager(vu.Context(), nil, nil, nil, log) + frame := NewFrame(vu.Context(), fm, nil, cdp.FrameID("42"), log) + + // frame should not panic with a nil document + stub := &executionContextTestStub{ + evalFn: func(apiCtx context.Context, opts evalOptions, js string, args ...any) (res any, err error) { + // return nil to test for panic + return nil, nil //nolint:nilnil + }, + } + + // document() waits for the main execution context + ok := make(chan struct{}, 1) + go func() { + frame.setContext(mainWorld, stub) + ok <- struct{}{} + }() + select { + case <-ok: + case <-time.After(time.Second): + require.FailNow(t, "cannot set the main execution context, frame.setContext timed out") + } + + require.NotPanics(t, func() { + _, err := frame.document() + require.Error(t, err) + }) + + // frame gets the document from the evaluate call + want := &ElementHandle{} + stub.evalFn = func( + apiCtx context.Context, opts evalOptions, js string, args ...any, + ) (res any, err error) { + return want, nil + } + got, err := frame.document() + require.NoError(t, err) + require.Equal(t, want, got) + + // frame sets documentHandle in the document method + got = frame.documentHandle + require.Equal(t, want, got) +} + +// See: Issue #177 for details. +func TestFrameManagerFrameAbortedNavigationShouldEmitANonNilPendingDocument(t *testing.T) { + t.Parallel() + + ctx, log := context.Background(), log.NewNullLogger() + + // add the frame to frame manager + fm := NewFrameManager(ctx, nil, nil, NewTimeoutSettings(nil), log) + frame := NewFrame(ctx, fm, nil, cdp.FrameID("42"), log) + fm.frames[frame.id] = frame + + // listen for frame navigation events + recv := make(chan Event) + frame.on(ctx, []string{EventFrameNavigation}, recv) + + // emit the navigation event + frame.pendingDocument = &DocumentInfo{ + documentID: "42", + } + fm.frameAbortedNavigation(frame.id, "any error", frame.pendingDocument.documentID) + + // receive the emitted event and verify that emitted document + // is not nil. + e := <-recv + require.IsType(t, &NavigationEvent{}, e.data, "event should be a navigation event") + ne := e.data.(*NavigationEvent) + require.NotNil(t, ne, "event should not be nil") + require.NotNil(t, ne.newDocument, "emitted document should not be nil") + + // since the navigation is aborted, the aborting frame should have + // a nil pending document. + require.Nil(t, frame.pendingDocument) +} + +type executionContextTestStub struct { + ExecutionContext + evalFn func( + apiCtx context.Context, opts evalOptions, js string, args ...any, + ) (res any, err error) +} + +func (e *executionContextTestStub) eval( // this needs to be a pointer as otherwise it will copy the mutex inside of it + apiCtx context.Context, opts evalOptions, js string, args ...any, +) (res any, err error) { + return e.evalFn(apiCtx, opts, js, args...) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/helpers.go b/js/modules/k6/browser/common/helpers.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/helpers.go rename to js/modules/k6/browser/common/helpers.go index 0b2f2ee3a6b..2a6f8175f18 100644 --- a/vendor/github.com/grafana/xk6-browser/common/helpers.go +++ b/js/modules/k6/browser/common/helpers.go @@ -11,7 +11,7 @@ import ( cdpruntime "github.com/chromedp/cdproto/runtime" "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) func convertBaseJSHandleTypes( diff --git a/js/modules/k6/browser/common/helpers_test.go b/js/modules/k6/browser/common/helpers_test.go new file mode 100644 index 00000000000..25f78e1cc5d --- /dev/null +++ b/js/modules/k6/browser/common/helpers_test.go @@ -0,0 +1,233 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "math" + "testing" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/runtime" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/log" +) + +func newExecCtx() (*ExecutionContext, context.Context) { + ctx := context.Background() + logger := log.NewNullLogger() + execCtx := NewExecutionContext(ctx, nil, nil, runtime.ExecutionContextID(123456789), logger) + + return execCtx, ctx +} + +func TestConvertArgument(t *testing.T) { + t.Parallel() + + t.Run("int64", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + var value int64 = 777 + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + result, _ := json.Marshal(value) + require.Equal(t, result, []byte(arg.Value)) + require.Empty(t, arg.UnserializableValue) + require.Empty(t, arg.ObjectID) + }) + + t.Run("int64 maxint", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + + var value int64 = math.MaxInt32 + 1 + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + require.Equal(t, fmt.Sprintf("%dn", value), string(arg.UnserializableValue)) + require.Empty(t, arg.Value) + require.Empty(t, arg.ObjectID) + }) + + t.Run("float64", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + + var value float64 = 777.0 + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + result, _ := json.Marshal(value) + require.Equal(t, result, []byte(arg.Value)) + require.Empty(t, arg.UnserializableValue) + require.Empty(t, arg.ObjectID) + }) + + t.Run("float64 unserializable values", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + + unserializableValues := []struct { + value float64 + expected string + }{ + { + value: math.Float64frombits(0 | (1 << 63)), + expected: "-0", + }, + { + value: math.Inf(0), + expected: "Infinity", + }, + { + value: math.Inf(-1), + expected: "-Infinity", + }, + { + value: math.NaN(), + expected: "NaN", + }, + } + + for _, v := range unserializableValues { + arg, _ := convertArgument(ctx, execCtx, v.value) + require.NotNil(t, arg) + require.Equal(t, v.expected, string(arg.UnserializableValue)) + require.Empty(t, arg.Value) + require.Empty(t, arg.ObjectID) + } + }) + + t.Run("bool", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + + value := true + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + result, _ := json.Marshal(value) + require.Equal(t, result, []byte(arg.Value)) + require.Empty(t, arg.UnserializableValue) + require.Empty(t, arg.ObjectID) + }) + + t.Run("string", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + value := "hello world" + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + result, _ := json.Marshal(value) + require.Equal(t, result, []byte(arg.Value)) + require.Empty(t, arg.UnserializableValue) + require.Empty(t, arg.ObjectID) + }) + + t.Run("*BaseJSHandle", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + log := log.NewNullLogger() + + timeoutSettings := NewTimeoutSettings(nil) + frameManager := NewFrameManager(ctx, nil, nil, timeoutSettings, log) + frame := NewFrame(ctx, frameManager, nil, cdp.FrameID("frame_id_0123456789"), log) + remoteObjValue := "hellow world" + result, _ := json.Marshal(remoteObjValue) + remoteObject := &runtime.RemoteObject{ + Type: "string", + Value: result, + } + + value := NewJSHandle(ctx, nil, execCtx, frame, remoteObject, execCtx.logger) + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + require.Equal(t, result, []byte(arg.Value)) + require.Empty(t, arg.UnserializableValue) + require.Empty(t, arg.ObjectID) + }) + + t.Run("*BaseJSHandle wrong context", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + log := log.NewNullLogger() + + timeoutSettings := NewTimeoutSettings(nil) + frameManager := NewFrameManager(ctx, nil, nil, timeoutSettings, log) + frame := NewFrame(ctx, frameManager, nil, cdp.FrameID("frame_id_0123456789"), log) + remoteObjectID := runtime.RemoteObjectID("object_id_0123456789") + remoteObject := &runtime.RemoteObject{ + Type: "object", + Subtype: "node", + ObjectID: remoteObjectID, + } + execCtx2 := NewExecutionContext(ctx, nil, nil, runtime.ExecutionContextID(123456789), execCtx.logger) + + value := NewJSHandle(ctx, nil, execCtx2, frame, remoteObject, execCtx.logger) + arg, err := convertArgument(ctx, execCtx, value) + + require.Nil(t, arg) + require.ErrorIs(t, ErrWrongExecutionContext, err) + }) + + t.Run("*BaseJSHandle is disposed", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + log := log.NewNullLogger() + + timeoutSettings := NewTimeoutSettings(nil) + frameManager := NewFrameManager(ctx, nil, nil, timeoutSettings, log) + frame := NewFrame(ctx, frameManager, nil, cdp.FrameID("frame_id_0123456789"), log) + remoteObjectID := runtime.RemoteObjectID("object_id_0123456789") + remoteObject := &runtime.RemoteObject{ + Type: "object", + Subtype: "node", + ObjectID: remoteObjectID, + } + + value := NewJSHandle(ctx, nil, execCtx, frame, remoteObject, execCtx.logger) + value.(*BaseJSHandle).disposed = true + arg, err := convertArgument(ctx, execCtx, value) + + require.Nil(t, arg) + require.ErrorIs(t, ErrJSHandleDisposed, err) + }) + + t.Run("*BaseJSHandle as *ElementHandle", func(t *testing.T) { + t.Parallel() + + execCtx, ctx := newExecCtx() + log := log.NewNullLogger() + + timeoutSettings := NewTimeoutSettings(nil) + frameManager := NewFrameManager(ctx, nil, nil, timeoutSettings, log) + frame := NewFrame(ctx, frameManager, nil, cdp.FrameID("frame_id_0123456789"), log) + remoteObjectID := runtime.RemoteObjectID("object_id_0123456789") + remoteObject := &runtime.RemoteObject{ + Type: "object", + Subtype: "node", + ObjectID: remoteObjectID, + } + + value := NewJSHandle(ctx, nil, execCtx, frame, remoteObject, execCtx.logger) + arg, _ := convertArgument(ctx, execCtx, value) + + require.NotNil(t, arg) + require.Equal(t, remoteObjectID, arg.ObjectID) + require.Empty(t, arg.Value) + require.Empty(t, arg.UnserializableValue) + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/hooks.go b/js/modules/k6/browser/common/hooks.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/hooks.go rename to js/modules/k6/browser/common/hooks.go diff --git a/vendor/github.com/grafana/xk6-browser/common/http.go b/js/modules/k6/browser/common/http.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/common/http.go rename to js/modules/k6/browser/common/http.go index f81dfbba0a0..7c55209cd3d 100644 --- a/vendor/github.com/grafana/xk6-browser/common/http.go +++ b/js/modules/k6/browser/common/http.go @@ -14,8 +14,8 @@ import ( "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/network" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" ) @@ -236,7 +236,7 @@ func (r *Request) Method() string { // PostData returns the request post data, if any. // // If will not attempt to fetch the data if it should have some but nothing is -// cached locally: https://github.com/grafana/xk6-browser/issues/1470 +// cached locally: https://go.k6.io/k6/js/modules/k6/browser/issues/1470 // // This relies on PostDataEntries. It will only ever return the 0th entry. // TODO: Create a PostDataEntries API when we have a better idea of when that @@ -252,7 +252,7 @@ func (r *Request) PostData() string { // PostDataBuffer returns the request post data as an ArrayBuffer. // // If will not attempt to fetch the data if it should have some but nothing is -// cached locally: https://github.com/grafana/xk6-browser/issues/1470 +// cached locally: https://go.k6.io/k6/js/modules/k6/browser/issues/1470 // // This relies on PostDataEntries. It will only ever return the 0th entry. // TODO: Create a PostDataEntries API when we have a better idea of when that @@ -372,7 +372,7 @@ func NewHTTPResponse( r := Response{ ctx: ctx, // TODO: Pass an internal logger instead of basing it on k6's logger? - // See https://github.com/grafana/xk6-browser/issues/54 + // See https://go.k6.io/k6/js/modules/k6/browser/issues/54 logger: log.New(state.Logger, GetIterationID(ctx)), request: req, remoteAddress: &RemoteAddress{IPAddress: resp.RemoteIPAddress, Port: resp.RemotePort}, diff --git a/js/modules/k6/browser/common/http_test.go b/js/modules/k6/browser/common/http_test.go new file mode 100644 index 00000000000..7f770246b1a --- /dev/null +++ b/js/modules/k6/browser/common/http_test.go @@ -0,0 +1,126 @@ +package common + +import ( + "testing" + "time" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" +) + +func TestRequest(t *testing.T) { + t.Parallel() + + ts := cdp.MonotonicTime(time.Now()) + wt := cdp.TimeSinceEpoch(time.Now()) + headers := map[string]any{"key": "value"} + evt := &network.EventRequestWillBeSent{ + RequestID: network.RequestID("1234"), + Request: &network.Request{ + URL: "https://test/post", + Method: "POST", + Headers: network.Headers(headers), + PostDataEntries: []*network.PostDataEntry{{Bytes: "aGVsbG8="}}, // base64 encoded "hello" + }, + Timestamp: &ts, + WallTime: &wt, + } + vu := k6test.NewVU(t) + req, err := NewRequest(vu.Context(), NewRequestParams{ + event: evt, + interceptionID: "intercept", + }) + require.NoError(t, err) + + t.Run("error_parse_url", func(t *testing.T) { + t.Parallel() + + evt := &network.EventRequestWillBeSent{ + RequestID: network.RequestID("1234"), + Request: &network.Request{ + URL: ":", + Method: "POST", + Headers: network.Headers(headers), + PostDataEntries: []*network.PostDataEntry{{Bytes: "aGVsbG8="}}, // base64 encoded "hello" + }, + Timestamp: &ts, + WallTime: &wt, + } + vu := k6test.NewVU(t) + req, err := NewRequest(vu.Context(), NewRequestParams{ + event: evt, + interceptionID: "intercept", + }) + require.EqualError(t, err, `parsing URL ":": missing protocol scheme`) + require.Nil(t, req) + }) + + t.Run("Headers()", func(t *testing.T) { + t.Parallel() + assert.Equal(t, map[string]string{"key": "value"}, req.Headers()) + }) + + t.Run("HeadersArray()", func(t *testing.T) { + t.Parallel() + assert.Equal(t, []HTTPHeader{ + {Name: "key", Value: "value"}, + }, req.HeadersArray()) + }) + + t.Run("HeaderValue()_key", func(t *testing.T) { + t.Parallel() + got, ok := req.HeaderValue("key") + assert.True(t, ok) + assert.Equal(t, "value", got) + }) + + t.Run("HeaderValue()_KEY", func(t *testing.T) { + t.Parallel() + got, ok := req.HeaderValue("KEY") + assert.True(t, ok) + assert.Equal(t, "value", got) + }) + + t.Run("Size()", func(t *testing.T) { + t.Parallel() + assert.Equal(t, + HTTPMessageSize{Headers: int64(33), Body: int64(5)}, + req.Size()) + }) +} + +func TestResponse(t *testing.T) { + t.Parallel() + + ts := cdp.MonotonicTime(time.Now()) + headers := map[string]any{"key": "value"} + vu := k6test.NewVU(t) + vu.ActivateVU() + req := &Request{ + offset: 0, + } + res := NewHTTPResponse(vu.Context(), req, &network.Response{ + URL: "https://test/post", + Headers: network.Headers(headers), + }, &ts) + + t.Run("HeaderValue()_key", func(t *testing.T) { + t.Parallel() + + got, ok := res.HeaderValue("key") + assert.True(t, ok) + assert.Equal(t, "value", got) + }) + + t.Run("HeaderValue()_KEY", func(t *testing.T) { + t.Parallel() + + got, ok := res.HeaderValue("KEY") + assert.True(t, ok) + assert.Equal(t, "value", got) + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/js/actions.go b/js/modules/k6/browser/common/js/actions.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/actions.go rename to js/modules/k6/browser/common/js/actions.go diff --git a/vendor/github.com/grafana/xk6-browser/common/js/doc.go b/js/modules/k6/browser/common/js/doc.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/doc.go rename to js/modules/k6/browser/common/js/doc.go diff --git a/vendor/github.com/grafana/xk6-browser/common/js/embedded_scripts.go b/js/modules/k6/browser/common/js/embedded_scripts.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/embedded_scripts.go rename to js/modules/k6/browser/common/js/embedded_scripts.go diff --git a/vendor/github.com/grafana/xk6-browser/common/js/injected_script.js b/js/modules/k6/browser/common/js/injected_script.js similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/injected_script.js rename to js/modules/k6/browser/common/js/injected_script.js diff --git a/vendor/github.com/grafana/xk6-browser/common/js/query_all.js b/js/modules/k6/browser/common/js/query_all.js similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/query_all.js rename to js/modules/k6/browser/common/js/query_all.js diff --git a/vendor/github.com/grafana/xk6-browser/common/js/scroll_into_view.js b/js/modules/k6/browser/common/js/scroll_into_view.js similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/scroll_into_view.js rename to js/modules/k6/browser/common/js/scroll_into_view.js diff --git a/vendor/github.com/grafana/xk6-browser/common/js/selectors.go b/js/modules/k6/browser/common/js/selectors.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/selectors.go rename to js/modules/k6/browser/common/js/selectors.go diff --git a/vendor/github.com/grafana/xk6-browser/common/js/web_vital_iife.js b/js/modules/k6/browser/common/js/web_vital_iife.js similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/web_vital_iife.js rename to js/modules/k6/browser/common/js/web_vital_iife.js diff --git a/vendor/github.com/grafana/xk6-browser/common/js/web_vital_init.js b/js/modules/k6/browser/common/js/web_vital_init.js similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/js/web_vital_init.js rename to js/modules/k6/browser/common/js/web_vital_init.js diff --git a/vendor/github.com/grafana/xk6-browser/common/js_handle.go b/js/modules/k6/browser/common/js_handle.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/js_handle.go rename to js/modules/k6/browser/common/js_handle.go index 3b51b33593e..81dd913d631 100644 --- a/vendor/github.com/grafana/xk6-browser/common/js_handle.go +++ b/js/modules/k6/browser/common/js_handle.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/runtime" diff --git a/vendor/github.com/grafana/xk6-browser/common/keyboard.go b/js/modules/k6/browser/common/keyboard.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/keyboard.go rename to js/modules/k6/browser/common/keyboard.go index 255e03001ae..46aece9602c 100644 --- a/vendor/github.com/grafana/xk6-browser/common/keyboard.go +++ b/js/modules/k6/browser/common/keyboard.go @@ -10,7 +10,7 @@ import ( "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/input" - "github.com/grafana/xk6-browser/keyboardlayout" + "go.k6.io/k6/js/modules/k6/browser/keyboardlayout" ) const ( diff --git a/js/modules/k6/browser/common/keyboard_test.go b/js/modules/k6/browser/common/keyboard_test.go new file mode 100644 index 00000000000..3fbccfb8459 --- /dev/null +++ b/js/modules/k6/browser/common/keyboard_test.go @@ -0,0 +1,200 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + "go.k6.io/k6/js/modules/k6/browser/keyboardlayout" +) + +func TestSplit(t *testing.T) { + t.Parallel() + + type args struct { + keys string + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "empty slice on empty string", + args: args{ + keys: "", + }, + want: []string{""}, + }, + { + name: "empty slice on string without separator", + args: args{ + keys: "HelloWorld!", + }, + want: []string{"HelloWorld!"}, + }, + { + name: "string split with separator", + args: args{ + keys: "Hello+World+!", + }, + want: []string{"Hello", "World", "!"}, + }, + { + name: "do not split on single +", + args: args{ + keys: "+", + }, + want: []string{"+"}, + }, + { + name: "split ++ to + and ''", + args: args{ + keys: "++", + }, + want: []string{"+", ""}, + }, + { + name: "split +++ to + and +", + args: args{ + keys: "+++", + }, + want: []string{"+", "+"}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := split(tt.args.keys) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestKeyboardPress(t *testing.T) { + t.Parallel() + + t.Run("panics when '' empty key passed in", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + k := NewKeyboard(vu.Context(), nil) + require.Error(t, k.Press("", KeyboardOptions{})) + }) +} + +func TestKeyDefinitionCode(t *testing.T) { + t.Parallel() + + var ( + vu = k6test.NewVU(t) + k = NewKeyboard(vu.Context(), nil) + ) + + tests := []struct { + key keyboardlayout.KeyInput + expectedCodes []string + }{ + {key: "Escape", expectedCodes: []string{"Escape"}}, + {key: "F1", expectedCodes: []string{"F1"}}, + {key: "F2", expectedCodes: []string{"F2"}}, + {key: "F3", expectedCodes: []string{"F3"}}, + {key: "F4", expectedCodes: []string{"F4"}}, + {key: "F5", expectedCodes: []string{"F5"}}, + {key: "F6", expectedCodes: []string{"F6"}}, + {key: "F7", expectedCodes: []string{"F7"}}, + {key: "F8", expectedCodes: []string{"F8"}}, + {key: "F9", expectedCodes: []string{"F9"}}, + {key: "F10", expectedCodes: []string{"F10"}}, + {key: "F11", expectedCodes: []string{"F11"}}, + {key: "F12", expectedCodes: []string{"F12"}}, + {key: "`", expectedCodes: []string{"Backquote"}}, + {key: "-", expectedCodes: []string{"Minus", "NumpadSubtract"}}, + {key: "=", expectedCodes: []string{"Equal"}}, + {key: "\\", expectedCodes: []string{"Backslash"}}, + {key: "Backspace", expectedCodes: []string{"Backspace"}}, + {key: "Tab", expectedCodes: []string{"Tab"}}, + {key: "q", expectedCodes: []string{"KeyQ"}}, + {key: "w", expectedCodes: []string{"KeyW"}}, + {key: "e", expectedCodes: []string{"KeyE"}}, + {key: "r", expectedCodes: []string{"KeyR"}}, + {key: "t", expectedCodes: []string{"KeyT"}}, + {key: "y", expectedCodes: []string{"KeyY"}}, + {key: "u", expectedCodes: []string{"KeyU"}}, + {key: "i", expectedCodes: []string{"KeyI"}}, + {key: "o", expectedCodes: []string{"KeyO"}}, + {key: "p", expectedCodes: []string{"KeyP"}}, + {key: "[", expectedCodes: []string{"BracketLeft"}}, + {key: "]", expectedCodes: []string{"BracketRight"}}, + {key: "CapsLock", expectedCodes: []string{"CapsLock"}}, + {key: "a", expectedCodes: []string{"KeyA"}}, + {key: "s", expectedCodes: []string{"KeyS"}}, + {key: "d", expectedCodes: []string{"KeyD"}}, + {key: "f", expectedCodes: []string{"KeyF"}}, + {key: "g", expectedCodes: []string{"KeyG"}}, + {key: "h", expectedCodes: []string{"KeyH"}}, + {key: "j", expectedCodes: []string{"KeyJ"}}, + {key: "k", expectedCodes: []string{"KeyK"}}, + {key: "l", expectedCodes: []string{"KeyL"}}, + {key: ";", expectedCodes: []string{"Semicolon"}}, + {key: "'", expectedCodes: []string{"Quote"}}, + {key: "Shift", expectedCodes: []string{"ShiftLeft", "ShiftRight"}}, + {key: "z", expectedCodes: []string{"KeyZ"}}, + {key: "x", expectedCodes: []string{"KeyX"}}, + {key: "c", expectedCodes: []string{"KeyC"}}, + {key: "v", expectedCodes: []string{"KeyV"}}, + {key: "b", expectedCodes: []string{"KeyB"}}, + {key: "n", expectedCodes: []string{"KeyN"}}, + {key: "m", expectedCodes: []string{"KeyM"}}, + {key: ",", expectedCodes: []string{"Comma"}}, + {key: "/", expectedCodes: []string{"Slash", "NumpadDivide"}}, + {key: "Control", expectedCodes: []string{"ControlLeft", "ControlRight"}}, + {key: "Meta", expectedCodes: []string{"MetaLeft", "MetaRight"}}, + {key: "Alt", expectedCodes: []string{"AltLeft", "AltRight"}}, + {key: " ", expectedCodes: []string{"Space"}}, + {key: "AltGraph", expectedCodes: []string{"AltGraph"}}, + {key: "ConTextMenu", expectedCodes: []string{"ConTextMenu"}}, + {key: "PrintScreen", expectedCodes: []string{"PrintScreen"}}, + {key: "ScrollLock", expectedCodes: []string{"ScrollLock"}}, + {key: "Pause", expectedCodes: []string{"Pause"}}, + {key: "PageUp", expectedCodes: []string{"PageUp"}}, + {key: "PageDown", expectedCodes: []string{"PageDown"}}, + {key: "Insert", expectedCodes: []string{"Insert"}}, + {key: "Delete", expectedCodes: []string{"Delete"}}, + {key: "Home", expectedCodes: []string{"Home"}}, + {key: "End", expectedCodes: []string{"End"}}, + {key: "ArrowLeft", expectedCodes: []string{"ArrowLeft"}}, + {key: "ArrowUp", expectedCodes: []string{"ArrowUp"}}, + {key: "ArrowRight", expectedCodes: []string{"ArrowRight"}}, + {key: "ArrowDown", expectedCodes: []string{"ArrowDown"}}, + {key: "NumLock", expectedCodes: []string{"NumLock"}}, + {key: "*", expectedCodes: []string{"NumpadMultiply"}}, + {key: "7", expectedCodes: []string{"Numpad7", "Digit7"}}, + {key: "8", expectedCodes: []string{"Numpad8", "Digit8"}}, + {key: "9", expectedCodes: []string{"Numpad9", "Digit9"}}, + {key: "4", expectedCodes: []string{"Numpad4", "Digit4"}}, + {key: "5", expectedCodes: []string{"Numpad5", "Digit5"}}, + {key: "6", expectedCodes: []string{"Numpad6", "Digit6"}}, + {key: "+", expectedCodes: []string{"NumpadAdd"}}, + {key: "1", expectedCodes: []string{"Numpad1", "Digit1"}}, + {key: "2", expectedCodes: []string{"Numpad2", "Digit2"}}, + {key: "3", expectedCodes: []string{"Numpad3", "Digit3"}}, + {key: "0", expectedCodes: []string{"Numpad0", "Digit0"}}, + {key: ".", expectedCodes: []string{"NumpadDecimal", "Period"}}, + {key: "Enter", expectedCodes: []string{"NumpadEnter", "Enter"}}, + } + + for _, tt := range tests { + tt := tt + t.Run(string(tt.key), func(t *testing.T) { + t.Parallel() + + kd := k.keyDefinitionFromKey(tt.key) + assert.Contains(t, tt.expectedCodes, kd.Code) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/kill_linux.go b/js/modules/k6/browser/common/kill_linux.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/kill_linux.go rename to js/modules/k6/browser/common/kill_linux.go diff --git a/vendor/github.com/grafana/xk6-browser/common/kill_other.go b/js/modules/k6/browser/common/kill_other.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/kill_other.go rename to js/modules/k6/browser/common/kill_other.go diff --git a/vendor/github.com/grafana/xk6-browser/common/layout.go b/js/modules/k6/browser/common/layout.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/common/layout.go rename to js/modules/k6/browser/common/layout.go index 5c01525898a..80abe45b04b 100644 --- a/vendor/github.com/grafana/xk6-browser/common/layout.go +++ b/js/modules/k6/browser/common/layout.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) // Position represents a position. diff --git a/js/modules/k6/browser/common/layout_test.go b/js/modules/k6/browser/common/layout_test.go new file mode 100644 index 00000000000..29d13af9dc0 --- /dev/null +++ b/js/modules/k6/browser/common/layout_test.go @@ -0,0 +1,62 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// See: Issue #183 for details. +func TestViewportCalculateInset(t *testing.T) { + t.Parallel() + + t.Run("headless", func(t *testing.T) { + t.Parallel() + + headless, vp := true, Viewport{} + vp = vp.recalculateInset(headless, "any os") + assert.Equal(t, vp, Viewport{}, + "should not change the viewport if headless is true") + }) + + t.Run("headful", func(t *testing.T) { + t.Parallel() + + var ( + headless bool + vp Viewport + ) + vp = vp.recalculateInset(headless, "any os") + assert.NotEqual(t, vp, Viewport{}, + "should add the default inset to the viewport if the"+ + " operating system is unrecognized by the addInset.") + }) + + // should add a different inset to viewport than the default one + // if a recognized os is given. + for _, os := range []string{"windows", "linux", "darwin"} { + os := os + t.Run(os, func(t *testing.T) { + t.Parallel() + + var ( + headless bool + vp Viewport + ) + // add the default inset to the viewport + vp = vp.recalculateInset(headless, "any os") + defaultVp := vp + // add an os specific inset to the viewport + vp = vp.recalculateInset(headless, os) + + assert.NotEqual(t, vp, defaultVp, "inset for %q should exist", os) + // we multiply the default viewport by two to detect + // whether an os specific inset is adding the default + // viewport, instead of its own. + assert.NotEqual(t, vp, Viewport{ + Width: defaultVp.Width * 2, + Height: defaultVp.Height * 2, + }, "inset for %q should not be the same as the default one", os) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/lifecycle.go b/js/modules/k6/browser/common/lifecycle.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/lifecycle.go rename to js/modules/k6/browser/common/lifecycle.go diff --git a/js/modules/k6/browser/common/lifecycle_test.go b/js/modules/k6/browser/common/lifecycle_test.go new file mode 100644 index 00000000000..5d7d391e648 --- /dev/null +++ b/js/modules/k6/browser/common/lifecycle_test.go @@ -0,0 +1,76 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLifecycleEventMarshalText(t *testing.T) { + t.Parallel() + + t.Run("ok/nil", func(t *testing.T) { + t.Parallel() + + var evt *LifecycleEvent + m, err := evt.MarshalText() + require.NoError(t, err) + assert.Equal(t, []byte(""), m) + }) + + t.Run("err/invalid", func(t *testing.T) { + t.Parallel() + + evt := LifecycleEvent(-1) + _, err := evt.MarshalText() + require.EqualError(t, err, "invalid lifecycle event: -1") + }) +} + +func TestLifecycleEventMarshalTextRound(t *testing.T) { + t.Parallel() + + evt := LifecycleEventLoad + m, err := evt.MarshalText() + require.NoError(t, err) + assert.Equal(t, []byte("load"), m) + + var evt2 LifecycleEvent + err = evt2.UnmarshalText(m) + require.NoError(t, err) + assert.Equal(t, evt, evt2) +} + +func TestLifecycleEventUnmarshalText(t *testing.T) { + t.Parallel() + + t.Run("ok", func(t *testing.T) { + t.Parallel() + + var evt LifecycleEvent + err := evt.UnmarshalText([]byte("load")) + require.NoError(t, err) + assert.Equal(t, LifecycleEventLoad, evt) + }) + + t.Run("err/invalid", func(t *testing.T) { + t.Parallel() + + var evt LifecycleEvent + err := evt.UnmarshalText([]byte("none")) + require.EqualError(t, err, + `invalid lifecycle event: "none"; `+ + `must be one of: load, domcontentloaded, networkidle`) + }) + + t.Run("err/invalid_empty", func(t *testing.T) { + t.Parallel() + + var evt LifecycleEvent + err := evt.UnmarshalText([]byte("")) + require.EqualError(t, err, + `invalid lifecycle event: ""; `+ + `must be one of: load, domcontentloaded, networkidle`) + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/locator.go b/js/modules/k6/browser/common/locator.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/locator.go rename to js/modules/k6/browser/common/locator.go index 01e193b2cb3..09c70968cff 100644 --- a/vendor/github.com/grafana/xk6-browser/common/locator.go +++ b/js/modules/k6/browser/common/locator.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" ) // Strict mode: diff --git a/vendor/github.com/grafana/xk6-browser/common/mouse.go b/js/modules/k6/browser/common/mouse.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/mouse.go rename to js/modules/k6/browser/common/mouse.go diff --git a/vendor/github.com/grafana/xk6-browser/common/mouse_options.go b/js/modules/k6/browser/common/mouse_options.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/common/mouse_options.go rename to js/modules/k6/browser/common/mouse_options.go index 94a6a494fa8..bf99d4ca866 100644 --- a/vendor/github.com/grafana/xk6-browser/common/mouse_options.go +++ b/js/modules/k6/browser/common/mouse_options.go @@ -5,7 +5,7 @@ import ( "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) type MouseClickOptions struct { diff --git a/vendor/github.com/grafana/xk6-browser/common/network_manager.go b/js/modules/k6/browser/common/network_manager.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/network_manager.go rename to js/modules/k6/browser/common/network_manager.go index 6508c753b93..a74a3b7de14 100644 --- a/vendor/github.com/grafana/xk6-browser/common/network_manager.go +++ b/js/modules/k6/browser/common/network_manager.go @@ -11,9 +11,9 @@ import ( "sync" "time" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" k6modules "go.k6.io/k6/js/modules" k6lib "go.k6.io/k6/lib" @@ -98,7 +98,7 @@ func NewNetworkManager( BaseEventEmitter: NewBaseEventEmitter(ctx), ctx: ctx, // TODO: Pass an internal logger instead of basing it on k6's logger? - // See https://github.com/grafana/xk6-browser/issues/54 + // See https://go.k6.io/k6/js/modules/k6/browser/issues/54 logger: log.New(state.Logger, GetIterationID(ctx)), session: s, parent: parent, diff --git a/js/modules/k6/browser/common/network_manager_test.go b/js/modules/k6/browser/common/network_manager_test.go new file mode 100644 index 00000000000..07861162f04 --- /dev/null +++ b/js/modules/k6/browser/common/network_manager_test.go @@ -0,0 +1,395 @@ +package common + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + "go.k6.io/k6/js/modules/k6/browser/log" + + k6lib "go.k6.io/k6/lib" + k6mockresolver "go.k6.io/k6/lib/testutils/mockresolver" + k6types "go.k6.io/k6/lib/types" + k6metrics "go.k6.io/k6/metrics" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/fetch" + "github.com/chromedp/cdproto/network" + "github.com/mailru/easyjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const mockHostname = "host.test" + +type fakeSession struct { + session + cdpCalls []string +} + +// Execute implements the cdp.Executor interface to record calls made to it and +// allow assertions in tests. +func (s *fakeSession) Execute( + ctx context.Context, method string, params easyjson.Marshaler, res easyjson.Unmarshaler, +) error { + s.cdpCalls = append(s.cdpCalls, method) + return nil +} + +func newTestNetworkManager(t *testing.T, k6opts k6lib.Options) (*NetworkManager, *fakeSession) { + t.Helper() + + session := &fakeSession{ + session: &Session{ + id: "1234", + }, + } + + mr := k6mockresolver.New(map[string][]net.IP{ + mockHostname: { + net.ParseIP("127.0.0.10"), + net.ParseIP("127.0.0.11"), + net.ParseIP("127.0.0.12"), + net.ParseIP("2001:db8::10"), + net.ParseIP("2001:db8::11"), + net.ParseIP("2001:db8::12"), + }, + }) + + vu := k6test.NewVU(t) + vu.ActivateVU() + st := vu.State() + st.Options = k6opts + logger := log.New(st.Logger, "") + nm := &NetworkManager{ + ctx: vu.Context(), + logger: logger, + session: session, + resolver: mr, + vu: vu, + reqIDToRequest: map[network.RequestID]*Request{}, + } + + return nm, session +} + +func TestOnRequestPausedBlockedHostnames(t *testing.T) { + t.Parallel() + + testCases := []struct { + name, reqURL string + blockedHostnames, expCDPCalls []string + }{ + { + name: "ok_fail_simple", + blockedHostnames: []string{"*.test"}, + reqURL: fmt.Sprintf("http://%s/", mockHostname), + expCDPCalls: []string{"Fetch.failRequest"}, + }, + { + name: "ok_continue_simple", + blockedHostnames: []string{"*.test"}, + reqURL: "http://host.com/", + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + { + name: "ok_continue_empty", + blockedHostnames: nil, + reqURL: "http://host.com/", + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + { + name: "ok_continue_ip", + blockedHostnames: []string{"*.test"}, + reqURL: "http://127.0.0.1:8000/", + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + { + name: "err_url_continue", + blockedHostnames: []string{"*.test"}, + reqURL: ":::", + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + blocked, err := k6types.NewNullHostnameTrie(tc.blockedHostnames) + require.NoError(t, err) + + k6opts := k6lib.Options{BlockedHostnames: blocked} + nm, session := newTestNetworkManager(t, k6opts) + ev := &fetch.EventRequestPaused{ + RequestID: "1234", + Request: &network.Request{ + Method: "GET", + URL: tc.reqURL, + }, + } + + nm.onRequestPaused(ev) + + assert.Equal(t, tc.expCDPCalls, session.cdpCalls) + }) + } +} + +func TestOnRequestPausedBlockedIPs(t *testing.T) { + t.Parallel() + + testCases := []struct { + name, reqURL string + blockedIPs, expCDPCalls []string + }{ + { + name: "ok_fail_simple", + blockedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"}, + reqURL: "http://10.0.0.1:8000/", + expCDPCalls: []string{"Fetch.failRequest"}, + }, + { + name: "ok_fail_resolved_ip", + blockedIPs: []string{"127.0.0.10/32"}, + reqURL: fmt.Sprintf("http://%s/", mockHostname), + expCDPCalls: []string{"Fetch.failRequest"}, + }, + { + name: "ok_continue_resolved_ip", + blockedIPs: []string{"127.0.0.50/32"}, + reqURL: fmt.Sprintf("http://%s/", mockHostname), + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + { + name: "ok_continue_simple", + blockedIPs: []string{"127.0.0.0/8"}, + reqURL: "http://10.0.0.1:8000/", + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + { + name: "ok_continue_empty", + blockedIPs: nil, + reqURL: "http://127.0.0.1/", + expCDPCalls: []string{"Fetch.continueRequest"}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + blockedIPs := make([]*k6lib.IPNet, len(tc.blockedIPs)) + for i, ipcidr := range tc.blockedIPs { + ipnet, err := k6lib.ParseCIDR(ipcidr) + require.NoError(t, err) + blockedIPs[i] = ipnet + } + + k6opts := k6lib.Options{BlacklistIPs: blockedIPs} + nm, session := newTestNetworkManager(t, k6opts) + ev := &fetch.EventRequestPaused{ + RequestID: "1234", + Request: &network.Request{ + Method: "GET", + URL: tc.reqURL, + }, + } + + nm.onRequestPaused(ev) + + assert.Equal(t, tc.expCDPCalls, session.cdpCalls) + }) + } +} + +type MetricInterceptorMock struct{} + +func (m *MetricInterceptorMock) urlTagName(_ string, _ string) (string, bool) { + return "", false +} + +func TestNetworkManagerEmitRequestResponseMetricsTimingSkew(t *testing.T) { + t.Parallel() + + now := time.Now() + type tm struct{ ts, wt time.Time } + tests := []struct { + name string + req, res, wantReq, wantRes tm + }{ + { + name: "ok", + req: tm{ts: now, wt: now}, + res: tm{ts: now, wt: now}, + wantReq: tm{wt: now}, + wantRes: tm{wt: now}, + }, + { + name: "ok2", + req: tm{ts: now, wt: now}, + res: tm{ts: now.Add(time.Minute)}, + wantReq: tm{wt: now}, + wantRes: tm{wt: now.Add(time.Minute)}, + }, + { + name: "ts_past", + req: tm{ts: now.Add(-time.Hour), wt: now}, + res: tm{ts: now.Add(-time.Hour).Add(time.Minute)}, + wantReq: tm{wt: now}, + wantRes: tm{wt: now.Add(time.Minute)}, + }, + { + name: "ts_future", + req: tm{ts: now.Add(time.Hour), wt: now}, + res: tm{ts: now.Add(time.Hour).Add(time.Minute)}, + wantReq: tm{wt: now}, + wantRes: tm{wt: now.Add(time.Minute)}, + }, + { + name: "wt_past", + req: tm{ts: now, wt: now.Add(-time.Hour)}, + res: tm{ts: now.Add(time.Minute)}, + wantReq: tm{wt: now.Add(-time.Hour)}, + wantRes: tm{wt: now.Add(-time.Hour).Add(time.Minute)}, + }, + { + name: "wt_future", + req: tm{ts: now, wt: now.Add(time.Hour)}, + res: tm{ts: now.Add(time.Minute)}, + wantReq: tm{wt: now.Add(time.Hour)}, + wantRes: tm{wt: now.Add(time.Hour).Add(time.Minute)}, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + registry := k6metrics.NewRegistry() + k6m := k6ext.RegisterCustomMetrics(registry) + + var ( + vu = k6test.NewVU(t) + nm = &NetworkManager{ctx: vu.Context(), vu: vu, customMetrics: k6m, mi: &MetricInterceptorMock{}} + ) + vu.ActivateVU() + + req, err := NewRequest(vu.Context(), NewRequestParams{ + event: &network.EventRequestWillBeSent{ + Request: &network.Request{}, + Timestamp: (*cdp.MonotonicTime)(&tt.req.ts), + WallTime: (*cdp.TimeSinceEpoch)(&tt.req.wt), + }, + }) + require.NoError(t, err) + nm.emitRequestMetrics(req) + n := vu.AssertSamples(func(s k6metrics.Sample) { + assert.Equalf(t, tt.wantReq.wt, s.Time, "timing skew in %s", s.Metric.Name) + }) + assert.Equalf(t, 1, n, "should emit %d request metric", 1) + res := NewHTTPResponse(vu.Context(), req, + &network.Response{Timing: &network.ResourceTiming{}}, + (*cdp.MonotonicTime)(&tt.res.ts), + ) + nm.emitResponseMetrics(res, req) + n = vu.AssertSamples(func(s k6metrics.Sample) { + assert.Equalf(t, tt.wantRes.wt, s.Time, "timing skew in %s", s.Metric.Name) + }) + assert.Equalf(t, 3, n, "should emit 8 response metrics") + }) + } +} + +func TestRequestForOnLoadingFinished(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + ID network.RequestID + request *Request + parent *Request + want *Request + }{ + "non_nil_request": { + ID: "1234", + request: &Request{ + requestID: "1234", + documentID: "1234", + }, + want: &Request{ + requestID: "1234", + documentID: "1234", + }, + }, + "non_nil_request_with_parent": { + ID: "1234", + request: &Request{ + requestID: "1234", + documentID: "1234", + }, + parent: &Request{ + documentID: "3421", + }, + want: &Request{ + requestID: "1234", + documentID: "1234", + }, + }, + "nil_request": { + request: nil, + want: nil, + }, + "nil_request_with_non_nil_parent_with_matching_document_id": { + ID: "1234", + request: nil, + parent: &Request{ + requestID: "1234", + documentID: "1234", + }, + want: &Request{ + requestID: "1234", + documentID: "1234", + }, + }, + "nil_request_with_non_nil_parent_with_non_matching_document_id": { + ID: "1234", + request: nil, + parent: &Request{ + requestID: "1234", + documentID: "4321", + }, + want: nil, + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + + nm, _ := newTestNetworkManager(t, k6lib.Options{}) + nm.parent, _ = newTestNetworkManager(t, k6lib.Options{}) + + if tt.request != nil { + tt.request.requestID = tt.ID + nm.reqIDToRequest[tt.ID] = tt.request + } + if tt.parent != nil { + nm.parent.reqIDToRequest[tt.parent.requestID] = tt.parent + } + + r := nm.requestForOnLoadingFinished(tt.ID) + if tt.want == nil { + require.Nil(t, r) + return + } + require.NotNil(t, r) + assert.Equal(t, tt.want, r) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/network_profile.go b/js/modules/k6/browser/common/network_profile.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/network_profile.go rename to js/modules/k6/browser/common/network_profile.go diff --git a/vendor/github.com/grafana/xk6-browser/common/page.go b/js/modules/k6/browser/common/page.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/page.go rename to js/modules/k6/browser/common/page.go index 3442a115830..dc3d20a3e29 100644 --- a/vendor/github.com/grafana/xk6-browser/common/page.go +++ b/js/modules/k6/browser/common/page.go @@ -22,8 +22,8 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/xk6-browser/k6ext" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/log" k6modules "go.k6.io/k6/js/modules" ) diff --git a/vendor/github.com/grafana/xk6-browser/common/page_options.go b/js/modules/k6/browser/common/page_options.go similarity index 98% rename from vendor/github.com/grafana/xk6-browser/common/page_options.go rename to js/modules/k6/browser/common/page_options.go index d34865abc71..585c9389f3d 100644 --- a/vendor/github.com/grafana/xk6-browser/common/page_options.go +++ b/js/modules/k6/browser/common/page_options.go @@ -9,7 +9,7 @@ import ( "github.com/chromedp/cdproto/page" "github.com/grafana/sobek" - "github.com/grafana/xk6-browser/k6ext" + "go.k6.io/k6/js/modules/k6/browser/k6ext" ) type PageEmulateMediaOptions struct { diff --git a/js/modules/k6/browser/common/page_test.go b/js/modules/k6/browser/common/page_test.go new file mode 100644 index 00000000000..db2b040e6e8 --- /dev/null +++ b/js/modules/k6/browser/common/page_test.go @@ -0,0 +1,33 @@ +package common + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestPageLocator can be removed later on when we add integration +// tests. Since we don't yet have any of them, it makes sense to keep +// this test. +func TestPageLocator(t *testing.T) { + t.Parallel() + + const ( + wantMainFrameID = "1" + wantSelector = "span" + ) + ctx := context.TODO() + p := &Page{ + ctx: ctx, + frameManager: &FrameManager{ + ctx: ctx, + mainFrame: &Frame{id: wantMainFrameID, ctx: ctx}, + }, + } + l := p.Locator(wantSelector, nil) + assert.Equal(t, wantSelector, l.selector) + assert.Equal(t, wantMainFrameID, string(l.frame.id)) + + // other behavior will be tested via integration tests +} diff --git a/vendor/github.com/grafana/xk6-browser/common/remote_object.go b/js/modules/k6/browser/common/remote_object.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/remote_object.go rename to js/modules/k6/browser/common/remote_object.go index 5767c6e5713..d86daf8a691 100644 --- a/vendor/github.com/grafana/xk6-browser/common/remote_object.go +++ b/js/modules/k6/browser/common/remote_object.go @@ -10,7 +10,7 @@ import ( "strconv" "strings" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" "github.com/chromedp/cdproto/runtime" ) diff --git a/js/modules/k6/browser/common/remote_object_test.go b/js/modules/k6/browser/common/remote_object_test.go new file mode 100644 index 00000000000..089aa675a9f --- /dev/null +++ b/js/modules/k6/browser/common/remote_object_test.go @@ -0,0 +1,276 @@ +package common + +import ( + "encoding/json" + "math" + "testing" + + "github.com/chromedp/cdproto/runtime" + "github.com/mailru/easyjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" +) + +func TestValueFromRemoteObject(t *testing.T) { + t.Parallel() + + t.Run("unserializable value error", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + unserializableValue := runtime.UnserializableValue("a string instead") + remoteObject := &runtime.RemoteObject{ + Type: "number", + UnserializableValue: unserializableValue, + } + + arg, err := valueFromRemoteObject(vu.Context(), remoteObject) + require.Nil(t, arg) + require.ErrorIs(t, UnserializableValueError{unserializableValue}, err) + }) + + t.Run("bigint parsing error", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + unserializableValue := runtime.UnserializableValue("a string instead") + remoteObject := &runtime.RemoteObject{ + Type: "bigint", + UnserializableValue: unserializableValue, + } + + arg, err := valueFromRemoteObject(vu.Context(), remoteObject) + + require.Nil(t, arg) + assert.ErrorIs(t, UnserializableValueError{unserializableValue}, err) + }) + + t.Run("float64 unserializable values", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + unserializableValues := []struct { + value string + expected float64 + }{ + { + value: "-0", + expected: math.Float64frombits(0 | (1 << 63)), + }, + { + value: "Infinity", + expected: math.Inf(0), + }, + { + value: "-Infinity", + expected: math.Inf(-1), + }, + { + value: "NaN", + expected: math.NaN(), + }, + } + + for _, v := range unserializableValues { + remoteObject := &runtime.RemoteObject{ + Type: "number", + UnserializableValue: runtime.UnserializableValue(v.value), + } + arg, err := valueFromRemoteObject(vu.Context(), remoteObject) + require.NoError(t, err) + require.NotNil(t, arg) + require.IsType(t, float64(0), arg) + if v.value == "NaN" { + require.True(t, math.IsNaN(arg.(float64))) + } else { + require.Equal(t, v.expected, arg.(float64)) + } + } + }) + + t.Run("undefined", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + remoteObject := &runtime.RemoteObject{ + Type: runtime.TypeUndefined, + } + + arg, err := valueFromRemoteObject(vu.Context(), remoteObject) + require.NoError(t, err) + require.Nil(t, arg) + }) + + t.Run("null", func(t *testing.T) { + t.Parallel() + + vu := k6test.NewVU(t) + remoteObject := &runtime.RemoteObject{ + Type: runtime.TypeObject, + Subtype: runtime.SubtypeNull, + } + + arg, err := valueFromRemoteObject(vu.Context(), remoteObject) + require.NoError(t, err) + require.Nil(t, arg) + }) + + t.Run("primitive types", func(t *testing.T) { + t.Parallel() + + primitiveTypes := []struct { + typ runtime.Type + value any + }{ + { + typ: "number", + value: float64(777), // js numbers are float64 + }, + { + typ: "number", + value: float64(777.0), + }, + { + typ: "string", + value: "hello world", + }, + { + typ: "boolean", + value: true, + }, + } + + vu := k6test.NewVU(t) + for _, p := range primitiveTypes { + marshalled, _ := json.Marshal(p.value) + remoteObject := &runtime.RemoteObject{ + Type: p.typ, + Value: marshalled, + } + + arg, err := valueFromRemoteObject(vu.Context(), remoteObject) + + require.Nil(t, err) + require.IsType(t, p.value, arg) + } + }) + + t.Run("remote object with ID", func(t *testing.T) { + t.Parallel() + + remoteObjectID := runtime.RemoteObjectID("object_id_0123456789") + remoteObject := &runtime.RemoteObject{ + Type: "object", + Subtype: "node", + ObjectID: remoteObjectID, + Preview: &runtime.ObjectPreview{ + Properties: []*runtime.PropertyPreview{ + {Name: "num", Type: runtime.TypeNumber, Value: "1"}, + }, + }, + } + + vu := k6test.NewVU(t) + val, err := valueFromRemoteObject(vu.Context(), remoteObject) + require.NoError(t, err) + assert.Equal(t, map[string]any{"num": float64(1)}, val) + }) +} + +func TestParseRemoteObject(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + subtype string + preview *runtime.ObjectPreview + value easyjson.RawMessage + expected any + expErr string + }{ + { + name: "most_types", + preview: &runtime.ObjectPreview{ + Properties: []*runtime.PropertyPreview{ + {Name: "accessor", Type: runtime.TypeAccessor, Value: ""}, + {Name: "bigint", Type: runtime.TypeBigint, Value: "100n"}, + {Name: "bool", Type: runtime.TypeBoolean, Value: "true"}, + {Name: "fn", Type: runtime.TypeFunction, Value: ""}, + {Name: "num", Type: runtime.TypeNumber, Value: "1"}, + {Name: "str", Type: runtime.TypeString, Value: "string"}, + {Name: "strquot", Type: runtime.TypeString, Value: `"quoted string"`}, + {Name: "sym", Type: runtime.TypeSymbol, Value: "Symbol()"}, + }, + }, + expected: map[string]any{ + "accessor": "accessor", + "bigint": int64(100), + "bool": true, + "fn": "function()", + "num": float64(1), + "str": "string", + "strquot": "quoted string", + "sym": "Symbol()", + }, + }, + { + name: "nested", + preview: &runtime.ObjectPreview{ + Properties: []*runtime.PropertyPreview{ + // We don't actually get nested ObjectPreviews from CDP. + // I.e. the object `{nested: {one: 1}}` returns value "Object" + // for the "nested" property, with a nil *ValuePreview. :-/ + {Name: "nested", Type: runtime.TypeObject, Value: "Object"}, + }, + }, + expected: map[string]any{ + "nested": "Object", + }, + }, + { + name: "err_overflow", + preview: &runtime.ObjectPreview{Overflow: true}, + expected: map[string]any{}, + expErr: "object is too large and will be parsed partially", + }, + { + name: "err_parsing_property", + preview: &runtime.ObjectPreview{ + Properties: []*runtime.PropertyPreview{ + {Name: "failprop", Type: runtime.TypeObject, Value: "some"}, + }, + }, + expected: map[string]any{}, + expErr: "parsing object property", + }, + { + name: "null", + subtype: "null", + value: nil, + expected: nil, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + remoteObject := &runtime.RemoteObject{ + Type: "object", + Subtype: runtime.Subtype(tc.subtype), + ObjectID: runtime.RemoteObjectID("object_id_0123456789"), + Preview: tc.preview, + Value: tc.value, + } + val, err := parseRemoteObject(remoteObject) + assert.Equal(t, tc.expected, val) + if tc.expErr != "" { + assert.Contains(t, err.Error(), tc.expErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/common/screenshotter.go b/js/modules/k6/browser/common/screenshotter.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/screenshotter.go rename to js/modules/k6/browser/common/screenshotter.go index 035c17e2966..609fe97dc05 100644 --- a/vendor/github.com/grafana/xk6-browser/common/screenshotter.go +++ b/js/modules/k6/browser/common/screenshotter.go @@ -13,7 +13,7 @@ import ( "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/emulation" cdppage "github.com/chromedp/cdproto/page" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" ) // ScreenshotPersister is the type that all file persisters must implement. It's job is @@ -261,7 +261,7 @@ func getViewPortDimensions(ctx context.Context, sess session, logger *log.Logger "chrome browser returned nil on page.getLayoutMetrics, falling back to defaults for visualViewport "+ "(scale: %v, pageX: %v, pageY: %v)."+ "This is non-standard behavior, if possible please report this issue (with reproducible script) "+ - "to the https://github.com/grafana/xk6-browser/issues/1502.", + "to the https://go.k6.io/k6/js/modules/k6/browser/issues/1502.", visualViewportScale, visualViewportPageX, visualViewportPageY, ) } diff --git a/vendor/github.com/grafana/xk6-browser/common/selectors.go b/js/modules/k6/browser/common/selectors.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/selectors.go rename to js/modules/k6/browser/common/selectors.go diff --git a/vendor/github.com/grafana/xk6-browser/common/session.go b/js/modules/k6/browser/common/session.go similarity index 99% rename from vendor/github.com/grafana/xk6-browser/common/session.go rename to js/modules/k6/browser/common/session.go index caaa659b06d..2d544b5d3c1 100644 --- a/vendor/github.com/grafana/xk6-browser/common/session.go +++ b/js/modules/k6/browser/common/session.go @@ -9,7 +9,7 @@ import ( "github.com/chromedp/cdproto/target" "github.com/mailru/easyjson" - "github.com/grafana/xk6-browser/log" + "go.k6.io/k6/js/modules/k6/browser/log" ) // Session represents a CDP session to a target. diff --git a/js/modules/k6/browser/common/session_test.go b/js/modules/k6/browser/common/session_test.go new file mode 100644 index 00000000000..963f68de4fc --- /dev/null +++ b/js/modules/k6/browser/common/session_test.go @@ -0,0 +1,113 @@ +package common + +import ( + "context" + "fmt" + "net/url" + "testing" + + "go.k6.io/k6/js/modules/k6/browser/log" + "go.k6.io/k6/js/modules/k6/browser/tests/ws" + + "github.com/chromedp/cdproto" + "github.com/chromedp/cdproto/cdp" + cdppage "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/target" + "github.com/gorilla/websocket" + "github.com/mailru/easyjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionCreateSession(t *testing.T) { + t.Parallel() + + const ( + cdpTargetID = "target_id_0123456789" + cdpBrowserContextID = "browser_context_id_0123456789" + + targetAttachedToTargetEvent = ` + { + "sessionId": "session_id_0123456789", + "targetInfo": { + "targetId": "target_id_0123456789", + "type": "page", + "title": "", + "url": "about:blank", + "attached": true, + "browserContextId": "browser_context_id_0123456789" + }, + "waitingForDebugger": false + }` + + targetAttachedToTargetResult = ` + { + "sessionId":"session_id_0123456789" + } + ` + ) + + cmdsReceived := make([]cdproto.MethodType, 0) + handler := func(conn *websocket.Conn, msg *cdproto.Message, writeCh chan cdproto.Message, done chan struct{}) { + if msg.SessionID != "" && msg.Method != "" { + if msg.Method == cdproto.MethodType(cdproto.CommandPageEnable) { + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + } + close(done) // We're done after receiving the Page.enable command + } + } else if msg.Method != "" { + switch msg.Method { + case cdproto.MethodType(cdproto.CommandTargetSetDiscoverTargets): + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + Result: easyjson.RawMessage([]byte("{}")), + } + case cdproto.MethodType(cdproto.CommandTargetAttachToTarget): + writeCh <- cdproto.Message{ + Method: cdproto.EventTargetAttachedToTarget, + Params: easyjson.RawMessage([]byte(targetAttachedToTargetEvent)), + } + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + Result: easyjson.RawMessage([]byte(targetAttachedToTargetResult)), + } + } + } + } + + server := ws.NewServer(t, ws.WithCDPHandler("/cdp", handler, &cmdsReceived)) + + t.Run("send and recv session commands", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + url, _ := url.Parse(server.ServerHTTP.URL) + wsURL := fmt.Sprintf("ws://%s/cdp", url.Host) + conn, err := NewConnection(ctx, wsURL, log.NewNullLogger(), nil) + + if assert.NoError(t, err) { + session, err := conn.createSession(&target.Info{ + Type: "page", + TargetID: cdpTargetID, + BrowserContextID: cdpBrowserContextID, + }) + + if assert.NoError(t, err) { + action := cdppage.Enable() + err := action.Do(cdp.WithExecutor(ctx, session)) + + require.NoError(t, err) + require.Equal(t, []cdproto.MethodType{ + cdproto.CommandTargetAttachToTarget, + cdproto.CommandPageEnable, + }, cmdsReceived) + } + + conn.Close() + } + }) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/timeout.go b/js/modules/k6/browser/common/timeout.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/timeout.go rename to js/modules/k6/browser/common/timeout.go diff --git a/js/modules/k6/browser/common/timeout_test.go b/js/modules/k6/browser/common/timeout_test.go new file mode 100644 index 00000000000..a8e67cf2c77 --- /dev/null +++ b/js/modules/k6/browser/common/timeout_test.go @@ -0,0 +1,139 @@ +package common + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +//nolint:paralleltest // the linter is having problems, maybe this should just be dropped and we should just have separate functions +func TestTimeoutSettings(t *testing.T) { + t.Parallel() + + t.Run("TimeoutSettings.NewTimeoutSettings", func(t *testing.T) { + t.Parallel() + + t.Run("should work", testTimeoutSettingsNewTimeoutSettings) + t.Run("should work with parent", testTimeoutSettingsNewTimeoutSettingsWithParent) + }) + t.Run("TimeoutSettings.setDefaultTimeout", func(t *testing.T) { + t.Parallel() + + t.Run("should work", testTimeoutSettingsSetDefaultTimeout) + }) + t.Run("TimeoutSettings.setDefaultNavigationTimeout", func(t *testing.T) { + t.Parallel() + + t.Run("should work", testTimeoutSettingsSetDefaultNavigationTimeout) + }) + t.Run("TimeoutSettings.navigationTimeout", func(t *testing.T) { + t.Parallel() + + t.Run("should work", testTimeoutSettingsNavigationTimeout) + t.Run("should work with parent", testTimeoutSettingsNavigationTimeoutWithParent) + }) + t.Run("TimeoutSettings.timeout", func(t *testing.T) { + t.Parallel() + + t.Run("should work", testTimeoutSettingsTimeout) + t.Run("should work with parent", testTimeoutSettingsTimeoutWithParent) + }) +} + +func testTimeoutSettingsNewTimeoutSettings(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + assert.Nil(t, ts.parent) + assert.Nil(t, ts.defaultTimeout) + assert.Nil(t, ts.defaultNavigationTimeout) +} + +func testTimeoutSettingsNewTimeoutSettingsWithParent(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + tsWithParent := NewTimeoutSettings(ts) + assert.Equal(t, ts, tsWithParent.parent) + assert.Nil(t, tsWithParent.defaultTimeout) + assert.Nil(t, tsWithParent.defaultNavigationTimeout) +} + +func testTimeoutSettingsSetDefaultTimeout(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + ts.setDefaultTimeout(time.Duration(100) * time.Millisecond) + assert.Equal(t, int64(100), ts.defaultTimeout.Milliseconds()) +} + +func testTimeoutSettingsSetDefaultNavigationTimeout(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + ts.setDefaultNavigationTimeout(time.Duration(100) * time.Millisecond) + assert.Equal(t, int64(100), ts.defaultNavigationTimeout.Milliseconds()) +} + +func testTimeoutSettingsNavigationTimeout(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + + // Assert default timeout value is used + assert.Equal(t, DefaultTimeout, ts.navigationTimeout()) + + // Assert custom default timeout is used + ts.setDefaultNavigationTimeout(time.Duration(100) * time.Millisecond) + assert.Equal(t, int64(100), ts.navigationTimeout().Milliseconds()) +} + +func testTimeoutSettingsNavigationTimeoutWithParent(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + tsWithParent := NewTimeoutSettings(ts) + + // Assert default timeout value is used + assert.Equal(t, DefaultTimeout, tsWithParent.navigationTimeout()) + + // Assert custom default timeout from parent is used + ts.setDefaultNavigationTimeout(time.Duration(1000) * time.Millisecond) + assert.Equal(t, int64(1000), tsWithParent.navigationTimeout().Milliseconds()) + + // Assert custom default timeout is used (over parent) + tsWithParent.setDefaultNavigationTimeout(time.Duration(100) * time.Millisecond) + assert.Equal(t, int64(100), tsWithParent.navigationTimeout().Milliseconds()) +} + +func testTimeoutSettingsTimeout(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + + // Assert default timeout value is used + assert.Equal(t, DefaultTimeout, ts.timeout()) + + // Assert custom default timeout is used + ts.setDefaultTimeout(time.Duration(100) * time.Millisecond) + assert.Equal(t, int64(100), ts.timeout().Milliseconds()) +} + +func testTimeoutSettingsTimeoutWithParent(t *testing.T) { + t.Parallel() + + ts := NewTimeoutSettings(nil) + tsWithParent := NewTimeoutSettings(ts) + + // Assert default timeout value is used + assert.Equal(t, DefaultTimeout, tsWithParent.timeout()) + + // Assert custom default timeout from parent is used + ts.setDefaultTimeout(time.Duration(1000) * time.Millisecond) + assert.Equal(t, int64(1000), tsWithParent.timeout().Milliseconds()) + + // Assert custom default timeout is used (over parent) + tsWithParent.setDefaultTimeout(time.Duration(100) * time.Millisecond) + assert.Equal(t, int64(100), tsWithParent.timeout().Milliseconds()) +} diff --git a/vendor/github.com/grafana/xk6-browser/common/touchscreen.go b/js/modules/k6/browser/common/touchscreen.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/touchscreen.go rename to js/modules/k6/browser/common/touchscreen.go diff --git a/vendor/github.com/grafana/xk6-browser/common/trace.go b/js/modules/k6/browser/common/trace.go similarity index 97% rename from vendor/github.com/grafana/xk6-browser/common/trace.go rename to js/modules/k6/browser/common/trace.go index fea5edd2742..1ce5ed10cce 100644 --- a/vendor/github.com/grafana/xk6-browser/common/trace.go +++ b/js/modules/k6/browser/common/trace.go @@ -6,7 +6,7 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" - browsertrace "github.com/grafana/xk6-browser/trace" + browsertrace "go.k6.io/k6/js/modules/k6/browser/trace" ) // Tracer defines the interface with the tracing methods used in the common package. diff --git a/vendor/github.com/grafana/xk6-browser/common/worker.go b/js/modules/k6/browser/common/worker.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/common/worker.go rename to js/modules/k6/browser/common/worker.go diff --git a/vendor/github.com/grafana/xk6-browser/env/env.go b/js/modules/k6/browser/env/env.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/env/env.go rename to js/modules/k6/browser/env/env.go diff --git a/vendor/github.com/grafana/xk6-browser/k6error/internal.go b/js/modules/k6/browser/k6error/internal.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/k6error/internal.go rename to js/modules/k6/browser/k6error/internal.go diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/context.go b/js/modules/k6/browser/k6ext/context.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/k6ext/context.go rename to js/modules/k6/browser/k6ext/context.go diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/doc.go b/js/modules/k6/browser/k6ext/doc.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/k6ext/doc.go rename to js/modules/k6/browser/k6ext/doc.go diff --git a/js/modules/k6/browser/k6ext/k6test/doc.go b/js/modules/k6/browser/k6ext/k6test/doc.go new file mode 100644 index 00000000000..125b2c77dce --- /dev/null +++ b/js/modules/k6/browser/k6ext/k6test/doc.go @@ -0,0 +1,2 @@ +// Package k6test provides mock implementations of k6 elements for testing purposes. +package k6test diff --git a/js/modules/k6/browser/k6ext/k6test/executor.go b/js/modules/k6/browser/k6ext/k6test/executor.go new file mode 100644 index 00000000000..e5edec08fcb --- /dev/null +++ b/js/modules/k6/browser/k6ext/k6test/executor.go @@ -0,0 +1,34 @@ +package k6test + +import ( + "github.com/sirupsen/logrus" + + k6lib "go.k6.io/k6/lib" + k6executor "go.k6.io/k6/lib/executor" +) + +// TestExecutor is a k6lib.ExecutorConfig implementation +// for testing purposes. +type TestExecutor struct { + k6executor.BaseConfig +} + +// GetDescription returns a mock Executor description. +func (te *TestExecutor) GetDescription(*k6lib.ExecutionTuple) string { + return "TestExecutor" +} + +// GetExecutionRequirements is a dummy implementation that just returns nil. +func (te *TestExecutor) GetExecutionRequirements(*k6lib.ExecutionTuple) []k6lib.ExecutionStep { + return nil +} + +// NewExecutor is a dummy implementation that just returns nil. +func (te *TestExecutor) NewExecutor(*k6lib.ExecutionState, *logrus.Entry) (k6lib.Executor, error) { + return nil, nil //nolint:nilnil +} + +// HasWork is a dummy implementation that returns true. +func (te *TestExecutor) HasWork(*k6lib.ExecutionTuple) bool { + return true +} diff --git a/js/modules/k6/browser/k6ext/k6test/vu.go b/js/modules/k6/browser/k6ext/k6test/vu.go new file mode 100644 index 00000000000..83518adf708 --- /dev/null +++ b/js/modules/k6/browser/k6ext/k6test/vu.go @@ -0,0 +1,249 @@ +package k6test + +import ( + "context" + "fmt" + "testing" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/require" + "gopkg.in/guregu/null.v3" + + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext" + + "go.k6.io/k6/event" + k6common "go.k6.io/k6/js/common" + "go.k6.io/k6/js/eventloop" + k6modulestest "go.k6.io/k6/js/modulestest" + "go.k6.io/k6/lib" + k6executor "go.k6.io/k6/lib/executor" + k6testutils "go.k6.io/k6/lib/testutils" + k6trace "go.k6.io/k6/lib/trace" + k6metrics "go.k6.io/k6/metrics" +) + +// VU is a k6 VU instance. +// TODO: Do we still need this VU wrapper? +// ToSobekValue can be a helper function that takes a sobek.Runtime (although it's +// not much of a helper from calling ToValue(i) directly...), and we can access +// EventLoop from modulestest.Runtime.EventLoop. +type VU struct { + *k6modulestest.VU + Loop *eventloop.EventLoop + toBeState *lib.State + samples chan k6metrics.SampleContainer + TestRT *k6modulestest.Runtime +} + +// ToSobekValue is a convenience method for converting any value to a sobek value. +func (v *VU) ToSobekValue(i any) sobek.Value { return v.Runtime().ToValue(i) } + +// ActivateVU mimicks activation of the VU as in k6. +// It transitions the VU from the init stage to the execution stage by +// setting the VU's state to the state that was passed to NewVU. +func (v *VU) ActivateVU() { + v.VU.StateField = v.toBeState + v.VU.InitEnvField = nil +} + +// AssertSamples asserts each sample VU received since AssertSamples +// is last called, then it returns the number of received samples. +func (v *VU) AssertSamples(assertSample func(s k6metrics.Sample)) int { + var n int + for _, bs := range k6metrics.GetBufferedSamples(v.samples) { + for _, s := range bs.GetSamples() { + assertSample(s) + n++ + } + } + return n +} + +// WithScenarioName is used to set the scenario name in the IterData +// for the 'IterStart' event. +type WithScenarioName = string + +// WithVUID is used to set the VU id in the IterData for the 'IterStart' +// event. +type WithVUID = uint64 + +// WithIteration is used to set the iteration in the IterData for the +// 'IterStart' event. +type WithIteration = int64 + +// StartIteration generates a new IterStart event through the VU event system. +// +// opts can be used to parameterize the iteration data such as: +// - WithScenarioName: sets the scenario name (default is 'default'). +// - WithVUID: sets the VUID (default 1). +// - WithIteration: sets the iteration (default 0). +func (v *VU) StartIteration(tb testing.TB, opts ...any) { + tb.Helper() + v.iterEvent(tb, event.IterStart, "IterStart", opts...) +} + +// EndIteration generates a new IterEnd event through the VU event system. +// +// opts can be used to parameterize the iteration data such as: +// - WithScenarioName: sets the scenario name (default is 'default'). +// - WithVUID: sets the VUID (default 1). +// - WithIteration: sets the iteration (default 0). +func (v *VU) EndIteration(tb testing.TB, opts ...any) { + tb.Helper() + v.iterEvent(tb, event.IterEnd, "IterEnd", opts...) +} + +// iterEvent generates an iteration event for the VU. +func (v *VU) iterEvent(tb testing.TB, eventType event.Type, eventName string, opts ...any) { + tb.Helper() + + data := event.IterData{ + Iteration: 0, + VUID: 1, + ScenarioName: "default", + } + + for _, opt := range opts { + switch opt := opt.(type) { + case WithScenarioName: + data.ScenarioName = opt + case WithVUID: + data.VUID = opt + case WithIteration: + data.Iteration = opt + } + } + + events, ok := v.EventsField.Local.(*event.System) + require.True(tb, ok, "want *event.System; got %T", events) + waitDone := events.Emit(&event.Event{ + Type: eventType, + Data: data, + }) + require.NoError(tb, waitDone(context.Background()), "error waiting on %s done", eventName) +} + +// RunOnEventLoop runs the given JavaScript code on the VU's event loop and +// returns the result as a sobek.Value. +func (v *VU) RunOnEventLoop(tb testing.TB, js string, args ...any) (sobek.Value, error) { + tb.Helper() + + return v.TestRT.RunOnEventLoop(fmt.Sprintf(js, args...)) +} + +// RunAsync runs the given JavaScript code on the VU's event loop and returns +// the result as a sobek.Value. +func (v *VU) RunAsync(tb testing.TB, js string, args ...any) (sobek.Value, error) { + tb.Helper() + + jsWithArgs := fmt.Sprintf(js, args...) + + return v.RunOnEventLoop(tb, "(async function() { %s })();", jsWithArgs) +} + +// RunPromise runs the given JavaScript code on the VU's event loop and returns +// the result as a *sobek.Promise. +func (v *VU) RunPromise(tb testing.TB, js string, args ...any) *sobek.Promise { + tb.Helper() + + gv, err := v.RunAsync(tb, js, args...) + require.NoError(tb, err, "running promise on event loop") + return ToPromise(tb, gv) +} + +// SetVar sets a variable in the VU's sobek runtime's global scope. +func (v *VU) SetVar(tb testing.TB, name string, value any) { + tb.Helper() + + err := v.TestRT.VU.Runtime().GlobalObject().Set(name, value) + require.NoError(tb, err, "setting variable %q to %v", name, value) +} + +// ToPromise asserts and returns a sobek.Value as a *sobek.Promise. +func ToPromise(tb testing.TB, gv sobek.Value) *sobek.Promise { + tb.Helper() + + p, ok := gv.Export().(*sobek.Promise) + require.True(tb, ok, "got: %T, want *sobek.Promise", gv.Export()) + return p +} + +// WithSamples is used to indicate we want to use a bidirectional channel +// so that the test can read the metrics being emitted to the channel. +type WithSamples chan k6metrics.SampleContainer + +// WithTracerProvider allows to set the VU TracerProvider. +type WithTracerProvider lib.TracerProvider + +// NewVU returns a mock k6 VU. +// +// opts can be one of the following: +// - WithSamples: a bidirectional channel that will be used to emit metrics. +// - env.LookupFunc: a lookup function that will be used to lookup environment variables. +// - WithTracerProvider: a TracerProvider that will be set as the VU TracerProvider. +func NewVU(tb testing.TB, opts ...any) *VU { + tb.Helper() + + var ( + samples = make(chan k6metrics.SampleContainer, 1000) + lookupFunc = env.EmptyLookup + tracerProvider lib.TracerProvider = k6trace.NewNoopTracerProvider() + ) + for _, opt := range opts { + switch opt := opt.(type) { + case WithSamples: + samples = opt + case env.LookupFunc: + lookupFunc = opt + case WithTracerProvider: + tracerProvider = opt + } + } + + logger := k6testutils.NewLogger(tb) + + testRT := k6modulestest.NewRuntime(tb) + testRT.VU.InitEnvField.LookupEnv = lookupFunc + testRT.VU.EventsField = k6common.Events{ + Global: event.NewEventSystem(100, logger), + Local: event.NewEventSystem(100, logger), + } + + state := &lib.State{ + Options: lib.Options{ + MaxRedirects: null.IntFrom(10), + UserAgent: null.StringFrom("TestUserAgent"), + Throw: null.BoolFrom(true), + SystemTags: &k6metrics.DefaultSystemTagSet, + Batch: null.IntFrom(20), + BatchPerHost: null.IntFrom(20), + // HTTPDebug: null.StringFrom("full"), + Scenarios: lib.ScenarioConfigs{ + "default": &TestExecutor{ + BaseConfig: k6executor.BaseConfig{ + Options: &lib.ScenarioOptions{ + Browser: map[string]any{ + "type": "chromium", + }, + }, + }, + }, + }, + }, + Logger: logger, + BufferPool: lib.NewBufferPool(), + Samples: samples, + Tags: lib.NewVUStateTags( + testRT.VU.InitEnvField.Registry.RootTagSet().With("group", lib.RootGroupPath), + ), + BuiltinMetrics: k6metrics.RegisterBuiltinMetrics(k6metrics.NewRegistry()), + TracerProvider: tracerProvider, + } + + ctx := k6ext.WithVU(testRT.VU.CtxField, testRT.VU) + ctx = lib.WithScenarioState(ctx, &lib.ScenarioState{Name: "default"}) + testRT.VU.CtxField = ctx + + return &VU{VU: testRT.VU, Loop: testRT.EventLoop, toBeState: state, samples: samples, TestRT: testRT} +} diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/metrics.go b/js/modules/k6/browser/k6ext/metrics.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/k6ext/metrics.go rename to js/modules/k6/browser/k6ext/metrics.go diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/panic.go b/js/modules/k6/browser/k6ext/panic.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/k6ext/panic.go rename to js/modules/k6/browser/k6ext/panic.go diff --git a/vendor/github.com/grafana/xk6-browser/k6ext/promise.go b/js/modules/k6/browser/k6ext/promise.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/k6ext/promise.go rename to js/modules/k6/browser/k6ext/promise.go diff --git a/vendor/github.com/grafana/xk6-browser/keyboardlayout/layout.go b/js/modules/k6/browser/keyboardlayout/layout.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/keyboardlayout/layout.go rename to js/modules/k6/browser/keyboardlayout/layout.go diff --git a/vendor/github.com/grafana/xk6-browser/keyboardlayout/us.go b/js/modules/k6/browser/keyboardlayout/us.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/keyboardlayout/us.go rename to js/modules/k6/browser/keyboardlayout/us.go diff --git a/vendor/github.com/grafana/xk6-browser/log/logger.go b/js/modules/k6/browser/log/logger.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/log/logger.go rename to js/modules/k6/browser/log/logger.go diff --git a/js/modules/k6/browser/packaging/full-white-stripe.jpg b/js/modules/k6/browser/packaging/full-white-stripe.jpg new file mode 100644 index 00000000000..6724de2b96e Binary files /dev/null and b/js/modules/k6/browser/packaging/full-white-stripe.jpg differ diff --git a/js/modules/k6/browser/packaging/nfpm.yaml b/js/modules/k6/browser/packaging/nfpm.yaml new file mode 100644 index 00000000000..144c827e8c8 --- /dev/null +++ b/js/modules/k6/browser/packaging/nfpm.yaml @@ -0,0 +1,21 @@ +name: "xk6-browser" +arch: "${GOARCH}" +platform: "linux" +version: "${VERSION}" +version_schema: semver +section: "default" +maintainer: "Raintank Inc. d.b.a. Grafana Labs" +description: | + Load testing for the 21st century. +depends: +- ca-certificates +homepage: "https://k6.io" +license: "AGPL-3.0" +contents: +- src: ./xk6-browser + dst: /usr/bin/xk6-browser + +deb: + compression: xz + fields: + Bugs: https://github.com/grafana/xk6-browser/issues diff --git a/js/modules/k6/browser/packaging/thin-white-stripe.jpg b/js/modules/k6/browser/packaging/thin-white-stripe.jpg new file mode 100644 index 00000000000..5eaa9c374e9 Binary files /dev/null and b/js/modules/k6/browser/packaging/thin-white-stripe.jpg differ diff --git a/js/modules/k6/browser/packaging/xk6-browser.ico b/js/modules/k6/browser/packaging/xk6-browser.ico new file mode 100644 index 00000000000..ee720d31d02 Binary files /dev/null and b/js/modules/k6/browser/packaging/xk6-browser.ico differ diff --git a/js/modules/k6/browser/packaging/xk6-browser.wxs b/js/modules/k6/browser/packaging/xk6-browser.wxs new file mode 100644 index 00000000000..d3bb9f768e6 --- /dev/null +++ b/js/modules/k6/browser/packaging/xk6-browser.wxs @@ -0,0 +1,48 @@ + +