Skip to content

Commit 07835c8

Browse files
feat: data type support cross-scope enhancement, polish & E2E coverage (#112)
* fix(vars): support data types in collection, folder and request var tables Object values rendered as '[object Object]' in the Collection/Folder/Request Vars tables, and there was no way to set a variable's type from those tables (only environment variables had it). Mirror the desktop app: render the value cell through valueToString (objects show as JSON) and add a per-row data-type selector for request-scoped vars. Response vars are excluded since they hold a JS expression, not a literal. The selector rebuilds the row via the existing set*Vars reducers, which already carry dataType through to disk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(vars): render the data-type dropdown above the table The type dropdown mounted inside the vars/environment table cell, so the table's overflow clipped it (only the first option was visible). Portal it to document.body like the desktop app, so it overlays at the correct z-order. * test(e2e): cover data types in request var tables Add stable test hooks (per-row datatype-selector testid; testId on the vars EditableTables) and an e2e spec covering the acceptance criteria: an @object var displays as JSON (not [object Object]), the type selector reflects the on-disk type, post-response vars have no selector, the dropdown lists all four types above the table (z-order), and choosing a type applies it. * test(e2e): cover data types across request, collection, folder and env vars Fill the e2e gaps for the typed-variable feature: - request vars: add a disk round-trip (change type -> Cmd/Ctrl+S -> the .bru gains/loses the @type annotation). - collection & folder Vars: open their settings, assert an @object var displays as JSON (not [object Object]) with a selector reflecting the on-disk type. - environment Vars: display + a save round-trip (change type -> Save -> the env file gains @Number), the pre-existing @object annotation surviving the save. Covers parse / preserve / save-keeps-types / no-break across every scope that supports data types (runtime & prompt vars have no editable value table). * test(e2e): add collection & folder var disk round-trips Change a var's type via the selector, click Save, and assert the collection.bru / folder.bru gains the @Number annotation while the pre-existing @object one survives. Every scope that supports data types now has a UI->disk round-trip. * test(e2e): address review — share findCollectionDir, robust folder-settings frame - extract findCollectionDir into tests/e2e/utils/page/actions.ts (was copy-pasted) - add data-testid="folder-settings" and key the folder-settings frame on it, instead of the ambiguous [role="tab"] scan that could match a request editor - drop task-bound AC-number labels from spec comments (ai-hygiene) * test(e2e): fold tab-overflow handling into shared openRequestPaneTab Move the narrow-pane '>>' overflow fallback from datatype-vars.spec into the shared openRequestPaneTab helper so every spec reaches overflowed tabs; the direct-click path is unchanged for callers whose tab is visible. Addresses PR review feedback. * refactor(vars): extract shared VarsDataTypeSelector for the three var tables The per-row data-type selector + row-update logic was duplicated in the collection, folder and request Vars tables. Extract a single VarsDataTypeSelector that owns the gating (request-scoped, non-empty rows) and the array update, and use it in all three. Addresses PR review feedback. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c163bf commit 07835c8

9 files changed

Lines changed: 351 additions & 35 deletions

File tree

src/webview/components/CollectionSettings/Vars/VarsTable/index.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
55
import MultiLineEditor from 'components/MultiLineEditor';
66
import InfoTip from 'components/InfoTip';
77
import EditableTable from 'components/EditableTable';
8+
import VarsDataTypeSelector from 'components/DataTypeSelector/VarsDataTypeSelector';
89
import StyledWrapper from './StyledWrapper';
910
import toast from 'react-hot-toast';
1011
import { variableNameRegex } from 'utils/common/regex';
1112
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
13+
import { valueToString } from '@usebruno/common/utils';
1214

1315
interface VarsTableProps {
1416
collection?: React.ReactNode;
@@ -63,14 +65,19 @@ const VarsTable = ({
6365
onChange,
6466
isLastEmptyRow
6567
}: any) => (
66-
<MultiLineEditor
67-
value={value || ''}
68-
theme={storedTheme}
69-
onSave={onSave}
70-
onChange={onChange}
71-
collection={collection}
72-
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
73-
/>
68+
<div className="flex items-center w-full gap-2">
69+
<div className="flex-1 min-w-0">
70+
<MultiLineEditor
71+
value={valueToString(value)}
72+
theme={storedTheme}
73+
onSave={onSave}
74+
onChange={onChange}
75+
collection={collection}
76+
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
77+
/>
78+
</div>
79+
<VarsDataTypeSelector row={row} vars={vars} isLastEmptyRow={isLastEmptyRow} varType={varType} onVarsChange={handleVarsChange} />
80+
</div>
7481
)
7582
}
7683
];
@@ -84,6 +91,7 @@ const VarsTable = ({
8491
return (
8592
<StyledWrapper className="w-full">
8693
<EditableTable
94+
testId={`collection-vars-${varType === 'request' ? 'req' : 'res'}`}
8795
columns={columns}
8896
rows={vars}
8997
onChange={handleVarsChange}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import type { BrunoVariableDataType } from '@bruno-types';
3+
import DataTypeSelector from './index';
4+
5+
interface Var {
6+
uid?: string;
7+
name?: string;
8+
value?: unknown;
9+
dataType?: BrunoVariableDataType;
10+
}
11+
12+
interface VarsDataTypeSelectorProps {
13+
row: Var;
14+
vars: Var[] | undefined;
15+
isLastEmptyRow?: boolean;
16+
varType?: string;
17+
onVarsChange: (vars: Var[]) => void;
18+
}
19+
20+
/**
21+
* Data-type selector for a Vars-table row. Updates the matching variable's type and hands the full
22+
* array back to the table's change handler. Only request-scoped literal vars get a type — response
23+
* vars hold a JS expression, and the trailing empty row has nothing to type.
24+
*/
25+
const VarsDataTypeSelector = ({ row, vars, isLastEmptyRow, varType, onVarsChange }: VarsDataTypeSelectorProps) => {
26+
if (isLastEmptyRow || varType !== 'request') {
27+
return null;
28+
}
29+
return (
30+
<DataTypeSelector
31+
variable={row}
32+
onChange={(fields) => {
33+
onVarsChange((vars || []).map((v) => (v.uid === row.uid ? { ...v, ...fields } : v)));
34+
}}
35+
/>
36+
);
37+
};
38+
39+
export default VarsDataTypeSelector;

src/webview/components/DataTypeSelector/index.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import MenuDropdown from 'ui/MenuDropdown';
77
import StyledWrapper from './StyledWrapper';
88

99
interface DataTypeSelectorProps {
10-
variable: { uid?: string; value?: unknown; dataType?: BrunoVariableDataType };
10+
variable: { uid?: string; name?: string; value?: unknown; dataType?: BrunoVariableDataType };
1111
onChange: (fields: { dataType?: BrunoVariableDataType }) => void;
1212
}
1313

@@ -30,7 +30,14 @@ const DataTypeSelector = ({ variable, onChange }: DataTypeSelectorProps) => {
3030
return (
3131
<StyledWrapper>
3232
<div className="flex items-center relative">
33-
<MenuDropdown items={items} selectedItemId={selectedType} placement="bottom-end" showTickMark={true}>
33+
<MenuDropdown
34+
items={items}
35+
selectedItemId={selectedType}
36+
placement="bottom-end"
37+
showTickMark={true}
38+
appendTo={() => document.body}
39+
data-testid={`datatype-selector-${variable.name || 'new'}`}
40+
>
3441
<div className="flex items-center cursor-pointer select-none">
3542
<span className="type-label">{selectedType}</span>
3643
<IconCaretDown className="caret-icon ml-1" size={14} strokeWidth={2} />

src/webview/components/FolderSettings/Vars/VarsTable/index.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
55
import MultiLineEditor from 'components/MultiLineEditor';
66
import InfoTip from 'components/InfoTip';
77
import EditableTable from 'components/EditableTable';
8+
import VarsDataTypeSelector from 'components/DataTypeSelector/VarsDataTypeSelector';
89
import StyledWrapper from './StyledWrapper';
910
import toast from 'react-hot-toast';
1011
import { variableNameRegex } from 'utils/common/regex';
1112
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
13+
import { valueToString } from '@usebruno/common/utils';
1214

1315
interface VarsTableProps {
1416
folder: React.ReactNode;
@@ -70,15 +72,20 @@ const VarsTable = ({
7072
onChange,
7173
isLastEmptyRow
7274
}: any) => (
73-
<MultiLineEditor
74-
value={value || ''}
75-
theme={storedTheme}
76-
onSave={onSave}
77-
onChange={onChange}
78-
collection={collection}
79-
item={folder}
80-
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
81-
/>
75+
<div className="flex items-center w-full gap-2">
76+
<div className="flex-1 min-w-0">
77+
<MultiLineEditor
78+
value={valueToString(value)}
79+
theme={storedTheme}
80+
onSave={onSave}
81+
onChange={onChange}
82+
collection={collection}
83+
item={folder}
84+
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
85+
/>
86+
</div>
87+
<VarsDataTypeSelector row={row} vars={vars} isLastEmptyRow={isLastEmptyRow} varType={varType} onVarsChange={handleVarsChange} />
88+
</div>
8289
)
8390
}
8491
];
@@ -92,6 +99,7 @@ const VarsTable = ({
9299
return (
93100
<StyledWrapper className="w-full">
94101
<EditableTable
102+
testId={`folder-vars-${varType === 'request' ? 'req' : 'res'}`}
95103
columns={columns}
96104
rows={vars}
97105
onChange={handleVarsChange}

src/webview/components/FolderSettings/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const FolderSettings = ({
8383
};
8484

8585
return (
86-
<StyledWrapper className="flex flex-col h-full overflow-auto">
86+
<StyledWrapper className="flex flex-col h-full overflow-auto" data-testid="folder-settings">
8787
<div className="flex flex-col h-full relative px-4 py-4">
8888
<div className="flex flex-wrap items-center tabs" role="tablist">
8989
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>

src/webview/components/RequestPane/Vars/VarsTable/index.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
66
import MultiLineEditor from 'components/MultiLineEditor';
77
import InfoTip from 'components/InfoTip';
88
import EditableTable from 'components/EditableTable';
9+
import VarsDataTypeSelector from 'components/DataTypeSelector/VarsDataTypeSelector';
910
import StyledWrapper from './StyledWrapper';
1011
import toast from 'react-hot-toast';
1112
import { variableNameRegex } from 'utils/common/regex';
13+
import { valueToString } from '@usebruno/common/utils';
1214

1315
interface VarsTableProps {
1416
item: any;
@@ -87,16 +89,21 @@ const VarsTable = ({
8789
onChange,
8890
isLastEmptyRow
8991
}: any) => (
90-
<MultiLineEditor
91-
value={value || ''}
92-
theme={storedTheme}
93-
onSave={onSave}
94-
onChange={onChange}
95-
onRun={handleRun}
96-
collection={collection}
97-
item={item}
98-
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
99-
/>
92+
<div className="flex items-center w-full gap-2">
93+
<div className="flex-1 min-w-0">
94+
<MultiLineEditor
95+
value={valueToString(value)}
96+
theme={storedTheme}
97+
onSave={onSave}
98+
onChange={onChange}
99+
onRun={handleRun}
100+
collection={collection}
101+
item={item}
102+
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
103+
/>
104+
</div>
105+
<VarsDataTypeSelector row={row} vars={vars} isLastEmptyRow={isLastEmptyRow} varType={varType} onVarsChange={handleVarsChange} />
106+
</div>
100107
)
101108
}
102109
];
@@ -110,6 +117,7 @@ const VarsTable = ({
110117
return (
111118
<StyledWrapper className="w-full">
112119
<EditableTable
120+
testId={`request-vars-${varType === 'request' ? 'req' : 'res'}`}
113121
columns={columns}
114122
rows={vars || []}
115123
onChange={handleVarsChange}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import type { Page, Frame } from '@playwright/test';
4+
import { test, expect } from '../utils/fixtures';
5+
import { openBrunoSidebar, createCollection, openRequest, createFolder, findCollectionDir } from '../utils/page/actions';
6+
import { buildCommonLocators } from '../utils/page/locators';
7+
8+
// Scan the webview frames for the one exposing `marker`, re-acquiring after a panel opens.
9+
async function frameWith(page: Page, marker: string, timeout = 15_000): Promise<Frame> {
10+
const deadline = Date.now() + timeout;
11+
while (Date.now() < deadline) {
12+
for (const frame of page.frames()) {
13+
if (frame === page.mainFrame()) continue;
14+
try {
15+
if ((await frame.locator(marker).count()) > 0) return frame;
16+
} catch {
17+
/* frame detached mid-scan */
18+
}
19+
}
20+
await page.waitForTimeout(400);
21+
}
22+
throw new Error(`No webview frame with ${marker} within ${timeout}ms`);
23+
}
24+
25+
test.describe('Data types in collection & environment variables', () => {
26+
test('collection Vars: an object value shows as JSON with a type selector reflecting the on-disk type', async ({ page, tmpDir }) => {
27+
const sidebar = await openBrunoSidebar(page);
28+
const collectionName = 'Typed Coll';
29+
await createCollection(page, sidebar, collectionName, tmpDir, 'bru');
30+
31+
const collectionDir = findCollectionDir(tmpDir);
32+
fs.writeFileSync(path.join(collectionDir, 'collection.bru'), [
33+
'vars:pre-request {', ' @object', ' cfg: {"host":"localhost"}', ' tok: plain', '}', ''
34+
].join('\n'), 'utf8');
35+
36+
// Open collection settings by clicking the collection name, then the Vars tab.
37+
await buildCommonLocators(sidebar).sidebar.collectionName(collectionName).click();
38+
const settings = await frameWith(page, '[data-testid="collection-settings"]');
39+
await settings.locator('[role="tab"]').filter({ hasText: 'Vars' }).first().click();
40+
41+
const table = settings.locator('[data-testid="collection-vars-req"]');
42+
await expect(table).toBeVisible({ timeout: 15_000 });
43+
// Parsed from disk, the object renders as JSON and the collection doesn't break.
44+
await expect(table).toContainText('{"host":"localhost"}');
45+
await expect(table).not.toContainText('[object Object]');
46+
// The selector reflects the on-disk data type.
47+
await expect(settings.locator('[data-testid="datatype-selector-cfg"]')).toContainText('object');
48+
await expect(settings.locator('[data-testid="datatype-selector-tok"]')).toContainText('string');
49+
50+
// Change tok -> number and Save; the collection file gains @number, @object survives.
51+
const collectionFile = path.join(collectionDir, 'collection.bru');
52+
await settings.locator('[data-testid="datatype-selector-tok"]').click();
53+
await settings.locator('[data-testid="datatype-selector-tok-number"]').click();
54+
await expect(settings.locator('[data-testid="datatype-selector-tok"]')).toContainText('number');
55+
await settings.getByRole('button', { name: 'Save', exact: true }).click();
56+
await expect.poll(() => fs.readFileSync(collectionFile, 'utf8'), { timeout: 15_000 }).toContain('@number');
57+
expect(fs.readFileSync(collectionFile, 'utf8')).toContain('@object');
58+
});
59+
60+
test('environment Vars: typed values display, and choosing a type persists it to the env file', async ({ page, tmpDir }) => {
61+
const sidebar = await openBrunoSidebar(page);
62+
const collectionName = 'Typed Env';
63+
await createCollection(page, sidebar, collectionName, tmpDir, 'bru');
64+
65+
const collectionDir = findCollectionDir(tmpDir);
66+
fs.mkdirSync(path.join(collectionDir, 'environments'), { recursive: true });
67+
const envFile = path.join(collectionDir, 'environments', 'Local.bru');
68+
// Environment vars use the `vars { }` block (not `vars:pre-request`).
69+
fs.writeFileSync(envFile, ['vars {', ' @object', ' cfg: {"host":"localhost"}', ' tok: plain', '}', ''].join('\n'), 'utf8');
70+
// A request is needed so the environment selector renders in the editor toolbar.
71+
fs.writeFileSync(path.join(collectionDir, 'Ping.bru'), [
72+
'meta {', ' name: Ping', ' type: http', ' seq: 1', '}', '',
73+
'get {', ' url: https://usebruno.com', ' body: none', ' auth: inherit', '}', ''
74+
].join('\n'), 'utf8');
75+
76+
const editor = await openRequest(page, sidebar, collectionName, 'Ping');
77+
await editor.locator('[data-testid="environment-selector-trigger"]').click();
78+
await editor.locator('.dropdown-item').filter({ hasText: 'Local' }).first().click();
79+
80+
// Re-open the selector and click "Configure" to open the environment settings panel.
81+
await editor.locator('[data-testid="environment-selector-trigger"]').click();
82+
await editor.locator('#configure-env').click();
83+
84+
const envSettings = await frameWith(page, '[data-testid="save-env"]');
85+
// The @object env var round-trips from disk and renders as JSON (the env editor
86+
// pretty-prints, so assert on format-agnostic substrings rather than the exact JSON string).
87+
const cfgRow = envSettings.locator('[data-testid="env-var-row-cfg"]');
88+
await expect(cfgRow).toContainText('"host"');
89+
await expect(cfgRow).toContainText('localhost');
90+
await expect(cfgRow).not.toContainText('[object Object]');
91+
await expect(envSettings.locator('[data-testid="datatype-selector-cfg"]')).toContainText('object');
92+
93+
// Change `tok` to number and save — the env file gains the @number annotation.
94+
await envSettings.locator('[data-testid="datatype-selector-tok"]').click();
95+
await envSettings.locator('[data-testid="datatype-selector-tok-number"]').click();
96+
await expect(envSettings.locator('[data-testid="datatype-selector-tok"]')).toContainText('number');
97+
await envSettings.locator('[data-testid="save-env"]').click();
98+
await expect.poll(() => fs.readFileSync(envFile, 'utf8'), { timeout: 15_000 }).toContain('@number');
99+
// The pre-existing @object annotation survives the save.
100+
expect(fs.readFileSync(envFile, 'utf8')).toContain('@object');
101+
});
102+
103+
test('folder Vars: an object value shows as JSON with a type selector reflecting the on-disk type', async ({ page, tmpDir }) => {
104+
const sidebar = await openBrunoSidebar(page);
105+
const collectionName = 'Typed Folder';
106+
await createCollection(page, sidebar, collectionName, tmpDir, 'bru');
107+
await createFolder(sidebar, collectionName, 'sub');
108+
109+
const collectionDir = findCollectionDir(tmpDir);
110+
fs.writeFileSync(path.join(collectionDir, 'sub', 'folder.bru'), [
111+
'vars:pre-request {', ' @object', ' cfg: {"host":"localhost"}', ' tok: plain', '}', ''
112+
].join('\n'), 'utf8');
113+
114+
// Open folder settings via the folder row's context menu.
115+
const folderRow = sidebar.locator('[data-testid="sidebar-collection-item-row"]').filter({ hasText: 'sub' });
116+
await folderRow.hover();
117+
await folderRow.locator('[data-testid="collection-item-menu"]').click();
118+
await sidebar.locator('[role="menuitem"]').filter({ hasText: 'Settings' }).click();
119+
120+
const settings = await frameWith(page, '[data-testid="folder-settings"]');
121+
await settings.locator('[role="tab"]').filter({ hasText: 'Vars' }).first().click();
122+
123+
const table = settings.locator('[data-testid="folder-vars-req"]');
124+
await expect(table).toBeVisible({ timeout: 15_000 });
125+
await expect(table).toContainText('{"host":"localhost"}');
126+
await expect(table).not.toContainText('[object Object]');
127+
await expect(settings.locator('[data-testid="datatype-selector-cfg"]')).toContainText('object');
128+
await expect(settings.locator('[data-testid="datatype-selector-tok"]')).toContainText('string');
129+
130+
// Change tok -> number and Save; the folder file gains @number, @object survives.
131+
const folderFile = path.join(collectionDir, 'sub', 'folder.bru');
132+
await settings.locator('[data-testid="datatype-selector-tok"]').click();
133+
await settings.locator('[data-testid="datatype-selector-tok-number"]').click();
134+
await expect(settings.locator('[data-testid="datatype-selector-tok"]')).toContainText('number');
135+
await settings.getByRole('button', { name: 'Save', exact: true }).click();
136+
await expect.poll(() => fs.readFileSync(folderFile, 'utf8'), { timeout: 15_000 }).toContain('@number');
137+
expect(fs.readFileSync(folderFile, 'utf8')).toContain('@object');
138+
});
139+
});

0 commit comments

Comments
 (0)