feat: variable descriptions (#8269)

* feat: add description column to various tables and enhance UI interactions
- Introduced a description column in Headers, VarsTable, and other components to allow multi-line descriptions.
- Added toggle buttons to show/hide the description column in relevant tables.
- Updated EditableTable component to support dynamic row addition with customizable labels.
- Enhanced UI for better user experience with consistent button styles and spacing adjustments.

* chore: resize fix handles

* fix: restrict column check for last index

* refactor: remove unwanted style and fix bottom padding of the last child in the table row

* tests: improve testability and fix impacted locators

* tests: fix locators for value fields

* fix: improve newline handling in description prefix

* fix: ts changes

* chore: remove redundant code and fix description roundtrip

* chore: remove redundant check

* tests(bru-lang): tests for description and annotation

* test: improve locators and tests

* tests: descriptions

* chore: re-add description setup

* fix: re-add description cols

* chore: fix the double click reset

* fix: account for hidden sidebar in request pane width calculation

* refactor: ui/ux fixes for data type toggle

* chore: inline common deps into bruno-lang and bruno-schema

* chore: tests

* chore: fix ux for descriptions

* fix: layout for environment vars table

* fix: ensure correct row selection for multipart file upload in tests

* feat: enhance UID assignment for request headers and variables in collections

* chore: simplify

* chore: update pkg

* chore: abstract the e2e utils

* chore: fix exports

* tests: fix locators for datatype vars

* fix: ux issue with secrets in environment table

* tests: update locators for collection headers and vars descriptions

* tests: update locators to use data attributes for datatype selectors

* chore: update default css width for actions column

* chore: remove tooltip for string type warnings

* fix: reduce name widths

* refactor: update annotation serialization to use single-quote delimiters for descriptions

* refactor(tests): simplify description formatting in jsonToEnv tests

* feat: add multiline description escaping and unescaping functions with tests

* refactor: clean up imports and improve multiline text block handling

* refactor: update locators to use new test IDs for environment variable editors

* refactor: streamline header input handling and improve environment variable row access

* refactor: update e2e-test job to support self-hosted runners

* refactor: update E2E test runner configuration to include e2e and macOS environments

---------

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
This commit is contained in:
Sid
2026-07-07 21:43:39 +05:30
committed by GitHub
parent 958ca49156
commit 0bba66a590
177 changed files with 6570 additions and 482 deletions

View File

@@ -0,0 +1,312 @@
{
"name": "descriptions-imported-bru",
"version": "1",
"items": [
{
"type": "folder",
"name": "folder",
"seq": 1,
"root": {
"meta": {
"name": "folder"
},
"request": {
"headers": [
{
"name": "X-Folder-Version",
"value": "1.0",
"enabled": true,
"description": "Single-line folder header desc"
},
{
"name": "X-Folder-Multi",
"value": "value",
"enabled": true,
"description": "Folder header line one\nFolder header line two"
},
{
"name": "X-Folder-Plain",
"value": "plain",
"enabled": true
}
],
"vars": {
"req": [
{
"name": "folderBaseUrl",
"value": "https://folder.example.com",
"enabled": true,
"description": "Single-line folder var desc"
},
{
"name": "folderApiKey",
"value": "folder-key",
"enabled": true,
"description": "Folder var line one\nFolder var line two"
},
{
"name": "folderPlain",
"value": "no-desc",
"enabled": true
}
],
"res": []
},
"script": {},
"tests": null
}
},
"items": [
{
"type": "http",
"name": "request",
"seq": 1,
"request": {
"url": "https://example.com/api",
"method": "POST",
"headers": [
{
"name": "X-Version",
"value": "2.0",
"enabled": true,
"description": "Single-line header desc"
},
{
"name": "X-Multi",
"value": "value",
"enabled": true,
"description": "Header line one\nHeader line two"
},
{
"name": "X-Plain",
"value": "plain-value",
"enabled": true
}
],
"params": [
{
"name": "q",
"value": "search",
"type": "query",
"enabled": true,
"description": "Single-line query desc"
},
{
"name": "page",
"value": "1",
"type": "query",
"enabled": true,
"description": "Multi-line query desc line one\nMulti-line query desc line two"
},
{
"name": "plain-query",
"value": "value",
"type": "query",
"enabled": true
}
],
"body": {
"mode": "multipartForm",
"formUrlEncoded": [
{
"name": "username",
"value": "alice",
"enabled": true,
"description": "Single-line form desc"
},
{
"name": "email",
"value": "alice@example.com",
"enabled": true,
"description": "Multi-line form desc line one\nMulti-line form desc line two"
},
{
"name": "plain-form",
"value": "value",
"enabled": true
}
],
"multipartForm": [
{
"type": "text",
"name": "username",
"value": "alice",
"enabled": true,
"description": "Single-line field desc"
},
{
"type": "text",
"name": "email",
"value": "alice@example.com",
"enabled": true,
"description": "Multi-line field desc line one\nMulti-line field desc line two"
},
{
"type": "text",
"name": "plain-field",
"value": "value",
"enabled": true
}
],
"file": []
},
"script": {},
"vars": {
"req": [
{
"name": "reqBaseUrl",
"value": "https://example.com",
"enabled": true,
"description": "Single-line req var desc"
},
{
"name": "reqApiKey",
"value": "req-key",
"enabled": true,
"description": "Req var line one\nReq var line two"
},
{
"name": "reqPlain",
"value": "no-desc",
"enabled": true
}
],
"res": []
},
"assertions": [],
"tests": "",
"docs": "",
"auth": {
"mode": "none"
}
}
},
{
"type": "http",
"name": "form-request",
"seq": 2,
"request": {
"url": "https://example.com/form",
"method": "POST",
"headers": [],
"params": [],
"body": {
"mode": "formUrlEncoded",
"formUrlEncoded": [
{
"name": "username",
"value": "alice",
"enabled": true,
"description": "Single-line form desc"
},
{
"name": "email",
"value": "alice@example.com",
"enabled": true,
"description": "Multi-line form desc line one\nMulti-line form desc line two"
},
{
"name": "plain-form",
"value": "value",
"enabled": true
}
],
"multipartForm": [],
"file": []
},
"script": {},
"vars": {},
"assertions": [],
"tests": "",
"docs": "",
"auth": {
"mode": "none"
}
}
}
]
}
],
"environments": [
{
"name": "test_env",
"variables": [
{
"name": "envHost",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false,
"description": "Single-line env desc"
},
{
"name": "envToken",
"value": "abc123",
"type": "text",
"enabled": true,
"secret": false,
"description": "Env line one\nEnv line two"
},
{
"name": "envPlain",
"value": "no-desc",
"type": "text",
"enabled": true,
"secret": false
}
]
}
],
"root": {
"request": {
"headers": [
{
"name": "X-Collection-Version",
"value": "2.0",
"enabled": true,
"description": "Single-line collection header desc"
},
{
"name": "X-Collection-Multi",
"value": "value",
"enabled": true,
"description": "Collection header line one\nCollection header line two"
},
{
"name": "X-Collection-Plain",
"value": "plain-value",
"enabled": true
}
],
"vars": {
"req": [
{
"name": "baseUrl",
"value": "https://example.com",
"enabled": true,
"description": "Single-line collection var desc"
},
{
"name": "apiKey",
"value": "abc123",
"enabled": true,
"description": "Collection var line one\nCollection var line two"
},
{
"name": "plain",
"value": "no-desc",
"enabled": true
}
],
"res": []
},
"script": {},
"tests": null
}
},
"brunoConfig": {
"version": "1",
"name": "descriptions-imported-bru",
"type": "collection"
}
}

View File

@@ -0,0 +1,154 @@
opencollection: "1.0.0"
info:
name: descriptions-imported-yml
config:
environments:
- name: test_env
variables:
- name: envHost
value: http://localhost:3000
description: Single-line env desc
- name: envToken
value: abc123
description: |-
Env line one
Env line two
- name: envPlain
value: no-desc
request:
headers:
- name: X-Collection-Version
value: "2.0"
description: Single-line collection header desc
- name: X-Collection-Multi
value: value
description: |-
Collection header line one
Collection header line two
- name: X-Collection-Plain
value: plain-value
variables:
- name: baseUrl
value: https://example.com
description: Single-line collection var desc
- name: apiKey
value: abc123
description: |-
Collection var line one
Collection var line two
- name: plain
value: no-desc
items:
- info:
type: folder
name: folder
seq: 1
request:
headers:
- name: X-Folder-Version
value: "1.0"
description: Single-line folder header desc
- name: X-Folder-Multi
value: value
description: |-
Folder header line one
Folder header line two
- name: X-Folder-Plain
value: plain
variables:
- name: folderBaseUrl
value: https://folder.example.com
description: Single-line folder var desc
- name: folderApiKey
value: folder-key
description: |-
Folder var line one
Folder var line two
- name: folderPlain
value: no-desc
items:
- info:
type: http
name: request
seq: 1
http:
method: POST
url: https://example.com/api
headers:
- name: X-Version
value: "2.0"
description: Single-line header desc
- name: X-Multi
value: value
description: |-
Header line one
Header line two
- name: X-Plain
value: plain-value
params:
- name: q
value: search
type: query
description: Single-line query desc
- name: page
value: "1"
type: query
description: |-
Multi-line query desc line one
Multi-line query desc line two
- name: plain-query
value: value
type: query
body:
type: multipart-form
data:
- name: username
type: text
value: alice
description: Single-line field desc
- name: email
type: text
value: alice@example.com
description: |-
Multi-line field desc line one
Multi-line field desc line two
- name: plain-field
type: text
value: value
runtime:
variables:
- name: reqBaseUrl
value: https://example.com
description: Single-line req var desc
- name: reqApiKey
value: req-key
description: |-
Req var line one
Req var line two
- name: reqPlain
value: no-desc
- info:
type: http
name: form-request
seq: 2
http:
method: POST
url: https://example.com/form
body:
type: form-urlencoded
data:
- name: username
value: alice
description: Single-line form desc
- name: email
value: alice@example.com
description: |-
Multi-line form desc line one
Multi-line form desc line two
- name: plain-form
value: value
bundled: true

View File

@@ -0,0 +1,254 @@
import { test, expect, Page, Locator } from '../../../playwright';
import path from 'path';
import {
closeAllCollections,
expandFolder,
importCollection,
selectRequestPaneTab
} from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
type DescriptionRow = {
singleLine?: string;
multiLine?: [string, string];
empty?: boolean;
};
const expectDescriptionCell = async (descCell: Locator, expected: DescriptionRow) => {
const cm = descCell.getByTestId('column-description').locator('.CodeMirror');
if (expected.empty) {
await expect(cm.locator('.CodeMirror-line').first()).toHaveText('');
return;
}
if (expected.singleLine) {
await expect(cm.locator('.CodeMirror-line').first()).toHaveText(expected.singleLine);
return;
}
if (expected.multiLine) {
await expect(cm.locator('.CodeMirror-line').nth(0)).toHaveText(expected.multiLine[0]);
await expect(cm.locator('.CodeMirror-line').nth(1)).toHaveText(expected.multiLine[1]);
}
};
const expectVarDescriptionCell = async (row: Locator, expected: DescriptionRow) => {
const cm = row.getByTestId('column-description').locator('.CodeMirror');
if (expected.empty) {
await expect(cm.locator('.CodeMirror-line').first()).toHaveText('');
return;
}
if (expected.singleLine) {
await expect(cm.locator('.CodeMirror-line').first()).toHaveText(expected.singleLine);
return;
}
if (expected.multiLine) {
await expect(cm.locator('.CodeMirror-line').nth(0)).toHaveText(expected.multiLine[0]);
await expect(cm.locator('.CodeMirror-line').nth(1)).toHaveText(expected.multiLine[1]);
}
};
const openImportedRequest = async (page: Page, collectionName: string, requestName = 'request') => {
const locators = buildCommonLocators(page);
const collectionScope = locators.sidebar.collectionScope(collectionName);
const collectionRow = locators.sidebar.collection(collectionName);
await expect(collectionRow).toBeVisible();
const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: 'folder' });
if (!(await folderRow.isVisible().catch(() => false))) {
await collectionRow.click();
await expect(folderRow).toBeVisible();
}
await expandFolder(page, 'folder');
const requestLink = collectionScope
.getByTestId('sidebar-collection-item-row')
.filter({ has: page.getByText(requestName, { exact: true }) });
await expect(requestLink).toBeVisible();
await requestLink.click();
await expect(locators.tabs.activeRequestTab()).toContainText(requestName);
};
const openCollectionSettings = async (page: Page, collectionName: string) => {
await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click();
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible();
};
const openFolderSettings = async (page: Page, collectionName: string) => {
const locators = buildCommonLocators(page);
const collectionScope = locators.sidebar.collectionScope(collectionName);
const collectionRow = locators.sidebar.collection(collectionName);
await expect(collectionRow).toBeVisible();
const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: 'folder' });
if (!(await folderRow.isVisible().catch(() => false))) {
await collectionRow.click();
await expect(folderRow).toBeVisible();
}
await folderRow.dblclick();
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'folder' })).toBeVisible();
};
const openEnvironmentConfigure = async (page: Page, collectionName: string, envName: string) => {
const locators = buildCommonLocators(page);
await locators.sidebar.collection(collectionName).click();
await locators.environment.currentEnvironment().click();
await expect(locators.environment.envOption(envName)).toBeVisible();
await locators.environment.envOption(envName).click();
await expect(locators.environment.currentEnvironment().filter({ hasText: envName })).toBeVisible();
await locators.environment.currentEnvironment().click();
await expect(locators.dropdown.item('Configure')).toBeVisible();
await locators.dropdown.item('Configure').click();
await expect(locators.tabs.requestTab('Environments')).toBeVisible();
};
const assertRequestDescriptions = async (page: Page, collectionName: string) => {
await openImportedRequest(page, collectionName, 'request');
await selectRequestPaneTab(page, 'Headers');
const headerRows = page.getByTestId('request-headers-table').locator('tbody tr');
await expectDescriptionCell(headerRows.nth(0), { singleLine: 'Single-line header desc' });
await expectDescriptionCell(headerRows.nth(1), { multiLine: ['Header line one', 'Header line two'] });
await expectDescriptionCell(headerRows.nth(2), { empty: true });
await selectRequestPaneTab(page, 'Params');
const paramRows = page.getByTestId('query-params-table').locator('tbody tr');
await expectDescriptionCell(paramRows.nth(0), { singleLine: 'Single-line query desc' });
await expectDescriptionCell(paramRows.nth(1), {
multiLine: ['Multi-line query desc line one', 'Multi-line query desc line two']
});
await expectDescriptionCell(paramRows.nth(2), { empty: true });
await selectRequestPaneTab(page, 'Vars');
const reqVarRows = page.getByTestId('request-vars-req').locator('tbody tr');
await expectVarDescriptionCell(reqVarRows.nth(0), { singleLine: 'Single-line req var desc' });
await expectVarDescriptionCell(reqVarRows.nth(1), { multiLine: ['Req var line one', 'Req var line two'] });
await expectVarDescriptionCell(reqVarRows.nth(2), { empty: true });
await selectRequestPaneTab(page, 'Body');
const multipartRows = page.getByTestId('multipart-form-table').locator('tbody tr');
await expectDescriptionCell(multipartRows.nth(0), { singleLine: 'Single-line field desc' });
await expectDescriptionCell(multipartRows.nth(1), {
multiLine: ['Multi-line field desc line one', 'Multi-line field desc line two']
});
await expectDescriptionCell(multipartRows.nth(2), { empty: true });
await openImportedRequest(page, collectionName, 'form-request');
await selectRequestPaneTab(page, 'Body');
const formRows = page.getByTestId('form-urlencoded-table').locator('tbody tr');
await expectDescriptionCell(formRows.nth(0), { singleLine: 'Single-line form desc' });
await expectDescriptionCell(formRows.nth(1), {
multiLine: ['Multi-line form desc line one', 'Multi-line form desc line two']
});
await expectDescriptionCell(formRows.nth(2), { empty: true });
};
const assertCollectionDescriptions = async (page: Page, collectionName: string) => {
await openCollectionSettings(page, collectionName);
await page.getByTestId('collection-settings-tab-headers').click();
const headerTable = page.getByTestId('collection-headers');
const headerRows = headerTable.locator('tbody tr');
await expectDescriptionCell(headerRows.nth(0), { singleLine: 'Single-line collection header desc' });
await expectDescriptionCell(headerRows.nth(1), {
multiLine: ['Collection header line one', 'Collection header line two']
});
await expectDescriptionCell(headerRows.nth(2), { empty: true });
await page.getByTestId('collection-settings-tab-vars').click();
const varRows = page.getByTestId('collection-vars-req').locator('tbody tr');
await expectVarDescriptionCell(varRows.nth(0), { singleLine: 'Single-line collection var desc' });
await expectVarDescriptionCell(varRows.nth(1), {
multiLine: ['Collection var line one', 'Collection var line two']
});
await expectVarDescriptionCell(varRows.nth(2), { empty: true });
};
const assertFolderDescriptions = async (page: Page, collectionName: string) => {
await openFolderSettings(page, collectionName);
await page.getByTestId('folder-settings-tab-headers').click();
const headerRows = page.locator('table').first().locator('tbody tr');
await expectDescriptionCell(headerRows.nth(0), { singleLine: 'Single-line folder header desc' });
await expectDescriptionCell(headerRows.nth(1), {
multiLine: ['Folder header line one', 'Folder header line two']
});
await expectDescriptionCell(headerRows.nth(2), { empty: true });
await page.getByTestId('folder-settings-tab-vars').click();
const varRows = page.getByTestId('folder-vars-req').locator('tbody tr');
await expectVarDescriptionCell(varRows.nth(0), { singleLine: 'Single-line folder var desc' });
await expectVarDescriptionCell(varRows.nth(1), { multiLine: ['Folder var line one', 'Folder var line two'] });
await expectVarDescriptionCell(varRows.nth(2), { empty: true });
};
const assertEnvironmentDescriptions = async (page: Page, collectionName: string) => {
const locators = buildCommonLocators(page);
await openEnvironmentConfigure(page, collectionName, 'test_env');
const hostDesc = locators.environment.variableDescriptionEditor(0);
await expect(hostDesc.locator('.CodeMirror-line').first()).toHaveText('Single-line env desc');
const tokenDesc = locators.environment.variableDescriptionEditor(1);
await expect(tokenDesc.locator('.CodeMirror-line').nth(0)).toHaveText('Env line one');
await expect(tokenDesc.locator('.CodeMirror-line').nth(1)).toHaveText('Env line two');
const plainDesc = locators.environment.variableDescriptionEditor(2);
await expect(plainDesc.locator('.CodeMirror-line').first()).toHaveText('');
};
const runDescriptionImportAssertions = async (page: Page, collectionName: string) => {
await assertRequestDescriptions(page, collectionName);
await assertCollectionDescriptions(page, collectionName);
await assertFolderDescriptions(page, collectionName);
await assertEnvironmentDescriptions(page, collectionName);
};
test.describe.configure({ mode: 'serial' });
test.describe('Description import — Bruno JSON (.bru) collection', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('preserves descriptions across request, collection, folder, and environment surfaces', async ({
page,
createTmpDir
}, testInfo) => {
testInfo.setTimeout(120_000);
const fixtureFile = path.resolve(__dirname, 'fixtures', 'descriptions-collection-bru.json');
const collectionLocation = await createTmpDir('descriptions-imported-bru');
await importCollection(page, fixtureFile, collectionLocation, {
expectedCollectionName: 'descriptions-imported-bru',
sidebarTimeout: 15000
});
await runDescriptionImportAssertions(page, 'descriptions-imported-bru');
});
});
test.describe('Description import — OpenCollection (.yml) collection', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('preserves descriptions across request, collection, folder, and environment surfaces', async ({
page,
createTmpDir
}, testInfo) => {
testInfo.setTimeout(120_000);
const fixtureFile = path.resolve(__dirname, 'fixtures', 'descriptions-collection.yml');
const collectionLocation = await createTmpDir('descriptions-imported-yml');
await importCollection(page, fixtureFile, collectionLocation, {
expectedCollectionName: 'descriptions-imported-yml',
sidebarTimeout: 15000
});
await runDescriptionImportAssertions(page, 'descriptions-imported-yml');
});
});