Merge pull request #4651 from usebruno/feature/playwright

Playwright Codegen and CI setup
This commit is contained in:
Anoop M D
2025-05-12 22:08:31 +05:30
committed by GitHub
14 changed files with 158 additions and 292 deletions

44
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

5
.gitignore vendored
View File

@@ -47,4 +47,7 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
.vscode
.vscode
# Playwright
/blob-report/

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

46
package-lock.json generated
View File

@@ -23,9 +23,10 @@
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"@playwright/test": "^1.51.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
@@ -34,6 +35,7 @@
"jest": "^29.2.0",
"lint-staged": "^15.5.2",
"lodash-es": "^4.17.21",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -6105,13 +6107,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
"integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.49.1"
"playwright": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@@ -8290,6 +8292,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
@@ -8312,6 +8315,7 @@
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "*",
@@ -8322,6 +8326,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
@@ -8332,13 +8337,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
"version": "22.15.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
"integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/plist": {
@@ -13268,6 +13273,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -21305,13 +21311,13 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
"playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@@ -21324,9 +21330,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -26479,7 +26485,7 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -26505,9 +26511,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},

View File

@@ -20,9 +20,10 @@
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"@playwright/test": "^1.51.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
@@ -31,6 +32,7 @@
"jest": "^29.2.0",
"lint-staged": "^15.5.2",
"lodash-es": "^4.17.21",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -58,9 +60,8 @@
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install",
"lint": "npx eslint ./"

View File

@@ -1,110 +0,0 @@
// @ts-check
const { devices } = require('@playwright/test');
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
process.env.PLAYWRIGHT = "1";
/**
* @see https://playwright.dev/docs/test-configuration
* @type {import('@playwright/test').PlaywrightTestConfig}
*/
const config = {
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev:web',
port: 3000,
},
};
module.exports = config;

25
playwright.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e-tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? undefined : 1,
reporter: 'html',
use: {
trace: 'on-first-retry'
},
projects: [
{
name: 'Bruno Electron App'
}
],
webServer: {
command: 'npm run dev:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
});

13
playwright/codegen.ts Normal file
View File

@@ -0,0 +1,13 @@
const path = require('path');
const { startApp } = require('./electron.ts');
async function main() {
const { app, context } = await startApp();
let outputFile = process.argv[2]?.trim();
if (outputFile && !/\.(ts|js)$/.test(outputFile)) {
outputFile = path.join(__dirname, '../e2e-tests/', outputFile + '.spec.ts');
}
await context._enableRecorder({ language: 'playwright-test', mode: 'recording', outputFile });
}
main();

13
playwright/electron.ts Normal file
View File

@@ -0,0 +1,13 @@
const path = require('path');
const { _electron: electron } = require('playwright');
const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
exports.startApp = async () => {
const app = await electron.launch({ args: [electronAppPath] });
const context = await app.context();
app.process().stdout.on('data', (data) => console.log(data.toString()));
app.process().stderr.on('data', (error) => console.error(error.toString()));
return { app, context };
};

23
playwright/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import { test as baseTest, ElectronApplication, Page } from '@playwright/test';
const { startApp } = require('./electron.ts');
export const test = baseTest.extend<{ page: Page }, { electronApp: ElectronApplication }>({
electronApp: [
async ({}, use) => {
const { app: electronApp, context } = await startApp();
await use(electronApp);
await context.close();
await electronApp.close();
},
{ scope: 'worker' }
],
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await use(page);
await page.reload();
}
});
export * from '@playwright/test'

View File

@@ -1,17 +0,0 @@
const path = require('path');
const timer = require('node:timers/promises');
const { _electron: electron } = require('playwright');
const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
(async () => {
const browser = await electron.launch({ args: [electronAppPath] });
const context = await browser.context();
await context.route('**/*', (route) => route.continue());
while (true) {
if(browser.windows().length) break;
await timer.setTimeout(200);
}
await browser.windows()[0].pause();
})();

View File

