Files
bruno/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts
Sid 0bba66a590 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>
2026-07-07 21:43:39 +05:30

352 lines
15 KiB
TypeScript

import path from 'path';
import fs from 'fs';
import { test, expect, closeElectronApp } from '../../../playwright';
import {
openCollection,
openRequest,
sendRequest,
selectEnvironment,
expectResponseContains,
waitForReadyPage,
openEnvironmentConfigTab,
closeEnvironmentPanel
} from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
import { waitForCollectionMount } from '../../utils/page/mounting';
const NEW_VALUE = 'NEW_VALUE_collection_env_e2e_42';
const INITIAL_VALUE = 'INITIAL_VALUE_collection_env_e2e_001';
const initUserDataPath = path.join(__dirname, 'init-user-data');
const fixturesPath = path.join(__dirname, 'fixtures', 'collections');
type Format = {
label: 'bru' | 'yml';
subdir: string;
collectionName: string;
envFile: string;
expectInitial: (content: string) => void;
expectAfterScript: (content: string) => void;
envSettlePattern: RegExp;
};
const FORMATS: Format[] = [
{
label: 'bru',
subdir: 'bru',
collectionName: 'api-setEnvVar-secret-bru',
envFile: 'Local.bru',
envSettlePattern: /vars:secret\s*\[[^\]]*apiToken/,
expectInitial: (content) => {
expect(content).toMatch(/vars:secret\s*\[[^\]]*apiToken/);
expect(content).not.toContain(NEW_VALUE);
},
expectAfterScript: (content) => {
expect(content).toMatch(/vars:secret\s*\[[^\]]*apiToken/);
expect(content).not.toContain(NEW_VALUE);
expect(content).not.toMatch(/^\s*apiToken\s*:/m);
}
},
{
label: 'yml',
subdir: 'yml',
collectionName: 'api-setEnvVar-secret-yml',
envFile: 'Local.yml',
envSettlePattern: /name:\s*apiToken/,
expectInitial: (content) => {
expect(content).toMatch(/name:\s*apiToken/);
expect(content).toMatch(/secret:\s*true/);
expect(content).not.toContain(NEW_VALUE);
},
expectAfterScript: (content) => {
expect(content).toMatch(/name:\s*apiToken/);
expect(content).toMatch(/secret:\s*true/);
expect(content).not.toContain(NEW_VALUE);
}
}
];
const findApiTokenSecret = (secretsJson: any, collectionDir: string) => {
const collection = secretsJson.collections?.find((c: any) =>
path.normalize(c.path) === path.normalize(collectionDir)
);
const env = collection?.environments?.find((e: any) => e.name === 'Local');
return env?.secrets?.find((s: any) => s.name === 'apiToken');
};
test.describe('bru.setEnvVar(name, value) - secret variable persistence', () => {
for (const fmt of FORMATS) {
test(`(${fmt.label}) script-set secret encrypts to the secrets store and reaches a subsequent request via interpolation`, async ({
launchElectronApp,
createTmpDir
}) => {
const userDataPath = await createTmpDir('userdata');
const collectionPath = await createTmpDir('collections');
await fs.promises.cp(fixturesPath, collectionPath, { recursive: true });
const envFilePath = path.join(collectionPath, fmt.subdir, 'environments', fmt.envFile);
const secretsPath = path.join(userDataPath, 'secrets.json');
const collectionDir = path.join(collectionPath, fmt.subdir);
await test.step('Fixture sanity: env file declares apiToken as secret with no value; no secrets store yet', () => {
fmt.expectInitial(fs.readFileSync(envFilePath, 'utf8'));
expect(fs.existsSync(secretsPath)).toBe(false);
});
const app = await launchElectronApp({
initUserDataPath,
userDataPath,
templateVars: { collectionPath }
});
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
try {
await test.step('Open the setter request and select the "Local" environment', async () => {
await waitForCollectionMount(page, fmt.collectionName);
await openCollection(page, fmt.collectionName);
await openRequest(page, fmt.collectionName, 'set-secret', { persist: true });
await selectEnvironment(page, 'Local', 'collection');
});
await test.step('Env panel BEFORE script: eye toggle reveals an empty initial value', async () => {
await openEnvironmentConfigTab(page, 'collection');
const envTab = locators.environment.collectionEnvTab();
await locators.environment.secretsTab().click();
await expect(locators.environment.varRow('apiToken')).toBeVisible();
await locators.environment.varRowEyeToggle('apiToken').click();
await expect(locators.environment.varRowValueEditor('apiToken'))
.toHaveClass(/CodeMirror-empty/);
await expect(locators.environment.varRowValueEditor('apiToken'))
.not.toContainText(NEW_VALUE);
await closeEnvironmentPanel(page, 'collection');
});
await test.step('Run the request whose post-response script sets apiToken', async () => {
await sendRequest(page, 200);
});
await test.step('On-disk env file: secret value is never written; secret marker preserved', async () => {
await expect.poll(() => fs.readFileSync(envFilePath, 'utf8'), { timeout: 5000 })
.toMatch(fmt.envSettlePattern);
fmt.expectAfterScript(fs.readFileSync(envFilePath, 'utf8'));
});
await test.step('Secrets store: encrypted apiToken entry is persisted under this collection', async () => {
await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
await expect.poll(() => {
const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
return secret?.value ?? '';
}, { timeout: 5000 }).not.toBe('');
const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
const secret = findApiTokenSecret(secretsJson, collectionDir);
expect(secret).toBeDefined();
expect(typeof secret!.value).toBe('string');
expect(secret!.value.length).toBeGreaterThan(0);
expect(secret!.value).not.toContain(NEW_VALUE);
expect(JSON.stringify(secretsJson)).not.toContain(NEW_VALUE);
expect(fs.readFileSync(envFilePath, 'utf8')).not.toContain(secret!.value);
});
await test.step('Env panel AFTER script: eye toggle reveals NEW_VALUE, no draft icon', async () => {
await openEnvironmentConfigTab(page, 'collection');
const envTab = locators.environment.collectionEnvTab();
await locators.environment.secretsTab().click();
await expect(locators.environment.varRow('apiToken')).toBeVisible();
await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/);
await locators.environment.varRowEyeToggle('apiToken').click();
await expect(locators.environment.varRowValueEditor('apiToken'))
.toContainText(NEW_VALUE);
await closeEnvironmentPanel(page, 'collection');
});
await test.step('Interpolation: a subsequent request resolves {{apiToken}} to the new value', async () => {
await openRequest(page, fmt.collectionName, 'read-secret', { persist: true });
await sendRequest(page, 200);
await expectResponseContains(page, [NEW_VALUE]);
});
} finally {
await closeElectronApp(app);
}
});
test(`(${fmt.label}) script overwrites a previously-set secret, encrypted store entry changes`, async ({
launchElectronApp,
createTmpDir
}) => {
const userDataPath = await createTmpDir('userdata');
const collectionPath = await createTmpDir('collections');
await fs.promises.cp(fixturesPath, collectionPath, { recursive: true });
const envFilePath = path.join(collectionPath, fmt.subdir, 'environments', fmt.envFile);
const secretsPath = path.join(userDataPath, 'secrets.json');
const collectionDir = path.join(collectionPath, fmt.subdir);
const app = await launchElectronApp({
initUserDataPath,
userDataPath,
templateVars: { collectionPath }
});
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
try {
await test.step('Open the seed-secret request and select the "Local" environment', async () => {
await waitForCollectionMount(page, fmt.collectionName);
await openCollection(page, fmt.collectionName);
await openRequest(page, fmt.collectionName, 'seed-secret', { persist: true });
await selectEnvironment(page, 'Local', 'collection');
});
await test.step('Seed the secret: post-response script sets apiToken to INITIAL_VALUE', async () => {
await sendRequest(page, 200);
});
let initialEncryptedValue = '';
await test.step('After seed: env panel shows INITIAL_VALUE; encrypted entry persisted', async () => {
await openEnvironmentConfigTab(page, 'collection');
const envTab = locators.environment.collectionEnvTab();
await locators.environment.secretsTab().click();
await expect(locators.environment.varRow('apiToken')).toBeVisible();
await locators.environment.varRowEyeToggle('apiToken').click();
await expect(locators.environment.varRowValueEditor('apiToken'))
.toContainText(INITIAL_VALUE);
await closeEnvironmentPanel(page, 'collection');
await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
await expect.poll(() => {
const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
return secret?.value ?? '';
}, { timeout: 5000 }).not.toBe('');
const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
const secret = findApiTokenSecret(secretsJson, collectionDir);
expect(secret!.value).not.toContain(INITIAL_VALUE);
expect(JSON.stringify(secretsJson)).not.toContain(INITIAL_VALUE);
expect(fs.readFileSync(envFilePath, 'utf8')).not.toContain(secret!.value);
initialEncryptedValue = secret!.value;
});
await test.step('Overwrite: open set-secret and run it (script writes NEW_VALUE over INITIAL_VALUE)', async () => {
await openRequest(page, fmt.collectionName, 'set-secret', { persist: true });
await sendRequest(page, 200);
});
await test.step('After overwrite: env panel shows NEW_VALUE (not INITIAL_VALUE); encrypted entry changed; .bru/.yml still clean', async () => {
await openEnvironmentConfigTab(page, 'collection');
const envTab = locators.environment.collectionEnvTab();
await locators.environment.secretsTab().click();
await expect(locators.environment.varRow('apiToken')).toBeVisible();
await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/);
await locators.environment.varRowEyeToggle('apiToken').click();
await expect(locators.environment.varRowValueEditor('apiToken'))
.toContainText(NEW_VALUE);
await expect(locators.environment.varRowValueEditor('apiToken'))
.not.toContainText(INITIAL_VALUE);
await closeEnvironmentPanel(page, 'collection');
// Encrypted blob on disk changed — proves the secrets store was rewritten, not stale-cached.
await expect.poll(() => {
const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
return secret?.value;
}, { timeout: 5000 }).not.toBe(initialEncryptedValue);
const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
const secret = findApiTokenSecret(secretsJson, collectionDir);
expect(secret!.value).not.toContain(NEW_VALUE);
expect(secret!.value.length).toBeGreaterThan(0);
expect(JSON.stringify(secretsJson)).not.toContain(NEW_VALUE);
expect(JSON.stringify(secretsJson)).not.toContain(INITIAL_VALUE);
const content = fs.readFileSync(envFilePath, 'utf8');
expect(content).not.toContain(NEW_VALUE);
expect(content).not.toContain(INITIAL_VALUE);
expect(content).not.toContain(secret!.value);
fmt.expectAfterScript(content);
});
await test.step('Interpolation: a subsequent request resolves {{apiToken}} to NEW_VALUE', async () => {
await openRequest(page, fmt.collectionName, 'read-secret', { persist: true });
await sendRequest(page, 200);
await expectResponseContains(page, [NEW_VALUE]);
});
} finally {
await closeElectronApp(app);
}
});
}
test('persisted secret survives app restart and decrypts on the next launch', async ({
launchElectronApp,
createTmpDir
}) => {
// Encryption pathway is format-agnostic; one format is enough to prove the round-trip.
const fmt = FORMATS[0];
const userDataPath = await createTmpDir('userdata');
const collectionPath = await createTmpDir('collections');
await fs.promises.cp(fixturesPath, collectionPath, { recursive: true });
const secretsPath = path.join(userDataPath, 'secrets.json');
const collectionDir = path.join(collectionPath, fmt.subdir);
let blobBeforeRestart = '';
const firstApp = await launchElectronApp({
initUserDataPath,
userDataPath,
templateVars: { collectionPath }
});
try {
const page = await waitForReadyPage(firstApp);
await test.step('First launch: run set-secret and confirm the encrypted blob is on disk', async () => {
await waitForCollectionMount(page, fmt.collectionName);
await openCollection(page, fmt.collectionName);
await openRequest(page, fmt.collectionName, 'set-secret', { persist: true });
await selectEnvironment(page, 'Local', 'collection');
await sendRequest(page, 200);
await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
await expect.poll(() => {
const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
return secret?.value ?? '';
}, { timeout: 5000 }).not.toBe('');
const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
blobBeforeRestart = secret!.value;
});
} finally {
await closeElectronApp(firstApp);
}
const secondApp = await launchElectronApp({
initUserDataPath,
userDataPath,
templateVars: { collectionPath }
});
try {
const page = await waitForReadyPage(secondApp);
await test.step('Second launch: encrypted blob unchanged on disk', async () => {
const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
expect(secret!.value).toBe(blobBeforeRestart);
});
await test.step('Decryption round-trip: read-secret interpolates {{apiToken}} to the original plaintext', async () => {
await waitForCollectionMount(page, fmt.collectionName);
await openCollection(page, fmt.collectionName);
await selectEnvironment(page, 'Local', 'collection');
await openRequest(page, fmt.collectionName, 'read-secret', { persist: true });
await sendRequest(page, 200);
await expectResponseContains(page, [NEW_VALUE]);
});
} finally {
await closeElectronApp(secondApp);
}
});
});