mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 07:04:10 +00:00
718 lines
30 KiB
TypeScript
718 lines
30 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { test, expect, Page, Locator } from '../../playwright';
|
|
import {
|
|
saveRequest,
|
|
scrollVirtuosoRowIntoView,
|
|
selectEnvironment,
|
|
selectRequestPaneTab,
|
|
selectResponsePaneTab,
|
|
sendRequestAndWaitForResponse
|
|
} from '../utils/page';
|
|
import { buildCommonLocators } from '../utils/page/locators';
|
|
|
|
/**
|
|
* These tests cover the DataTypeSelector on the surfaces where it remains
|
|
* after scoping the feature down: request vars, collection environment
|
|
* vars, and global environment vars. Query params, path params, headers,
|
|
* and form-data fields no longer expose dataType, so they are not asserted
|
|
* here.
|
|
*
|
|
* Fixtures under `fixtures/workspace/collections/{bru,yml}` declare a single
|
|
* request whose vars block carries one row per supported dataType (string,
|
|
* number, boolean, object). Each describe block ends with a save test that
|
|
* mutates one row's dataType via the selector and asserts the change
|
|
* round-trips through the serializer to the on-disk file.
|
|
*/
|
|
|
|
const BRU_COLLECTION = 'datatypes-bru';
|
|
const YML_COLLECTION = 'datatypes-yml';
|
|
|
|
type VarScope = 'request' | 'folder' | 'collection';
|
|
|
|
/**
|
|
* DataType expectations for each variable scope. Every scope declares one
|
|
* row per supported dataType (string, number, boolean, object). The test
|
|
* navigates to each scope's Vars page and asserts the type label.
|
|
*/
|
|
const VAR_TYPE_TABLE: Array<{ scope: VarScope; tableId: string; rows: Array<[string, string]> }> = [
|
|
{
|
|
scope: 'request',
|
|
tableId: 'request-vars-req',
|
|
rows: [
|
|
['req_str', 'string'],
|
|
['req_num', 'number'],
|
|
['req_bool', 'boolean'],
|
|
['req_obj', 'object']
|
|
]
|
|
},
|
|
{
|
|
scope: 'folder',
|
|
tableId: 'folder-vars-req',
|
|
rows: [
|
|
['fold_str', 'string'],
|
|
['fold_num', 'number'],
|
|
['fold_bool', 'boolean'],
|
|
['fold_obj', 'object']
|
|
]
|
|
},
|
|
{
|
|
scope: 'collection',
|
|
tableId: 'collection-vars-req',
|
|
rows: [
|
|
['coll_str', 'string'],
|
|
['coll_num', 'number'],
|
|
['coll_bool', 'boolean'],
|
|
['coll_obj', 'object']
|
|
]
|
|
}
|
|
];
|
|
|
|
/**
|
|
* Open a request that lives at `collections/{bru|yml}/folder/<name>.{bru|yml}`.
|
|
* Defaults to the JSON-body `request` fixture; pass `multipart` to open the
|
|
* sibling multipart-form request. Expands the collection and folder rows as
|
|
* needed. Both fixture collections share the same folder/request names, so
|
|
* we scope to the collection wrapper to keep strict-mode happy.
|
|
*/
|
|
const openRequestInCollection = async (
|
|
page: Page,
|
|
collectionName: string,
|
|
requestName: string = 'request'
|
|
) => {
|
|
const locators = buildCommonLocators(page);
|
|
const collection = locators.sidebar.collection(collectionName);
|
|
await expect(collection).toBeVisible();
|
|
|
|
const collectionScope = locators.sidebar.collectionScope(collectionName);
|
|
|
|
const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: 'folder' });
|
|
if (!(await folderRow.isVisible().catch(() => false))) {
|
|
await collection.click();
|
|
await expect(folderRow).toBeVisible();
|
|
}
|
|
|
|
const requestLink = collectionScope.locator('.collection-item-name').filter({ hasText: requestName });
|
|
if (!(await requestLink.isVisible().catch(() => false))) {
|
|
await folderRow.click();
|
|
await expect(requestLink).toBeVisible();
|
|
}
|
|
|
|
await requestLink.click();
|
|
await expect(locators.tabs.activeRequestTab()).toContainText(requestName);
|
|
};
|
|
|
|
/**
|
|
* Rows in `EditableTable` carry `data-row-name="{name}"`, derived from the
|
|
* key-field column. Use that directly so row matching works regardless of
|
|
* whether the name cell renders a plain `<input>` or a CodeMirror editor.
|
|
*/
|
|
const tableRowByName = (table: ReturnType<ReturnType<typeof buildCommonLocators>['table']>, name: string) =>
|
|
table.rowByName(name);
|
|
|
|
const SLOW_RENDER_TIMEOUT_MS = 15_000;
|
|
|
|
const expectTypeLabel = async (row: Locator, label: string) => {
|
|
await scrollVirtuosoRowIntoView(row.page(), row);
|
|
const { dataTypeSelector } = buildCommonLocators(row.page());
|
|
await expect(dataTypeSelector.typeLabel(row)).toHaveText(label, { timeout: SLOW_RENDER_TIMEOUT_MS });
|
|
};
|
|
|
|
/**
|
|
* Open the DataTypeSelector for a row and pick a new type. The picker is a
|
|
* MenuDropdown rendered via Tippy at the page scope, so the menu items
|
|
* surface outside the row.
|
|
*/
|
|
const changeRowDataType = async (page: Page, row: Locator, newType: string) => {
|
|
const { dataTypeSelector } = buildCommonLocators(page);
|
|
const trigger = dataTypeSelector.typeLabel(row);
|
|
await trigger.click();
|
|
|
|
const menuItem = dataTypeSelector.menuItem(newType);
|
|
await expect(menuItem).toBeVisible();
|
|
await menuItem.click();
|
|
|
|
await expect(trigger).toHaveText(newType);
|
|
// Let the dispatched Redux mutation propagate before the caller saves.
|
|
await page.waitForTimeout(300);
|
|
};
|
|
|
|
/**
|
|
* Navigate to the Vars page for a given scope:
|
|
* - `request`: open the request, then select the Vars pane tab.
|
|
* - `folder`: dbl-click the `folder` row to open Folder Settings, then click the Vars tab.
|
|
* - `collection`: click the collection row (single click opens Collection Settings),
|
|
* then click the Vars tab.
|
|
*
|
|
* The Vars table is rendered with `tableId="<scope>-vars"` (request-vars-req,
|
|
* folder-vars-req, collection-vars-req).
|
|
*/
|
|
const openVarsPageForScope = async (page: Page, collectionName: string, scope: VarScope) => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
if (scope === 'request') {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectRequestPaneTab(page, 'Vars');
|
|
return;
|
|
}
|
|
|
|
const collectionScope = locators.sidebar.collectionScope(collectionName);
|
|
const collectionRow = locators.sidebar.collection(collectionName);
|
|
await expect(collectionRow).toBeVisible();
|
|
|
|
if (scope === 'folder') {
|
|
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 locators.paneTabs.folderSettingsTab('vars').click();
|
|
return;
|
|
}
|
|
|
|
// scope === 'collection': clicking the collection name opens Collection Settings
|
|
// (and expands the row if it was collapsed).
|
|
await collectionRow.click();
|
|
await locators.paneTabs.collectionSettingsTab('vars').click();
|
|
};
|
|
|
|
/**
|
|
* Walk every scope declared in VAR_TYPE_TABLE, navigate to its Vars page, and
|
|
* verify each row's `.type-label` matches the declared dataType.
|
|
*/
|
|
const expectAllVarLabels = async (page: Page, collectionName: string) => {
|
|
for (const { scope, tableId, rows } of VAR_TYPE_TABLE) {
|
|
await openVarsPageForScope(page, collectionName, scope);
|
|
const table = buildCommonLocators(page).table(tableId);
|
|
for (const [name, label] of rows) {
|
|
await expectTypeLabel(tableRowByName(table, name), label);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Close every open tab so each test starts clean. If a save failed earlier,
|
|
* the "Unsaved changes" modal can intercept close clicks — dismiss it with
|
|
* "Don't Save" so the next test isn't blocked.
|
|
*/
|
|
const closeAllTabs = async (page: Page) => {
|
|
const closeButtons = page.locator('.request-tab [data-testid="request-tab-close-icon"]');
|
|
for (let i = 0; i < 20; i++) {
|
|
const count = await closeButtons.count();
|
|
if (count === 0) break;
|
|
await closeButtons.first().click({ force: true }).catch(() => {});
|
|
const dontSave = page.getByRole('button', { name: 'Don\'t Save' });
|
|
if (await dontSave.isVisible().catch(() => false)) {
|
|
await dontSave.click().catch(() => {});
|
|
}
|
|
await page.waitForTimeout(50);
|
|
}
|
|
};
|
|
|
|
// Number of `test(...)` blocks in the request fixture's tests script.
|
|
const EXPECTED_TEST_COUNT = 14;
|
|
|
|
/**
|
|
* Send the request and verify every assertion in its `tests {}` block passes.
|
|
* Reads the result from the response pane's Tests tab. Assumes the caller
|
|
* has already activated the right environments (otherwise env-scoped tests
|
|
* would fail).
|
|
*
|
|
* The fixture posts to the local testbench echo endpoint. We key off the
|
|
* test summary line ("Tests (N), Passed: X, Failed: Y") since each dataType
|
|
* check renders as a separate test row.
|
|
*/
|
|
const sendAndAssertAllTestsPass = async (page: Page) => {
|
|
await sendRequestAndWaitForResponse(page, 200, { timeout: 30000 });
|
|
await selectResponsePaneTab(page, 'Tests');
|
|
|
|
const { response } = buildCommonLocators(page);
|
|
await expect(response.testSummary()).toContainText(`Tests (${EXPECTED_TEST_COUNT}), Passed: ${EXPECTED_TEST_COUNT}, Failed: 0`);
|
|
|
|
// Catch any individual failure even if the summary text changes.
|
|
await expect(response.testFailures()).toHaveCount(0);
|
|
};
|
|
|
|
/**
|
|
* Convenience wrapper: activate the collection + global envs, then send and
|
|
* verify all tests pass. Used by tests that own the env-selection step.
|
|
*/
|
|
const runAndAssertTestsPass = async (page: Page) => {
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await selectEnvironment(page, 'variables', 'global');
|
|
await sendAndAssertAllTestsPass(page);
|
|
};
|
|
|
|
/**
|
|
* Open the Environments / Global Environments configuration tab for the
|
|
* currently active env. Mirrors the UI flow: click env selector → switch to
|
|
* the matching tab (collection or global) → click the "Configure" button.
|
|
*
|
|
* The collection/global tab in the dropdown is sticky across opens — if a
|
|
* previous test selected a global env, the dropdown reopens on the global
|
|
* tab. We always click the target tab explicitly so the right Configure
|
|
* button is wired up regardless of prior state.
|
|
*/
|
|
const openEnvironmentSettings = async (page: Page, type: 'collection' | 'global') => {
|
|
const locators = buildCommonLocators(page);
|
|
await locators.environment.selector().click();
|
|
if (type === 'global') {
|
|
await locators.environment.globalTab().click();
|
|
} else {
|
|
await locators.environment.collectionTab().click();
|
|
}
|
|
await locators.environment.configureButton().click();
|
|
const tabTitle = type === 'collection' ? 'Environments' : 'Global Environments';
|
|
await expect(locators.tabs.activeRequestTab()).toContainText(tabTitle);
|
|
};
|
|
|
|
/**
|
|
* Assert the DataTypeSelector inside an env-var row reports the expected label.
|
|
* Variables and secrets render on separate tabs in the env editor, so activate
|
|
* the tab that owns this var before asserting (pass `{ secret: true }` for
|
|
* secret rows, which live on the Secrets tab).
|
|
*/
|
|
const expectEnvVarTypeLabel = async (page: Page, name: string, label: string, { secret = false } = {}) => {
|
|
const { environment, dataTypeSelector } = buildCommonLocators(page);
|
|
await (secret ? environment.secretsTab() : environment.variablesTab()).click();
|
|
const row = environment.varRow(name);
|
|
await scrollVirtuosoRowIntoView(page, row);
|
|
await expect(dataTypeSelector.typeLabel(row)).toHaveText(label, { timeout: SLOW_RENDER_TIMEOUT_MS });
|
|
};
|
|
|
|
/**
|
|
* Secret env vars carry a dataType too: the value is never written to disk but
|
|
* the dataType is, so the editor renders the declared type label. Asserted
|
|
* before any request runs so the file-declared dataType isn't overwritten by a
|
|
* runtime setEnvVar mutation. Covers the collection env (per fixture format)
|
|
* and the shared global env.
|
|
*/
|
|
const runSecretDataTypeLabelAssertions = async (page: Page, collectionName: string) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await selectEnvironment(page, 'variables', 'global');
|
|
|
|
await openEnvironmentSettings(page, 'collection');
|
|
await expectEnvVarTypeLabel(page, 'env_secret_num', 'number', { secret: true });
|
|
await expectEnvVarTypeLabel(page, 'env_secret_bool', 'boolean', { secret: true });
|
|
await expectEnvVarTypeLabel(page, 'env_secret_obj', 'object', { secret: true });
|
|
|
|
await openEnvironmentSettings(page, 'global');
|
|
await expectEnvVarTypeLabel(page, 'glob_secret_num', 'number', { secret: true });
|
|
await expectEnvVarTypeLabel(page, 'glob_secret_bool', 'boolean', { secret: true });
|
|
await expectEnvVarTypeLabel(page, 'glob_secret_obj', 'object', { secret: true });
|
|
};
|
|
|
|
/**
|
|
* The DataTypeSelector renders a yellow `IconAlertCircle` whenever the
|
|
* variable's value can't be coerced to the declared dataType (e.g.
|
|
* dataType=number with value="not-a-number"). The icon's distinguishing
|
|
* class is `text-yellow-600`, applied to the SVG itself.
|
|
*/
|
|
const mismatchIcon = (row: Locator) => buildCommonLocators(row.page()).dataTypeSelector.mismatchIcon(row);
|
|
|
|
// Popups are position:fixed and overlap the next token; the mouse-leave close
|
|
// timer is too slow between hovers, so we tear them down first.
|
|
const hoverVarInBody = async (page: Page, varName: string) => {
|
|
// _hidePopup runs the popup's own cleanup (listeners, MaskedEditor.destroy,
|
|
// inner CodeMirror teardown); raw .remove() leaks across the loop.
|
|
await page.evaluate(() => {
|
|
document.querySelectorAll('.CodeMirror-brunoVarInfo').forEach((el: any) => {
|
|
if (typeof el._hidePopup === 'function') el._hidePopup({ immediate: true });
|
|
else el.remove();
|
|
});
|
|
});
|
|
const { request, varInfoPopup } = buildCommonLocators(page);
|
|
await page.mouse.move(0, 0);
|
|
await expect(varInfoPopup.all()).toHaveCount(0);
|
|
|
|
const varToken = request.bodyVariableToken(varName).first();
|
|
await expect(varToken).toBeVisible();
|
|
|
|
const tooltip = varInfoPopup.byName(varName);
|
|
await expect(async () => {
|
|
await page.mouse.move(0, 0);
|
|
await varToken.hover();
|
|
await expect(tooltip).toBeVisible({ timeout: 2_000 });
|
|
}).toPass({ timeout: SLOW_RENDER_TIMEOUT_MS });
|
|
return tooltip;
|
|
};
|
|
|
|
// Objects render as pretty-printed JSON in the popup — parse back and
|
|
// deep-compare so the assertion isn't coupled to whitespace.
|
|
const expectPopupParsedValue = async (tooltip: Locator, expected: string | number | boolean | object) => {
|
|
const display = buildCommonLocators(tooltip.page()).varInfoPopup.valueDisplay(tooltip);
|
|
await expect(display).toBeVisible();
|
|
|
|
if (typeof expected === 'object' && expected !== null) {
|
|
await expect.poll(async () => {
|
|
const text = (await display.textContent()) ?? '';
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}).toEqual(expected);
|
|
} else {
|
|
await expect(display).toHaveText(String(expected));
|
|
}
|
|
};
|
|
|
|
const executeAndOpenBody = async (page: Page) => {
|
|
// The all-tests-pass assertion doubles as a wait for post-response
|
|
// dispatches (main:script-environment-update) to land before we hover.
|
|
await sendAndAssertAllTestsPass(page);
|
|
await selectRequestPaneTab(page, 'Body');
|
|
};
|
|
|
|
const expectPopupMaskedValue = async (page: Page, varName: string, actualValue: string) => {
|
|
const { varInfoPopup } = buildCommonLocators(page);
|
|
const tooltip = await hoverVarInBody(page, varName);
|
|
const display = varInfoPopup.editableValue(tooltip);
|
|
await expect(display).toBeVisible();
|
|
await expect(display).toHaveText('*'.repeat(actualValue.length));
|
|
await expect(display).not.toContainText(actualValue);
|
|
await expect(varInfoPopup.secretToggle(tooltip)).toBeVisible();
|
|
};
|
|
|
|
// Guards the editor seeding path: objects must not be dispatched as
|
|
// `[object Object]`, and confirms primitives still seed correctly under
|
|
// the JSON.stringify branch.
|
|
const expectPopupEditorOpensWith = async (tooltip: Locator, expected: string | object) => {
|
|
const { varInfoPopup } = buildCommonLocators(tooltip.page());
|
|
await varInfoPopup.editableValue(tooltip).click();
|
|
const editor = varInfoPopup.editor(tooltip);
|
|
await expect(editor).toBeVisible();
|
|
const code = editor.locator('.CodeMirror-code');
|
|
|
|
if (typeof expected === 'object' && expected !== null) {
|
|
await expect.poll(async () => {
|
|
const text = (await code.textContent()) ?? '';
|
|
if (text.includes('[object Object]')) return text;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}).toEqual(expected);
|
|
} else {
|
|
await expect.poll(async () => (await code.textContent()) ?? '').toBe(String(expected));
|
|
}
|
|
};
|
|
|
|
const BODY_VAR_VALUES: Array<[string, string | object]> = [
|
|
['coll_str', 'collection_string'],
|
|
['coll_num', '100'],
|
|
['coll_bool', 'false'],
|
|
['coll_obj', { scope: 'collection' }],
|
|
['fold_str', 'folder_string'],
|
|
['fold_num', '200'],
|
|
['fold_bool', 'true'],
|
|
['fold_obj', { scope: 'folder' }],
|
|
['req_str', 'request_string'],
|
|
['req_num', '42'],
|
|
['req_bool', 'true'],
|
|
['req_obj', { key: 'value' }],
|
|
// Precision must survive the file → parse → coerce path.
|
|
['req_obj_precision', {
|
|
integer: 123,
|
|
negativeInteger: -99,
|
|
zero: 0,
|
|
float: 2.718,
|
|
negativeFloat: -1.618,
|
|
largeDouble: 12345.678901234567,
|
|
smallDouble: 9.876e-12,
|
|
booleanTrue: true,
|
|
booleanFalse: false
|
|
}],
|
|
// {} must render as the object, not fall back to the empty string.
|
|
['req_obj_empty', {}],
|
|
// String([1,2,3]) === "1,2,3" — distinct from the object regression.
|
|
['req_arr', [1, 2, 'three', true]],
|
|
// @number with non-numeric data falls back to the raw string.
|
|
['mismatched_num', 'not-a-number'],
|
|
['env_str', 'env_string'],
|
|
['env_num', '300'],
|
|
['env_bool', 'true'],
|
|
['env_obj', { scope: 'env' }],
|
|
// Falsy typed values are a known regression surface — must not render as empty.
|
|
['falsy_num', '0'],
|
|
['falsy_bool', 'false'],
|
|
['glob_str', 'global_string'],
|
|
['glob_num', '400'],
|
|
['glob_bool', 'false'],
|
|
['glob_obj', { scope: 'global' }],
|
|
['runtime_str', 'runtime_string'],
|
|
['runtime_num', '999'],
|
|
['runtime_bool', 'true'],
|
|
['runtime_obj', { nested: 'yes' }],
|
|
['inferred_env_num', '700'],
|
|
['inferred_env_bool', 'true'],
|
|
['inferred_env_obj', { from: 'script' }],
|
|
['inferred_glob_num', '800'],
|
|
['inferred_glob_bool', 'false'],
|
|
['inferred_glob_obj', { from: 'script' }]
|
|
];
|
|
|
|
// Asserted separately: the popup masks these and the value must never reach the DOM.
|
|
const BODY_SECRET_VAR_VALUES: Array<[string, string]> = [
|
|
['env_secret_str', 'secret_env_string'],
|
|
['glob_secret_str', 'secret_global_string']
|
|
];
|
|
|
|
const setupBodyHover = async (page: Page, collectionName: string) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await selectEnvironment(page, 'variables', 'global');
|
|
await executeAndOpenBody(page);
|
|
};
|
|
|
|
const runBodyHoverPopupAssertions = async (page: Page, collectionName: string) => {
|
|
await setupBodyHover(page, collectionName);
|
|
|
|
for (const [name, expected] of BODY_VAR_VALUES) {
|
|
await expectPopupParsedValue(await hoverVarInBody(page, name), expected);
|
|
}
|
|
for (const [name, actual] of BODY_SECRET_VAR_VALUES) {
|
|
await expectPopupMaskedValue(page, name, actual);
|
|
}
|
|
};
|
|
|
|
const runBodyEditorSeedAssertions = async (page: Page, collectionName: string) => {
|
|
await setupBodyHover(page, collectionName);
|
|
|
|
// runtime_* are read-only in the popup, so they have no editable display to click.
|
|
for (const [name, expected] of BODY_VAR_VALUES.filter(([n]) => !n.startsWith('runtime_'))) {
|
|
await expectPopupEditorOpensWith(await hoverVarInBody(page, name), expected);
|
|
}
|
|
};
|
|
|
|
test.afterEach(async ({ pageWithUserData: page }) => {
|
|
await closeAllTabs(page);
|
|
});
|
|
|
|
// Both collection formats (.bru annotations and .yml { type, data }) must surface
|
|
// identical dataType behaviour, so the suite is defined once and run per fixture.
|
|
// Only the save round-trip differs (the on-disk shape), so it's passed in.
|
|
const runDataTypeSelectorTests = (
|
|
collectionName: string,
|
|
{ savedRequestFile, savedDataTypeMatcher }: { savedRequestFile: string[]; savedDataTypeMatcher: RegExp }
|
|
) => {
|
|
test('vars: all datatypes render correctly across collection / folder / request scopes', async ({ pageWithUserData: page }) => {
|
|
await expectAllVarLabels(page, collectionName);
|
|
});
|
|
|
|
test('request vars: query params and headers do NOT expose the dataType selector', async ({ pageWithUserData: page }) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectRequestPaneTab(page, 'Params');
|
|
const queryTable = buildCommonLocators(page).table('query-params');
|
|
await expect(queryTable.container().locator('.type-label')).toHaveCount(0);
|
|
|
|
await selectRequestPaneTab(page, 'Headers');
|
|
const headersTable = buildCommonLocators(page).table('request-headers');
|
|
await expect(headersTable.container().locator('.type-label')).toHaveCount(0);
|
|
});
|
|
|
|
test('test script: every typed var across scopes asserts true', async ({ pageWithUserData: page }) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await runAndAssertTestsPass(page);
|
|
});
|
|
|
|
test('multipart form: typed pre-request vars stringify on the wire', async ({ pageWithUserData: page }) => {
|
|
// Asserts the toFormValue stringifier in
|
|
// packages/bruno-electron/src/utils/form-data.js converts typed
|
|
// (number/boolean/object) values to strings before form.append.
|
|
await openRequestInCollection(page, collectionName, 'multipart');
|
|
await sendRequestAndWaitForResponse(page, 200, { timeout: 30000 });
|
|
await selectResponsePaneTab(page, 'Tests');
|
|
const multipartResponse = buildCommonLocators(page).response;
|
|
await expect(multipartResponse.testSummary()).toContainText('Tests (1), Passed: 1, Failed: 0');
|
|
await expect(multipartResponse.testFailures()).toHaveCount(0);
|
|
});
|
|
|
|
test('form-urlencoded: typed pre-request vars stringify on the wire', async ({ pageWithUserData: page }) => {
|
|
// form-urlencoded uses a separate code path from multipart:
|
|
// buildFormUrlEncodedPayload in @usebruno/common coerces values via String().
|
|
// Verifies typed (number/boolean/object) vars round-trip into the
|
|
// application/x-www-form-urlencoded body and the echo endpoint echoes the wire form.
|
|
await openRequestInCollection(page, collectionName, 'form_urlencoded');
|
|
await sendRequestAndWaitForResponse(page, 200, { timeout: 30000 });
|
|
await selectResponsePaneTab(page, 'Tests');
|
|
const formUrlEncodedResponse = buildCommonLocators(page).response;
|
|
await expect(formUrlEncodedResponse.testSummary()).toContainText('Tests (1), Passed: 1, Failed: 0');
|
|
await expect(formUrlEncodedResponse.testFailures()).toHaveCount(0);
|
|
});
|
|
|
|
test('vars: warning icon shows when value does not match the declared dataType', async ({ pageWithUserData: page }) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectRequestPaneTab(page, 'Vars');
|
|
|
|
const table = buildCommonLocators(page).table('request-vars-req');
|
|
|
|
// mismatched_num declares the number dataType but its value is "not-a-number" —
|
|
// coercion fails, the value falls back to the raw string, so the row warns.
|
|
const mismatchedRow = tableRowByName(table, 'mismatched_num');
|
|
await expectTypeLabel(mismatchedRow, 'number');
|
|
await expect(mismatchIcon(mismatchedRow)).toBeVisible();
|
|
|
|
// req_num declares number with value 42 — no warning.
|
|
const matchingRow = tableRowByName(table, 'req_num');
|
|
await expectTypeLabel(matchingRow, 'number');
|
|
await expect(mismatchIcon(matchingRow)).toHaveCount(0);
|
|
|
|
// req_str defaults to the string dataType — no warning.
|
|
const stringRow = tableRowByName(table, 'req_str');
|
|
await expectTypeLabel(stringRow, 'string');
|
|
await expect(mismatchIcon(stringRow)).toHaveCount(0);
|
|
});
|
|
|
|
test('script setEnvVar / setGlobalEnvVar: inferred dataType renders in the env editor', async ({
|
|
restartApp,
|
|
workspaceFixturePath
|
|
}) => {
|
|
// bru.setGlobalEnvVar persists changes to disk on dispatch — use restartApp
|
|
// so the mutation lands in this test's tmp workspace and doesn't leak into
|
|
// sibling tests (which share `pageWithUserData`'s fixture copy).
|
|
expect(workspaceFixturePath).not.toBeNull();
|
|
const app = await restartApp({});
|
|
const page = await app.firstWindow();
|
|
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
|
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await selectEnvironment(page, 'variables', 'global');
|
|
|
|
// Pre-request script seeds inferred_env_* and inferred_glob_* via
|
|
// bru.setEnvVar / bru.setGlobalEnvVar. After send, the env stores those
|
|
// vars with the inferred dataType attached.
|
|
await sendRequestAndWaitForResponse(page, 200, { timeout: 30000 });
|
|
|
|
// The 200 status arrives before post-response IPC has propagated to redux.
|
|
// Wait for the Tests panel to populate — that guarantees the runner finished
|
|
// post-response work and the env state is in sync.
|
|
await selectResponsePaneTab(page, 'Tests');
|
|
await expect(
|
|
buildCommonLocators(page).response.testSummary()
|
|
).toContainText(`Tests (${EXPECTED_TEST_COUNT}), Passed: ${EXPECTED_TEST_COUNT}`);
|
|
|
|
await openEnvironmentSettings(page, 'collection');
|
|
await expectEnvVarTypeLabel(page, 'inferred_env_num', 'number');
|
|
await expectEnvVarTypeLabel(page, 'inferred_env_bool', 'boolean');
|
|
await expectEnvVarTypeLabel(page, 'inferred_env_obj', 'object');
|
|
// getDataTypeFromValue is strict — string content doesn't promote.
|
|
await expectEnvVarTypeLabel(page, 'strict_num_str', 'string');
|
|
await expectEnvVarTypeLabel(page, 'strict_bool_str', 'string');
|
|
await expectEnvVarTypeLabel(page, 'strict_obj_str', 'string');
|
|
|
|
await openEnvironmentSettings(page, 'global');
|
|
await expectEnvVarTypeLabel(page, 'inferred_glob_num', 'number');
|
|
await expectEnvVarTypeLabel(page, 'inferred_glob_bool', 'boolean');
|
|
await expectEnvVarTypeLabel(page, 'inferred_glob_obj', 'object');
|
|
await expectEnvVarTypeLabel(page, 'strict_glob_num_str', 'string');
|
|
});
|
|
|
|
test('env editor: typed falsy values (0, false) display their dataType label', async ({ pageWithUserData: page }) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await openEnvironmentSettings(page, 'collection');
|
|
|
|
await expectEnvVarTypeLabel(page, 'falsy_num', 'number');
|
|
await expectEnvVarTypeLabel(page, 'falsy_bool', 'boolean');
|
|
|
|
await expectEnvVarTypeLabel(page, 'env_num', 'number');
|
|
await expectEnvVarTypeLabel(page, 'env_bool', 'boolean');
|
|
});
|
|
|
|
test('env editor: vars without a declared dataType show the default string label', async ({ pageWithUserData: page }) => {
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await selectEnvironment(page, 'variables', 'global');
|
|
|
|
// Numeric / boolean / object-looking values with no declared dataType are
|
|
// never inferred as typed — the selector shows the default 'string'. An
|
|
// untyped secret likewise never gains an inferred dataType.
|
|
await openEnvironmentSettings(page, 'collection');
|
|
await expectEnvVarTypeLabel(page, 'env_untyped_num', 'string');
|
|
await expectEnvVarTypeLabel(page, 'env_untyped_bool', 'string');
|
|
await expectEnvVarTypeLabel(page, 'env_untyped_obj', 'string');
|
|
await expectEnvVarTypeLabel(page, 'env_secret_untyped', 'string', { secret: true });
|
|
|
|
await openEnvironmentSettings(page, 'global');
|
|
await expectEnvVarTypeLabel(page, 'glob_untyped_num', 'string');
|
|
await expectEnvVarTypeLabel(page, 'glob_untyped_bool', 'string');
|
|
await expectEnvVarTypeLabel(page, 'glob_untyped_obj', 'string');
|
|
await expectEnvVarTypeLabel(page, 'glob_secret_untyped', 'string', { secret: true });
|
|
});
|
|
|
|
test('env editor: secret variables display their declared dataType label', async ({ pageWithUserData: page }) => {
|
|
await runSecretDataTypeLabelAssertions(page, collectionName);
|
|
});
|
|
|
|
test('hover popup: body variables show parsed values for each dataType after execution', async ({ pageWithUserData: page }) => {
|
|
await runBodyHoverPopupAssertions(page, collectionName);
|
|
});
|
|
|
|
test('hover popup: clicking to edit seeds the editor with the right text for every dataType', async ({ pageWithUserData: page }) => {
|
|
await runBodyEditorSeedAssertions(page, collectionName);
|
|
});
|
|
|
|
test('save: dataType change round-trips to disk, then execution honors the new dataType', async ({
|
|
restartApp,
|
|
workspaceFixturePath
|
|
}, testInfo) => {
|
|
// restartApp boots a fresh electron (~10s) AND we send a real network request
|
|
// after save, which combined exceeds the default 30s timeout.
|
|
testInfo.setTimeout(90_000);
|
|
|
|
// restartApp gives this test an isolated electron app pointed at its own
|
|
// workspaceFixturePath (the shared app binds to the first test's tmp workspace).
|
|
expect(workspaceFixturePath).not.toBeNull();
|
|
const requestFile = path.join(workspaceFixturePath!, ...savedRequestFile);
|
|
|
|
const app = await restartApp({});
|
|
const page = await app.firstWindow();
|
|
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
|
|
|
await openRequestInCollection(page, collectionName);
|
|
await selectEnvironment(page, 'variables', 'collection');
|
|
await selectEnvironment(page, 'variables', 'global');
|
|
await selectRequestPaneTab(page, 'Vars');
|
|
|
|
// Step 1: change req_str's dataType in the UI (puts the request in draft state).
|
|
const row = tableRowByName(buildCommonLocators(page).table('request-vars-req'), 'req_str');
|
|
await expectTypeLabel(row, 'string');
|
|
await changeRowDataType(page, row, 'number');
|
|
await expect(buildCommonLocators(page).tabs.draftIndicator()).toBeVisible();
|
|
|
|
// Step 2: save — the number dataType round-trips to disk in the fixture's format.
|
|
await saveRequest(page);
|
|
await expect(buildCommonLocators(page).tabs.draftIndicator()).not.toBeVisible();
|
|
await expectTypeLabel(row, 'number');
|
|
await expect.poll(
|
|
async () => fs.promises.readFile(requestFile, 'utf8'),
|
|
{ timeout: 5000 }
|
|
).toMatch(savedDataTypeMatcher);
|
|
|
|
// Step 3: execute. The runner reads the saved fixture, sees req_str is now a
|
|
// number, and applies datatype-driven coercion. Envs are already activated.
|
|
await sendAndAssertAllTestsPass(page);
|
|
});
|
|
};
|
|
|
|
test.describe('DataType selector — BRU collection fixture', () => {
|
|
runDataTypeSelectorTests(BRU_COLLECTION, {
|
|
savedRequestFile: ['collections', 'bru', 'folder', 'request.bru'],
|
|
// bare `@number` annotation above the var
|
|
savedDataTypeMatcher: /@number\n {2}req_str: request_string/
|
|
});
|
|
});
|
|
|
|
test.describe('DataType selector — YML collection fixture', () => {
|
|
runDataTypeSelectorTests(YML_COLLECTION, {
|
|
savedRequestFile: ['collections', 'yml', 'folder', 'request.yml'],
|
|
// value becomes a { type, data } object
|
|
savedDataTypeMatcher: /- name: req_str\n\s+value:\n\s+type: number\n\s+data: ['"]?request_string['"]?/
|
|
});
|
|
});
|