@@ -1,48 +0,0 @@
const { test, expect } = require('@playwright/test');
const { HomePage } = require('../tests/pages/home.page');
const { faker } = require('./utils/data-faker');
test.describe('bruno e2e test', () => {
let homePage;
test.beforeEach(async ({ page }) => {
homePage = new HomePage(page);
await homePage.open();
await expect(page).toHaveURL('/');
await expect(page).toHaveTitle(/bruno/);
});
test('user should be able to create new collection & new request', async () => {
await homePage.createNewCollection(faker.randomWords);
await expect(homePage.createNewCollectionSuccessToast).toBeVisible();
// using fake data to simulate negative case
await homePage.createNewRequest(faker.randomVerb, faker.randomHttpMethod, faker.randomUrl);
await expect(homePage.networkErrorToast).toBeVisible();
// using real data to simulate positive case
await homePage.createNewRequest('Single User', 'GET', 'https://reqres.in/api/users/2');
await expect(homePage.statusRequestSuccess).toBeVisible();
});
test('user should be able to load & use sample collection', async () => {
await homePage.loadSampleCollection();
await expect(homePage.loadSampleCollectionSuccessToast).toBeVisible();
await homePage.getUsers();
await expect(homePage.statusRequestSuccess).toBeVisible();
await homePage.getSingleUser();
await expect(homePage.statusRequestSuccess).toBeVisible();
await homePage.getUserNotFound();
await expect(homePage.statusRequestNotFound).toBeVisible();
await homePage.createUser();
await expect(homePage.statusRequestCreated).toBeVisible();
await homePage.updateUser();
await expect(homePage.statusRequestSuccess).toBeVisible();
});
});

View File

@@ -1,86 +0,0 @@
exports.HomePage = class HomePage {
constructor(page) {
this.page = page;
// welcome
this.createCollectionSelector = page.locator('#create-collection');
this.addCollectionSelector = page.locator('#add-collection');
this.importCollectionSelector = page.locator('#import-collection');
this.loadSampleCollectionSelector = page.locator('#load-sample-collection');
// sample collection
this.loadSampleCollectionSuccessToast = page.getByText('Sample Collection loaded successfully');
this.sampleCollectionSelector = page.locator('#sidebar-collection-name');
this.getUsersSelector = page.getByText('Users');
this.getSingleUserSelector = page.getByText('Single User');
this.getUserNotFoundSelector = page.getByText('User Not Found');
this.postCreateSelector = page.getByText('Create');
this.putUpdateSelector = page.getByText('Update');
// request panel
this.sendRequestButton = page.locator('#send-request');
this.statusRequestSuccess = page.getByText('200 OK');
this.statusRequestNotFound = page.getByText('404 Not Found');
this.statusRequestCreated = page.getByText('201 Created');
// create collection
this.collectionNameField = page.locator('#collection-name');
this.submitButton = page.locator(`button[type='submit']`);
this.createNewCollectionSuccessToast = page.getByText('Collection created');
this.createNewTab = page.locator('#create-new-tab');
this.requestNameField = page.locator('input[name="requestName"]');
this.methodName = page.locator('#create-new-request-method').first();
this.requestUrlField = page.locator('#request-url');
this.networkErrorToast = page.getByText('Network Error');
}
async open() {
await this.page.goto('/');
}
async loadSampleCollection() {
await this.loadSampleCollectionSelector.click();
}
async getUsers() {
await this.sampleCollectionSelector.click();
await this.getUsersSelector.click();
await this.sendRequestButton.click();
}
async getSingleUser() {
await this.getSingleUserSelector.click();
await this.sendRequestButton.click();
}
async getUserNotFound() {
await this.getUserNotFoundSelector.click();
await this.sendRequestButton.click();
}
async createUser() {
await this.postCreateSelector.click();
await this.sendRequestButton.click();
}
async updateUser() {
await this.putUpdateSelector.click();
await this.sendRequestButton.click();
}
async createNewCollection(collectionName) {
await this.createCollectionSelector.click();
await this.collectionNameField.fill(collectionName);
await this.submitButton.click();
}
async createNewRequest(name, method, endpoint) {
await this.createNewTab.click();
await this.requestNameField.fill(name);
await this.methodName.click();
await this.page.click(`text=${method}`);
await this.requestUrlField.fill(endpoint);
await this.submitButton.click();
await this.sendRequestButton.click();
}
};

View File

@@ -1,6 +0,0 @@
const { faker } = require('@faker-js/faker');
export let randomWords = faker.random.words();
export let randomVerb = faker.hacker.verb();
export let randomHttpMethod = faker.internet.httpMethod();
export let randomUrl = faker.internet.url();