Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/ten-things-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@usebruno/api-docs": patch
---

What changed
18. Environment value fields. Multiline value fields grow to fit their content instead of scrolling inside a fixed box, and they re-fit when a column is dragged narrower. Table, card and secret variants now behave the same.

21. Header name suggestions. The suggestions list renders in a portal, so the playground's scrollbar styling never reached it. It now uses the same thin themed scrollbar as the rest of the app.

24. Request tabs across a dock change. Changing the dock placement swaps the dock component and remounts the request and response panes, which reset their tab to the default. Both panes now keep the selected tab in session storage, the lane the collapsible sections and dock sizes already use.

26. Assertion descriptions. The Assertions tab was the only tab without a Description column, although the format and the desktop app both carry the field. It now shows and persists the description, and its column labels match the app (Expr, Value).

29. First column alignment. The first column header now starts exactly where the cell text below it starts, with and without the enable checkbox. The query params table labels that column Name instead of Key, as the app does.

31. Script error cards. When both the post-response and tests scripts failed, closing one error card closed both, and the cards took the panel's height from the content below them. Each card now closes on its own and the response body and test results keep their full height.

Also in this PR
KeyValueTable.css becomes an Emotion StyledWrapper, matching every other component in the package. The legacy .text-input rules are dropped because HighlightedInput already owns those fields.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ export class KeyValueTableComponent extends BaseComponent {
readonly container: Locator;
readonly table: Locator;
readonly nameInputs: Locator;
readonly nameTexts: Locator;
readonly valueInputs: Locator;
readonly descriptionInputs: Locator;
readonly nameHeader: Locator;
readonly descriptionHeader: Locator;
readonly cellErrors: Locator;
readonly autocomplete: Locator;
Expand All @@ -18,8 +20,10 @@ export class KeyValueTableComponent extends BaseComponent {
this.container = page.getByTestId(`${testId}-container`);
this.table = page.getByTestId(`${testId}-table`);
this.nameInputs = page.getByTestId(`${testId}-name-input`);
this.nameTexts = page.getByTestId(`${testId}-name-text`);
this.valueInputs = page.getByTestId(`${testId}-value-input`);
this.descriptionInputs = page.getByTestId(`${testId}-description-input`);
this.nameHeader = page.getByTestId(`${testId}-name-header`);
this.descriptionHeader = page.getByTestId(`${testId}-description-header`);
this.cellErrors = page.getByTestId(`${testId}-error`);
this.autocomplete = page.getByTestId('variable-autocomplete');
Expand Down
10 changes: 8 additions & 2 deletions packages/bruno-api-docs/e2e/components/playground.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { DockMode } from '../../src/utils/playgroundDock';
export class PlaygroundComponent extends BaseComponent {
readonly keyValueTable = new KeyValueTableComponent(this.page);
readonly preRequestVars = new KeyValueTableComponent(this.page, 'variables-pre-request');
readonly pathParams = new KeyValueTableComponent(this.page, 'params-path');
// The Auth tab lives inside the playground request pane; open it with selectTab('auth').
readonly auth = new RequestAuthComponent(this.page);
readonly methodSelector = new MethodSelectorComponent(this.page);
Expand Down Expand Up @@ -140,8 +141,13 @@ export class PlaygroundComponent extends BaseComponent {
async selectTab(id: string): Promise<void> {
const direct = this.tab(id);
if ((await direct.count()) > 0 && (await direct.isVisible())) {
await direct.click();
return;
try {
await direct.click({ timeout: 1000 });
return;
} catch {
// The responsive tab bar re-measures a frame after a switch and may have just moved this
// tab into the overflow menu, detaching the button we resolved.
}
}
await this.page.getByTestId('tabs-more').click();
await this.page.getByTestId(`tabs-more-${id}`).click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export class ResponsePaneComponent extends BaseComponent {
* switch to settle (the target becomes the selected tab) so a following switch never races the
* layout mid-transition.
*/
tab(id: string): Locator {
return this.page.getByTestId(`response-tabs-tab-${id}`);
}

async switchToTab(id: string): Promise<void> {
const inlineTab = this.page.getByTestId(`response-tabs-tab-${id}`);
if (await inlineTab.isVisible()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '../../playwright';

test.describe('Environment variables: value cells (table view)', () => {
test.beforeEach(async ({ playground }) => {
await playground.open('bottom');
await playground.openEnvironments();
await expect(playground.keyValueTable.root).toBeVisible();
});

test('a long multi-line value grows its cell to fit instead of scrolling inside it', async ({ playground }) => {
const valueInput = playground.keyValueTable.valueInputs.first();
const oneLineHeight = (await valueInput.boundingBox())!.height;

const lines = Array.from({ length: 16 }, (_, i) => `line ${i + 1}`);
await valueInput.fill(lines.join('\n'));

await expect.poll(async () => (await valueInput.boundingBox())!.height).toBeGreaterThan(oneLineHeight * 4);
await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false);
});

test('a wrapped value re-fits its cell after the value column is dragged narrower', async ({ page, playground }) => {
const { keyValueTable } = playground;
const valueInput = keyValueTable.valueInputs.first();
await valueInput.fill('word '.repeat(60).trim());
await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false);
const widthBefore = await valueInput.evaluate((el) => el.clientWidth);

const handle = keyValueTable.resizeHandles.first();
await handle.scrollIntoViewIfNeeded();
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 200, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();

await expect.poll(() => valueInput.evaluate((el) => el.clientWidth)).toBeLessThan(widthBefore - 50);
await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { test, expect } from '../../playwright';

test.describe('KeyValueTable — tooltips & mobile scroll', () => {
const textStart = (el: HTMLElement) =>
el.getBoundingClientRect().left
+ parseFloat(getComputedStyle(el).borderLeftWidth)
+ parseFloat(getComputedStyle(el).paddingLeft);

test.describe('KeyValueTable: cells and layout', () => {
test.beforeEach(async ({ page, playground }) => {
await page.goto('/#/?pg=1&dock=bottom');
await playground.openSidebarItem('get users');
Expand All @@ -19,35 +24,73 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => {
await expect(nameInput).toHaveAttribute('title', value);
});

test('the first column header lines up with the name text in the rows below it', async ({ playground }) => {
const { keyValueTable } = playground;
const headerStart = await keyValueTable.nameHeader.evaluate(textStart);
const cellStart = await keyValueTable.nameInputs.first().evaluate(textStart);
expect(Math.abs(headerStart - cellStart)).toBeLessThan(0.5);
});

test('a one-line description cell is exactly as tall as the name cell beside it', async ({ playground }) => {
const { keyValueTable } = playground;
const nameHeight = (await keyValueTable.nameInputs.first().boundingBox())!.height;
const descriptionHeight = (await keyValueTable.descriptionInputs.first().boundingBox())!.height;
expect(Math.abs(descriptionHeight - nameHeight)).toBeLessThan(0.5);
});

test('a read-only path-param key lines up with its header and matches the value cell height', async ({ playground }) => {
await playground.openSidebarItem('Jokes');
await playground.selectTab('params');
const { pathParams } = playground;
await expect(pathParams.nameTexts.first()).toHaveText('postId');

const headerStart = await pathParams.nameHeader.evaluate(textStart);
const keyStart = await pathParams.nameTexts.first().evaluate(textStart);
expect(Math.abs(headerStart - keyStart)).toBeLessThan(0.5);

const keyHeight = (await pathParams.nameTexts.first().boundingBox())!.height;
const valueHeight = (await pathParams.valueInputs.first().boundingBox())!.height;
expect(Math.abs(keyHeight - valueHeight)).toBeLessThan(0.5);
});

test('the query params table labels its first column Name, like the app', async ({ playground }) => {
await playground.selectTab('params');
await expect(playground.keyValueTable.nameHeader).toHaveText('Name');
});

test('the table has a min-width and a horizontally-scrollable container', async ({ page, playground }) => {
const { keyValueTable } = playground;
await expect(keyValueTable.container).toHaveCSS('overflow-x', 'auto');
await expect(keyValueTable.table).toHaveCSS('min-width', '448px'); // 28rem @16px
await expect(keyValueTable.table).toHaveCSS('min-width', '448px');

// Narrow the viewport below the min-width → the container actually overflows and scrolls.
await page.setViewportSize({ width: 360, height: 800 });
const overflows = await keyValueTable.container.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(overflows).toBe(true);
});

test('offers {{variable}} autocomplete in the value cell but not the name cell', async ({ page, playground }) => {
const { keyValueTable } = playground;
// Value cell: a `{{` reference surfaces the collection's variables.
await keyValueTable.valueInputs.last().click();
await page.keyboard.type('{{coll');
await expect(keyValueTable.autocomplete).toBeVisible();

await page.keyboard.press('Escape');
await expect(keyValueTable.autocomplete).toHaveCount(0);

// Name cell: the same reference must not open the dropdown — the app only
// autocompletes variables in value cells, never in param/variable name cells.
await keyValueTable.nameInputs.last().click();
await page.keyboard.type('{{coll');
await page.waitForTimeout(250);
await expect(keyValueTable.autocomplete).toHaveCount(0);
});

test('the header-name suggestions list scrolls with the thin themed scrollbar', async ({ page, playground }) => {
const { keyValueTable } = playground;
await keyValueTable.nameInputs.last().click();
await page.keyboard.type('Content');
await expect(keyValueTable.autocomplete).toBeVisible();
await expect(keyValueTable.autocomplete).toHaveCSS('scrollbar-width', 'thin');
});

test('flags a header name that contains a space with an inline error', async ({ page, playground }) => {
const { keyValueTable } = playground;
await keyValueTable.nameInputs.last().click();
Expand All @@ -59,7 +102,6 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => {

test('a named row can be enabled and disabled via its checkbox', async ({ playground }) => {
const { keyValueTable } = playground;
// The trailing blank row has no checkbox; naming a row promotes it to a real row with one.
await keyValueTable.nameInputs.last().fill('X-Custom');

const toggle = keyValueTable.enableToggle('X-Custom');
Expand All @@ -77,8 +119,6 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => {
const valueHeader = keyValueTable.columnHeader('col-value');
const before = (await valueHeader.boundingBox())!.width;

// Drag the Name/Value divider (the first handle) to the right: Name grows and Value shrinks by
// the same amount (zero-sum), so the Value column gets measurably narrower.
const handle = keyValueTable.resizeHandles.first();
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from '../../playwright';

// A request whose params, headers, variables and form body all carry authored descriptions.
// A request whose params, headers, variables, assertions and form body all carry authored descriptions.
const DESCRIBED = '/?fixture=descriptions#/?pg=1&dock=bottom';

test.describe('Playground — field descriptions', () => {
Expand Down Expand Up @@ -29,6 +29,22 @@ test.describe('Playground — field descriptions', () => {
await expect(playground.preRequestVars.descriptionInputs.first()).toHaveValue('The order identifier under test');
});

test('assertions show a Description column with the authored text', async ({ playground }) => {
await playground.selectTab('assertions');
await expect(playground.keyValueTable.descriptionHeader).toBeVisible();
await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('Creating an order returns Created');
});

test('an assertion description is editable and the edit persists across a tab switch', async ({ playground }) => {
await playground.selectTab('assertions');
const descriptionInput = playground.keyValueTable.descriptionInputs.first();
await descriptionInput.fill('Created, with the new order in the body');
await expect(descriptionInput).toHaveValue('Created, with the new order in the body');
await playground.selectTab('headers');
await playground.selectTab('assertions');
await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('Created, with the new order in the body');
});

test('form-urlencoded body fields show the authored description', async ({ playground }) => {
await playground.selectTab('body');
await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('The OAuth2 grant type to use');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const axios = require('axios');
await axios.get('https://unreachable.invalid/get');
`;

const REQUIRE_LODASH_POST_RESPONSE_SCRIPT = `
const _ = require('lodash');
`;

const REQUIRE_LODASH_PRE_REQUEST_SCRIPT = `
const _ = require('lodash');
`;
Expand Down Expand Up @@ -97,6 +101,39 @@ test.describe('playground script execution', () => {
await expect(responsePane.bodyEditor.surface).toBeVisible();
});

test('each script error card closes on its own and the body keeps its height below the cards', async ({ page, playground, responsePane }) => {
await page.setViewportSize({ width: 1280, height: 640 });
await responsePane.mockUsersResponse(JSON.stringify({ users: [] }));

await page.goto('/#/?pg=1&dock=bottom');
await playground.openSidebarItem('get users');
await playground.selectTab('scripts');
await page.getByTestId('scripts-tabs-tab-post-response').click();
await setEditorScript(page, playground.postResponseScriptEditor, REQUIRE_LODASH_POST_RESPONSE_SCRIPT);
await playground.selectTab('tests');
await setEditorScript(page, playground.testsEditor, REQUIRE_FS_TESTS_SCRIPT);

await responsePane.send();

await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText(['Post-Response Script Error', 'Test Script Error']);
await expect.poll(() => responsePane.bodyEditor.surface.evaluate((el) => el.clientHeight)).toBeGreaterThan(100);
await responsePane.bodyPanel.evaluate((panel) => { panel.scrollTop = panel.scrollHeight; });
await expect.poll(() => responsePane.bodyPanel.evaluate((panel) => {
const editor = panel.querySelector('[data-testid="response-body-editor"]') as HTMLElement;
return panel.getBoundingClientRect().bottom - editor.getBoundingClientRect().bottom;
})).toBeGreaterThanOrEqual(15);

await responsePane.switchToTab('tests');
const summary = responsePane.testsPanel.getByText('Tests (3), Passed: 1, Failed: 2');
await summary.scrollIntoViewIfNeeded();
await expect(summary).toBeInViewport();

await responsePane.testsScriptErrors.getByTestId('error-banner-dismiss').first().click();
await expect(responsePane.testsScriptErrors.getByTestId('error-title')).toHaveText(['Test Script Error']);
await responsePane.switchToTab('response');
await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText(['Test Script Error']);
});

test('a pre-request script that throws shows a Pre-Request Script Error card instead of a response', async ({ page, playground, responsePane }) => {
await page.goto('/#/?pg=1&dock=bottom');
await playground.openSidebarItem('get users');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '../../playwright';

const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}';

test.describe('Playground tabs across a dock change', () => {
test.use({ viewport: { width: 1280, height: 900 } });

test('the selected request tab is kept when the playground moves to another dock', async ({ playground }) => {
await playground.open('bottom');
await playground.openRequest('get users');
await playground.selectTab('headers');
await expect(playground.tab('headers')).toHaveAttribute('aria-selected', 'true');

await playground.selectDock('inline');
await expect(playground.inlinePanel).toBeVisible();
await expect(playground.tab('headers')).toHaveAttribute('aria-selected', 'true');

await playground.selectDock('modal');
await expect(playground.modalPanel).toBeVisible();
await expect(playground.tab('headers')).toHaveAttribute('aria-selected', 'true');
});

test('the selected response tab is kept when the playground moves to another dock', async ({
playground,
responsePane
}) => {
await responsePane.mockUsersResponse(USERS_BODY);
await playground.open('bottom');
await playground.openRequest('get users');
await responsePane.send();
await responsePane.switchToTab('headers');

await playground.selectDock('inline');
await expect(playground.inlinePanel).toBeVisible();
await expect(responsePane.tab('headers')).toHaveAttribute('aria-selected', 'true');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,32 @@ export const HighlightedInput: React.FC<HighlightedInputProps> = ({
}
}, [value]);

useLayoutEffect(() => {
const fitFieldHeight = useCallback(() => {
const el = inputRef.current;
if (!multiline || !el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`;
const mirror = mirrorRef.current;
if (mirror) {
mirror.scrollTop = el.scrollTop;
mirror.scrollLeft = el.scrollLeft;
}
}, [value, multiline]);
}, [multiline]);

useLayoutEffect(fitFieldHeight, [value, fitFieldHeight]);

useEffect(() => {
const el = inputRef.current;
if (!multiline || !el || typeof ResizeObserver === 'undefined') return;
let width = el.clientWidth;
const observer = new ResizeObserver(() => {
if (el.clientWidth === width) return;
width = el.clientWidth;
fitFieldHeight();
});
observer.observe(el);
return () => observer.disconnect();
}, [multiline, fitFieldHeight]);

useLayoutEffect(() => {
if (!hovered || !cardEl) {
Expand Down
Loading
Loading