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(` +
Hello world
+ +
Edit me
+ + + + + `); + + // Check state + await check(page, { + 'is visible': async p => { + const e = await p.$('.visible'); + return await e.isVisible(); + }, + 'is hidden': async p => { + const e = await p.$('.hidden'); + return await e.isHidden() + }, + 'is editable': async p => { + const e = await p.$('.editable'); + return await e.isEditable(); + }, + 'is enabled': async p => { + const e = await p.$('.enabled'); + return await e.isEnabled(); + }, + 'is disabled': async p => { + const e = await p.$('.disabled'); + return await e.isDisabled(); + }, + 'is checked': async p => { + const e = await p.$('.checked'); + return await e.isChecked(); + }, + 'is unchecked': async p => { + const e = await p.$('.unchecked'); + return !await e.isChecked(); + } + }); + + // Change state and check again + await check(page, { + 'is unchecked checked': async p => { + const e = await p.$(".unchecked"); + await e.setChecked(true); + return e.isChecked(); + }, + 'is checked unchecked': async p => { + const e = await p.$(".checked"); + await e.setChecked(false); + return !await e.isChecked(); + } + }); + + await page.close(); +} diff --git a/examples/browser/evaluate.js b/examples/browser/evaluate.js new file mode 100644 index 00000000000..6f60b610cd0 --- /dev/null +++ b/examples/browser/evaluate.js @@ -0,0 +1,45 @@ +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: "load" }); + + // calling evaluate without arguments + await check(page, { + 'result should be 210': async p => { + const n = await p.evaluate(() => 5 * 42); + return n == 210; + } + }); + + // calling evaluate with arguments + await check(page, { + 'result should be 25': async p => { + const n = await p.evaluate((x, y) => x * y, 5, 5); + return n == 25; + } + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser.js b/examples/browser/fillform.js similarity index 67% rename from examples/browser.js rename to examples/browser/fillform.js index d96518677d4..14ce7e6b414 100644 --- a/examples/browser.js +++ b/examples/browser/fillform.js @@ -1,5 +1,5 @@ -import { check } from 'k6'; import { browser } from 'k6/browser'; +import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; export const options = { scenarios: { @@ -28,9 +28,11 @@ export default async function() { 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="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. @@ -38,9 +40,19 @@ export default async function() { page.waitForNavigation(), page.locator('input[type="submit"]').click(), ]); - const content = await page.locator("h2").textContent(); - check(content, { - 'header': content => content == 'Welcome, admin!', + + await check(page.locator('h2'), { + 'header': async lo => { + return await lo.textContent() == 'Welcome, admin!' + } + }); + + // Check whether we receive cookies from the logged site. + await check(context, { + 'session cookie is set': async ctx => { + const cookies = await ctx.cookies(); + return cookies.find(c => c.name == 'sid') !== undefined; + } }); } finally { await page.close(); diff --git a/examples/browser/getattribute.js b/examples/browser/getattribute.js new file mode 100644 index 00000000000..57dcbc1e31c --- /dev/null +++ b/examples/browser/getattribute.js @@ -0,0 +1,37 @@ +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://googlechromelabs.github.io/dark-mode-toggle/demo/', { + waitUntil: 'load', + }); + await check(page, { + "GetAttribute('mode')": async p => { + const e = await p.$('#dark-mode-toggle-3'); + return await e.getAttribute('mode') === 'light'; + } + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/grant_permission.js b/examples/browser/grant_permission.js new file mode 100644 index 00000000000..161adf71b10 --- /dev/null +++ b/examples/browser/grant_permission.js @@ -0,0 +1,35 @@ +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() { + // grant camera and microphone permissions to the + // new browser context. + const context = await browser.newContext({ + permissions: ["camera", "microphone"], + }); + + const page = await context.newPage(); + + try { + await page.goto('https://test.k6.io/'); + await page.screenshot({ path: `example-chromium.png` }); + await context.clearPermissions(); + } finally { + await page.close(); + } +} diff --git a/examples/browser/hosts.js b/examples/browser/hosts.js new file mode 100644 index 00000000000..66ae8a18de1 --- /dev/null +++ b/examples/browser/hosts.js @@ -0,0 +1,35 @@ +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', + }, + }, + }, + }, + hosts: { 'test.k6.io': '127.0.0.254' }, + thresholds: { + checks: ["rate==1.0"] + } +}; + +export default async function() { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + const res = await page.goto('http://test.k6.io/', { + waitUntil: 'load' + }); + await check(res, { + 'null response': r => r === null, + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/keyboard.js b/examples/browser/keyboard.js new file mode 100644 index 00000000000..fccb7cd9843 --- /dev/null +++ b/examples/browser/keyboard.js @@ -0,0 +1,33 @@ +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/my_messages.php', { waitUntil: 'networkidle' }); + + const userInput = page.locator('input[name="login"]'); + await userInput.click(); + await page.keyboard.type("admin"); + + const pwdInput = page.locator('input[name="password"]'); + await pwdInput.click(); + await page.keyboard.type("123"); + + await page.keyboard.press('Enter'); // submit + await page.waitForNavigation(); + + await page.close(); +} diff --git a/examples/browser/locator.js b/examples/browser/locator.js new file mode 100644 index 00000000000..ce338de03fa --- /dev/null +++ b/examples/browser/locator.js @@ -0,0 +1,74 @@ +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 { + await page.goto("https://test.k6.io/flip_coin.php", { + waitUntil: "networkidle", + }) + + /* + In this example, we will use two locators, matching a + different betting button on the page. If you were to query + the buttons once and save them as below, you would see an + error after the initial navigation. Try it! + + const heads = page.$("input[value='Bet on heads!']"); + const tails = page.$("input[value='Bet on tails!']"); + + The Locator API allows you to get a fresh element handle each + time you use one of the locator methods. And, you can carry a + locator across frame navigations. Let's create two locators; + each locates a button on the page. + */ + const heads = page.locator("input[value='Bet on heads!']"); + const tails = page.locator("input[value='Bet on tails!']"); + + const currentBet = page.locator("//p[starts-with(text(),'Your bet: ')]"); + + // In the following Promise.all the tails locator clicks + // on the tails button by using the locator's selector. + // Since clicking on each button causes page navigation, + // waitForNavigation is needed -- this is because the page + // won't be ready until the navigation completes. + // Setting up the waitForNavigation first before the click + // is important to avoid race conditions. + await Promise.all([ + page.waitForNavigation(), + tails.click(), + ]); + console.log(await currentBet.innerText()); + // the heads locator clicks on the heads button + // by using the locator's selector. + await Promise.all([ + page.waitForNavigation(), + heads.click(), + ]); + console.log(await currentBet.innerText()); + await Promise.all([ + page.waitForNavigation(), + tails.click(), + ]); + console.log(await currentBet.innerText()); + } finally { + await page.close(); + } +} diff --git a/examples/browser/locator_pom.js b/examples/browser/locator_pom.js new file mode 100644 index 00000000000..4164536b98d --- /dev/null +++ b/examples/browser/locator_pom.js @@ -0,0 +1,77 @@ +import { browser } from 'k6/browser'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + checks: ["rate==1.0"] + } +} + +/* +Page Object Model is a well-known pattern to abstract a web page. + +The Locator API enables using the Page Object Model pattern to organize +and simplify test code. + +Note: For comparison, you can see another example that does not use +the Page Object Model pattern in locator.js. +*/ +export class Bet { + constructor(page) { + this.page = page; + this.headsButton = page.locator("input[value='Bet on heads!']"); + this.tailsButton = page.locator("input[value='Bet on tails!']"); + this.currentBet = page.locator("//p[starts-with(text(),'Your bet: ')]"); + } + + goto() { + return this.page.goto("https://test.k6.io/flip_coin.php", { waitUntil: "networkidle" }); + } + + heads() { + return Promise.all([ + this.page.waitForNavigation(), + this.headsButton.click(), + ]); + } + + tails() { + return Promise.all([ + this.page.waitForNavigation(), + this.tailsButton.click(), + ]); + } + + current() { + return this.currentBet.innerText(); + } +} + +export default async function() { + const context = await browser.newContext(); + const page = await context.newPage(); + + const bet = new Bet(page); + try { + await bet.goto() + await bet.tails(); + console.log("Current bet:", await bet.current()); + await bet.heads(); + console.log("Current bet:", await bet.current()); + await bet.tails(); + console.log("Current bet:", await bet.current()); + await bet.heads(); + console.log("Current bet:", await bet.current()); + } finally { + await page.close(); + } +} diff --git a/examples/browser/mouse.js b/examples/browser/mouse.js new file mode 100644 index 00000000000..b89414dba2e --- /dev/null +++ b/examples/browser/mouse.js @@ -0,0 +1,31 @@ +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 clicking in the 'a' element's bounding box + const newsLinkBox = await page.$('a[href="/news.php"]').then(e => e.boundingBox()); + + await Promise.all([ + page.waitForNavigation(), + page.mouse.click(newsLinkBox.x + newsLinkBox.width / 2, newsLinkBox.y) + ]); + + await page.close(); +} diff --git a/examples/browser/multiple-scenario.js b/examples/browser/multiple-scenario.js new file mode 100644 index 00000000000..9d039e0c096 --- /dev/null +++ b/examples/browser/multiple-scenario.js @@ -0,0 +1,53 @@ +import { browser } from 'k6/browser'; + +export const options = { + scenarios: { + messages: { + executor: 'constant-vus', + exec: 'messages', + vus: 2, + duration: '2s', + options: { + browser: { + type: 'chromium', + }, + }, + }, + news: { + executor: 'per-vu-iterations', + exec: 'news', + vus: 2, + iterations: 4, + maxDuration: '5s', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, + thresholds: { + browser_web_vital_fcp: ['max < 5000'], + checks: ["rate==1.0"] + } +} + +export async function messages() { + const page = await browser.newPage(); + + try { + await page.goto('https://test.k6.io/my_messages.php', { waitUntil: 'networkidle' }); + } finally { + await page.close(); + } +} + +export async function news() { + const page = await browser.newPage(); + + try { + await page.goto('https://test.k6.io/news.php', { waitUntil: 'networkidle' }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/pageon-metric.js b/examples/browser/pageon-metric.js new file mode 100644 index 00000000000..a5fb7c972be --- /dev/null +++ b/examples/browser/pageon-metric.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(); + + page.on('metric', (metric) => { + metric.tag({ + name:'test', + matches: [ + {url: /^https:\/\/test\.k6\.io\/\?q=[0-9a-z]+$/, method: 'GET'}, + ] + }); + }); + + try { + await page.goto('https://test.k6.io/?q=abc123'); + await page.goto('https://test.k6.io/?q=def456'); + } finally { + await page.close(); + } +} diff --git a/examples/browser/pageon.js b/examples/browser/pageon.js new file mode 100644 index 00000000000..9465da9dbfb --- /dev/null +++ b/examples/browser/pageon.js @@ -0,0 +1,45 @@ +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(); + + try { + await page.goto('https://test.k6.io/'); + + page.on('console', async msg => check(msg, { + 'assert console message type': msg => + msg.type() == 'log', + 'assert console message text': msg => + msg.text() == 'this is a console.log message 42', + 'assert console message first argument': async msg => { + const arg1 = await msg.args()[0].jsonValue(); + return arg1 == 'this is a console.log message'; + }, + 'assert console message second argument': async msg => { + const arg2 = await msg.args()[1].jsonValue(); + return arg2 == 42; + } + })); + + await page.evaluate(() => console.log('this is a console.log message', 42)); + } finally { + await page.close(); + } +} diff --git a/examples/browser/querying.js b/examples/browser/querying.js new file mode 100644 index 00000000000..efeaf2bb959 --- /dev/null +++ b/examples/browser/querying.js @@ -0,0 +1,40 @@ +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/'); + + await check(page, { + 'Title with CSS selector': async p => { + const e = await p.$('header h1.title'); + return await e.textContent() === 'test.k6.io'; + }, + 'Title with XPath selector': async p => { + const e = await p.$('//header//h1[@class="title"]'); + return await e.textContent() === 'test.k6.io'; + } + }); + } finally { + await page.close(); + } +} diff --git a/examples/browser/screenshot.js b/examples/browser/screenshot.js new file mode 100644 index 00000000000..c41e9893340 --- /dev/null +++ b/examples/browser/screenshot.js @@ -0,0 +1,31 @@ +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 { + await page.goto('https://test.k6.io/'); + await page.screenshot({ path: 'screenshot.png' }); + // TODO: Assert this somehow. Upload as CI artifact or just an external `ls`? + // Maybe even do a fuzzy image comparison against a preset known good screenshot? + } finally { + await page.close(); + } +} diff --git a/examples/browser/shadowdom.js b/examples/browser/shadowdom.js new file mode 100644 index 00000000000..83af1e2fab6 --- /dev/null +++ b/examples/browser/shadowdom.js @@ -0,0 +1,41 @@ +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(); + + await page.setContent("hello!") + + await page.evaluate(() => { + const shadowRoot = document.createElement('div'); + shadowRoot.id = 'shadow-root'; + shadowRoot.attachShadow({mode: 'open'}); + shadowRoot.shadowRoot.innerHTML = '

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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vendor/github.com/grafana/xk6-browser/storage/file_persister.go b/js/modules/k6/browser/storage/file_persister.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/storage/file_persister.go rename to js/modules/k6/browser/storage/file_persister.go diff --git a/js/modules/k6/browser/storage/file_persister_test.go b/js/modules/k6/browser/storage/file_persister_test.go new file mode 100644 index 00000000000..85a93a0c690 --- /dev/null +++ b/js/modules/k6/browser/storage/file_persister_test.go @@ -0,0 +1,311 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocalFilePersister(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + existingData string + data string + truncates bool + }{ + { + name: "just_file", + path: "test.txt", + data: "some data", + }, + { + name: "with_dir", + path: "path/test.txt", + data: "some data", + }, + { + name: "truncates", + path: "test.txt", + data: "some data", + truncates: true, + existingData: "existing data", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + p := filepath.Join(dir, tt.path) + + // We want to make sure that the persister truncates the existing + // data and therefore overwrites existing data. This sets up a file + // with some existing data that should be overwritten. + if tt.truncates { + err := os.WriteFile(p, []byte(tt.existingData), 0o600) //nolint:forbidigo + require.NoError(t, err) + } + + var l LocalFilePersister + err := l.Persist(context.Background(), p, strings.NewReader(tt.data)) + assert.NoError(t, err) + + i, err := os.Stat(p) //nolint:forbidigo + require.NoError(t, err) + assert.False(t, i.IsDir()) + + bb, err := os.ReadFile(filepath.Clean(p)) //nolint:forbidigo + require.NoError(t, err) + + if tt.truncates { + assert.NotEqual(t, tt.existingData, string(bb)) + } + + assert.Equal(t, tt.data, string(bb)) + }) + } +} + +func TestRemoteFilePersister(t *testing.T) { + t.Parallel() + + const ( + basePath = "screenshots" + presignedEndpoint = "/presigned" + uploadEndpoint = "/upload" + ) + + tests := []struct { + name string + path string + dataToUpload string + multipartFormFields map[string]string + wantPresignedURLBody string + wantPresignedHeaders map[string]string + wantPresignedURLMethod string + uploadResponse int + getPresignedURLResponse int + wantError string + }{ + { + name: "upload_file", + path: "some/path/file.png", + dataToUpload: "here's some data", + multipartFormFields: map[string]string{ + "fooKey": "foo", + "barKey": "bar", + }, + wantPresignedURLBody: `{ + "service":"aws_s3", + "operation": "upload_post", + "files":[{"name":"%s"}] + }`, + wantPresignedHeaders: map[string]string{ + "Authorization": "token asd123", + "Run_id": "123456", + }, + wantPresignedURLMethod: http.MethodPost, + uploadResponse: http.StatusOK, + getPresignedURLResponse: http.StatusOK, + }, + { + name: "upload_file", + path: "some/path/file.png", + dataToUpload: "here's some data", + multipartFormFields: map[string]string{ // provide different form fields then the previous test + "bazKey": "baz", + "quxKey": "qux", + }, + wantPresignedURLBody: `{ + "service":"aws_s3", + "operation": "upload_post", + "files":[{"name":"%s"}] + }`, + wantPresignedHeaders: map[string]string{ + "Authorization": "token asd123", + "Run_id": "123456", + }, + wantPresignedURLMethod: http.MethodPut, // accepts dynamic methods + uploadResponse: http.StatusOK, + getPresignedURLResponse: http.StatusOK, + }, + { + name: "get_presigned_rate_limited", + path: "some/path/file.png", + dataToUpload: "here's some data", + wantPresignedURLBody: `{ + "service":"aws_s3", + "operation": "upload_post", + "files":[{"name":"%s"}] + }`, + wantPresignedHeaders: map[string]string{ + "Authorization": "token asd123", + "Run_id": "123456", + }, + wantPresignedURLMethod: http.MethodPost, + getPresignedURLResponse: http.StatusTooManyRequests, + wantError: "requesting presigned url: server returned 429 (too many requests)", + }, + { + name: "get_presigned_fails", + path: "some/path/file.png", + dataToUpload: "here's some data", + wantPresignedURLBody: `{ + "service":"aws_s3", + "operation": "upload_post", + "files":[{"name":"%s"}] + }`, + wantPresignedHeaders: map[string]string{ + "Authorization": "token asd123", + "Run_id": "123456", + }, + wantPresignedURLMethod: http.MethodPost, + getPresignedURLResponse: http.StatusInternalServerError, + wantError: "requesting presigned url: server returned 500 (internal server error)", + }, + { + name: "upload_rate_limited", + path: "some/path/file.png", + dataToUpload: "here's some data", + wantPresignedURLBody: `{ + "service":"aws_s3", + "operation": "upload_post", + "files":[{"name":"%s"}] + }`, + wantPresignedHeaders: map[string]string{ + "Authorization": "token asd123", + "Run_id": "123456", + }, + wantPresignedURLMethod: http.MethodPost, + uploadResponse: http.StatusTooManyRequests, + getPresignedURLResponse: http.StatusOK, + wantError: "uploading: server returned 429 (too many requests)", + }, + { + name: "upload_fails", + path: "some/path/file.png", + dataToUpload: "here's some data", + wantPresignedURLBody: `{ + "service":"aws_s3", + "operation": "upload_post", + "files":[{"name":"%s"}] + }`, + wantPresignedHeaders: map[string]string{ + "Authorization": "token asd123", + "Run_id": "123456", + }, + wantPresignedURLMethod: http.MethodPost, + uploadResponse: http.StatusInternalServerError, + getPresignedURLResponse: http.StatusOK, + wantError: "uploading: server returned 500 (internal server error)", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + s := httptest.NewServer(mux) + defer s.Close() + + // This handles the request to retrieve a presigned url. + mux.HandleFunc(presignedEndpoint, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() //nolint:errcheck + + bb, err := io.ReadAll(r.Body) + require.NoError(t, err) + + // Does the response match the expected format? + wantPresignedURLBody := fmt.Sprintf( + tt.wantPresignedURLBody, + filepath.Join(basePath, tt.path), + ) + assert.JSONEq(t, wantPresignedURLBody, string(bb)) + + // Do the HTTP headers are sent to the server from the browser module? + for k, v := range tt.wantPresignedHeaders { + assert.Equal(t, v, r.Header[k][0]) + } + + var formFields string + for k, v := range tt.multipartFormFields { + formFields += fmt.Sprintf(`"%s":"%s",`, k, v) + } + formFields = strings.TrimRight(formFields, ",") + + w.WriteHeader(tt.getPresignedURLResponse) + _, err = fmt.Fprintf(w, `{ + "service": "aws_s3", + "urls": [{ + "name": "%s", + "pre_signed_url": "%s", + "method": "%s", + "form_fields": {%s} + }] + }`, + basePath+"/"+tt.path, + s.URL+uploadEndpoint, + tt.wantPresignedURLMethod, + formFields, + ) + + require.NoError(t, err) + }, + )) + + // This handles the upload of the files with the presigned url that + // is retrieved from the handler above. + mux.HandleFunc(uploadEndpoint, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() //nolint:errcheck + + assert.Equal(t, tt.wantPresignedURLMethod, r.Method) + + // Does the multipart form data contain the file to upload? + file, header, err := r.FormFile("file") + require.NoError(t, err) + t.Cleanup(func() { + _ = file.Close() + }) + cd := header.Header.Get("Content-Disposition") + assert.Equal(t, cd, `form-data; name="file"; filename="`+basePath+`/`+tt.path+`"`) + + // Does the file content match the expected data? + bb, err := io.ReadAll(file) + require.NoError(t, err) + assert.Equal(t, string(bb), tt.dataToUpload) + + // Is the content type set correctly to the binary data? + assert.Equal(t, "application/octet-stream", header.Header.Get("Content-Type")) + + w.WriteHeader(tt.uploadResponse) + })) + + r := NewRemoteFilePersister(s.URL+presignedEndpoint, tt.wantPresignedHeaders, basePath) + err := r.Persist(context.Background(), tt.path, strings.NewReader(tt.dataToUpload)) + if tt.wantError != "" { + assert.EqualError(t, err, tt.wantError) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/vendor/github.com/grafana/xk6-browser/storage/storage.go b/js/modules/k6/browser/storage/storage.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/storage/storage.go rename to js/modules/k6/browser/storage/storage.go diff --git a/js/modules/k6/browser/storage/storage_test.go b/js/modules/k6/browser/storage/storage_test.go new file mode 100644 index 00000000000..253d15dd424 --- /dev/null +++ b/js/modules/k6/browser/storage/storage_test.go @@ -0,0 +1,65 @@ +package storage + +import ( + "io/fs" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDirMake(t *testing.T) { + t.Parallel() + + tmpDir := os.TempDir() //nolint:forbidigo + + t.Run("dir_provided", func(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "*") //nolint:forbidigo + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) //nolint:forbidigo + + var s Dir + require.NoError(t, s.Make("", dir)) + require.Equal(t, dir, s.Dir, "should return the directory") + assert.NotPanics(t, func() { + t.Helper() + err := s.Cleanup() + assert.NoError(t, err) + }) // should be a no-op + assert.DirExists(t, dir, "should not remove directory") + }) + + t.Run("dir_absent", func(t *testing.T) { + t.Parallel() + + var s Dir + require.NoError(t, s.Make("", "")) + require.True(t, strings.HasPrefix(s.Dir, tmpDir)) + require.DirExists(t, s.Dir) + + assert.NotPanics(t, func() { + t.Helper() + err := s.Cleanup() + assert.NoError(t, err) + }) + require.NoDirExists(t, s.Dir) + }) + + t.Run("dir_mk_err", func(t *testing.T) { + t.Parallel() + + var s Dir + require.ErrorIs(t, s.Make("/NOT_EXISTING_DIRECTORY/K6/BROWSER", ""), fs.ErrNotExist) + assert.Empty(t, s.Dir) + + assert.NotPanics(t, func() { + t.Helper() + err := s.Cleanup() + assert.NoError(t, err) + }) + }) +} diff --git a/js/modules/k6/browser/tests/browser_context_options_test.go b/js/modules/k6/browser/tests/browser_context_options_test.go new file mode 100644 index 00000000000..5b8814f80dc --- /dev/null +++ b/js/modules/k6/browser/tests/browser_context_options_test.go @@ -0,0 +1,121 @@ +package tests + +import ( + _ "embed" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" +) + +func TestBrowserContextOptionsDefaultValues(t *testing.T) { + t.Parallel() + + opts := common.DefaultBrowserContextOptions() + assert.False(t, opts.AcceptDownloads) + assert.Empty(t, opts.DownloadsPath) + assert.False(t, opts.BypassCSP) + assert.Equal(t, common.ColorSchemeLight, opts.ColorScheme) + assert.Equal(t, 1.0, opts.DeviceScaleFactor) + assert.Empty(t, opts.ExtraHTTPHeaders) + assert.Nil(t, opts.Geolocation) + assert.False(t, opts.HasTouch) + assert.True(t, opts.HTTPCredentials.IsEmpty()) + assert.False(t, opts.IgnoreHTTPSErrors) + assert.False(t, opts.IsMobile) + assert.True(t, opts.JavaScriptEnabled) + assert.Equal(t, common.DefaultLocale, opts.Locale) + assert.False(t, opts.Offline) + assert.Empty(t, opts.Permissions) + assert.Equal(t, common.ReducedMotionNoPreference, opts.ReducedMotion) + assert.Equal(t, common.Screen{Width: common.DefaultScreenWidth, Height: common.DefaultScreenHeight}, opts.Screen) + assert.Equal(t, "", opts.TimezoneID) + assert.Equal(t, "", opts.UserAgent) + assert.Equal(t, common.Viewport{Width: common.DefaultScreenWidth, Height: common.DefaultScreenHeight}, opts.Viewport) +} + +func TestBrowserContextOptionsDefaultViewport(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + viewportSize := p.ViewportSize() + assert.Equal(t, float64(common.DefaultScreenWidth), viewportSize["width"]) + assert.Equal(t, float64(common.DefaultScreenHeight), viewportSize["height"]) +} + +func TestBrowserContextOptionsSetViewport(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + opts := common.DefaultBrowserContextOptions() + opts.Viewport = common.Viewport{ + Width: 800, + Height: 600, + } + bctx, err := tb.NewContext(opts) + require.NoError(t, err) + t.Cleanup(func() { + if err := bctx.Close(); err != nil { + t.Log("closing browser context:", err) + } + }) + p, err := bctx.NewPage() + require.NoError(t, err) + + viewportSize := p.ViewportSize() + assert.Equal(t, float64(800), viewportSize["width"]) + assert.Equal(t, float64(600), viewportSize["height"]) +} + +func TestBrowserContextOptionsExtraHTTPHeaders(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withHTTPServer()) + + opts := common.DefaultBrowserContextOptions() + opts.ExtraHTTPHeaders = map[string]string{ + "Some-Header": "Some-Value", + } + bctx, err := tb.NewContext(opts) + require.NoError(t, err) + t.Cleanup(func() { + if err := bctx.Close(); err != nil { + t.Log("closing browser context:", err) + } + }) + + p, err := bctx.NewPage() + require.NoError(t, err) + + err = tb.awaitWithTimeout(time.Second*5, func() error { + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := p.Goto( + tb.url("/get"), + opts, + ) + if err != nil { + return err + } + require.NotNil(t, resp) + + responseBody, err := resp.Body() + require.NoError(t, err) + + var body struct{ Headers map[string][]string } + require.NoError(t, json.Unmarshal(responseBody, &body)) + + h := body.Headers["Some-Header"] + require.NotEmpty(t, h) + assert.Equal(t, "Some-Value", h[0]) + + return nil + }) + require.NoError(t, err) +} diff --git a/js/modules/k6/browser/tests/browser_context_test.go b/js/modules/k6/browser/tests/browser_context_test.go new file mode 100644 index 00000000000..3779e959b00 --- /dev/null +++ b/js/modules/k6/browser/tests/browser_context_test.go @@ -0,0 +1,985 @@ +package tests + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "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/env" +) + +func TestBrowserContextAddCookies(t *testing.T) { + t.Parallel() + + dayAfter := time.Now(). + Add(24 * time.Hour). + Unix() + dayBefore := time.Now(). + Add(-24 * time.Hour). + Unix() + + tests := map[string]struct { + name string + cookies []*common.Cookie + wantCookiesToSet []*common.Cookie + wantErr bool + }{ + "cookie": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + URL: "http://test.go", + }, + }, + wantCookiesToSet: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + Domain: "test.go", + Expires: -1, + HTTPOnly: false, + Path: "/", + SameSite: "", + Secure: false, + }, + }, + wantErr: false, + }, + "cookie_with_url": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + URL: "http://test.go", + }, + }, + wantCookiesToSet: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + Domain: "test.go", + Expires: -1, + HTTPOnly: false, + Path: "/", + SameSite: "", + Secure: false, + }, + }, + wantErr: false, + }, + "cookie_with_domain_and_path": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + Domain: "test.go", + Path: "/to/page", + }, + }, + wantCookiesToSet: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + Domain: "test.go", + Expires: -1, + HTTPOnly: false, + Path: "/to/page", + SameSite: "", + Secure: false, + }, + }, + wantErr: false, + }, + "cookie_with_expiration": { + cookies: []*common.Cookie{ + // session cookie + { + Name: "session_cookie", + Value: "session_cookie_value", + URL: "http://test.go", + }, + // persistent cookie + { + Name: "persistent_cookie_name", + Value: "persistent_cookie_value", + Expires: dayAfter, + URL: "http://test.go", + }, + // expired cookie + { + Name: "expired_cookie_name", + Value: "expired_cookie_value", + Expires: dayBefore, + URL: "http://test.go", + }, + }, + wantCookiesToSet: []*common.Cookie{ + { + Name: "session_cookie", + Value: "session_cookie_value", + Domain: "test.go", + Expires: -1, + Path: "/", + }, + { + Name: "persistent_cookie_name", + Value: "persistent_cookie_value", + Domain: "test.go", + Expires: dayAfter, + Path: "/", + }, + }, + wantErr: false, + }, + "nil_cookies": { + cookies: nil, + wantErr: true, + }, + "cookie_missing_name": { + cookies: []*common.Cookie{ + { + Value: "test_cookie_value", + URL: "http://test.go", + }, + }, + wantErr: true, + }, + "cookie_missing_value": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + URL: "http://test.go", + }, + }, + wantErr: true, + }, + "cookie_missing_url": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + }, + }, + wantErr: true, + }, + "cookies_missing_path": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + Domain: "test.go", + }, + }, + wantErr: true, + }, + "cookies_missing_domain": { + cookies: []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + Path: "/to/page", + }, + }, + wantErr: true, + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + bc, err := tb.NewContext(nil) + require.NoError(t, err) + + err = bc.AddCookies(tt.cookies) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // ensure cookies are set. + cookies, err := bc.Cookies() + require.NoErrorf(t, + err, "failed to get cookies from the browser context", + ) + assert.Equalf(t, + tt.wantCookiesToSet, cookies, + "incorrect cookies received from the browser context", + ) + }) + } +} + +func TestBrowserContextCookies(t *testing.T) { + t.Parallel() + + // an empty page is required to set cookies. we're just using a + // simple handler that returns 200 OK to have an empty page. + okHandler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + } + + tests := map[string]struct { + // setupHandler is a handler that will be used to setup the + // test environment. it acts like a page returning cookies. + setupHandler func(w http.ResponseWriter, r *http.Request) + + // documentCookiesSnippet is a JavaScript snippet that will be + // evaluated in the page to set document.cookie. + documentCookiesSnippet string + + // addCookies is a list of cookies that will be added to the + // browser context using the AddCookies method. + // if empty, no cookies will be added. + addCookies []*common.Cookie + + // filterCookiesByURLs allows to filter cookies by URLs. + // if nil, all cookies will be returned. + filterCookiesByURLs []string + + // wantDocumentCookies is a string representation of the + // document.cookie value that is expected to be set. + wantDocumentCookies string + + // wantContextCookies is a list of cookies that are expected + // to be set in the browser context. + wantContextCookies []*common.Cookie + + wantErr bool + }{ + "no_cookies": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "", + wantContextCookies: nil, + }, + "cookie": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + document.cookie = "name=value"; + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "name=value", + wantContextCookies: []*common.Cookie{ + { + Name: "name", + Value: "value", + Domain: "127.0.0.1", + Expires: -1, + HTTPOnly: false, + Path: "/", + SameSite: "", + Secure: false, + }, + }, + }, + "cookies": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + document.cookie = "name=value"; + document.cookie = "name2=value2"; + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "name=value; name2=value2", + wantContextCookies: []*common.Cookie{ + { + Name: "name", + Value: "value", + Domain: "127.0.0.1", + Expires: -1, + HTTPOnly: false, + Path: "/", + SameSite: "", + Secure: false, + }, + { + Name: "name2", + Value: "value2", + Domain: "127.0.0.1", + Expires: -1, + HTTPOnly: false, + Path: "/", + SameSite: "", + Secure: false, + }, + }, + }, + "cookie_with_path": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + document.cookie = "name=value; path=/empty"; + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "name=value", + wantContextCookies: []*common.Cookie{ + { + Name: "name", + Value: "value", + Domain: "127.0.0.1", + Expires: -1, + HTTPOnly: false, + Path: "/empty", + SameSite: "", + Secure: false, + }, + }, + }, + "cookie_with_different_domain": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + document.cookie = "name=value; domain=k6.io"; + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "", // some cookies cannot be set (i.e. cookies using different domains) + wantContextCookies: nil, + }, + "http_only_cookie": { + setupHandler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Set-Cookie", "name=value;HttpOnly; Path=/") + }, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "", + wantContextCookies: []*common.Cookie{ + { + HTTPOnly: true, + Name: "name", + Value: "value", + Domain: "127.0.0.1", + Expires: -1, + Path: "/", + SameSite: "", + Secure: false, + }, + }, + }, + "same_site_strict_cookie": { + setupHandler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Set-Cookie", "name=value;SameSite=Strict") + }, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "name=value", + wantContextCookies: []*common.Cookie{ + { + SameSite: common.CookieSameSiteStrict, + Name: "name", + Value: "value", + Domain: "127.0.0.1", + Expires: -1, + HTTPOnly: false, + Path: "/", + Secure: false, + }, + }, + }, + "same_site_lax_cookie": { + setupHandler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Set-Cookie", "name=value;SameSite=Lax") + }, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + filterCookiesByURLs: nil, + wantDocumentCookies: "name=value", + wantContextCookies: []*common.Cookie{ + { + SameSite: common.CookieSameSiteLax, + Name: "name", + Value: "value", + Domain: "127.0.0.1", + Expires: -1, + HTTPOnly: false, + Path: "/", + Secure: false, + }, + }, + }, + "filter_cookies_by_urls": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + addCookies: []*common.Cookie{ + { + Name: "fooCookie", + Value: "fooValue", + URL: "https://foo.com", + SameSite: common.CookieSameSiteNone, + }, + { + Name: "barCookie", + Value: "barValue", + URL: "https://bar.com", + SameSite: common.CookieSameSiteLax, + }, + { + Name: "bazCookie", + Value: "bazValue", + URL: "https://baz.com", + SameSite: common.CookieSameSiteLax, + }, + }, + filterCookiesByURLs: []string{ + "https://foo.com", + "https://baz.com", + }, + wantDocumentCookies: "", + wantContextCookies: []*common.Cookie{ + { + Name: "fooCookie", + Value: "fooValue", + Domain: "foo.com", + Expires: -1, + HTTPOnly: false, + Path: "/", + Secure: true, + SameSite: common.CookieSameSiteNone, + }, + { + Name: "bazCookie", + Value: "bazValue", + Domain: "baz.com", + Expires: -1, + HTTPOnly: false, + Path: "/", + Secure: true, + SameSite: common.CookieSameSiteLax, + }, + }, + }, + "filter_no_cookies": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + addCookies: []*common.Cookie{ + { + Name: "fooCookie", + Value: "fooValue", + URL: "https://foo.com", + SameSite: common.CookieSameSiteNone, + }, + { + Name: "barCookie", + Value: "barValue", + URL: "https://bar.com", + SameSite: common.CookieSameSiteLax, + }, + }, + filterCookiesByURLs: []string{ + "https://baz.com", + }, + wantDocumentCookies: "", + wantContextCookies: nil, + }, + "filter_invalid": { + setupHandler: okHandler, + documentCookiesSnippet: ` + () => { + return document.cookie; + } + `, + addCookies: []*common.Cookie{ + { + Name: "fooCookie", + Value: "fooValue", + URL: "https://foo.com", + SameSite: common.CookieSameSiteNone, + }, + }, + filterCookiesByURLs: []string{ + "LOREM IPSUM", + }, + wantDocumentCookies: "", + wantContextCookies: nil, + wantErr: true, + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + + // an empty page is required to set cookies + // since we want to run cookie tests in parallel + // we're creating a new browser context for each test. + tb := newTestBrowser(t, withHTTPServer()) + p := tb.NewPage(nil) + + // the setupHandler can set some cookies + // that will be received by the browser context. + tb.withHandler("/empty", tt.setupHandler) + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := p.Goto( + tb.url("/empty"), + opts, + ) + require.NoErrorf(t, + err, "failed to open an empty page", + ) + + // setting document.cookie into the page + cookie, err := p.Evaluate(tt.documentCookiesSnippet) + require.NoError(t, err) + require.Equalf(t, + tt.wantDocumentCookies, + cookie, + "incorrect document.cookie received", + ) + + // adding cookies to the browser context by our API. + if tt.addCookies != nil { + err := p.Context().AddCookies(tt.addCookies) + require.NoErrorf(t, + err, "failed to add cookies to the browser context: %#v", tt.addCookies, + ) + } + + // getting cookies from the browser context + // either from the page or from the context + // some cookies can be set by the response handler + cookies, err := p.Context().Cookies(tt.filterCookiesByURLs...) + if tt.wantErr { + require.Errorf(t, + err, "expected an error, but got none", + ) + return + } + require.NoErrorf(t, + err, "failed to get cookies from the browser context", + ) + assert.Equalf(t, + tt.wantContextCookies, cookies, + "incorrect cookies received from the browser context", + ) + }) + } +} + +func TestBrowserContextClearCookies(t *testing.T) { + t.Parallel() + + // add a cookie and clear it out + + tb := newTestBrowser(t, withHTTPServer()) + p := tb.NewPage(nil) + bctx := p.Context() + + err := bctx.AddCookies( + []*common.Cookie{ + { + Name: "test_cookie_name", + Value: "test_cookie_value", + URL: "http://test.go", + }, + }, + ) + require.NoError(t, err) + require.NoError(t, bctx.ClearCookies()) + + cookies, err := bctx.Cookies() + require.NoError(t, err) + require.Emptyf(t, cookies, "want no cookies, but got: %#v", cookies) +} + +func TestK6Object(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + testRunID string + want string + }{ + { + name: "empty_testRunId", + want: `{"testRunId":""}`, + }, + { + name: "with_testRunId", + testRunID: "123456", + want: `{"testRunId":"123456"}`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + vu, _, _, cleanUp := startIteration(t, env.ConstLookup(env.K6TestRunID, tt.testRunID)) + defer cleanUp() + + // First test with browser.newPage + got := vu.RunPromise(t, ` + const p = await browser.newPage(); + await p.goto("about:blank"); + const o = await p.evaluate(() => window.k6); + return JSON.stringify(o); + `) + assert.Equal(t, tt.want, got.Result().String()) + + // Now test with browser.newContext + got = vu.RunPromise(t, ` + await browser.closeContext(); + const c = await browser.newContext(); + const p2 = await c.newPage(); + await p2.goto("about:blank"); + const o2 = await p2.evaluate(() => window.k6); + return JSON.stringify(o2); + `) + assert.Equal(t, tt.want, got.Result().String()) + }) + } +} + +// This test ensures that when opening a new tab, this it is possible to navigate +// to the url. If the mapping layer is not setup correctly we can end up with a +// NPD. +func TestNewTab(t *testing.T) { + t.Parallel() + + // Start a server that will return static html files. + mux := http.NewServeMux() + s := httptest.NewServer(mux) + t.Cleanup(s.Close) + + const ( + slash = string(os.PathSeparator) //nolint:forbidigo + path = slash + testBrowserStaticDir + slash + ) + fs := http.FileServer(http.Dir(testBrowserStaticDir)) + mux.Handle(path, http.StripPrefix(path, fs)) + + // Start the iteration + vu, _, _, cleanUp := startIteration(t, env.ConstLookup(env.K6TestRunID, "12345")) + defer cleanUp() + + // Run the test script + _, err := vu.RunAsync(t, ` + const p = await browser.newPage() + await p.goto("%s/%s/ping.html") + + const p2 = await browser.context().newPage() + await p2.goto("%s/%s/ping.html") + `, s.URL, testBrowserStaticDir, s.URL, testBrowserStaticDir) + require.NoError(t, err) +} + +func TestBrowserContextTimeout(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + defaultTimeout time.Duration + defaultNavigationTimeout time.Duration + }{ + { + name: "fail when timeout exceeds default timeout", + defaultTimeout: 1 * time.Millisecond, + }, + { + name: "fail when timeout exceeds default navigation timeout", + defaultNavigationTimeout: 1 * time.Millisecond, + }, + { + name: "default navigation timeout supersedes default timeout", + defaultTimeout: 30 * time.Second, + defaultNavigationTimeout: 1 * time.Millisecond, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withHTTPServer()) + + tb.withHandler("/slow", func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(100 * time.Millisecond) + _, err := fmt.Fprintf(w, `sorry for being so slow`) + require.NoError(t, err) + }) + + bc, err := tb.NewContext(nil) + require.NoError(t, err) + + p, err := bc.NewPage() + require.NoError(t, err) + + var timeout time.Duration + if tc.defaultTimeout != 0 { + timeout = tc.defaultTimeout + bc.SetDefaultTimeout(tc.defaultTimeout.Milliseconds()) + } + if tc.defaultNavigationTimeout != 0 { + timeout = tc.defaultNavigationTimeout + bc.SetDefaultNavigationTimeout(tc.defaultNavigationTimeout.Milliseconds()) + } + res, err := p.Goto( + tb.url("/slow"), + &common.FrameGotoOptions{ + Timeout: timeout, + }, + ) + require.Nil(t, res) + assert.ErrorContains(t, err, "timed out after") + }) + } +} + +func TestBrowserContextWaitForEvent(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + event string + predicate func(p *common.Page) (bool, error) + timeout time.Duration + wantErr string + }{ + { + // No predicate and default timeout. + name: "success", + event: "page", + timeout: 30 * time.Second, + }, + { + // With a predicate function and default timeout. + name: "success_with_predicate", + event: "page", + predicate: func(p *common.Page) (bool, error) { return true, nil }, + timeout: 30 * time.Second, + }, + { + // Fails when an event other than "page" is passed in. + name: "fails_incorrect_event", + event: "browser", + wantErr: `incorrect event "browser", "page" is the only event supported`, + }, + { + // Fails when the timeout fires while waiting on waitForEvent. + name: "fails_timeout", + event: "page", + predicate: func(p *common.Page) (bool, error) { return false, nil }, + timeout: 10 * time.Millisecond, + wantErr: "waitForEvent timed out after 10ms", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Use withSkipClose() opt as we will close it manually to force the + // page.TaskQueue closing, which seems to be a requirement otherwise + // it doesn't complete the test. + tb := newTestBrowser(t) + + bc, err := tb.NewContext(nil) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(tb.context(), 5*time.Second) + defer cancel() + + var ( + aboutToCallWait = make(chan bool) + p1ID, p2ID string + ) + + err = tb.run(ctx, + func() error { + var resp any + close(aboutToCallWait) + resp, err := bc.WaitForEvent(tc.event, tc.predicate, tc.timeout) + if err != nil { + return err + } + + p, ok := resp.(*common.Page) + if !ok { + return errors.New("response from waitForEvent is not a page") + } + p1ID = p.MainFrame().ID() + + return nil + }, + func() error { + <-aboutToCallWait + + if tc.wantErr == "" { + p, err := bc.NewPage() + require.NoError(t, err) + + p2ID = p.MainFrame().ID() + } + + return nil + }, + ) + + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + assert.NoError(t, err) + // We want to make sure that the page that was created with + // newPage matches the return value from waitForEvent. + assert.Equal(t, p1ID, p2ID) + }) + } +} + +func TestBrowserContextGrantPermissions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + permission string + wantErr string + }{ + {name: "geolocation", permission: "geolocation"}, + {name: "midi", permission: "midi"}, + {name: "midi-sysex", permission: "midi-sysex"}, + {name: "notifications", permission: "notifications"}, + {name: "camera", permission: "camera"}, + {name: "microphone", permission: "microphone"}, + {name: "background-sync", permission: "background-sync"}, + {name: "ambient-light-sensor", permission: "ambient-light-sensor"}, + {name: "accelerometer", permission: "accelerometer"}, + {name: "gyroscope", permission: "gyroscope"}, + {name: "magnetometer", permission: "magnetometer"}, + {name: "clipboard-read", permission: "clipboard-read"}, + {name: "clipboard-write", permission: "clipboard-write"}, + {name: "payment-handler", permission: "payment-handler"}, + {name: "fake-permission", permission: "fake-permission", wantErr: `"fake-permission" is an invalid permission`}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + bCtx, err := tb.NewContext(nil) + require.NoError(t, err) + + err = bCtx.GrantPermissions( + []string{tc.permission}, + common.GrantPermissionsOptions{}, + ) + + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + assert.EqualError(t, err, tc.wantErr) + }) + } +} + +func TestBrowserContextClearPermissions(t *testing.T) { + t.Parallel() + + hasPermission := func(_ *testBrowser, p *common.Page, perm string) bool { + t.Helper() + + js := fmt.Sprintf(` + (perm) => navigator.permissions.query( + { name: %q } + ).then(result => result.state) + `, perm) + v, err := p.Evaluate(js) + require.NoError(t, err) + s := asString(t, v) + return s == "granted" + } + + t.Run("no_permissions_set", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + bCtx, err := tb.NewContext(nil) + require.NoError(t, err) + p, err := bCtx.NewPage() + require.NoError(t, err) + + require.False(t, hasPermission(tb, p, "geolocation")) + + err = bCtx.ClearPermissions() + assert.NoError(t, err) + require.False(t, hasPermission(tb, p, "geolocation")) + }) + + t.Run("permissions_set", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + bCtx, err := tb.NewContext(nil) + require.NoError(t, err) + p, err := bCtx.NewPage() + require.NoError(t, err) + + require.False(t, hasPermission(tb, p, "geolocation")) + + err = bCtx.GrantPermissions( + []string{"geolocation"}, + common.GrantPermissionsOptions{}, + ) + require.NoError(t, err) + require.True(t, hasPermission(tb, p, "geolocation")) + + err = bCtx.ClearPermissions() + assert.NoError(t, err) + require.False(t, hasPermission(tb, p, "geolocation")) + }) +} diff --git a/js/modules/k6/browser/tests/browser_test.go b/js/modules/k6/browser/tests/browser_test.go new file mode 100644 index 00000000000..8503e10ecca --- /dev/null +++ b/js/modules/k6/browser/tests/browser_test.go @@ -0,0 +1,424 @@ +package tests + +import ( + "context" + "errors" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/browser" + "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/k6ext/k6test" +) + +func TestBrowserNewPage(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t) + p1 := b.NewPage(nil) + c := b.Context() + assert.NotNil(t, c) + + _, err := b.Browser.NewPage(nil) + assert.EqualError(t, err, "new page: existing browser context must be closed before creating a new one") + + err = p1.Close(nil) + require.NoError(t, err) + c = b.Context() + assert.NotNil(t, c) + + _, err = b.Browser.NewPage(nil) + assert.EqualError(t, err, "new page: existing browser context must be closed before creating a new one") + + require.NoError(t, b.Context().Close()) + c = b.Context() + assert.Nil(t, c) + + _ = b.NewPage(nil) + c = b.Context() + assert.NotNil(t, c) +} + +func TestBrowserNewContext(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t) + bc1, err := b.NewContext(nil) + assert.NoError(t, err) + c := b.Context() + assert.NotNil(t, c) + + _, err = b.NewContext(nil) + assert.EqualError(t, err, "existing browser context must be closed before creating a new one") + + require.NoError(t, bc1.Close()) + c = b.Context() + assert.Nil(t, c) + + _, err = b.NewContext(nil) + assert.NoError(t, err) + c = b.Context() + assert.NotNil(t, c) +} + +func TestTmpDirCleanup(t *testing.T) { + t.Parallel() + + tmpDirPath, err := os.MkdirTemp("./", "") //nolint:forbidigo + t.Cleanup( + func() { + err := os.RemoveAll(tmpDirPath) //nolint:forbidigo + require.NoError(t, err) + }, + ) + require.NoError(t, err) + + b := newTestBrowser( + t, + withSkipClose(), + withEnvLookup(env.ConstLookup("TMPDIR", tmpDirPath)), + ) + p := b.NewPage(nil) + err = p.Close(nil) + require.NoError(t, err) + + matches, err := filepath.Glob(tmpDirPath + "/xk6-browser-data-*") + assert.NoError(t, err) + assert.NotEmpty(t, matches, "a dir should exist that matches the pattern `xk6-browser-data-*`") + + b.Close() + + // We need to wait for something (k6 browser, chromium or the os) to + // actually complete the removal of the directory. It's a race condition. + // To try to mitigate the issue, we're adding a retry which waits half a + // second if the dir still exits. + for i := 0; i < 5; i++ { + matches, err = filepath.Glob(tmpDirPath + "/xk6-browser-data-*") + assert.NoError(t, err) + if len(matches) == 0 { + break + } + time.Sleep(time.Millisecond * 500) + } + + assert.Empty(t, matches, "a dir shouldn't exist which matches the pattern `xk6-browser-data-*`") +} + +func TestTmpDirCleanupOnContextClose(t *testing.T) { + t.Parallel() + + tmpDirPath, err := os.MkdirTemp("./", "") //nolint:forbidigo + t.Cleanup( + func() { + err := os.RemoveAll(tmpDirPath) //nolint:forbidigo + require.NoError(t, err) + }, + ) + require.NoError(t, err) + + b := newTestBrowser( + t, + withSkipClose(), + withEnvLookup(env.ConstLookup("TMPDIR", tmpDirPath)), + ) + + matches, err := filepath.Glob(tmpDirPath + "/xk6-browser-data-*") + assert.NoError(t, err) + assert.NotEmpty(t, matches, "a dir should exist that matches the pattern `xk6-browser-data-*`") + + b.cancelContext() + <-b.ctx.Done() + + require.NotPanicsf(t, b.Close, "first call to browser.close should not panic") + + matches, err = filepath.Glob(tmpDirPath + "/xk6-browser-data-*") + assert.NoError(t, err) + assert.Empty(t, matches, "a dir shouldn't exist which matches the pattern `xk6-browser-data-*`") +} + +func TestBrowserOn(t *testing.T) { + t.Parallel() + + script := ` + const result = b.on('%s') + log(result);` + + t.Run("err_wrong_event", func(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t) + require.NoError(t, b.runtime().Set("b", b.Browser)) + + _, err := b.runJavaScript(script, "wrongevent") + require.Error(t, err) + assert.ErrorContains(t, err, `unknown browser event: "wrongevent", must be "disconnected"`) + }) + + t.Run("ok_promise_resolved", func(t *testing.T) { + t.Parallel() + + var ( + b = newTestBrowser(t, withSkipClose()) + rt = b.runtime() + log []string + ) + + require.NoError(t, rt.Set("b", b.Browser)) + require.NoError(t, rt.Set("log", func(s string) { log = append(log, s) })) + + time.AfterFunc(100*time.Millisecond, b.Browser.Close) + _, err := b.runJavaScript(script, "disconnected") + require.NoError(t, err) + assert.Contains(t, log, "true") + }) + + t.Run("ok_promise_rejected", func(t *testing.T) { + t.Parallel() + + var ( + b = newTestBrowser(t) + rt = b.runtime() + log []string + ) + + require.NoError(t, rt.Set("b", b.Browser)) + require.NoError(t, rt.Set("log", func(s string) { log = append(log, s) })) + + time.AfterFunc(100*time.Millisecond, b.cancelContext) + _, err := b.runJavaScript(script, "disconnected") + assert.ErrorContains(t, err, "browser.on promise rejected: context canceled") + }) +} + +// This only works for Chrome! +func TestBrowserVersion(t *testing.T) { + t.Parallel() + + const re = `^\d+\.\d+\.\d+\.\d+$` + r, err := regexp.Compile(re) //nolint:gocritic + require.NoError(t, err) + ver := newTestBrowser(t).Version() + assert.Regexp(t, r, ver, "expected browser version to match regex %q, but found %q", re, ver) +} + +// This only works for Chrome! +// TODO: Improve this test, see: +// https://go.k6.io/k6/js/modules/k6/browser/pull/51#discussion_r742696736 +func TestBrowserUserAgent(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t) + + ua := b.UserAgent() + if prefix := "Mozilla/5.0"; !strings.HasPrefix(ua, prefix) { + t.Errorf("UserAgent should start with %q, but got: %q", prefix, ua) + } + // We default to removing the "Headless" part of the user agent string. + assert.NotContains(t, ua, "Headless") +} + +func TestBrowserCrashErr(t *testing.T) { + // Skip until we get answer from Chromium team in an open issue + // https://issues.chromium.org/issues/364089353. + t.Skip("Skipping until we get response from Chromium team") + + t.Parallel() + + // create a new VU in an environment that requires a bad remote-debugging-port. + vu := k6test.NewVU(t, env.ConstLookup(env.BrowserArguments, "remote-debugging-port=99999")) + + mod := browser.New().NewModuleInstance(vu) + jsMod, ok := mod.Exports().Default.(*browser.JSModule) + require.Truef(t, ok, "unexpected default mod export type %T", mod.Exports().Default) + + vu.ActivateVU() + vu.StartIteration(t) + + vu.SetVar(t, "browser", jsMod.Browser) + _, err := vu.RunAsync(t, ` + const p = await browser.newPage(); + await p.close(); + `) + assert.ErrorContains(t, err, "launching browser: Invalid devtools server port") +} + +func TestBrowserLogIterationID(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withLogCache()) + + var ( + iterID = common.GetIterationID(tb.ctx) + tracedEvts int + ) + + require.NotEmpty(t, iterID) + + tb.logCache.mu.RLock() + defer tb.logCache.mu.RUnlock() + + require.NotEmpty(t, tb.logCache.entries) + + for _, evt := range tb.logCache.entries { + for k, v := range evt.Data { + if k == "iteration_id" { + assert.Equal(t, iterID, v) + tracedEvts++ + } + } + } + + assert.Equal(t, len(tb.logCache.entries), tracedEvts) +} + +//nolint:paralleltest +func TestMultiBrowserPanic(t *testing.T) { + // this test should run sequentially. + // don't use t.Parallel() here. + + var b1, b2 *testBrowser + + // run it in a test to kick in the Cleanup() in testBrowser. + t.Run("browsers", func(t *testing.T) { + b1 = newTestBrowser(t) + b2 = newTestBrowser(t) + + func() { + defer func() { _ = recover() }() + k6ext.Panic(b1.ctx, "forcing a panic") + }() + }) + + // FindProcess only returns alive/dead processes on nixes. + // Sending Interrupt on Windows is not implemented. + if runtime.GOOS == "windows" { + t.Skip("skipping on windows") + } + + assertProcess := func(t *testing.T, pid int, n int) { + t.Helper() + + p, err := os.FindProcess(pid) //nolint:forbidigo + if err != nil { + // process is already dead. + // no need to check if it's dead with Signal(0). + return + } + if err = p.Signal(syscall.Signal(0)); !errors.Is(err, os.ErrProcessDone) { //nolint:forbidigo + assert.Errorf(t, err, "process #%d should be dead, but exists", n) + } + } + assertProcess(t, b1.pid, 1) + assertProcess(t, b2.pid, 2) +} + +func TestBrowserMultiClose(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withSkipClose(), withLogCache()) + + require.NotPanicsf(t, tb.Close, "first call to browser.close should not panic") + require.NotPanicsf(t, tb.Close, "second call to browser.close should not panic") + tb.logCache.assertContains(t, "browser.close only once") +} + +func TestMultiConnectToSingleBrowser(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withSkipClose()) + defer tb.Close() + + ctx := context.Background() + + b1, err := tb.browserType.Connect(context.Background(), ctx, tb.wsURL) + require.NoError(t, err) + bctx1, err := b1.NewContext(nil) + require.NoError(t, err) + p1, err := bctx1.NewPage() + require.NoError(t, err, "failed to create page #1") + + b2, err := tb.browserType.Connect(context.Background(), ctx, tb.wsURL) + require.NoError(t, err) + bctx2, err := b2.NewContext(nil) + require.NoError(t, err) + + err = p1.Close(nil) + require.NoError(t, err, "failed to close page #1") + require.NoError(t, bctx1.Close()) + + p2, err := bctx2.NewPage() + require.NoError(t, err, "failed to create page #2") + err = p2.Close(nil) + require.NoError(t, err, "failed to close page #2") +} + +func TestCloseContext(t *testing.T) { + t.Parallel() + + t.Run("close_context", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + _, err := tb.NewContext(nil) + require.NoError(t, err) + + assert.NotNil(t, tb.Context()) + + err = tb.CloseContext() + require.NoError(t, err) + + assert.Nil(t, tb.Context()) + }) + + t.Run("err_no_context", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + assert.Nil(t, tb.Context()) + assert.Error(t, tb.CloseContext()) + }) +} + +func TestIsolateBrowserContexts(t *testing.T) { + t.Parallel() + tb := newTestBrowser(t) + + b1 := tb.Browser + b2, err := tb.browserType.Connect(context.Background(), tb.context(), tb.wsURL) + require.NoError(t, err) + t.Cleanup(b2.Close) + + bctx1, err := b1.NewContext(nil) + require.NoError(t, err) + bctx2, err := b2.NewContext(nil) + require.NoError(t, err) + + // both browser connections will receive onAttachedToTarget events. + // each Connection value should filter out events that are not related to + // the browser context it wasn't created from. + err = tb.run(tb.context(), func() error { + _, err := bctx1.NewPage() + return err + }, func() error { + _, err := bctx2.NewPage() + return err + }) + require.NoError(t, err) + + // assert.Len produces verbose output. so, use our own len. + bctx1PagesLen := len(bctx1.Pages()) + bctx2PagesLen := len(bctx2.Pages()) + assert.Equalf(t, 1, bctx1PagesLen, "browser context #1 should be attached to a single page, but got %d", bctx1PagesLen) + assert.Equalf(t, 1, bctx2PagesLen, "browser context #2 should be attached to a single page, but got %d", bctx2PagesLen) +} diff --git a/js/modules/k6/browser/tests/browser_type_test.go b/js/modules/k6/browser/tests/browser_type_test.go new file mode 100644 index 00000000000..67907e1b10b --- /dev/null +++ b/js/modules/k6/browser/tests/browser_type_test.go @@ -0,0 +1,65 @@ +package tests + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/browser" + "go.k6.io/k6/js/modules/k6/browser/chromium" + "go.k6.io/k6/js/modules/k6/browser/env" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" +) + +func TestBrowserTypeConnect(t *testing.T) { + t.Parallel() + + // Start a test browser so we can get its WS URL + // and use it to connect through BrowserType.Connect. + tb := newTestBrowser(t) + vu := k6test.NewVU(t) + bt := chromium.NewBrowserType(vu) + vu.ActivateVU() + + b, err := bt.Connect(context.Background(), context.Background(), tb.wsURL) + require.NoError(t, err) + _, err = b.NewPage(nil) + require.NoError(t, err) +} + +func TestBrowserTypeLaunchToConnect(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + bp := newTestBrowserProxy(t, tb) + + // Export WS URL env var + // pointing to test browser proxy + vu := k6test.NewVU(t, env.ConstLookup(env.WebSocketURLs, bp.wsURL())) + + // We have to call launch method through JS API in sobek + // to take mapping layer into account, instead of calling + // BrowserType.Launch method directly + root := browser.New() + mod := root.NewModuleInstance(vu) + jsMod, ok := mod.Exports().Default.(*browser.JSModule) + require.Truef(t, ok, "unexpected default mod export type %T", mod.Exports().Default) + + vu.ActivateVU() + vu.StartIteration(t) + + vu.SetVar(t, "browser", jsMod.Browser) + _, err := vu.RunAsync(t, ` + const p = await browser.newPage(); + await p.close(); + `) + require.NoError(t, err) + + // Verify the proxy, which's WS URL was set as + // K6_BROWSER_WS_URL, has received a connection req + require.True(t, bp.connected) + // Verify that no new process pids have been added + // to pid registry + require.Len(t, root.PidRegistry.Pids(), 0) +} diff --git a/js/modules/k6/browser/tests/doc.go b/js/modules/k6/browser/tests/doc.go new file mode 100644 index 00000000000..6abb9ccc8c7 --- /dev/null +++ b/js/modules/k6/browser/tests/doc.go @@ -0,0 +1,3 @@ +// Package tests provides integration tests. +// The `testBrowser` type enables us to test the browser module with a real browser. +package tests diff --git a/js/modules/k6/browser/tests/element_handle_test.go b/js/modules/k6/browser/tests/element_handle_test.go new file mode 100644 index 00000000000..1f52583d0d8 --- /dev/null +++ b/js/modules/k6/browser/tests/element_handle_test.go @@ -0,0 +1,601 @@ +package tests + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "image/png" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" +) + +//go:embed static/mouse_helper.js +var mouseHelperScriptSource string + +//nolint:gochecknoglobals +var htmlInputButton = fmt.Sprintf(` + + + + Button test + + + + + + + +`, mouseHelperScriptSource) + +func TestElementHandleBoundingBoxInvisibleElement(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + err := p.SetContent(`
hello
`, nil) + require.NoError(t, err) + element, err := p.Query("div") + require.NoError(t, err) + require.Nil(t, element.BoundingBox()) +} + +func TestElementHandleBoundingBoxSVG(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetContent(` + + + + `, nil) + require.NoError(t, err) + + element, err := p.Query("#therect") + require.NoError(t, err) + + bbox := element.BoundingBox() + pageFn := `e => { + const rect = e.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }` + box, err := p.Evaluate(pageFn, element) + require.NoError(t, err) + rect := convert(t, box, &common.Rect{}) + require.EqualValues(t, bbox, rect) +} + +func TestElementHandleClick(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetContent(htmlInputButton, nil) + require.NoError(t, err) + + button, err := p.Query("button") + require.NoError(t, err) + + opts := common.NewElementHandleClickOptions(button.Timeout()) + // FIX: this is just a workaround because navigation is never triggered + // and we'd be waiting for it to happen otherwise! + opts.NoWaitAfter = true + err = button.Click(opts) + require.NoError(t, err) + + res, err := p.Evaluate(`() => window['result']`) + require.NoError(t, err) + assert.Equal(t, res, "Clicked") +} + +func TestElementHandleClickWithNodeRemoved(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetContent(htmlInputButton, nil) + require.NoError(t, err) + + // Remove all nodes + _, err = p.Evaluate(`() => delete window['Node']`) + require.NoError(t, err) + + button, err := p.Query("button") + require.NoError(t, err) + + opts := common.NewElementHandleClickOptions(button.Timeout()) + // FIX: this is just a workaround because navigation is never triggered + // and we'd be waiting for it to happen otherwise! + opts.NoWaitAfter = true + err = button.Click(opts) + require.NoError(t, err) + + res, err := p.Evaluate(`() => window['result']`) + require.NoError(t, err) + assert.Equal(t, res, "Clicked") +} + +func TestElementHandleClickWithDetachedNode(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetContent(htmlInputButton, nil) + require.NoError(t, err) + button, err := p.Query("button") + require.NoError(t, err) + + // Detach node to panic when clicked + _, err = p.Evaluate(`button => button.remove()`, button) + require.NoError(t, err) + + opts := common.NewElementHandleClickOptions(button.Timeout()) + // FIX: this is just a workaround because navigation is never triggered + // and we'd be waiting for it to happen otherwise! + opts.NoWaitAfter = true + err = button.Click(opts) + assert.ErrorContains( + t, err, + "element is not attached to the DOM", + "expected click to result in correct error to panic", + ) +} + +func TestElementHandleClickConcealedLink(t *testing.T) { + t.Parallel() + + const ( + wantBefore = "🙈" + wantAfter = "🐵" + ) + + tb := newTestBrowser(t, withFileServer()) + + bcopts := common.DefaultBrowserContextOptions() + bcopts.Viewport = common.Viewport{ + Width: 500, + Height: 240, + } + bc, err := tb.NewContext(bcopts) + require.NoError(t, err) + + p, err := bc.NewPage() + require.NoError(t, err) + + clickResult := func() (any, error) { + const cmd = ` + () => window.clickResult + ` + return p.Evaluate(cmd) + } + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := p.Goto( + tb.staticURL("/concealed_link.html"), + opts, + ) + require.NotNil(t, resp) + require.NoError(t, err) + result, err := clickResult() + require.NoError(t, err) + require.Equal(t, wantBefore, result) + + err = p.Click("#concealed", common.NewFrameClickOptions(p.Timeout())) + require.NoError(t, err) + result, err = clickResult() + require.NoError(t, err) + require.Equal(t, wantAfter, result) +} + +func TestElementHandleNonClickable(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + + bctx, err := tb.NewContext(nil) + require.NoError(t, err) + p, err := bctx.NewPage() + require.NoError(t, err) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := p.Goto( + tb.staticURL("/non_clickable.html"), + opts, + ) + require.NotNil(t, resp) + require.NoError(t, err) + + err = p.Click("#non-clickable", common.NewFrameClickOptions(p.Timeout())) + require.Errorf(t, err, "element should not be clickable") +} + +func TestElementHandleGetAttribute(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + el, err := p.Query("#el") + require.NoError(t, err) + + got, ok, err := el.GetAttribute("href") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "null", got) +} + +func TestElementHandleGetAttributeMissing(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + el, err := p.Query("#el") + require.NoError(t, err) + + got, ok, err := el.GetAttribute("missing") + require.NoError(t, err) + require.False(t, ok) + assert.Equal(t, "", got) +} + +func TestElementHandleGetAttributeEmpty(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + el, err := p.Query("#el") + require.NoError(t, err) + + got, ok, err := el.GetAttribute("empty") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "", got) +} + +func TestElementHandleInputValue(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + err := p.SetContent(` + + + + `, nil) + require.NoError(t, err) + + element, err := p.Query("input") + require.NoError(t, err) + + value, err := element.InputValue(nil) + require.NoError(t, err) + require.NoError(t, element.Dispose()) + assert.Equal(t, value, "hello1", `expected input value "hello1", got %q`, value) + + element, err = p.Query("select") + require.NoError(t, err) + + value, err = element.InputValue(nil) + require.NoError(t, err) + require.NoError(t, element.Dispose()) + assert.Equal(t, value, "hello2", `expected input value "hello2", got %q`, value) + + element, err = p.Query("textarea") + require.NoError(t, err) + + value, err = element.InputValue(nil) + require.NoError(t, err) + require.NoError(t, element.Dispose()) + assert.Equal(t, value, "hello3", `expected input value "hello3", got %q`, value) +} + +func TestElementHandleIsChecked(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + element, err := p.Query("input") + require.NoError(t, err) + + checked, err := element.IsChecked() + require.NoError(t, err) + assert.True(t, checked, "expected checkbox to be checked") + require.NoError(t, element.Dispose()) + + err = p.SetContent(``, nil) + require.NoError(t, err) + element, err = p.Query("input") + require.NoError(t, err) + checked, err = element.IsChecked() + require.NoError(t, err) + assert.False(t, checked, "expected checkbox to be unchecked") + require.NoError(t, element.Dispose()) +} + +func TestElementHandleQueryAll(t *testing.T) { + t.Parallel() + + const ( + wantLiLen = 2 + query = "li.ali" + ) + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(` + + `, nil) + require.NoError(t, err) + + t.Run("element_handle", func(t *testing.T) { + t.Parallel() + + el, err := p.Query("#aul") + require.NoError(t, err) + + els, err := el.QueryAll(query) + require.NoError(t, err) + + assert.Equal(t, wantLiLen, len(els)) + }) + t.Run("page", func(t *testing.T) { + t.Parallel() + + els, err := p.QueryAll(query) + require.NoError(t, err) + + assert.Equal(t, wantLiLen, len(els)) + }) + t.Run("frame", func(t *testing.T) { + t.Parallel() + + els, err := p.MainFrame().QueryAll(query) + require.NoError(t, err) + assert.Equal(t, wantLiLen, len(els)) + }) +} + +type mockPersister struct{} + +func (m *mockPersister) Persist(_ context.Context, _ string, _ io.Reader) (err error) { + return nil +} + +func TestElementHandleScreenshot(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetViewportSize(tb.toSobekValue(struct { + Width float64 `js:"width"` + Height float64 `js:"height"` + }{Width: 800, Height: 600})) + require.NoError(t, err) + + _, err = p.Evaluate(` + () => { + document.body.style.margin = '0'; + document.body.style.padding = '0'; + document.documentElement.style.margin = '0'; + document.documentElement.style.padding = '0'; + + const div = document.createElement('div'); + div.style.marginTop = '400px'; + div.style.marginLeft = '100px'; + div.style.width = '100px'; + div.style.height = '100px'; + div.style.background = 'red'; + + document.body.appendChild(div); + } + `) + require.NoError(t, err) + + elem, err := p.Query("div") + require.NoError(t, err) + + buf, err := elem.Screenshot( + common.NewElementHandleScreenshotOptions(elem.Timeout()), + &mockPersister{}, + ) + require.NoError(t, err) + + reader := bytes.NewReader(buf) + img, err := png.Decode(reader) + assert.Nil(t, err) + + assert.Equal(t, 100, img.Bounds().Max.X, "screenshot width is not 100px as expected, but %dpx", img.Bounds().Max.X) + assert.Equal(t, 100, img.Bounds().Max.Y, "screenshot height is not 100px as expected, but %dpx", img.Bounds().Max.Y) + + r, g, b, _ := img.At(0, 0).RGBA() + assert.Equal(t, uint32(255), r>>8) // each color component has been scaled by alpha (<<8) + assert.Equal(t, uint32(0), g) + assert.Equal(t, uint32(0), b) + r, g, b, _ = img.At(99, 99).RGBA() + assert.Equal(t, uint32(255), r>>8) // each color component has been scaled by alpha (<<8) + assert.Equal(t, uint32(0), g) + assert.Equal(t, uint32(0), b) +} + +func TestElementHandleWaitForSelector(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + err := p.SetContent(`
`, nil) + require.NoError(t, err) + + root, err := p.Query(".root") + require.NoError(t, err) + + _, err = p.Evaluate(` + () => { + setTimeout(() => { + const div = document.createElement('div'); + div.className = 'element-to-appear'; + div.appendChild(document.createTextNode("Hello World")); + root = document.querySelector('.root'); + root.appendChild(div); + }, 100); + } + `) + require.NoError(t, err) + element, err := root.WaitForSelector(".element-to-appear", tb.toSobekValue(struct { + Timeout int64 `js:"timeout"` + }{Timeout: 1000})) + require.NoError(t, err) + require.NotNil(t, element, "expected element to have been found after wait") + + require.NoError(t, element.Dispose()) +} + +func TestElementHandlePress(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + + p := tb.NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + + el, err := p.Query("input") + require.NoError(t, err) + + require.NoError(t, el.Press("Shift+KeyA", nil)) + require.NoError(t, el.Press("KeyB", nil)) + require.NoError(t, el.Press("Shift+KeyC", nil)) + + v, err := el.InputValue(nil) + require.NoError(t, err) + require.Equal(t, "AbC", v) +} + +func TestElementHandleQuery(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`
hello
`, nil) + require.NoError(t, err) + + element, err := p.Query("bar") + + require.NoError(t, err) + require.Nil(t, element) +} + +func TestElementHandleTextContent(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`
Something
`, nil) + require.NoError(t, err) + + el, err := p.Query("#el") + require.NoError(t, err) + + got, ok, err := el.TextContent() + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "Something", got) +} + +func TestElementHandleTextContentMissing(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + // document never has text content. + js, err := p.EvaluateHandle(`() => document`) + require.NoError(t, err) + _, ok, err := js.AsElement().TextContent() + require.NoError(t, err) + require.False(t, ok) +} + +func TestElementHandleTextContentEmpty(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`
`, nil) + require.NoError(t, err) + + el, err := p.Query("#el") + require.NoError(t, err) + + got, ok, err := el.TextContent() + require.NoError(t, err) + require.True(t, ok) + require.Empty(t, got) +} + +func TestElementHandleSetChecked(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + element, err := p.Query("input") + require.NoError(t, err) + checked, err := element.IsChecked() + require.NoError(t, err) + require.False(t, checked, "expected checkbox to be unchecked") + + err = element.SetChecked(true, nil) + require.NoError(t, err) + checked, err = element.IsChecked() + require.NoError(t, err) + assert.True(t, checked, "expected checkbox to be checked") + + err = element.SetChecked(false, nil) + require.NoError(t, err) + checked, err = element.IsChecked() + require.NoError(t, err) + assert.False(t, checked, "expected checkbox to be unchecked") +} diff --git a/js/modules/k6/browser/tests/frame_manager_test.go b/js/modules/k6/browser/tests/frame_manager_test.go new file mode 100644 index 00000000000..7f1ff5a6df2 --- /dev/null +++ b/js/modules/k6/browser/tests/frame_manager_test.go @@ -0,0 +1,118 @@ +package tests + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" +) + +func TestWaitForFrameNavigationWithinDocument(t *testing.T) { + t.Parallel() + + const timeout = 5 * time.Second + + testCases := []struct { + name, selector string + }{ + {name: "history", selector: "a#nav-history"}, + {name: "anchor", selector: "a#nav-anchor"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + p := tb.NewPage(nil) + + opts := &common.FrameGotoOptions{ + WaitUntil: common.LifecycleEventNetworkIdle, + Timeout: timeout, + } + resp, err := p.Goto(tb.staticURL("/nav_in_doc.html"), opts) + require.NoError(t, err) + require.NotNil(t, resp) + + waitForNav := func() error { + opts := &common.FrameWaitForNavigationOptions{Timeout: timeout} + _, err := p.WaitForNavigation(opts) + return err + } + click := func() error { + return p.Click(tc.selector, common.NewFrameClickOptions(p.Timeout())) + } + ctx, cancel := context.WithTimeout(tb.ctx, timeout) + defer cancel() + err = tb.run(ctx, waitForNav, click) + require.NoError(t, err) + }) + } +} + +func TestWaitForFrameNavigation(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withHTTPServer()) + p := tb.NewPage(nil) + + tb.withHandler("/first", func(w http.ResponseWriter, _ *http.Request) { + _, err := fmt.Fprintf(w, ` + + + First page + + + click me + + + `) + require.NoError(t, err) + }) + tb.withHandler("/second", func(w http.ResponseWriter, _ *http.Request) { + _, err := fmt.Fprintf(w, ` + + + Second page + + + click me + + + `) + require.NoError(t, err) + }) + + opts := &common.FrameGotoOptions{ + WaitUntil: common.LifecycleEventNetworkIdle, + Timeout: common.DefaultTimeout, + } + _, err := p.Goto(tb.url("/first"), opts) + require.NoError(t, err) + + waitForNav := func() error { + opts := &common.FrameWaitForNavigationOptions{ + Timeout: 5000 * time.Millisecond, + } + _, err := p.WaitForNavigation(opts) + return err + } + click := func() error { + return p.Click(`a`, common.NewFrameClickOptions(p.Timeout())) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = tb.run(ctx, waitForNav, click) + require.NoError(t, err) + + title, err := p.Title() + require.NoError(t, err) + assert.Equal(t, "Second page", title) +} diff --git a/js/modules/k6/browser/tests/frame_test.go b/js/modules/k6/browser/tests/frame_test.go new file mode 100644 index 00000000000..5cc0fe388e3 --- /dev/null +++ b/js/modules/k6/browser/tests/frame_test.go @@ -0,0 +1,237 @@ +package tests + +import ( + "context" + "net/http" + "testing" + "time" + + "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/env" +) + +func TestFramePress(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + + p := tb.NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + + f := p.Frames()[0] + + require.NoError(t, f.Press("#text1", "Shift+KeyA", nil)) + require.NoError(t, f.Press("#text1", "KeyB", nil)) + require.NoError(t, f.Press("#text1", "Shift+KeyC", nil)) + + inputValue, err := f.InputValue("#text1", nil) + require.NoError(t, err) + require.Equal(t, "AbC", inputValue) +} + +func TestFrameDismissDialogBox(t *testing.T) { + t.Parallel() + + for _, tt := range []string{ + "alert", + "confirm", + "prompt", + "beforeunload", + } { + tt := tt + t.Run(tt, func(t *testing.T) { + t.Parallel() + + var ( + tb = newTestBrowser(t, withFileServer()) + p = tb.NewPage(nil) + ) + + opts := &common.FrameGotoOptions{ + WaitUntil: common.LifecycleEventNetworkIdle, + Timeout: common.DefaultTimeout, + } + _, err := p.Goto(tb.staticURL("dialog.html?dialogType="+tt), opts) + require.NoError(t, err) + + if tt == "beforeunload" { + err = p.Click("#clickHere", common.NewFrameClickOptions(p.Timeout())) + require.NoError(t, err) + } + + result, ok, err := p.TextContent("#textField", nil) + require.NoError(t, err) + require.True(t, ok) + assert.EqualValues(t, tt+" dismissed", result) + }) + } +} + +func TestFrameNoPanicWithEmbeddedIFrame(t *testing.T) { + t.Parallel() + + // We're skipping this when running in headless + // environments since the bug that the test fixes + // only surfaces when in headfull mode. + // Remove this skip once we have headfull mode in + // CI: https://go.k6.io/k6/js/modules/k6/browser/issues/678 + if env.IsBrowserHeadless() { + t.Skip("skipped when in headless mode") + } + + // run the browser in headfull mode. + tb := newTestBrowser( + t, + withFileServer(), + withEnvLookup(env.ConstLookup(env.BrowserHeadless, "0")), + ) + + p := tb.NewPage(nil) + opts := &common.FrameGotoOptions{ + WaitUntil: common.LifecycleEventDOMContentLoad, + Timeout: common.DefaultTimeout, + } + _, err := p.Goto(tb.staticURL("embedded_iframe.html"), opts) + require.NoError(t, err) + + result, ok, err := p.TextContent("#doneDiv", nil) + require.NoError(t, err) + require.True(t, ok) + assert.EqualValues(t, "Done!", result) +} + +// Without the fix in https://go.k6.io/k6/js/modules/k6/browser/pull/942 +// this test would hang on the "sign in" link click. +func TestFrameNoPanicNavigateAndClickOnPageWithIFrames(t *testing.T) { + t.Parallel() + + // We're skipping this when running in headless + // environments since the bug that the test fixes + // only surfaces when in headfull mode. + // Remove this skip once we have headfull mode in + // CI: https://go.k6.io/k6/js/modules/k6/browser/issues/678 + if env.IsBrowserHeadless() { + t.Skip("skipped when in headless mode") + } + + tb := newTestBrowser( + t, + withFileServer(), + withEnvLookup(env.ConstLookup(env.BrowserHeadless, "0")), + ) + p := tb.NewPage(nil) + tb.withHandler("/iframeSignIn", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, tb.staticURL("iframe_signin.html"), http.StatusMovedPermanently) + }) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := p.Goto( + tb.staticURL("iframe_home.html"), + opts, + ) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(tb.context(), 5*time.Second) + defer cancel() + + err = tb.run( + ctx, + func() error { return p.Click(`a[href="/iframeSignIn"]`, common.NewFrameClickOptions(p.Timeout())) }, + func() error { + _, err := p.WaitForNavigation( + common.NewFrameWaitForNavigationOptions(p.Timeout()), + ) + return err + }, + ) + require.NoError(t, err) + + result, ok, err := p.TextContent("#doneDiv", nil) + require.NoError(t, err) + require.True(t, ok) + assert.EqualValues(t, "Sign In Page", result) +} + +func TestFrameTitle(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent( + `Some title`, + nil, + ) + require.NoError(t, err) + + title, err := p.MainFrame().Title() + assert.NoError(t, err) + assert.Equal(t, "Some title", title) +} + +func TestFrameGetAttribute(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + got, ok, err := p.Frames()[0].GetAttribute("#el", "href", nil) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "null", got) +} + +func TestFrameGetAttributeMissing(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + got, ok, err := p.Frames()[0].GetAttribute("#el", "missing", nil) + require.NoError(t, err) + require.False(t, ok) + assert.Equal(t, "", got) +} + +func TestFrameGetAttributeEmpty(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + got, ok, err := p.Frames()[0].GetAttribute("#el", "empty", nil) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "", got) +} + +func TestFrameSetChecked(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(``, nil) + require.NoError(t, err) + checked, err := p.Frames()[0].IsChecked("#el", nil) + require.NoError(t, err) + assert.False(t, checked) + + err = p.Frames()[0].SetChecked("#el", true, nil) + require.NoError(t, err) + checked, err = p.Frames()[0].IsChecked("#el", nil) + require.NoError(t, err) + assert.True(t, checked) + + err = p.Frames()[0].SetChecked("#el", false, nil) + require.NoError(t, err) + checked, err = p.Frames()[0].IsChecked("#el", nil) + require.NoError(t, err) + assert.False(t, checked) +} diff --git a/js/modules/k6/browser/tests/helpers.go b/js/modules/k6/browser/tests/helpers.go new file mode 100644 index 00000000000..5e73a2451d7 --- /dev/null +++ b/js/modules/k6/browser/tests/helpers.go @@ -0,0 +1,40 @@ +package tests + +import ( + "testing" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/browser" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" +) + +// startIteration will work with the event system to start chrome and +// (more importantly) set browser as the mapped browser instance which will +// force all tests that work with this to go through the mapping layer. +// This returns a cleanup function which should be deferred. +// The opts are passed to k6test.NewVU as is without any modification. +func startIteration(t *testing.T, opts ...any) (*k6test.VU, *sobek.Runtime, *[]string, func()) { + t.Helper() + + vu := k6test.NewVU(t, opts...) + rt := vu.Runtime() + + mod := browser.New().NewModuleInstance(vu) + jsMod, ok := mod.Exports().Default.(*browser.JSModule) + require.Truef(t, ok, "unexpected default mod export type %T", mod.Exports().Default) + + // Setting the mapped browser into the vu's sobek runtime. + require.NoError(t, rt.Set("browser", jsMod.Browser)) + + // Setting log, which is used by the callers to assert that certain actions + // have been made. + var log []string + require.NoError(t, rt.Set("log", func(s string) { log = append(log, s) })) + + vu.ActivateVU() + vu.StartIteration(t) + + return vu, rt, &log, func() { t.Helper(); vu.EndIteration(t) } +} diff --git a/js/modules/k6/browser/tests/js_handle_get_properties_test.go b/js/modules/k6/browser/tests/js_handle_get_properties_test.go new file mode 100644 index 00000000000..59355640460 --- /dev/null +++ b/js/modules/k6/browser/tests/js_handle_get_properties_test.go @@ -0,0 +1,34 @@ +package tests + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSHandleGetProperties(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + handle, err := p.EvaluateHandle(` + () => { + return { + prop1: "one", + prop2: "two", + prop3: "three" + }; + } + `) + require.NoError(t, err, "expected no error when evaluating handle") + + props, err := handle.GetProperties() + require.NoError(t, err, "expected no error when getting properties") + + value, err := props["prop1"].JSONValue() + assert.NoError(t, err, "expected no error when getting JSONValue") + assert.Equal(t, value, "one", `expected property value of "one", got %q`, value) +} diff --git a/js/modules/k6/browser/tests/js_handle_test.go b/js/modules/k6/browser/tests/js_handle_test.go new file mode 100644 index 00000000000..7b1bb33e3b6 --- /dev/null +++ b/js/modules/k6/browser/tests/js_handle_test.go @@ -0,0 +1,109 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSHandleEvaluate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pageFunc string + args []any + expected string + }{ + { + name: "no_args", + pageFunc: `handle => handle.innerText`, + args: nil, + expected: "Some title", + }, + { + name: "with_args", + pageFunc: `(handle, a, b) => { + const c = a + b; + return handle.innerText + " " + c + }`, + args: []any{1, 2}, + expected: "Some title 3", + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetContent(`Some title`, nil) + require.NoError(t, err) + + result, err := p.EvaluateHandle(`() => document.head`) + require.NoError(t, err) + require.NotNil(t, result) + + got, err := result.Evaluate(tt.pageFunc, tt.args...) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestJSHandleEvaluateHandle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pageFunc string + args []any + expected string + }{ + { + name: "no_args", + pageFunc: `handle => { + return {"innerText": handle.innerText}; + }`, + args: nil, + expected: `{"innerText":"Some title"}`, + }, + { + name: "with_args", + pageFunc: `(handle, a, b) => { + return {"innerText": handle.innerText, "sum": a + b}; + }`, + args: []any{1, 2}, + expected: `{"innerText":"Some title","sum":3}`, + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetContent(`Some title`, nil) + require.NoError(t, err) + + result, err := p.EvaluateHandle(`() => document.head`) + require.NoError(t, err) + require.NotNil(t, result) + + got, err := result.EvaluateHandle(tt.pageFunc, tt.args...) + require.NoError(t, err) + assert.NotNil(t, got) + + j, err := got.JSONValue() + require.NoError(t, err) + assert.Equal(t, tt.expected, j) + }) + } +} diff --git a/js/modules/k6/browser/tests/keyboard_test.go b/js/modules/k6/browser/tests/keyboard_test.go new file mode 100644 index 00000000000..091892438f6 --- /dev/null +++ b/js/modules/k6/browser/tests/keyboard_test.go @@ -0,0 +1,316 @@ +package tests + +import ( + "context" + _ "embed" + "runtime" + "testing" + "time" + + "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/keyboardlayout" +) + +func TestKeyboardPress(t *testing.T) { + t.Parallel() + + t.Run("all_keys", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + kb := p.GetKeyboard() + layout := keyboardlayout.GetKeyboardLayout("us") + + for k := range layout.Keys { + assert.NoError(t, kb.Press(string(k), common.KeyboardOptions{})) + } + }) + + t.Run("backspace", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + kb := p.GetKeyboard() + + err := p.SetContent(``, nil) + require.NoError(t, err) + el, err := p.Query("input") + require.NoError(t, err) + require.NoError(t, p.Focus("input", nil)) + + require.NoError(t, kb.Type("Hello World!", common.KeyboardOptions{})) + v, err := el.InputValue(nil) + require.NoError(t, err) + require.Equal(t, "Hello World!", v) + + require.NoError(t, kb.Press("Backspace", common.KeyboardOptions{})) + v, err = el.InputValue(nil) + require.NoError(t, err) + assert.Equal(t, "Hello World", v) + }) + + t.Run("combo", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + kb := p.GetKeyboard() + + err := p.SetContent(``, nil) + require.NoError(t, err) + el, err := p.Query("input") + require.NoError(t, err) + require.NoError(t, p.Focus("input", nil)) + + require.NoError(t, kb.Press("Shift++", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+=", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+@", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+6", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+KeyA", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+b", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+C", common.KeyboardOptions{})) + + require.NoError(t, kb.Press("Control+KeyI", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Control+J", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Control+k", common.KeyboardOptions{})) + + v, err := el.InputValue(nil) + require.NoError(t, err) + require.Equal(t, "+=@6AbC", v) + }) + + t.Run("control_or_meta", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + p := tb.NewPage(nil) + + // Navigate to page1 + url := tb.staticURL("page1.html") + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := p.Goto( + url, + opts, + ) + assert.NoError(t, err) + + // Make sure the h1 header is "Page 1" + text, err := p.Locator("h1", nil).InnerText(nil) + assert.NoError(t, err) + assert.Equal(t, "Page 1", text) + + ctx, cancel := context.WithTimeout(tb.context(), 5*time.Second) + defer cancel() + + bc := tb.Browser.Context() + var newTab *common.Page + + // We want to meta/control click the link so that it opens in a new tab. + // At the same time we will wait for a new page creation with WaitForEvent. + err = tb.run(ctx, + func() error { + var resp any + resp, err := bc.WaitForEvent("page", nil, 5*time.Second) + if err != nil { + return err + } + + var ok bool + newTab, ok = resp.(*common.Page) + assert.True(t, ok) + + return nil + }, + func() error { + kb := p.GetKeyboard() + assert.NoError(t, kb.Down("ControlOrMeta")) + err = p.Locator(`a[href="page2.html"]`, nil).Click(common.NewFrameClickOptions(p.Timeout())) + assert.NoError(t, err) + assert.NoError(t, kb.Up("ControlOrMeta")) + + return nil + }, + ) + require.NoError(t, err) + + // Wait for the new tab to complete loading. + assert.NoError(t, newTab.WaitForLoadState("load", nil)) + + // Make sure the newTab has a different h1 heading. + text, err = newTab.Locator("h1", nil).InnerText(nil) + assert.NoError(t, err) + assert.Equal(t, "Page 2", text) + + // Make sure there are two pages open. + pp := bc.Pages() + assert.Len(t, pp, 2) + }) + + t.Run("meta", func(t *testing.T) { + t.Parallel() + t.Skip("FIXME") // See https://go.k6.io/k6/js/modules/k6/browser/issues/424 + tb := newTestBrowser(t) + p := tb.NewPage(nil) + kb := p.GetKeyboard() + + err := p.SetContent(``, nil) + require.NoError(t, err) + el, err := p.Query("input") + require.NoError(t, err) + require.NoError(t, p.Focus("input", nil)) + + require.NoError(t, kb.Press("Shift+KeyA", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+b", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Shift+C", common.KeyboardOptions{})) + + v, err := el.InputValue(nil) + require.NoError(t, err) + require.Equal(t, "AbC", v) + + metaKey := "Control" + if runtime.GOOS == "darwin" { + metaKey = "Meta" + } + require.NoError(t, kb.Press(metaKey+"+A", common.KeyboardOptions{})) + require.NoError(t, kb.Press("Delete", common.KeyboardOptions{})) + v, err = el.InputValue(nil) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("type does not split on +", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + kb := p.GetKeyboard() + + err := p.SetContent(` + `, nil) + require.NoError(t, err) + + inputValue, err := p.InputValue("input", nil) + require.NoError(t, err) + got, want := inputValue, "hello1" + assert.Equal(t, got, want) + + inputValue, err = p.InputValue("select", nil) + require.NoError(t, err) + got, want = inputValue, "hello2" + assert.Equal(t, got, want) + + inputValue, err = p.InputValue("textarea", nil) + require.NoError(t, err) + got, want = inputValue, "hello3" + assert.Equal(t, got, want) +} + +// test for: https://go.k6.io/k6/js/modules/k6/browser/issues/132 +func TestPageInputSpecialCharacters(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + el, err := p.Query("#special") + require.NoError(t, err) + + wants := []string{ + "test@k6.io", + "", + "{(hello world!)}", + "!#$%^&*()+_|~±", + `¯\_(ツ)_/¯`, + } + for _, want := range wants { + require.NoError(t, el.Fill("", nil)) + require.NoError(t, el.Type(want, nil)) + + got, err := el.InputValue(nil) + require.NoError(t, err) + assert.Equal(t, want, got) + } +} + +//nolint:paralleltest +func TestPageFill(t *testing.T) { + // these tests are not parallel by intention because + // they're testing the same page instance and they're + // faster when run sequentially. + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(` + + + + + `, nil) + require.NoError(t, err) + + happy := []struct{ name, selector, value string }{ + {name: "text", selector: "#text", value: "fill me up"}, + {name: "date", selector: "#date", value: "2012-03-13"}, + {name: "number", selector: "#number", value: "42"}, + } + sad := []struct{ name, selector, value string }{ + {name: "date", selector: "#date", value: "invalid date"}, + {name: "number", selector: "#number", value: "forty two"}, + {name: "unfillable", selector: "#unfillable", value: "can't touch this"}, + } + for _, tt := range happy { + t.Run("happy/"+tt.name, func(t *testing.T) { + err := p.Fill(tt.selector, tt.value, nil) + require.NoError(t, err) + inputValue, err := p.InputValue(tt.selector, nil) + require.NoError(t, err) + require.Equal(t, tt.value, inputValue) + }) + } + for _, tt := range sad { + t.Run("sad/"+tt.name, func(t *testing.T) { + err := p.Fill(tt.selector, tt.value, nil) + require.Error(t, err) + }) + } +} + +func TestPageIsChecked(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + checked, err := p.IsChecked("input", nil) + require.NoError(t, err) + assert.True(t, checked, "expected checkbox to be checked") + + err = p.SetContent(``, nil) + require.NoError(t, err) + checked, err = p.IsChecked("input", nil) + require.NoError(t, err) + assert.False(t, checked, "expected checkbox to be unchecked") +} + +func TestPageSetChecked(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(``, nil) + require.NoError(t, err) + checked, err := p.IsChecked("#el", nil) + require.NoError(t, err) + assert.False(t, checked) + + err = p.SetChecked("#el", true, nil) + require.NoError(t, err) + checked, err = p.IsChecked("#el", nil) + require.NoError(t, err) + assert.True(t, checked) + + err = p.SetChecked("#el", false, nil) + require.NoError(t, err) + checked, err = p.IsChecked("#el", nil) + require.NoError(t, err) + assert.False(t, checked) +} + +func TestPageScreenshotFullpage(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + err := p.SetViewportSize(tb.toSobekValue(struct { + Width float64 `js:"width"` + Height float64 `js:"height"` + }{ + Width: 1280, Height: 800, + })) + require.NoError(t, err) + + _, err = p.Evaluate(` + () => { + document.body.style.margin = '0'; + document.body.style.padding = '0'; + document.documentElement.style.margin = '0'; + document.documentElement.style.padding = '0'; + + const div = document.createElement('div'); + div.style.width = '1280px'; + div.style.height = '800px'; + div.style.background = 'linear-gradient(red, blue)'; + + document.body.appendChild(div); + }`) + require.NoError(t, err) + + opts := common.NewPageScreenshotOptions() + opts.FullPage = true + buf, err := p.Screenshot(opts, &mockPersister{}) + require.NoError(t, err) + + reader := bytes.NewReader(buf) + img, err := png.Decode(reader) + assert.Nil(t, err) + + assert.Equal(t, 1280, img.Bounds().Max.X, "screenshot width is not 1280px as expected, but %dpx", img.Bounds().Max.X) + assert.Equal(t, 800, img.Bounds().Max.Y, "screenshot height is not 800px as expected, but %dpx", img.Bounds().Max.Y) + + r, _, b, _ := img.At(0, 0).RGBA() + assert.Greater(t, r, uint32(128)) + assert.Less(t, b, uint32(128)) + r, _, b, _ = img.At(0, 799).RGBA() + assert.Less(t, r, uint32(128)) + assert.Greater(t, b, uint32(128)) +} + +func TestPageTitle(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Some title`, nil) + require.NoError(t, err) + title, err := p.Title() + require.NoError(t, err) + assert.Equal(t, "Some title", title) +} + +func TestPageSetExtraHTTPHeaders(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t, withHTTPServer()) + + p := b.NewPage(nil) + + headers := map[string]string{ + "Some-Header": "Some-Value", + } + err := p.SetExtraHTTPHeaders(headers) + require.NoError(t, err) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := p.Goto( + b.url("/get"), + opts, + ) + require.NoError(t, err) + require.NotNil(t, resp) + + responseBody, err := resp.Body() + require.NoError(t, err) + + var body struct{ Headers map[string][]string } + err = json.Unmarshal(responseBody, &body) + require.NoError(t, err) + + h := body.Headers["Some-Header"] + require.NotEmpty(t, h) + assert.Equal(t, "Some-Value", h[0]) +} + +func TestPageWaitForFunction(t *testing.T) { + t.Parallel() + + // script is here to test we're not getting an error from the + // waitForFunction call itself and the tests that use it are + // testing the polling functionality—not the response from + // waitForFunction. + script := ` + page = await browser.newPage(); + let resp = await page.waitForFunction(%s, %s, %s); + log('ok: '+resp);` + + t.Run("ok_func_raf_default", func(t *testing.T) { + t.Parallel() + + vu, _, log, cleanUp := startIteration(t) + defer cleanUp() + + vu.SetVar(t, "page", &sobek.Object{}) + _, err := vu.RunOnEventLoop(t, `fn = () => { + if (typeof window._cnt == 'undefined') window._cnt = 0; + if (window._cnt >= 50) return true; + window._cnt++; + return false; + }`) + require.NoError(t, err) + + _, err = vu.RunAsync(t, script, "fn", "{}", "null") + require.NoError(t, err) + assert.Contains(t, *log, "ok: null") + }) + + t.Run("ok_func_raf_default_arg", func(t *testing.T) { + t.Parallel() + + vu, _, log, cleanUp := startIteration(t) + defer cleanUp() + + _, err := vu.RunOnEventLoop(t, `fn = arg => { + window._arg = arg; + return true; + }`) + require.NoError(t, err) + + _, err = vu.RunAsync(t, script, "fn", "{}", `"raf_arg"`) + require.NoError(t, err) + assert.Contains(t, *log, "ok: null") + + p := vu.RunPromise(t, `return await page.evaluate(() => window._arg);`) + require.Equal(t, p.State(), sobek.PromiseStateFulfilled) + assert.Equal(t, "raf_arg", p.Result().String()) + }) + + t.Run("ok_func_raf_default_args", func(t *testing.T) { + t.Parallel() + + vu, rt, log, cleanUp := startIteration(t) + defer cleanUp() + + _, err := vu.RunOnEventLoop(t, `fn = (...args) => { + window._args = args; + return true; + }`) + require.NoError(t, err) + + args := []int{1, 2, 3} + argsJS, err := json.Marshal(args) + require.NoError(t, err) + + _, err = vu.RunAsync(t, script, "fn", "{}", "..."+string(argsJS)) + require.NoError(t, err) + assert.Contains(t, *log, "ok: null") + + p := vu.RunPromise(t, `return await page.evaluate(() => window._args);`) + require.Equal(t, p.State(), sobek.PromiseStateFulfilled) + var gotArgs []int + _ = rt.ExportTo(p.Result(), &gotArgs) + assert.Equal(t, args, gotArgs) + }) + + t.Run("err_expr_raf_timeout", func(t *testing.T) { + t.Parallel() + + vu, _, _, cleanUp := startIteration(t) + defer cleanUp() + + _, err := vu.RunAsync(t, script, "false", "{ polling: 'raf', timeout: 500 }", "null") + require.ErrorContains(t, err, "timed out after 500ms") + }) + + t.Run("err_wrong_polling", func(t *testing.T) { + t.Parallel() + + vu, _, _, cleanUp := startIteration(t) + defer cleanUp() + + _, err := vu.RunAsync(t, script, "false", "{ polling: 'blah' }", "null") + require.Error(t, err) + assert.Contains(t, err.Error(), + `parsing waitForFunction options: wrong polling option value:`, + `"blah"; possible values: "raf", "mutation" or number`) + }) + + t.Run("ok_expr_poll_interval", func(t *testing.T) { + t.Parallel() + + vu, _, log, cleanUp := startIteration(t) + defer cleanUp() + + vu.SetVar(t, "page", &sobek.Object{}) + _, err := vu.RunAsync(t, ` + page = await browser.newPage(); + await page.evaluate(() => { + setTimeout(() => { + const el = document.createElement('h1'); + el.innerHTML = 'Hello'; + document.body.appendChild(el); + }, 1000); + });`, + ) + require.NoError(t, err) + + script := ` + let resp = await page.waitForFunction(%s, %s, %s); + if (resp) { + log('ok: '+resp.innerHTML()); + } else { + log('err: '+err); + }` + _, err = vu.RunAsync(t, script, `"document.querySelector('h1')"`, "{ polling: 100, timeout: 2000, }", "null") + require.NoError(t, err) + assert.Contains(t, *log, "ok: Hello") + }) + + t.Run("ok_func_poll_mutation", func(t *testing.T) { + t.Parallel() + + vu, _, log, cleanUp := startIteration(t) + defer cleanUp() + + vu.SetVar(t, "page", &sobek.Object{}) + _, err := vu.RunAsync(t, ` + fn = () => document.querySelector('h1') !== null + + page = await browser.newPage(); + await page.evaluate(() => { + console.log('calling setTimeout...'); + setTimeout(() => { + console.log('creating element...'); + const el = document.createElement('h1'); + el.innerHTML = 'Hello'; + document.body.appendChild(el); + }, 1000); + })`, + ) + require.NoError(t, err) + + script := ` + let resp = await page.waitForFunction(%s, %s, %s); + log('ok: '+resp);` + + _, err = vu.RunAsync(t, script, "fn", "{ polling: 'mutation', timeout: 2000, }", "null") + require.NoError(t, err) + assert.Contains(t, *log, "ok: null") + }) +} + +func TestPageWaitForLoadState(t *testing.T) { + t.Parallel() + + t.Run("err_wrong_event", func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + p := tb.NewPage(nil) + err := p.WaitForLoadState("none", nil) + require.ErrorContains(t, err, `invalid lifecycle event: "none"; must be one of: load, domcontentloaded, networkidle`) + }) +} + +// See: The issue #187 for details. +func TestPageWaitForNavigationErrOnCtxDone(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t) + p := b.NewPage(nil) + go b.cancelContext() + <-b.context().Done() + _, err := p.WaitForNavigation( + common.NewFrameWaitForNavigationOptions(p.Timeout()), + ) + require.ErrorContains(t, err, "canceled") +} + +func TestPagePress(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + + p := tb.NewPage(nil) + + err := p.SetContent(``, nil) + require.NoError(t, err) + + require.NoError(t, p.Press("#text1", "Shift+KeyA", nil)) + require.NoError(t, p.Press("#text1", "KeyB", nil)) + require.NoError(t, p.Press("#text1", "Shift+KeyC", nil)) + + inputValue, err := p.InputValue("#text1", nil) + require.NoError(t, err) + require.Equal(t, "AbC", inputValue) +} + +func TestPageURL(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t, withHTTPServer()) + + p := b.NewPage(nil) + uri, err := p.URL() + require.NoError(t, err) + assert.Equal(t, common.BlankPage, uri) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := p.Goto( + b.url("/get"), + opts, + ) + require.NoError(t, err) + require.NotNil(t, resp) + uri, err = p.URL() + require.NoError(t, err) + assert.Regexp(t, "http://.*/get", uri) +} + +func TestPageClose(t *testing.T) { + t.Parallel() + + t.Run("page_from_browser", func(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t, withHTTPServer()) + + p := b.NewPage(nil) + + err := p.Close(nil) + assert.NoError(t, err) + }) + + t.Run("page_from_browserContext", func(t *testing.T) { + t.Parallel() + + b := newTestBrowser(t, withHTTPServer()) + + c, err := b.NewContext(nil) + require.NoError(t, err) + p, err := c.NewPage() + require.NoError(t, err) + + err = p.Close(nil) + assert.NoError(t, err) + }) +} + +func TestPageOn(t *testing.T) { + t.Parallel() + + const blankPage = "about:blank" + + testCases := []struct { + name string + consoleFn string + assertFn func(*testing.T, *common.ConsoleMessage) + }{ + { + name: "on console.log", + consoleFn: "() => console.log('this is a log message')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "log", cm.Type) + assert.Equal(t, "this is a log message", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, "this is a log message", val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.debug", + consoleFn: "() => console.debug('this is a debug message')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "debug", cm.Type) + assert.Equal(t, "this is a debug message", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, "this is a debug message", val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.info", + consoleFn: "() => console.info('this is an info message')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "info", cm.Type) + assert.Equal(t, "this is an info message", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, "this is an info message", val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.error", + consoleFn: "() => console.error('this is an error message')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "error", cm.Type) + assert.Equal(t, "this is an error message", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, "this is an error message", val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.warn", + consoleFn: "() => console.warn('this is a warning message')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "warning", cm.Type) + assert.Equal(t, "this is a warning message", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, "this is a warning message", val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.dir", + consoleFn: "() => console.dir(document.location)", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "dir", cm.Type) + assert.Equal(t, "Location", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.dirxml", + consoleFn: "() => console.dirxml(document.location)", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "dirxml", cm.Type) + assert.Equal(t, "Location", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.table", + consoleFn: "() => console.table([['Grafana', 'k6'], ['Grafana', 'Mimir']])", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "table", cm.Type) + assert.Equal(t, "Array(2)", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, `[["Grafana","k6"],["Grafana","Mimir"]]`, val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.trace", + consoleFn: "() => console.trace('trace example')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "trace", cm.Type) + assert.Equal(t, "trace example", cm.Text) + val, err := cm.Args[0].JSONValue() + assert.NoError(t, err) + assert.Equal(t, "trace example", val) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.clear", + consoleFn: "() => console.clear()", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "clear", cm.Type) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.group", + consoleFn: "() => console.group()", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "startGroup", cm.Type) + assert.Equal(t, "console.group", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.groupCollapsed", + consoleFn: "() => console.groupCollapsed()", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "startGroupCollapsed", cm.Type) + assert.Equal(t, "console.groupCollapsed", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.groupEnd", + consoleFn: "() => console.groupEnd()", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "endGroup", cm.Type) + assert.Equal(t, "console.groupEnd", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.assert", + consoleFn: "() => console.assert(2 == 3)", // Only writes to console if assertion is false + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "assert", cm.Type) + assert.Equal(t, "console.assert", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.count (default label)", + consoleFn: "() => console.count()", // default label + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "count", cm.Type) + assert.Equal(t, "default: 1", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.count", + consoleFn: "() => console.count('k6')", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "count", cm.Type) + assert.Equal(t, "k6: 1", cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + { + name: "on console.time", + consoleFn: "() => { console.time('k6'); console.timeEnd('k6'); }", + assertFn: func(t *testing.T, cm *common.ConsoleMessage) { + t.Helper() + assert.Equal(t, "timeEnd", cm.Type) + assert.Regexp(t, `^k6: [0-9]+\.[0-9]+`, cm.Text, `expected prefix "k6: " but got %q`, cm.Text) + uri, err := cm.Page.URL() + require.NoError(t, err) + assert.True(t, uri == blankPage, "url is not %s", blankPage) + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Use withSkipClose() opt as we will close it + // manually to force the page.TaskQueue closing + tb := newTestBrowser(t) + p := tb.NewPage(nil) + + var ( + done1 = make(chan bool) + done2 = make(chan bool) + + testTO = 2500 * time.Millisecond + ) + + // Console Messages should be multiplexed for every registered handler + eventHandlerOne := func(event common.PageOnEvent) error { + defer close(done1) + tc.assertFn(t, event.ConsoleMessage) + return nil + } + + eventHandlerTwo := func(event common.PageOnEvent) error { + defer close(done2) + tc.assertFn(t, event.ConsoleMessage) + return nil + } + + // eventHandlerOne and eventHandlerTwo will be called from a + // separate goroutine from within the page's async event loop. + // This is why we need to wait on done1 and done2 to be closed. + err := p.On("console", eventHandlerOne) + require.NoError(t, err) + + err = p.On("console", eventHandlerTwo) + require.NoError(t, err) + + _, err = p.Evaluate(tc.consoleFn) + require.NoError(t, err) + + select { + case <-done1: + case <-time.After(testTO): + assert.Fail(t, "test timed out before eventHandlerOne completed") + } + + select { + case <-done2: + case <-time.After(testTO): + assert.Fail(t, "test timed out before eventHandlerTwo completed") + } + }) + } +} + +func TestPageTimeout(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + defaultTimeout time.Duration + defaultNavigationTimeout time.Duration + }{ + { + name: "fail when timeout exceeds default timeout", + defaultTimeout: 1 * time.Millisecond, + }, + { + name: "fail when timeout exceeds default navigation timeout", + defaultNavigationTimeout: 1 * time.Millisecond, + }, + { + name: "default navigation timeout supersedes default timeout", + defaultTimeout: 30 * time.Second, + defaultNavigationTimeout: 1 * time.Millisecond, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withHTTPServer()) + + tb.withHandler("/slow", func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(100 * time.Millisecond) + _, err := fmt.Fprintf(w, `sorry for being so slow`) + require.NoError(t, err) + }) + + p := tb.NewPage(nil) + + var timeout time.Duration + if tc.defaultTimeout != 0 { + timeout = tc.defaultTimeout + p.SetDefaultTimeout(tc.defaultTimeout.Milliseconds()) + } + if tc.defaultNavigationTimeout != 0 { + timeout = tc.defaultNavigationTimeout + p.SetDefaultNavigationTimeout(tc.defaultNavigationTimeout.Milliseconds()) + } + + opts := &common.FrameGotoOptions{ + Timeout: timeout, + } + res, err := p.Goto( + tb.url("/slow"), + opts, + ) + require.Nil(t, res) + assert.ErrorContains(t, err, "timed out after") + }) + } +} + +func TestPageWaitForSelector(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + url string + opts map[string]any + selector string + errAssert func(*testing.T, error) + }{ + { + name: "should wait for selector", + url: "wait_for.html", + selector: "#my-div", + errAssert: func(t *testing.T, e error) { + t.Helper() + assert.Nil(t, e) + }, + }, + { + name: "should TO waiting for selector", + url: "wait_for.html", + opts: map[string]any{ + // set a timeout smaller than the time + // it takes the element to show up + "timeout": "1", + }, + selector: "#my-div", + errAssert: func(t *testing.T, e error) { + t.Helper() + assert.ErrorContains(t, e, "timed out after") + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + + page := tb.NewPage(nil) + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := page.Goto( + tb.staticURL(tc.url), + opts, + ) + require.NoError(t, err) + + _, err = page.WaitForSelector(tc.selector, tb.toSobekValue(tc.opts)) + tc.errAssert(t, err) + }) + } +} + +func TestPageThrottleNetwork(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + networkProfile common.NetworkProfile + wantMinRoundTripDuration int64 + }{ + { + name: "none", + networkProfile: common.NetworkProfile{ + Latency: 0, + Download: -1, + Upload: -1, + }, + }, + { + // In the ping.html file, an async ping request is made. The time it takes + // to perform the roundtrip of calling ping and getting the response is + // measured and used to assert that Latency has been correctly used. + name: "latency", + networkProfile: common.NetworkProfile{ + Latency: 100, + Download: -1, + Upload: -1, + }, + wantMinRoundTripDuration: 100, + }, + { + // In the ping.html file, an async ping request is made, the ping response + // returns the request body (around a 1MB). The time it takes to perform the + // roundtrip of calling ping and getting the response body is measured and + // used to assert that Download has been correctly used. + name: "download", + networkProfile: common.NetworkProfile{ + Latency: 0, + Download: 1000, + Upload: -1, + }, + wantMinRoundTripDuration: 1000, + }, + { + // In the ping.html file, an async ping request is made with around a 1MB body. + // The time it takes to perform the roundtrip of calling ping is measured + // and used to assert that Upload has been correctly used. + name: "upload", + networkProfile: common.NetworkProfile{ + Latency: 0, + Download: -1, + Upload: 1000, + }, + wantMinRoundTripDuration: 1000, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + + tb.withHandler("/ping", func(w http.ResponseWriter, req *http.Request) { + defer func() { + err := req.Body.Close() + require.NoError(t, err) + }() + bb, err := io.ReadAll(req.Body) + require.NoError(t, err) + + _, err = fmt.Fprint(w, string(bb)) + require.NoError(t, err) + }) + + page := tb.NewPage(nil) + + err := page.ThrottleNetwork(tc.networkProfile) + require.NoError(t, err) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err = page.Goto( + tb.staticURL("ping.html"), + opts, + ) + require.NoError(t, err) + + selector := `div[id="result"]` + + // result selector only appears once the page gets a response + // from the async ping request. + _, err = page.WaitForSelector(selector, nil) + require.NoError(t, err) + + resp, err := page.InnerText(selector, nil) + require.NoError(t, err) + ms, err := strconv.ParseInt(resp, 10, 64) + require.NoError(t, err) + assert.GreaterOrEqual(t, ms, tc.wantMinRoundTripDuration) + }) + } +} + +// This test will first navigate to the ping.html site a few times, record the +// average time it takes to run the test. Next it will repeat the steps but +// first apply CPU throttling. The average duration with CPU throttling +// enabled should be longer than without it. +func TestPageThrottleCPU(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + + tb.withHandler("/ping", func(w http.ResponseWriter, req *http.Request) { + defer func() { + err := req.Body.Close() + require.NoError(t, err) + }() + bb, err := io.ReadAll(req.Body) + require.NoError(t, err) + + _, err = fmt.Fprint(w, string(bb)) + require.NoError(t, err) + }) + + page := tb.NewPage(nil) + const iterations = 5 + + noCPUThrottle := performPingTest(t, tb, page, iterations) + + err := page.ThrottleCPU(common.CPUProfile{ + Rate: 50, + }) + require.NoError(t, err) + + withCPUThrottle := performPingTest(t, tb, page, iterations) + + assert.Greater(t, withCPUThrottle, noCPUThrottle) +} + +func performPingTest(t *testing.T, tb *testBrowser, page *common.Page, iterations int) int64 { + t.Helper() + + var ms int64 + for i := 0; i < iterations; i++ { + start := time.Now() + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := page.Goto( + tb.staticURL("ping.html"), + opts, + ) + require.NoError(t, err) + + selector := `div[id="result"]` + + // result selector only appears once the page gets a response + // from the async ping request. + _, err = page.WaitForSelector(selector, nil) + require.NoError(t, err) + + ms += time.Since(start).Abs().Milliseconds() + } + + return ms / int64(iterations) +} + +func TestPageIsVisible(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + selector string + options common.FrameIsVisibleOptions + want bool + wantErr string + }{ + { + name: "visible", + selector: "div[id=my-div]", + want: true, + }, + { + name: "not_visible", + selector: "div[id=my-div-3]", + want: false, + }, + { + name: "not_found", + selector: "div[id=does-not-exist]", + want: false, + }, + { + name: "first_div", + selector: "div", + want: true, + }, + { + name: "first_div", + selector: "div", + options: common.FrameIsVisibleOptions{ + Strict: true, + }, + wantErr: "error:strictmodeviolation", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + + page := tb.NewPage(nil) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := page.Goto( + tb.staticURL("visible.html"), + opts, + ) + require.NoError(t, err) + + got, err := page.IsVisible(tc.selector, tb.toSobekValue(tc.options)) + + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestPageIsHidden(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + selector string + options common.FrameIsVisibleOptions + want bool + wantErr string + }{ + { + name: "hidden", + selector: "div[id=my-div-3]", + want: true, + }, + { + name: "visible", + selector: "div[id=my-div]", + want: false, + }, + { + name: "not_found", + selector: "div[id=does-not-exist]", + want: true, + }, + { + name: "first_div", + selector: "div", + want: false, + }, + { + name: "first_div", + selector: "div", + options: common.FrameIsVisibleOptions{ + Strict: true, + }, + wantErr: "error:strictmodeviolation", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + + page := tb.NewPage(nil) + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + _, err := page.Goto( + tb.staticURL("visible.html"), + opts, + ) + require.NoError(t, err) + + got, err := page.IsHidden(tc.selector, tb.toSobekValue(tc.options)) + + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestShadowDOMAndDocumentFragment(t *testing.T) { + t.Parallel() + + // Start a server that will return static html files. + mux := http.NewServeMux() + s := httptest.NewServer(mux) + t.Cleanup(s.Close) + + const ( + slash = string(os.PathSeparator) //nolint:forbidigo + path = slash + testBrowserStaticDir + slash + ) + fs := http.FileServer(http.Dir(testBrowserStaticDir)) + mux.Handle(path, http.StripPrefix(path, fs)) + + tests := []struct { + name string + selector string + want string + }{ + { + // This test waits for an element that is in the DocumentFragment. + name: "waitFor_DocumentFragment", + selector: `//p[@id="inDocFrag"]`, + want: "This text is added via a document fragment!", + }, + { + // This test waits for an element that is in the DocumentFragment + // that is within an open shadow root. + name: "waitFor_ShadowRoot_DocumentFragment", + selector: `//p[@id="inShadowRootDocFrag"]`, + want: "This is inside Shadow DOM, added via a DocumentFragment!", + }, + { + // This test waits for an element that is in the original Document. + name: "waitFor_done", + selector: `//div[@id="done"]`, + want: "All additions to page completed (i'm in the original document)", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + vu, _, _, cleanUp := startIteration(t) + defer cleanUp() + + got := vu.RunPromise(t, ` + const p = await browser.newPage() + await p.goto("%s/%s/shadow_and_doc_frag.html") + + const s = p.locator('%s') + await s.waitFor({ + timeout: 1000, + state: 'attached', + }); + + const text = await s.innerText(); + return text; + `, s.URL, testBrowserStaticDir, tt.selector) + assert.Equal(t, tt.want, got.Result().String()) + }) + } +} + +func TestPageTargetBlank(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withHTTPServer()) + tb.withHandler("/home", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte( + ` + click me + `, + )) + require.NoError(t, err) + }) + tb.withHandler("/link", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write( + []byte(`

you clicked!

`), + ) + require.NoError(t, err) + }) + + p := tb.NewPage(nil) + + // Navigate to the page with a link that opens a new page. + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := p.Goto(tb.url("/home"), opts) + require.NoError(t, err) + require.NotNil(t, resp) + + // Current page count should be 1. + pp := p.Context().Pages() + assert.Equal(t, 1, len(pp)) + + // This link should open the link on a new page. + err = p.Click("a[href='/link']", common.NewFrameClickOptions(p.Timeout())) + require.NoError(t, err) + + // Wait for the page to be created and for it to navigate to the link. + obj, err := p.Context().WaitForEvent("page", nil, common.DefaultTimeout) + require.NoError(t, err) + p2, ok := obj.(*common.Page) + require.True(t, ok, "return from WaitForEvent is not a Page") + + err = p2.WaitForLoadState(common.LifecycleEventLoad.String(), nil) + require.NoError(t, err) + + // Now there should be 2 pages. + pp = p.Context().Pages() + assert.Equal(t, 2, len(pp)) + + // Make sure the new page contains the correct page. + got, err := p2.InnerHTML("h1", nil) + require.NoError(t, err) + assert.Equal(t, "you clicked!", got) +} + +func TestPageGetAttribute(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + got, ok, err := p.GetAttribute("#el", "href", nil) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "null", got) +} + +func TestPageGetAttributeMissing(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + got, ok, err := p.GetAttribute("#el", "missing", nil) + require.NoError(t, err) + require.False(t, ok) + assert.Equal(t, "", got) +} + +func TestPageGetAttributeEmpty(t *testing.T) { + t.Parallel() + + p := newTestBrowser(t).NewPage(nil) + err := p.SetContent(`Something`, nil) + require.NoError(t, err) + + got, ok, err := p.GetAttribute("#el", "empty", nil) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "", got) +} + +func TestPageOnMetric(t *testing.T) { + t.Parallel() + + // This page will perform many pings with a changing h query parameter. + // This URL should be grouped according to how page.on('metric') is used. + tb := newTestBrowser(t, withHTTPServer()) + tb.withHandler("/home", func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, ` + + + + + + `) + require.NoError(t, err) + }) + tb.withHandler("/ping", func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, `pong`) + require.NoError(t, err) + }) + + ignoreURLs := map[string]any{ + tb.url("/home"): nil, + tb.url("/favicon.ico"): nil, + } + + tests := []struct { + name string + fun string + want string + wantRegex string + wantErr string + }{ + { + // Just a single page.on. + name: "single_page.on", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + });`, + want: "ping-1", + }, + { + // A single page.on but with multiple calls to Tag. + name: "multi_tag", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + metric.tag({ + name:'ping-2', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + });`, + want: "ping-2", + }, + { + // Two page.on and in one of them multiple calls to Tag. + name: "multi_tag_page.on", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + metric.tag({ + name:'ping-2', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + }); + page.on('metric', (metric) => { + metric.tag({ + name:'ping-3', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + });`, + want: "ping-3", + }, + { + // A single page.on but within it another page.on. + name: "multi_page.on_call", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + page.on('metric', (metric) => { + metric.tag({ + name:'ping-4', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/}, + ] + }); + }); + });`, + want: "ping-4", + }, + { + // With method field GET, which is the correct method for the request. + name: "with_method", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, method: 'GET'}, + ] + }); + });`, + want: "ping-1", + }, + { + // With method field " get ", which is to ensure it is internally + // converted to "GET" before comparing. + name: "lowercase_needs_trimming", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, method: ' get '}, + ] + }); + });`, + want: "ping-1", + }, + { + // When supplying the wrong request method (POST) when it should be GET. + // In this case the URLs aren't grouped. + name: "wrong_method_should_skip_method_comparison", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, method: 'POST'}, + ] + }); + });`, + wantRegex: `http://127\.0\.0\.1:[0-9]+/ping\?h=[0-9a-z]+`, + }, + { + // We should get an error back when the name is invalid (empty string) + name: "with_invalid_name", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:' ', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, method: 'GET'}, + ] + }); + });`, + wantRegex: `http://127\.0\.0\.1:[0-9]+/ping\?h=[0-9a-z]+`, + wantErr: `name " " is invalid`, + }, + { + // We should get an error back when the method is invalid. + name: "with_invalid_name", + fun: `page.on('metric', (metric) => { + metric.tag({ + name:'ping-1', + matches: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, method: 'foo'}, + ] + }); + });`, + wantRegex: `http://127\.0\.0\.1:[0-9]+/ping\?h=[0-9a-z]+`, + wantErr: `method "foo" is invalid`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var foundAmended atomic.Int32 + var foundUnamended atomic.Int32 + + done := make(chan bool) + + samples := make(chan k6metrics.SampleContainer) + go func() { + defer close(done) + for e := range samples { + ss := e.GetSamples() + for _, s := range ss { + // At the moment all metrics that the browser emits contains + // both a url and name tag on each metric. + u, ok := s.TimeSeries.Tags.Get("url") + assert.True(t, ok) + n, ok := s.TimeSeries.Tags.Get("name") + assert.True(t, ok) + + // The name and url tags should have the same value. + assert.Equal(t, u, n) + + // If the url is in the ignoreURLs map then this will + // not have been matched on by the regex, so continue. + if _, ok := ignoreURLs[u]; ok { + foundUnamended.Add(1) + continue + } + + // Url shouldn't contain any of the hash values, and should + // instead take the name that was supplied in the Tag + // function on metric in page.on. + if tt.wantRegex != "" { + assert.Regexp(t, tt.wantRegex, u) + } else { + assert.Equal(t, tt.want, u) + } + + foundAmended.Add(1) + } + } + }() + + vu, _, _, cleanUp := startIteration(t, k6test.WithSamples(samples)) + defer cleanUp() + + // Some of the business logic is in the mapping layer unfortunately. + // To test everything is wried up correctly, we're required to work + // with RunPromise. + gv, err := vu.RunAsync(t, ` + const page = await browser.newPage() + + %s + + await page.goto('%s', {waitUntil: 'networkidle'}); + + await page.close() + `, tt.fun, tb.url("/home")) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + + got := k6test.ToPromise(t, gv) + + assert.True(t, got.Result().Equals(sobek.Null())) + + close(samples) + + <-done + + // We want to make sure that we found at least one occurrence + // of a metric which matches our expectations. + assert.True(t, foundAmended.Load() > 0) + + // We want to make sure that we found at least one occurrence + // of a metric which didn't match our expectations. + assert.True(t, foundUnamended.Load() > 0) + }) + } +} diff --git a/js/modules/k6/browser/tests/remote_obj_test.go b/js/modules/k6/browser/tests/remote_obj_test.go new file mode 100644 index 00000000000..4f28e7e7790 --- /dev/null +++ b/js/modules/k6/browser/tests/remote_obj_test.go @@ -0,0 +1,196 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" +) + +func TestConsoleLogParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + log string + want string + }{ + { + name: "number", log: "1", want: "1", + }, + { + name: "string", log: `"some string"`, want: "some string", + }, + { + name: "bool", log: "true", want: "true", + }, + { + name: "empty_array", log: "[]", want: "[]", + }, + { + name: "empty_object", log: "{}", want: "{}", + }, + { + name: "filled_object", log: `{"foo":{"bar1":"bar2"}}`, want: `{"foo":"Object"}`, + }, + { + name: "filled_array", log: `["foo","bar"]`, want: `["foo","bar"]`, + }, + { + name: "filled_array", log: `() => true`, want: `function()`, + }, + { + name: "empty", log: "", want: "", + }, + { + name: "null", log: "null", want: "null", + }, + { + name: "undefined", log: "undefined", want: "undefined", + }, + { + name: "bigint", log: `BigInt("2")`, want: "2n", + }, + { + name: "unwrapped_bigint", log: "3n", want: "3n", + }, + { + name: "float", log: "3.14", want: "3.14", + }, + { + name: "scientific_notation", log: "123e-5", want: "0.00123", + }, + { + name: "partially_parsed", + log: "window", + want: `{"document":"#document","location":"Location","name":"","self":"Window","window":"Window"}`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + p := tb.NewPage(nil) + + done := make(chan bool) + + eventHandler := func(event common.PageOnEvent) error { + defer close(done) + assert.Equal(t, tt.want, event.ConsoleMessage.Text) + return nil + } + + // eventHandler will be called from a separate goroutine from within + // the page's async event loop. This is why we need to wait on done + // to close. + err := p.On("console", eventHandler) + require.NoError(t, err) + + if tt.log == "" { + _, err = p.Evaluate(`() => console.log("")`) + } else { + _, err = p.Evaluate(fmt.Sprintf("() => console.log(%s)", tt.log)) + } + require.NoError(t, err) + + select { + case <-done: + case <-time.After(2500 * time.Millisecond): + assert.Fail(t, "test timed out before event handler was called") + } + }) + } +} + +func TestEvalRemoteObjectParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + eval string + want any + }{ + { + name: "number", eval: "1", want: float64(1), + }, + { + name: "string", eval: `"some string"`, want: "some string", + }, + { + name: "bool", eval: "true", want: true, + }, + { + name: "empty_array", eval: "[]", want: []any{}, + }, + { + name: "empty_object", eval: "{}", want: nil, + }, + { + name: "filled_object", eval: `{return {foo:"bar"};}`, want: map[string]any{"foo": "bar"}, + }, + { + name: "filled_array", eval: `{return ["foo","bar"];}`, want: []any{0: "foo", 1: "bar"}, + }, + { + name: "filled_array", eval: `() => true`, want: `function()`, + }, + { + name: "empty", eval: "", want: "", + }, + { + name: "null", eval: "null", want: nil, + }, + { + name: "undefined", eval: "undefined", want: nil, + }, + { + name: "bigint", eval: `BigInt("2")`, want: int64(2), + }, + { + name: "unwrapped_bigint", eval: "3n", want: int64(3), + }, + { + name: "float", eval: "3.14", want: 3.14, + }, + { + name: "scientific_notation", eval: "123e-5", want: 0.00123, + }, + // TODO: + // { + // // This test is ignored until https://go.k6.io/k6/js/modules/k6/browser/issues/1132 + // // has been resolved. + // name: "partially_parsed", + // eval: "window", + // want: `{"document":"#document","location":"Location","name":"","self":"Window","window":"Window"}`, + // }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t, withFileServer()) + p := tb.NewPage(nil) + + var ( + got any + err error + ) + if tt.eval == "" { + got, err = p.Evaluate(`() => ""`) + } else { + got, err = p.Evaluate(fmt.Sprintf("() => %s", tt.eval)) + } + require.NoError(t, err) + assert.EqualValues(t, tt.want, got) + }) + } +} diff --git a/js/modules/k6/browser/tests/setinputfiles_test.go b/js/modules/k6/browser/tests/setinputfiles_test.go new file mode 100644 index 00000000000..2691cab92c8 --- /dev/null +++ b/js/modules/k6/browser/tests/setinputfiles_test.go @@ -0,0 +1,207 @@ +package tests + +import ( + "testing" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" +) + +// TestSetInputFiles tests the SetInputFiles function. +func TestSetInputFiles(t *testing.T) { + t.Parallel() + + type file map[string]interface{} + type indexedFn func(idx int, propName string) interface{} + type testFn func(tb *testBrowser, page *common.Page, files sobek.Value) error + type setupFn func(tb *testBrowser) (sobek.Value, func()) + type checkFn func(t *testing.T, + getFileCountFn func() interface{}, + getFilePropFn indexedFn, + err error) + + const ( + propName = "name" + propType = "type" + propSize = "size" + ) + + pageContent := ` + + + + ` + + defaultTestPage := func(tb *testBrowser, page *common.Page, files sobek.Value) error { + return page.SetInputFiles("#upload", files, tb.toSobekValue(nil)) + } + defaultTestElementHandle := func(tb *testBrowser, page *common.Page, files sobek.Value) error { + handle, err := page.WaitForSelector("#upload", tb.toSobekValue(nil)) + assert.NoError(t, err) + return handle.SetInputFiles(files, tb.toSobekValue(nil)) + } + + testCases := []struct { + name string + setup setupFn + tests []testFn + check checkFn + }{ + { + name: "set_one_file_with_object", + setup: func(tb *testBrowser) (sobek.Value, func()) { + return tb.toSobekValue(file{"name": "test.json", "mimetype": "text/json", "buffer": "MDEyMzQ1Njc4OQ=="}), nil + }, + tests: []testFn{defaultTestPage, defaultTestElementHandle}, + check: func(t *testing.T, getFileCountFn func() interface{}, getFilePropFn indexedFn, err error) { + t.Helper() + assert.NoError(t, err) + // check if input has 1 file + assert.Equal(t, float64(1), getFileCountFn()) + // check added file is correct + assert.Equal(t, "test.json", getFilePropFn(0, propName)) + assert.Equal(t, float64(10), getFilePropFn(0, propSize)) + assert.Equal(t, "text/json", getFilePropFn(0, propType)) + }, + }, + { + name: "set_two_files_with_array_of_objects", + setup: func(tb *testBrowser) (sobek.Value, func()) { + return tb.toSobekValue( + []file{ + {"name": "test.json", "mimetype": "text/json", "buffer": "MDEyMzQ1Njc4OQ=="}, + {"name": "test.xml", "mimetype": "text/xml", "buffer": "MDEyMzQ1Njc4OTAxMjM0"}, + }), nil + }, + tests: []testFn{defaultTestPage, defaultTestElementHandle}, + check: func(t *testing.T, getFileCountFn func() interface{}, getFilePropFn indexedFn, err error) { + t.Helper() + assert.NoError(t, err) + // check if input has 2 files + assert.Equal(t, float64(2), getFileCountFn()) + // check added files are correct + assert.Equal(t, "test.json", getFilePropFn(0, propName)) + assert.Equal(t, float64(10), getFilePropFn(0, propSize)) + assert.Equal(t, "text/json", getFilePropFn(0, propType)) + assert.Equal(t, "test.xml", getFilePropFn(1, propName)) + assert.Equal(t, float64(15), getFilePropFn(1, propSize)) + assert.Equal(t, "text/xml", getFilePropFn(1, propType)) + }, + }, + { + name: "set_nil", + setup: func(tb *testBrowser) (sobek.Value, func()) { + return tb.toSobekValue(nil), nil + }, + tests: []testFn{defaultTestPage, defaultTestElementHandle}, + check: func(t *testing.T, getFileCountFn func() interface{}, getFilePropertyFn indexedFn, err error) { + t.Helper() + assert.NoError(t, err) + // check if input has 1 file + assert.Equal(t, float64(0), getFileCountFn()) + }, + }, + { + name: "set_invalid_parameter", + setup: func(tb *testBrowser) (sobek.Value, func()) { + return tb.toSobekValue([]int{12345}), nil + }, + tests: []testFn{defaultTestPage, defaultTestElementHandle}, + check: func(t *testing.T, getFileCountFn func() interface{}, getFilePropFn indexedFn, err error) { + t.Helper() + assert.ErrorContains(t, err, "invalid parameter type : int64") + // check if input has 0 file + assert.Equal(t, float64(0), getFileCountFn()) + }, + }, + { + name: "test_injected_script_notinput", + setup: func(tb *testBrowser) (sobek.Value, func()) { + return tb.toSobekValue(file{"name": "test.json", "mimetype": "text/json", "buffer": "MDEyMzQ1Njc4OQ=="}), nil + }, + tests: []testFn{ + func(tb *testBrowser, page *common.Page, files sobek.Value) error { + return page.SetInputFiles("#button1", files, tb.toSobekValue(nil)) + }, + func(tb *testBrowser, page *common.Page, files sobek.Value) error { + handle, err := page.WaitForSelector("#button1", tb.toSobekValue(nil)) + assert.NoError(t, err) + return handle.SetInputFiles(files, tb.toSobekValue(nil)) + }, + }, + check: func(t *testing.T, getFileCountFn func() interface{}, getFilePropFn indexedFn, err error) { + t.Helper() + assert.ErrorContains(t, err, "node is not an HTMLInputElement") + assert.ErrorContains(t, err, "setting input files") + // check if input has 0 file + assert.Equal(t, float64(0), getFileCountFn()) + }, + }, + { + name: "test_injected_script_notfile", + setup: func(tb *testBrowser) (sobek.Value, func()) { + return tb.toSobekValue(file{"name": "test.json", "mimetype": "text/json", "buffer": "MDEyMzQ1Njc4OQ=="}), nil + }, + tests: []testFn{ + func(tb *testBrowser, page *common.Page, files sobek.Value) error { + return page.SetInputFiles("#textinput", files, tb.toSobekValue(nil)) + }, + func(tb *testBrowser, page *common.Page, files sobek.Value) error { + handle, err := page.WaitForSelector("#textinput", tb.toSobekValue(nil)) + assert.NoError(t, err) + return handle.SetInputFiles(files, tb.toSobekValue(nil)) + }, + }, + check: func(t *testing.T, getFileCountFn func() interface{}, getFilePropFn indexedFn, err error) { + t.Helper() + assert.ErrorContains(t, err, "node is not an input[type=file] element") + assert.ErrorContains(t, err, "setting input files") + // check if input has 0 file + assert.Equal(t, float64(0), getFileCountFn()) + }, + }, + } + + for _, tc := range testCases { + tc := tc + for _, test := range tc.tests { + test := test + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tb := newTestBrowser(t) + defer tb.Browser.Close() + page := tb.NewPage(nil) + + err := page.SetContent(pageContent, nil) + require.NoError(t, err) + + getFileCountFn := func() interface{} { + v, err := page.Evaluate(`() => document.getElementById("upload").files.length`) + require.NoError(t, err) + + return v + } + + getFilePropertyFn := func(idx int, propName string) interface{} { + v, err := page.Evaluate( + `(idx, propName) => document.getElementById("upload").files[idx][propName]`, + idx, + propName) + require.NoError(t, err) + return v + } + + files, cleanup := tc.setup(tb) + if cleanup != nil { + defer cleanup() + } + err = test(tb, page, files) + tc.check(t, getFileCountFn, getFilePropertyFn, err) + }) + } + } +} diff --git a/js/modules/k6/browser/tests/static/concealed_link.html b/js/modules/k6/browser/tests/static/concealed_link.html new file mode 100644 index 00000000000..b611feb5872 --- /dev/null +++ b/js/modules/k6/browser/tests/static/concealed_link.html @@ -0,0 +1,50 @@ + + + Concealed element test + + + +
+
    +
  1. item 1
  2. +
  3. item 2
  4. +
  5. item 3
  6. +
  7. item 4
  8. +
  9. item 5
  10. +
  11. item 6
  12. +
  13. item 7
  14. +
  15. item 8
  16. +
  17. item 9
  18. +
  19. item 10
  20. +
+ + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/dialog.html b/js/modules/k6/browser/tests/static/dialog.html new file mode 100644 index 00000000000..24042e6f853 --- /dev/null +++ b/js/modules/k6/browser/tests/static/dialog.html @@ -0,0 +1,36 @@ + + + +
Hello World
+
+ + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/embedded_iframe.html b/js/modules/k6/browser/tests/static/embedded_iframe.html new file mode 100644 index 00000000000..68a97d5b56f --- /dev/null +++ b/js/modules/k6/browser/tests/static/embedded_iframe.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/js/modules/k6/browser/tests/static/iframe_home.html b/js/modules/k6/browser/tests/static/iframe_home.html new file mode 100644 index 00000000000..29a125d0202 --- /dev/null +++ b/js/modules/k6/browser/tests/static/iframe_home.html @@ -0,0 +1,8 @@ + + + + + Sign In + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/iframe_signin.html b/js/modules/k6/browser/tests/static/iframe_signin.html new file mode 100644 index 00000000000..528dcba0438 --- /dev/null +++ b/js/modules/k6/browser/tests/static/iframe_signin.html @@ -0,0 +1,8 @@ + + + + +
Sign In Page
+ + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/iframe_test_main.html b/js/modules/k6/browser/tests/static/iframe_test_main.html new file mode 100644 index 00000000000..cdeba63cdcf --- /dev/null +++ b/js/modules/k6/browser/tests/static/iframe_test_main.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/iframe_test_nested1.html b/js/modules/k6/browser/tests/static/iframe_test_nested1.html new file mode 100644 index 00000000000..ab469a5229b --- /dev/null +++ b/js/modules/k6/browser/tests/static/iframe_test_nested1.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/iframe_test_nested2.html b/js/modules/k6/browser/tests/static/iframe_test_nested2.html new file mode 100644 index 00000000000..16f9a7f4d5a --- /dev/null +++ b/js/modules/k6/browser/tests/static/iframe_test_nested2.html @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/lifecycle.html b/js/modules/k6/browser/tests/static/lifecycle.html new file mode 100644 index 00000000000..c3db753bb78 --- /dev/null +++ b/js/modules/k6/browser/tests/static/lifecycle.html @@ -0,0 +1,42 @@ + + + + + + Home +
Waiting...
+
Waiting...
+ + + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/lifecycle_main_frame.html b/js/modules/k6/browser/tests/static/lifecycle_main_frame.html new file mode 100644 index 00000000000..8141c1cb201 --- /dev/null +++ b/js/modules/k6/browser/tests/static/lifecycle_main_frame.html @@ -0,0 +1,12 @@ + + + + + +
main
+
Waiting...
+
Waiting...
+ + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/lifecycle_no_ping_js.html b/js/modules/k6/browser/tests/static/lifecycle_no_ping_js.html new file mode 100644 index 00000000000..1555daf8634 --- /dev/null +++ b/js/modules/k6/browser/tests/static/lifecycle_no_ping_js.html @@ -0,0 +1,28 @@ + + + + + +
Waiting...
+ + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/locators.html b/js/modules/k6/browser/tests/static/locators.html new file mode 100644 index 00000000000..394035ea238 --- /dev/null +++ b/js/modules/k6/browser/tests/static/locators.html @@ -0,0 +1,46 @@ + + + + + Clickable link test + + + + Click + Dblclick + Click + + + + +
hello
+
bye
+ +

original text

+

original text

+ + + + + + diff --git a/js/modules/k6/browser/tests/static/mouse_helper.js b/js/modules/k6/browser/tests/static/mouse_helper.js new file mode 100644 index 00000000000..5e07190b105 --- /dev/null +++ b/js/modules/k6/browser/tests/static/mouse_helper.js @@ -0,0 +1,63 @@ +// This injects a circle into the page that moves with the mouse; +// Useful for debugging +(function(){ + const box = document.createElement('div'); + box.classList.add('mouse-helper'); + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + .mouse-helper { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 20px; + height: 20px; + background: rgba(0,0,0,.4); + border: 1px solid white; + border-radius: 10px; + margin-left: -10px; + margin-top: -10px; + transition: background .2s, border-radius .2s, border-color .2s; + } + .mouse-helper.button-1 { + transition: none; + background: rgba(0,0,0,0.9); + } + .mouse-helper.button-2 { + transition: none; + border-color: rgba(0,0,255,0.9); + } + .mouse-helper.button-3 { + transition: none; + border-radius: 4px; + } + .mouse-helper.button-4 { + transition: none; + border-color: rgba(255,0,0,0.9); + } + .mouse-helper.button-5 { + transition: none; + border-color: rgba(0,255,0,0.9); + } + `; + document.head.appendChild(styleElement); + document.body.appendChild(box); + document.addEventListener('mousemove', event => { + box.style.left = event.pageX + 'px'; + box.style.top = event.pageY + 'px'; + updateButtons(event.buttons); + }, true); + document.addEventListener('mousedown', event => { + updateButtons(event.buttons); + box.classList.add('button-' + event.which); + }, true); + document.addEventListener('mouseup', event => { + updateButtons(event.buttons); + box.classList.remove('button-' + event.which); + }, true); + function updateButtons(buttons) { + for (let i = 0; i < 5; i++) { + box.classList.toggle('button-' + i, buttons & (1 << i)); + } + } +})(); \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/nav_in_doc.html b/js/modules/k6/browser/tests/static/nav_in_doc.html new file mode 100644 index 00000000000..1e252ff3a56 --- /dev/null +++ b/js/modules/k6/browser/tests/static/nav_in_doc.html @@ -0,0 +1,17 @@ + + + Navigation test within the same document + + + Navigate with History API + Navigate with anchor link +
Some div...
+ + + diff --git a/js/modules/k6/browser/tests/static/non_clickable.html b/js/modules/k6/browser/tests/static/non_clickable.html new file mode 100644 index 00000000000..27c9a1c9144 --- /dev/null +++ b/js/modules/k6/browser/tests/static/non_clickable.html @@ -0,0 +1,23 @@ + + + + Non-clickable test + + + +

I'm preventing clicking on elements

+ I am non clickable + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/page1.html b/js/modules/k6/browser/tests/static/page1.html new file mode 100644 index 00000000000..c64828267f2 --- /dev/null +++ b/js/modules/k6/browser/tests/static/page1.html @@ -0,0 +1,7 @@ + + + +

Page 1

+ Click Me + + diff --git a/js/modules/k6/browser/tests/static/page2.html b/js/modules/k6/browser/tests/static/page2.html new file mode 100644 index 00000000000..2e41cffbb43 --- /dev/null +++ b/js/modules/k6/browser/tests/static/page2.html @@ -0,0 +1,6 @@ + + + +

Page 2

+ + diff --git a/js/modules/k6/browser/tests/static/ping.html b/js/modules/k6/browser/tests/static/ping.html new file mode 100644 index 00000000000..7fdf14115f0 --- /dev/null +++ b/js/modules/k6/browser/tests/static/ping.html @@ -0,0 +1,29 @@ + + + Ping duration test + + +
NA
+ + + diff --git a/js/modules/k6/browser/tests/static/select_options.html b/js/modules/k6/browser/tests/static/select_options.html new file mode 100644 index 00000000000..ea854941d57 --- /dev/null +++ b/js/modules/k6/browser/tests/static/select_options.html @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/shadow_and_doc_frag.html b/js/modules/k6/browser/tests/static/shadow_and_doc_frag.html new file mode 100644 index 00000000000..9c89633cb8a --- /dev/null +++ b/js/modules/k6/browser/tests/static/shadow_and_doc_frag.html @@ -0,0 +1,74 @@ + + + + + + DocumentFragment and ShadowRoot Test page + + +

DocumentFragment and ShadowRoot Test page

+
+ + +
+ + + + diff --git a/js/modules/k6/browser/tests/static/shadow_dom_link.html b/js/modules/k6/browser/tests/static/shadow_dom_link.html new file mode 100644 index 00000000000..ae8b987aa5d --- /dev/null +++ b/js/modules/k6/browser/tests/static/shadow_dom_link.html @@ -0,0 +1,27 @@ + + + + + + +Sign up + + diff --git a/js/modules/k6/browser/tests/static/usual.html b/js/modules/k6/browser/tests/static/usual.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/js/modules/k6/browser/tests/static/visible.html b/js/modules/k6/browser/tests/static/visible.html new file mode 100644 index 00000000000..c25e59e76e6 --- /dev/null +++ b/js/modules/k6/browser/tests/static/visible.html @@ -0,0 +1,8 @@ + + + +
My DIV
+
My DIV 2
+ + + diff --git a/js/modules/k6/browser/tests/static/wait_for.html b/js/modules/k6/browser/tests/static/wait_for.html new file mode 100644 index 00000000000..22c08398a18 --- /dev/null +++ b/js/modules/k6/browser/tests/static/wait_for.html @@ -0,0 +1,15 @@ + + + + + + + diff --git a/js/modules/k6/browser/tests/static/wait_until.html b/js/modules/k6/browser/tests/static/wait_until.html new file mode 100644 index 00000000000..7bf20f41f71 --- /dev/null +++ b/js/modules/k6/browser/tests/static/wait_until.html @@ -0,0 +1,19 @@ + + + + + + WaitUntil options test + + + + + \ No newline at end of file diff --git a/js/modules/k6/browser/tests/static/web_vitals.html b/js/modules/k6/browser/tests/static/web_vitals.html new file mode 100644 index 00000000000..c9e3a8a6382 --- /dev/null +++ b/js/modules/k6/browser/tests/static/web_vitals.html @@ -0,0 +1,69 @@ + + + + + for web vital + + +
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore + et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui + officia deserunt mollit anim id est laborum. +
+ + + + + + + + + + + + + + + + + + + + + + +
TitleReview
Book AGood
Book BOk
Book CNot good
Book DVery good
+ + diff --git a/js/modules/k6/browser/tests/test_browser.go b/js/modules/k6/browser/tests/test_browser.go new file mode 100644 index 00000000000..ee3946d733d --- /dev/null +++ b/js/modules/k6/browser/tests/test_browser.go @@ -0,0 +1,382 @@ +package tests + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" + + "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" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + + k6http "go.k6.io/k6/js/modules/k6/http" + k6httpmultibin "go.k6.io/k6/lib/testutils/httpmultibin" + k6metrics "go.k6.io/k6/metrics" +) + +const testBrowserStaticDir = "static" + +// testBrowser is a test testBrowser for integration testing. +type testBrowser struct { + t testing.TB + + ctx context.Context + cancel context.CancelFunc + vu *k6test.VU + + browserType *chromium.BrowserType + pid int // the browser process ID + wsURL string + + *common.Browser + + // isBrowserTypeInitialized is true if the browser type has been + // initialized with a VU. Some options can only be used in the + // post-init stage and require the browser type to be initialized. + isBrowserTypeInitialized bool + + // http is set by the withHTTPServer option. + http *k6httpmultibin.HTTPMultiBin + // logCache is set by the withLogCache option. + logCache *logCache + // lookupFunc is set by the withEnvLookup option. + lookupFunc env.LookupFunc + // samples is set by the withSamples option. + samples chan k6metrics.SampleContainer + // skipClose is set by the withSkipClose option. + skipClose bool +} + +// newTestBrowser configures and launches a new chrome browser. +// +// It automatically closes it when `t` returns unless `withSkipClose` option is provided. +// +// The following opts are available to customize the testBrowser: +// - withEnvLookup: provides a custom lookup function for environment variables. +// - withFileServer: enables the HTTPMultiBin server and serves the given files. +// - withHTTPServer: enables the HTTPMultiBin server. +// - withLogCache: enables the log cache. +// - withSamples: provides a channel to receive the browser metrics. +// - withSkipClose: skips closing the browser when the test finishes. +func newTestBrowser(tb testing.TB, opts ...func(*testBrowser)) *testBrowser { + tb.Helper() + + tbr := &testBrowser{t: tb} + tbr.applyDefaultOptions() + tbr.applyOptions(opts...) // apply pre-init stage options. + tbr.vu, tbr.cancel = newTestBrowserVU(tb, tbr) + tbr.browserType = chromium.NewBrowserType(tbr.vu) + tbr.vu.ActivateVU() + tbr.isBrowserTypeInitialized = true // some option require the browser type to be initialized. + tbr.applyOptions(opts...) // apply post-init stage options. + + b, pid, err := tbr.browserType.Launch(context.Background(), tbr.vu.Context()) + if err != nil { + tb.Fatalf("testBrowser: %v", err) + } + tbr.Browser = b + tbr.ctx = tbr.browserType.Ctx + tbr.pid = pid + tbr.wsURL = b.WsURL() + tb.Cleanup(func() { + select { + case <-tbr.vu.Context().Done(): + default: + if !tbr.skipClose { + b.Close() + } + } + }) + + return tbr +} + +// newTestBrowserVU initializes a new VU for browser testing. +// It returns the VU and a cancel function to stop the VU. +// VU contains the context with the custom metrics registry. +func newTestBrowserVU(tb testing.TB, tbr *testBrowser) (_ *k6test.VU, cancel func()) { + tb.Helper() + + vu := k6test.NewVU(tb, k6test.WithSamples(tbr.samples)) + mi, ok := k6http.New().NewModuleInstance(vu).(*k6http.ModuleInstance) + require.Truef(tb, ok, "want *k6http.ModuleInstance; got %T", mi) + require.NoError(tb, vu.Runtime().Set("http", mi.Exports().Default)) + metricsCtx := k6ext.WithCustomMetrics( + vu.Context(), + k6ext.RegisterCustomMetrics(k6metrics.NewRegistry()), + ) + ctx, cancel := context.WithCancel(metricsCtx) + tb.Cleanup(cancel) + vu.CtxField = ctx + vu.InitEnvField.LookupEnv = tbr.lookupFunc + + return vu, cancel +} + +// applyDefaultOptions applies the default options for the testBrowser. +func (b *testBrowser) applyDefaultOptions() { + b.samples = make(chan k6metrics.SampleContainer, 1000) + // default lookup function is env.Lookup so that we can + // pass the environment variables while testing, i.e.: K6_BROWSER_LOG. + b.lookupFunc = env.Lookup +} + +// applyOptions applies the given options to the testBrowser. +func (b *testBrowser) applyOptions(opts ...func(*testBrowser)) { + for _, opt := range opts { + opt(b) + } +} + +// withEnvLookup sets the lookup function for environment variables. +// +// example: +// +// b := TestBrowser(t, withEnvLookup(env.ConstLookup(env.BrowserHeadless, "0"))) +func withEnvLookup(lookupFunc env.LookupFunc) func(*testBrowser) { + return func(tb *testBrowser) { tb.lookupFunc = lookupFunc } +} + +// withFileServer enables the HTTP test server and serves a file server +// for static files. +// +// see: WithFileServer +// +// example: +// +// b := TestBrowser(t, withFileServer()) +func withFileServer() func(*testBrowser) { + return func(tb *testBrowser) { + if !tb.isBrowserTypeInitialized { + return + } + if tb.http == nil { + // file server needs HTTP server. + apply := withHTTPServer() + apply(tb) + } + _ = tb.withFileServer() + } +} + +// withFileServer serves a file server using the HTTP test server that is +// accessible via `testBrowserStaticDir` prefix. +// +// This method is for enabling the static file server after starting a test +// browser. For early starting the file server see withFileServer function. +func (b *testBrowser) withFileServer() *testBrowser { + b.t.Helper() + + const ( + slash = string(os.PathSeparator) //nolint:forbidigo + path = slash + testBrowserStaticDir + slash + ) + + fs := http.FileServer(http.Dir(testBrowserStaticDir)) + + return b.withHandler(path, http.StripPrefix(path, fs).ServeHTTP) +} + +// withHandler adds the given handler to the HTTP test server and makes it +// accessible with the given pattern. +func (b *testBrowser) withHandler(pattern string, handler http.HandlerFunc) *testBrowser { + b.t.Helper() + + if b.http == nil { + b.t.Fatalf("You should enable HTTP test server, see: withHTTPServer option") + } + b.http.Mux.Handle(pattern, handler) + return b +} + +// withHTTPServer enables the HTTP test server. +// It is used to detect whether to enable the HTTP test server. +// +// example: +// +// b := TestBrowser(t, withHTTPServer()) +func withHTTPServer() func(*testBrowser) { + return func(tb *testBrowser) { + if !tb.isBrowserTypeInitialized { + return + } + if tb.http != nil { + // already initialized. + return + } + tb.http = k6httpmultibin.NewHTTPMultiBin(tb.t) + tb.vu.StateField.TLSConfig = tb.http.TLSClientConfig + tb.vu.StateField.Transport = tb.http.HTTPTransport + } +} + +// withLogCache enables the log cache. +// +// example: +// +// b := TestBrowser(t, withLogCache()) +func withLogCache() func(*testBrowser) { + return func(tb *testBrowser) { + if !tb.isBrowserTypeInitialized { + return + } + tb.logCache = attachLogCache(tb.t, tb.vu.StateField.Logger) + } +} + +// 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. +func withSamples(sc chan k6metrics.SampleContainer) func(*testBrowser) { + return func(tb *testBrowser) { tb.samples = sc } +} + +// withSkipClose skips calling Browser.Close() in t.Cleanup(). +// It indicates that we shouldn't call Browser.Close() in +// t.Cleanup(), since it will presumably be done by the test. +// +// example: +// +// b := TestBrowser(t, withSkipClose()) +func withSkipClose() func(*testBrowser) { + return func(tb *testBrowser) { tb.skipClose = true } +} + +// NewPage is a wrapper around Browser.NewPage that fails the test if an +// error occurs. Added this helper to avoid boilerplate code in tests. +func (b *testBrowser) NewPage(opts *common.BrowserContextOptions) *common.Page { + b.t.Helper() + + p, err := b.Browser.NewPage(opts) + require.NoError(b.t, err) + + return p +} + +// url returns the listening HTTP test server's url combined with the given path. +func (b *testBrowser) url(path string) string { + b.t.Helper() + + if b.http == nil { + b.t.Fatalf("You should enable HTTP test server, see: withHTTPServer option") + } + return b.http.ServerHTTP.URL + path +} + +// staticURL is a helper for URL("/`testBrowserStaticDir`/"+ path). +func (b *testBrowser) staticURL(path string) string { + b.t.Helper() + return b.url("/" + testBrowserStaticDir + "/" + path) +} + +// context returns the testBrowser context. +func (b *testBrowser) context() context.Context { return b.ctx } + +// cancelContext cancels the testBrowser context. +func (b *testBrowser) cancelContext() { b.cancel() } + +// runtime returns a VU runtime. +func (b *testBrowser) runtime() *sobek.Runtime { return b.vu.Runtime() } + +// toSobekValue converts a value to sobek value. +func (b *testBrowser) toSobekValue(i any) sobek.Value { return b.runtime().ToValue(i) } + +// runJavaScript in the sobek runtime. +func (b *testBrowser) runJavaScript(s string, args ...any) (sobek.Value, error) { //nolint:unparam + b.t.Helper() + v, err := b.runtime().RunString(fmt.Sprintf(s, args...)) + if err != nil { + return nil, fmt.Errorf("while running %q(%v): %w", s, args, err) + } + return v, nil +} + +// Run the given functions in parallel and waits for them to finish. +func (b *testBrowser) run(ctx context.Context, fs ...func() error) error { + b.t.Helper() + + g, ctx := errgroup.WithContext(ctx) + for _, f := range fs { + f := f + g.Go(func() error { + errc := make(chan error, 1) + go func() { errc <- f() }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + return fmt.Errorf("while running %T: %w", f, err) + } + } + + return nil + }) + } + + if err := g.Wait(); err != nil { + return fmt.Errorf("while waiting for %T: %w", fs, err) + } + + return nil +} + +// awaitWithTimeout is the same as await but takes a timeout and times out the function after the time runs out. +func (b *testBrowser) awaitWithTimeout(timeout time.Duration, fn func() error) error { + b.t.Helper() + errC := make(chan error) + go func() { + defer close(errC) + errC <- fn() + }() + + // use timer instead of time.After to not leak time.After for the duration of the timeout + t := time.NewTimer(timeout) + defer t.Stop() + + select { + case err := <-errC: + return err + case <-t.C: + return fmt.Errorf("test timed out after %s", timeout) + } +} + +// convert is a helper function to convert any value to a given type. +// returns a pointer to the converted value for convenience. +// +// underneath, it uses json.Marshal and json.Unmarshal to do the conversion. +func convert[T any](tb testing.TB, from any, to *T) *T { + tb.Helper() + buf, err := json.Marshal(from) + require.NoError(tb, err) + require.NoError(tb, json.Unmarshal(buf, to)) + return to +} + +// asBool asserts that v is a boolean and returns v as a boolean. +func asBool(tb testing.TB, v any) bool { + tb.Helper() + require.IsType(tb, true, v) + b, ok := v.(bool) + require.True(tb, ok) + return b +} + +// asString asserts that v is a boolean and returns v as a boolean. +func asString(tb testing.TB, v any) string { + tb.Helper() + require.IsType(tb, "", v) + s, ok := v.(string) + require.True(tb, ok) + return s +} diff --git a/js/modules/k6/browser/tests/test_browser_proxy.go b/js/modules/k6/browser/tests/test_browser_proxy.go new file mode 100644 index 00000000000..1e170e84588 --- /dev/null +++ b/js/modules/k6/browser/tests/test_browser_proxy.go @@ -0,0 +1,124 @@ +package tests + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "github.com/gorilla/websocket" +) + +// testBrowserProxy wraps a testBrowser and +// proxies WS messages to/from it. +type testBrowserProxy struct { + t testing.TB + + mu sync.Mutex // avoid concurrent connect requests + + tb *testBrowser + ts *httptest.Server + + connected bool +} + +func newTestBrowserProxy(tb testing.TB, b *testBrowser) *testBrowserProxy { + tb.Helper() + + p := &testBrowserProxy{ + t: tb, + tb: b, + } + p.ts = httptest.NewServer(p.connHandler()) + + return p +} + +func (p *testBrowserProxy) wsURL() string { + p.t.Helper() + + tsURL, err := url.Parse(p.ts.URL) + if err != nil { + p.t.Fatalf("error parsing test server URL: %v", err) + } + return fmt.Sprintf("ws://%s", tsURL.Host) +} + +func (p *testBrowserProxy) connHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + + upgrader := websocket.Upgrader{} // default options + + // Upgrade in connection from client + in, err := upgrader.Upgrade(w, r, nil) + if err != nil { + p.t.Fatalf("error upgrading proxy connection: %v", err) + } + defer in.Close() //nolint:errcheck + + // Connect to testBrowser CDP WS + out, _, err := websocket.DefaultDialer.Dial(p.tb.wsURL, nil) //nolint:bodyclose + if err != nil { + p.t.Fatalf("error connecting to test browser: %v", err) + } + defer out.Close() //nolint:errcheck + + p.connected = true + + // Stop proxy when test exits + ctx, cancel := context.WithCancel(context.Background()) + p.t.Cleanup(func() { + cancel() // stop forwarding mssgs + p.ts.Close() // close test server + }) + + var wg sync.WaitGroup + wg.Add(2) + + go p.fwdMssgs(ctx, in, out, &wg) + go p.fwdMssgs(ctx, out, in, &wg) + + wg.Wait() + }) +} + +func (p *testBrowserProxy) fwdMssgs(ctx context.Context, + in, out *websocket.Conn, wg *sync.WaitGroup, +) { + p.t.Helper() + defer wg.Done() + +LOOP: + for { + select { + case <-ctx.Done(): + break LOOP + default: + mt, message, err := in.ReadMessage() + if err != nil { + var cerr *websocket.CloseError + if errors.As(err, &cerr) { + // If WS conn is closed, just return + return + } + p.t.Fatalf("error reading message: %v", err) + } + + err = out.WriteMessage(mt, message) + if err != nil { + var cerr *websocket.CloseError + if errors.As(err, &cerr) { + // If WS conn is closed, just return + return + } + p.t.Fatalf("error writing message: %v", err) + } + } + } +} diff --git a/js/modules/k6/browser/tests/test_browser_test.go b/js/modules/k6/browser/tests/test_browser_test.go new file mode 100644 index 00000000000..53cfabd749d --- /dev/null +++ b/js/modules/k6/browser/tests/test_browser_test.go @@ -0,0 +1,52 @@ +package tests + +import ( + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/env" +) + +func TestTestBrowserAwaitWithTimeoutShortCircuit(t *testing.T) { + t.Parallel() + tb := newTestBrowser(t) + start := time.Now() + require.NoError(t, tb.awaitWithTimeout(time.Second*10, func() error { + runtime.Goexit() // this is what happens when a `require` fails + return nil + })) + require.Less(t, time.Since(start), time.Second) +} + +// testingT is a wrapper around testing.TB. +type testingT struct { + testing.TB + fatalfCalled bool +} + +// Fatalf skips the test immediately after a test is calling it. +// This is useful when a test is expected to fail, but we don't +// want to mark it as a failure since it's expected. +func (t *testingT) Fatalf(format string, args ...any) { + t.fatalfCalled = true + t.SkipNow() +} + +func TestTestBrowserWithLookupFunc(t *testing.T) { + // Skip until we get answer from Chromium team in an open issue + // https://issues.chromium.org/issues/364089353. + t.Skip("Skipping until we get response from Chromium team") + t.Parallel() + + tt := &testingT{TB: t} + // this operation is expected to fail because the remote debugging port is + // invalid, practically testing that the InitEnv.LookupEnv is used. + _ = newTestBrowser( + tt, + withEnvLookup(env.ConstLookup(env.BrowserArguments, "remote-debugging-port=99999")), + ) + require.True(t, tt.fatalfCalled) +} diff --git a/js/modules/k6/browser/tests/tracing_test.go b/js/modules/k6/browser/tests/tracing_test.go new file mode 100644 index 00000000000..eccbbab6fef --- /dev/null +++ b/js/modules/k6/browser/tests/tracing_test.go @@ -0,0 +1,416 @@ +package tests + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/grafana/sobek" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/embedded" + + "go.k6.io/k6/js/modules/k6/browser/browser" + "go.k6.io/k6/js/modules/k6/browser/k6ext/k6test" + browsertrace "go.k6.io/k6/js/modules/k6/browser/trace" + + k6lib "go.k6.io/k6/lib" +) + +const html = ` + + + + + Clickable link test + + + + Go to bottom +
+

Click Counter

+ +

Type input

+ +
+ +
+ + + +` + +// TestTracing verifies that all methods instrumented to generate +// traces behave correctly. +func TestTracing(t *testing.T) { + t.Parallel() + + // Init tracing mocks + tracer := &mockTracer{ + spans: make(map[string]struct{}), + } + tp := &mockTracerProvider{ + tracer: tracer, + } + // Start test server + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprint(w, html) + require.NoError(t, err) + }, + )) + defer ts.Close() + + // Initialize VU and browser module + vu := k6test.NewVU(t, k6test.WithTracerProvider(tp)) + + rt := vu.Runtime() + root := browser.New() + mod := root.NewModuleInstance(vu) + jsMod, ok := mod.Exports().Default.(*browser.JSModule) + require.Truef(t, ok, "unexpected default mod export type %T", mod.Exports().Default) + require.NoError(t, rt.Set("browser", jsMod.Browser)) + vu.ActivateVU() + + // Run the test + vu.StartIteration(t) + require.NoError(t, tracer.verifySpans("iteration")) + setupTestTracing(t, rt) + + testCases := []struct { + name string + js string + spans []string + }{ + { + name: "browser.newPage", + js: "page = await browser.newPage()", + spans: []string{ + "browser.newPage", + "browser.newContext", + "browserContext.newPage", + }, + }, + { + name: "page.goto", + js: fmt.Sprintf("page.goto('%s')", ts.URL), + spans: []string{ + "page.goto", + "navigation", + }, + }, + { + name: "page.screenshot", + js: "page.screenshot();", + spans: []string{ + "page.screenshot", + }, + }, + { + name: "locator.click", + js: "page.locator('#clickme').click();", + spans: []string{ + "locator.click", + }, + }, + { + name: "locator.type", + js: "page.locator('input#typeme').type('test');", + spans: []string{ + "locator.type", + }, + }, + { + name: "page.reload", + js: `await Promise.all([ + page.waitForNavigation(), + page.reload(), + ]);`, + spans: []string{ + "page.reload", + "page.waitForNavigation", + }, + }, + { + name: "page.waitForTimeout", + js: "page.waitForTimeout(10);", + spans: []string{ + "page.waitForTimeout", + }, + }, + { + name: "web_vital", + js: "page.close();", // on page.close, web vitals are collected and fired/received. + spans: []string{ + "web_vital", + "page.close", + }, + }, + } + + // Each sub test depends on the previous sub test, so they cannot be ran + // in parallel. + for _, tc := range testCases { + assertJSInEventLoop(t, vu, tc.js) + + require.NoError(t, tracer.verifySpans(tc.spans...)) + } +} + +// This test is testing to ensure that correct number of navigation spans are created +// and they are created in the correct order. +func TestNavigationSpanCreation(t *testing.T) { + t.Parallel() + + // Init tracing mocks + tracer := &mockTracer{ + spans: make(map[string]struct{}), + } + tp := &mockTracerProvider{ + tracer: tracer, + } + // Start test server + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprint(w, html) + require.NoError(t, err) + }, + )) + defer ts.Close() + + // Initialize VU and browser module + vu := k6test.NewVU(t, k6test.WithTracerProvider(tp)) + + rt := vu.Runtime() + root := browser.New() + mod := root.NewModuleInstance(vu) + jsMod, ok := mod.Exports().Default.(*browser.JSModule) + require.Truef(t, ok, "unexpected default mod export type %T", mod.Exports().Default) + require.NoError(t, rt.Set("browser", jsMod.Browser)) + vu.ActivateVU() + + testCases := []struct { + name string + js string + expected []string + }{ + { + name: "goto", + js: fmt.Sprintf(` + page = await browser.newPage(); + await page.goto('%s', {waitUntil:'networkidle'}); + page.close(); + `, ts.URL), + expected: []string{ + "iteration", + "browser.newPage", + "browser.newContext", + "browserContext.newPage", + "navigation", // created when a new page is created + "page.goto", + "navigation", // created when a navigation occurs after goto + "page.close", + }, + }, + { + name: "reload", + js: fmt.Sprintf(` + page = await browser.newPage(); + await page.goto('%s', {waitUntil:'networkidle'}); + await page.reload({waitUntil:'networkidle'}); + page.close(); + `, ts.URL), + expected: []string{ + "iteration", + "browser.newPage", + "browser.newContext", + "browserContext.newPage", + "navigation", // created when a new page is created + "page.goto", + "navigation", // created when a navigation occurs after goto + "page.reload", + "navigation", // created when a navigation occurs after reload + "page.close", + }, + }, + { + name: "go_back", + js: fmt.Sprintf(` + page = await browser.newPage(); + await page.goto('%s', {waitUntil:'networkidle'}); + await Promise.all([ + page.waitForNavigation(), + page.evaluate(() => window.history.back()), + ]); + page.close(); + `, ts.URL), + expected: []string{ + "iteration", + "browser.newPage", + "browser.newContext", + "browserContext.newPage", + "navigation", // created when a new page is created + "page.goto", + "navigation", // created when a navigation occurs after goto + "page.waitForNavigation", + "navigation", // created when going back to the previous page + "page.close", + }, + }, + { + name: "same_page_navigation", + js: fmt.Sprintf(` + page = await browser.newPage(); + await page.goto('%s', {waitUntil:'networkidle'}); + await Promise.all([ + page.waitForNavigation(), + page.locator('a[id=\"top\"]').click(), + ]); + page.close(); + `, ts.URL), + expected: []string{ + "iteration", + "browser.newPage", + "browser.newContext", + "browserContext.newPage", + "navigation", // created when a new page is created + "page.goto", + "navigation", // created when a navigation occurs after goto + "page.waitForNavigation", + "locator.click", + "navigation", // created when navigating within the same page + "page.close", + }, + }, + } + + for _, tc := range testCases { + // Cannot create new VUs that do not depend on each other due to the + // sync.Once in mod.NewModuleInstance, so we can't parallelize these + // subtests. + func() { + // Run the test + vu.StartIteration(t) + defer vu.EndIteration(t) + + assertJSInEventLoop(t, vu, tc.js) + + got := tracer.cloneOrderedSpans() + // We can't use assert.Equal since the order of the span creation + // changes slightly on every test run. Instead we're going to make + // sure that the slice matches but not the order. + assert.ElementsMatch(t, tc.expected, got, fmt.Sprintf("%s failed", tc.name)) + }() + } +} + +func setupTestTracing(t *testing.T, rt *sobek.Runtime) { + t.Helper() + + // Declare a global page var that we can use + // throughout the test cases + _, err := rt.RunString("var page;") + require.NoError(t, err) + + // Set a sleep function so we can use it to wait + // for async WebVitals processing + err = rt.Set("sleep", func(d int) { + time.Sleep(time.Duration(d) * time.Millisecond) + }) + require.NoError(t, err) +} + +func assertJSInEventLoop(t *testing.T, vu *k6test.VU, js string) { + t.Helper() + + f := fmt.Sprintf( + "test = async function() { %s; }", + js) + + rt := vu.Runtime() + _, err := rt.RunString(f) + require.NoError(t, err) + + test, ok := sobek.AssertFunction(rt.Get("test")) + require.True(t, ok) + + err = vu.Loop.Start(func() error { + _, err := test(sobek.Undefined()) + return err + }) + require.NoError(t, err) +} + +type mockTracerProvider struct { + k6lib.TracerProvider + + tracer trace.Tracer +} + +func (m *mockTracerProvider) Tracer( + name string, options ...trace.TracerOption, +) trace.Tracer { + return m.tracer +} + +type mockTracer struct { + embedded.Tracer + + mu sync.Mutex + spans map[string]struct{} + orderedSpans []string +} + +func (m *mockTracer) Start( + ctx context.Context, spanName string, opts ...trace.SpanStartOption, +) (context.Context, trace.Span) { + m.mu.Lock() + defer m.mu.Unlock() + + m.spans[spanName] = struct{}{} + + // Ignore web_vital spans since they're non deterministic. + if spanName != "web_vital" { + m.orderedSpans = append(m.orderedSpans, spanName) + } + + return ctx, browsertrace.NoopSpan{} +} + +func (m *mockTracer) verifySpans(spanNames ...string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, sn := range spanNames { + if _, ok := m.spans[sn]; !ok { + return fmt.Errorf("%q span was not found", sn) + } + delete(m.spans, sn) + } + + return nil +} + +func (m *mockTracer) cloneOrderedSpans() []string { + m.mu.Lock() + defer m.mu.Unlock() + + c := make([]string, len(m.orderedSpans)) + copy(c, m.orderedSpans) + + m.orderedSpans = []string{} + + return c +} diff --git a/js/modules/k6/browser/tests/webvital_test.go b/js/modules/k6/browser/tests/webvital_test.go new file mode 100644 index 00000000000..08bb63e03a1 --- /dev/null +++ b/js/modules/k6/browser/tests/webvital_test.go @@ -0,0 +1,147 @@ +package tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/js/modules/k6/browser/common" + + k6metrics "go.k6.io/k6/metrics" +) + +// TestWebVitalMetric is asserting that web vital metrics +// are being emitted when navigating and interacting with +// a web page. +func TestWebVitalMetric(t *testing.T) { + t.Parallel() + + var ( + samples = make(chan k6metrics.SampleContainer) + browser = newTestBrowser(t, withFileServer(), withSamples(samples)) + page = browser.NewPage(nil) + expected = map[string]bool{ + "browser_web_vital_ttfb": false, + "browser_web_vital_fcp": false, + "browser_web_vital_lcp": false, + "browser_web_vital_fid": false, + "browser_web_vital_cls": false, + } + ) + + done := make(chan struct{}) + ctx, cancel := context.WithTimeout(browser.context(), 5*time.Second) + defer cancel() + go func() { + for { + var metric k6metrics.SampleContainer + select { + case <-done: + return + case <-ctx.Done(): + return + case metric = <-samples: + } + samples := metric.GetSamples() + for _, s := range samples { + if _, ok := expected[s.Metric.Name]; ok { + expected[s.Metric.Name] = true + } + } + } + }() + + opts := &common.FrameGotoOptions{ + Timeout: common.DefaultTimeout, + } + resp, err := page.Goto( + browser.staticURL("/web_vitals.html"), + opts, + ) + require.NoError(t, err) + require.NotNil(t, resp) + + // A click action helps measure first input delay. + // The click action also refreshes the page, which + // also helps the web vital library to measure CLS. + err = browser.run( + ctx, + func() error { return page.Click("#clickMe", common.NewFrameClickOptions(page.Timeout())) }, + func() error { + _, err := page.WaitForNavigation( + common.NewFrameWaitForNavigationOptions(page.Timeout()), + ) + return err + }, + ) + require.NoError(t, err) + + // prevents `err:fetching response body: context canceled` warning.` + require.NoError(t, page.Close(nil)) + done <- struct{}{} + + for k, v := range expected { + assert.True(t, v, "expected %s to have been measured and emitted", k) + } +} + +func TestWebVitalMetricNoInteraction(t *testing.T) { + t.Parallel() + + var ( + samples = make(chan k6metrics.SampleContainer) + browser = newTestBrowser(t, withFileServer(), withSamples(samples)) + expected = map[string]bool{ + "browser_web_vital_ttfb": false, + "browser_web_vital_fcp": false, + "browser_web_vital_lcp": false, + "browser_web_vital_cls": false, + } + ) + + done := make(chan struct{}) + ctx, cancel := context.WithTimeout(browser.context(), 5*time.Second) + defer cancel() + go func() { + for { + var metric k6metrics.SampleContainer + select { + case <-done: + return + case <-ctx.Done(): + return + case metric = <-samples: + } + samples := metric.GetSamples() + for _, s := range samples { + if _, ok := expected[s.Metric.Name]; ok { + expected[s.Metric.Name] = true + } + } + } + }() + + // wait until the page is completely loaded. + page := browser.NewPage(nil) + opts := &common.FrameGotoOptions{ + WaitUntil: common.LifecycleEventNetworkIdle, + Timeout: common.DefaultTimeout, + } + resp, err := page.Goto( + browser.staticURL("web_vitals.html"), + opts, + ) + require.NoError(t, err) + require.NotNil(t, resp) + + // prevents `err:fetching response body: context canceled` warning.` + require.NoError(t, page.Close(nil)) + done <- struct{}{} + + for k, v := range expected { + assert.True(t, v, "expected %s to have been measured and emitted", k) + } +} diff --git a/js/modules/k6/browser/tests/ws/server.go b/js/modules/k6/browser/tests/ws/server.go new file mode 100644 index 00000000000..f6218000dec --- /dev/null +++ b/js/modules/k6/browser/tests/ws/server.go @@ -0,0 +1,286 @@ +// Package ws provides a test WebSocket server. +package ws + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + k6netext "go.k6.io/k6/lib/netext" + k6types "go.k6.io/k6/lib/types" + + "github.com/chromedp/cdproto" + "github.com/gorilla/websocket" + "github.com/mailru/easyjson" + "github.com/mailru/easyjson/jlexer" + "github.com/mailru/easyjson/jwriter" + "github.com/mccutchen/go-httpbin/httpbin" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +// Server can be used as a test alternative to a real CDP compatible browser. +type Server struct { + t testing.TB + Mux *http.ServeMux + ServerHTTP *httptest.Server + Dialer *k6netext.Dialer + HTTPTransport *http.Transport + Context context.Context +} + +// NewServer returns a fully configured and running WS test server. +func NewServer(t testing.TB, opts ...func(*Server)) *Server { + t.Helper() + + // Create a http.ServeMux and set the httpbin handler as the default + mux := http.NewServeMux() + mux.Handle("/", httpbin.New().Handler()) + + // Initialize the HTTP server and get its details + server := httptest.NewServer(mux) + url, err := url.Parse(server.URL) + require.NoError(t, err) + ip := net.ParseIP(url.Hostname()) + require.NotNil(t, ip) + domain, err := k6types.NewHost(ip, "") + require.NoError(t, err) + + // Set up the dialer with shorter timeouts and the custom domains + dialer := k6netext.NewDialer(net.Dialer{ + Timeout: 2 * time.Second, + KeepAlive: 10 * time.Second, + DualStack: true, + }, k6netext.NewResolver(net.LookupIP, 0, k6types.DNSfirst, k6types.DNSpreferIPv4)) + + const wsURL = "wsbin.local" + dialer.Hosts, err = k6types.NewHosts(map[string]k6types.Host{ + wsURL: *domain, + }) + require.NoError(t, err, "failed to set up dialer hosts") + + // Pre-configure the HTTP client transport with the dialer and TLS config (incl. HTTP2 support) + transport := &http.Transport{ + DialContext: dialer.DialContext, + } + require.NoError(t, http2.ConfigureTransport(transport)) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + server.Close() + cancel() + }) + s := &Server{ + t: t, + Mux: mux, + ServerHTTP: server, + Dialer: dialer, + HTTPTransport: transport, + Context: ctx, + } + for _, opt := range opts { + opt(s) + } + return s +} + +// WithClosureAbnormalHandler attaches an abnormal closure behavior to Server. +func WithClosureAbnormalHandler(path string) func(*Server) { + handler := func(w http.ResponseWriter, req *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, req, w.Header()) + if err != nil { + // TODO: log + return + } + err = conn.Close() // This forces a connection closure without a proper WS close message exchange + if err != nil { + // TODO: log + return + } + } + return func(s *Server) { + s.Mux.Handle(path, http.HandlerFunc(handler)) + } +} + +// WithEchoHandler attaches an echo handler to Server. +func WithEchoHandler(path string) func(*Server) { + handler := func(w http.ResponseWriter, req *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, req, w.Header()) + if err != nil { + return + } + messageType, r, e := conn.NextReader() + if e != nil { + return + } + var wc io.WriteCloser + wc, err = conn.NextWriter(messageType) + if err != nil { + return + } + if _, err = io.Copy(wc, r); err != nil { + return + } + if err = wc.Close(); err != nil { + return + } + err = conn.WriteControl(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + time.Now().Add(10*time.Second), + ) + if err != nil { + return + } + } + return func(s *Server) { + s.Mux.Handle(path, http.HandlerFunc(handler)) + } +} + +// WithCDPHandler attaches a custom CDP handler function to Server. +// +//nolint:funlen,gocognit +func WithCDPHandler( + path string, + fn func(conn *websocket.Conn, msg *cdproto.Message, writeCh chan cdproto.Message, done chan struct{}), + cmdsReceived *[]cdproto.MethodType, +) func(*Server) { + handler := func(w http.ResponseWriter, req *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, req, w.Header()) + if err != nil { + return + } + + done := make(chan struct{}) + writeCh := make(chan cdproto.Message) + + go func() { + read := func(conn *websocket.Conn) (*cdproto.Message, error) { + _, buf, err := conn.ReadMessage() + if err != nil { + return nil, err + } + + var msg cdproto.Message + decoder := jlexer.Lexer{Data: buf} + msg.UnmarshalEasyJSON(&decoder) + if err := decoder.Error(); err != nil { + return nil, err + } + + return &msg, nil + } + + for { + select { + case <-done: + return + default: + } + + msg, err := read(conn) + if err != nil { + close(done) + return + } + + if msg.Method != "" && cmdsReceived != nil { + *cmdsReceived = append(*cmdsReceived, msg.Method) + } + + fn(conn, msg, writeCh, done) + } + }() + + go func() { + write := func(conn *websocket.Conn, msg *cdproto.Message) { + encoder := jwriter.Writer{} + msg.MarshalEasyJSON(&encoder) + if err := encoder.Error; err != nil { + return + } + + writer, err := conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + if _, err := encoder.DumpTo(writer); err != nil { + return + } + if err := writer.Close(); err != nil { + return + } + } + + for { + select { + case msg := <-writeCh: + write(conn, &msg) + case <-done: + return + } + } + }() + + <-done // Wait for done channel to be closed before closing connection + } + return func(s *Server) { + s.Mux.Handle(path, http.HandlerFunc(handler)) + } +} + +// CDPDefaultHandler is a default handler for the CDP WS server. +func CDPDefaultHandler(conn *websocket.Conn, msg *cdproto.Message, writeCh chan cdproto.Message, done chan struct{}) { + const ( + 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" + }` + ) + + if msg.SessionID != "" && msg.Method != "" { + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + } + } else if msg.Method != "" { + switch msg.Method { + 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)), + } + default: + writeCh <- cdproto.Message{ + ID: msg.ID, + SessionID: msg.SessionID, + Result: easyjson.RawMessage([]byte("{}")), + } + } + } +} diff --git a/vendor/github.com/grafana/xk6-browser/trace/trace.go b/js/modules/k6/browser/trace/trace.go similarity index 100% rename from vendor/github.com/grafana/xk6-browser/trace/trace.go rename to js/modules/k6/browser/trace/trace.go diff --git a/vendor/github.com/grafana/xk6-browser/LICENSE b/vendor/github.com/grafana/xk6-browser/LICENSE deleted file mode 100644 index 0ad25db4bd1..00000000000 --- a/vendor/github.com/grafana/xk6-browser/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/vendor/golang.org/x/sync/errgroup/errgroup.go b/vendor/golang.org/x/sync/errgroup/errgroup.go new file mode 100644 index 00000000000..948a3ee63d4 --- /dev/null +++ b/vendor/golang.org/x/sync/errgroup/errgroup.go @@ -0,0 +1,135 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package errgroup provides synchronization, error propagation, and Context +// cancelation for groups of goroutines working on subtasks of a common task. +// +// [errgroup.Group] is related to [sync.WaitGroup] but adds handling of tasks +// returning errors. +package errgroup + +import ( + "context" + "fmt" + "sync" +) + +type token struct{} + +// A Group is a collection of goroutines working on subtasks that are part of +// the same overall task. +// +// A zero Group is valid, has no limit on the number of active goroutines, +// and does not cancel on error. +type Group struct { + cancel func(error) + + wg sync.WaitGroup + + sem chan token + + errOnce sync.Once + err error +} + +func (g *Group) done() { + if g.sem != nil { + <-g.sem + } + g.wg.Done() +} + +// WithContext returns a new Group and an associated Context derived from ctx. +// +// The derived Context is canceled the first time a function passed to Go +// returns a non-nil error or the first time Wait returns, whichever occurs +// first. +func WithContext(ctx context.Context) (*Group, context.Context) { + ctx, cancel := withCancelCause(ctx) + return &Group{cancel: cancel}, ctx +} + +// Wait blocks until all function calls from the Go method have returned, then +// returns the first non-nil error (if any) from them. +func (g *Group) Wait() error { + g.wg.Wait() + if g.cancel != nil { + g.cancel(g.err) + } + return g.err +} + +// Go calls the given function in a new goroutine. +// It blocks until the new goroutine can be added without the number of +// active goroutines in the group exceeding the configured limit. +// +// The first call to return a non-nil error cancels the group's context, if the +// group was created by calling WithContext. The error will be returned by Wait. +func (g *Group) Go(f func() error) { + if g.sem != nil { + g.sem <- token{} + } + + g.wg.Add(1) + go func() { + defer g.done() + + if err := f(); err != nil { + g.errOnce.Do(func() { + g.err = err + if g.cancel != nil { + g.cancel(g.err) + } + }) + } + }() +} + +// TryGo calls the given function in a new goroutine only if the number of +// active goroutines in the group is currently below the configured limit. +// +// The return value reports whether the goroutine was started. +func (g *Group) TryGo(f func() error) bool { + if g.sem != nil { + select { + case g.sem <- token{}: + // Note: this allows barging iff channels in general allow barging. + default: + return false + } + } + + g.wg.Add(1) + go func() { + defer g.done() + + if err := f(); err != nil { + g.errOnce.Do(func() { + g.err = err + if g.cancel != nil { + g.cancel(g.err) + } + }) + } + }() + return true +} + +// SetLimit limits the number of active goroutines in this group to at most n. +// A negative value indicates no limit. +// +// Any subsequent call to the Go method will block until it can add an active +// goroutine without exceeding the configured limit. +// +// The limit must not be modified while any goroutines in the group are active. +func (g *Group) SetLimit(n int) { + if n < 0 { + g.sem = nil + return + } + if len(g.sem) != 0 { + panic(fmt.Errorf("errgroup: modify limit while %v goroutines in the group are still active", len(g.sem))) + } + g.sem = make(chan token, n) +} diff --git a/vendor/golang.org/x/sync/errgroup/go120.go b/vendor/golang.org/x/sync/errgroup/go120.go new file mode 100644 index 00000000000..f93c740b638 --- /dev/null +++ b/vendor/golang.org/x/sync/errgroup/go120.go @@ -0,0 +1,13 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.20 + +package errgroup + +import "context" + +func withCancelCause(parent context.Context) (context.Context, func(error)) { + return context.WithCancelCause(parent) +} diff --git a/vendor/golang.org/x/sync/errgroup/pre_go120.go b/vendor/golang.org/x/sync/errgroup/pre_go120.go new file mode 100644 index 00000000000..88ce33434e2 --- /dev/null +++ b/vendor/golang.org/x/sync/errgroup/pre_go120.go @@ -0,0 +1,14 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !go1.20 + +package errgroup + +import "context" + +func withCancelCause(parent context.Context) (context.Context, func(error)) { + ctx, cancel := context.WithCancel(parent) + return ctx, func(error) { cancel() } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 5cf5fb9dd06..4fc6f0402a9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -179,19 +179,6 @@ github.com/grafana/sobek/ftoa/internal/fast github.com/grafana/sobek/parser github.com/grafana/sobek/token github.com/grafana/sobek/unistring -# github.com/grafana/xk6-browser v1.10.0 -## explicit; go 1.21 -github.com/grafana/xk6-browser/browser -github.com/grafana/xk6-browser/chromium -github.com/grafana/xk6-browser/common -github.com/grafana/xk6-browser/common/js -github.com/grafana/xk6-browser/env -github.com/grafana/xk6-browser/k6error -github.com/grafana/xk6-browser/k6ext -github.com/grafana/xk6-browser/keyboardlayout -github.com/grafana/xk6-browser/log -github.com/grafana/xk6-browser/storage -github.com/grafana/xk6-browser/trace # github.com/grafana/xk6-dashboard v0.7.5 ## explicit; go 1.20 github.com/grafana/xk6-dashboard/dashboard @@ -465,6 +452,7 @@ golang.org/x/net/internal/timeseries golang.org/x/net/trace # golang.org/x/sync v0.10.0 ## explicit; go 1.18 +golang.org/x/sync/errgroup golang.org/x/sync/semaphore # golang.org/x/sys v0.28.0 ## explicit; go 1.18