mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 11:51:30 +00:00
682 lines
18 KiB
TypeScript
682 lines
18 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
export type CollectionFormat = 'bru' | 'yml';
|
|
|
|
export interface GenerateCollectionOptions {
|
|
/** Name of the collection */
|
|
name?: string;
|
|
/** Total number of requests to generate */
|
|
requestCount: number;
|
|
/** Maximum folder nesting depth (0 = no folders, all requests at root) */
|
|
depth?: number;
|
|
/** Number of folders per level (default: 3) */
|
|
foldersPerLevel?: number;
|
|
/** Collection format: 'bru' or 'yml' */
|
|
format: CollectionFormat;
|
|
/** Number of environments to generate (default: 2) */
|
|
environmentCount?: number;
|
|
/** Include mixed HTTP methods (default: true) */
|
|
mixedMethods?: boolean;
|
|
}
|
|
|
|
export interface GeneratedCollection {
|
|
/** Path to the generated collection directory */
|
|
path: string;
|
|
/** Cleanup function to remove the collection */
|
|
cleanup: () => Promise<void>;
|
|
/** Collection name */
|
|
name: string;
|
|
/** Number of requests generated */
|
|
requestCount: number;
|
|
/** Number of folders generated */
|
|
folderCount: number;
|
|
/** Format used */
|
|
format: CollectionFormat;
|
|
}
|
|
|
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
|
|
|
|
/**
|
|
* Generate a collection with the specified parameters
|
|
*/
|
|
export async function generateCollection(
|
|
options: GenerateCollectionOptions
|
|
): Promise<GeneratedCollection> {
|
|
const {
|
|
name = `Generated Collection ${Date.now()}`,
|
|
requestCount,
|
|
depth = 2,
|
|
foldersPerLevel = 3,
|
|
format,
|
|
environmentCount = 2,
|
|
mixedMethods = true
|
|
} = options;
|
|
|
|
// Create temp directory
|
|
const tempDir = await fs.promises.mkdtemp(
|
|
path.join(os.tmpdir(), 'bruno-test-collection-')
|
|
);
|
|
|
|
try {
|
|
// Generate collection root files
|
|
await generateCollectionRoot(tempDir, name, format);
|
|
|
|
// Generate environments
|
|
if (environmentCount > 0) {
|
|
await generateEnvironments(tempDir, environmentCount, format);
|
|
}
|
|
|
|
// Calculate folder structure
|
|
const folders = generateFolderStructure(depth, foldersPerLevel);
|
|
const folderCount = folders.length;
|
|
|
|
// Create folders
|
|
for (const folder of folders) {
|
|
await createFolder(tempDir, folder, format);
|
|
}
|
|
|
|
// Distribute requests across folders
|
|
const requestsPerLocation = distributeRequests(requestCount, folders);
|
|
let globalIndex = 0;
|
|
|
|
for (const [folderPath, count] of Object.entries(requestsPerLocation)) {
|
|
for (let i = 0; i < count; i++) {
|
|
const method = mixedMethods
|
|
? HTTP_METHODS[globalIndex % HTTP_METHODS.length]
|
|
: 'GET';
|
|
// Pass per-folder index (i) for naming, global index for method cycling
|
|
await createRequest(tempDir, folderPath, i, method, format);
|
|
globalIndex++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
path: tempDir,
|
|
cleanup: async () => {
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
},
|
|
name,
|
|
requestCount,
|
|
folderCount,
|
|
format
|
|
};
|
|
} catch (error) {
|
|
// Cleanup on error
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate collection root files (bruno.json and collection.bru/opencollection.yml)
|
|
*/
|
|
async function generateCollectionRoot(
|
|
basePath: string,
|
|
name: string,
|
|
format: CollectionFormat
|
|
): Promise<void> {
|
|
// Always create bruno.json
|
|
const brunoJson = {
|
|
version: '1',
|
|
name,
|
|
type: 'collection'
|
|
};
|
|
await fs.promises.writeFile(
|
|
path.join(basePath, 'bruno.json'),
|
|
JSON.stringify(brunoJson, null, 2)
|
|
);
|
|
|
|
if (format === 'bru') {
|
|
const collectionBru = `meta {
|
|
name: ${name}
|
|
}
|
|
|
|
auth {
|
|
mode: none
|
|
}
|
|
`;
|
|
await fs.promises.writeFile(
|
|
path.join(basePath, 'collection.bru'),
|
|
collectionBru
|
|
);
|
|
} else {
|
|
const opencollectionYml = `opencollection: "1.0.0"
|
|
info:
|
|
name: ${name}
|
|
`;
|
|
await fs.promises.writeFile(
|
|
path.join(basePath, 'opencollection.yml'),
|
|
opencollectionYml
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate environment files
|
|
*/
|
|
async function generateEnvironments(
|
|
basePath: string,
|
|
count: number,
|
|
format: CollectionFormat
|
|
): Promise<void> {
|
|
const envDir = path.join(basePath, 'environments');
|
|
await fs.promises.mkdir(envDir, { recursive: true });
|
|
|
|
const envNames = ['dev', 'staging', 'prod', 'local', 'test'];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const envName = envNames[i % envNames.length];
|
|
const ext = format === 'bru' ? 'bru' : 'yml';
|
|
const filePath = path.join(envDir, `${envName}.${ext}`);
|
|
|
|
if (format === 'bru') {
|
|
const content = `vars {
|
|
host: https://api.${envName}.example.com
|
|
apiKey: ${envName}-api-key-${i}
|
|
}
|
|
`;
|
|
await fs.promises.writeFile(filePath, content);
|
|
} else {
|
|
const content = `name: ${envName}
|
|
variables:
|
|
- name: host
|
|
value: https://api.${envName}.example.com
|
|
- name: apiKey
|
|
value: ${envName}-api-key-${i}
|
|
`;
|
|
await fs.promises.writeFile(filePath, content);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate folder structure paths
|
|
* Folder names include parent prefix for verifiable parent-child relationships
|
|
* Uses short names: F1, F1-F1, F1-F1-F1 to avoid OS filename length limits
|
|
*/
|
|
function generateFolderStructure(
|
|
depth: number,
|
|
foldersPerLevel: number
|
|
): string[] {
|
|
if (depth === 0) {
|
|
return ['']; // Root only
|
|
}
|
|
|
|
const folders: string[] = [''];
|
|
|
|
function addFolders(currentPath: string, parentName: string, currentDepth: number): void {
|
|
if (currentDepth >= depth) return;
|
|
|
|
for (let i = 0; i < foldersPerLevel; i++) {
|
|
// Short folder name with parent prefix: F1, F1-F1, F1-F1-F1
|
|
const folderName = parentName ? `${parentName}-F${i + 1}` : `F${i + 1}`;
|
|
const newPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
|
folders.push(newPath);
|
|
addFolders(newPath, folderName, currentDepth + 1);
|
|
}
|
|
}
|
|
|
|
addFolders('', '', 0);
|
|
return folders;
|
|
}
|
|
|
|
/**
|
|
* Create a folder with its metadata file
|
|
*/
|
|
async function createFolder(
|
|
basePath: string,
|
|
folderPath: string,
|
|
format: CollectionFormat
|
|
): Promise<void> {
|
|
if (!folderPath) return; // Skip root
|
|
|
|
const fullPath = path.join(basePath, folderPath);
|
|
await fs.promises.mkdir(fullPath, { recursive: true });
|
|
|
|
const folderName = path.basename(folderPath);
|
|
const ext = format === 'bru' ? 'bru' : 'yml';
|
|
const metaPath = path.join(fullPath, `folder.${ext}`);
|
|
|
|
if (format === 'bru') {
|
|
const content = `meta {
|
|
name: ${folderName}
|
|
}
|
|
|
|
auth {
|
|
mode: inherit
|
|
}
|
|
`;
|
|
await fs.promises.writeFile(metaPath, content);
|
|
} else {
|
|
const content = `info:
|
|
name: ${folderName}
|
|
`;
|
|
await fs.promises.writeFile(metaPath, content);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Distribute requests across folders
|
|
*/
|
|
function distributeRequests(
|
|
requestCount: number,
|
|
folders: string[]
|
|
): Record<string, number> {
|
|
const distribution: Record<string, number> = {};
|
|
|
|
// Initialize all folders with 0
|
|
for (const folder of folders) {
|
|
distribution[folder] = 0;
|
|
}
|
|
|
|
// Distribute requests evenly, with remainder going to first folders
|
|
const perFolder = Math.floor(requestCount / folders.length);
|
|
const remainder = requestCount % folders.length;
|
|
|
|
for (let i = 0; i < folders.length; i++) {
|
|
distribution[folders[i]] = perFolder + (i < remainder ? 1 : 0);
|
|
}
|
|
|
|
return distribution;
|
|
}
|
|
|
|
/**
|
|
* Create a request file
|
|
* Request names include parent folder prefix for verifiable parent-child relationships
|
|
* Uses short names: R1, F1-R1, F1-F1-R1 to avoid OS filename length limits
|
|
*/
|
|
async function createRequest(
|
|
basePath: string,
|
|
folderPath: string,
|
|
indexInFolder: number,
|
|
method: typeof HTTP_METHODS[number],
|
|
format: CollectionFormat
|
|
): Promise<void> {
|
|
// Get parent folder name from path (e.g., "F1/F1-F1" -> "F1-F1")
|
|
const parentName = folderPath ? path.basename(folderPath) : '';
|
|
const requestName = parentName ? `${parentName}-R${indexInFolder + 1}` : `R${indexInFolder + 1}`;
|
|
const ext = format === 'bru' ? 'bru' : 'yml';
|
|
const fullFolderPath = folderPath
|
|
? path.join(basePath, folderPath)
|
|
: basePath;
|
|
const filePath = path.join(fullFolderPath, `${requestName}.${ext}`);
|
|
|
|
// Ensure folder exists
|
|
await fs.promises.mkdir(fullFolderPath, { recursive: true });
|
|
|
|
const hasBody = ['POST', 'PUT', 'PATCH'].includes(method);
|
|
|
|
if (format === 'bru') {
|
|
let content = `meta {
|
|
name: ${requestName}
|
|
type: http
|
|
seq: ${(indexInFolder % 100) + 1}
|
|
}
|
|
|
|
${method.toLowerCase()} {
|
|
url: {{host}}/api/resource/${indexInFolder + 1}
|
|
body: ${hasBody ? 'json' : 'none'}
|
|
auth: none
|
|
}
|
|
`;
|
|
|
|
if (hasBody) {
|
|
content += `
|
|
body:json {
|
|
{
|
|
"id": ${indexInFolder + 1},
|
|
"name": "Resource ${indexInFolder + 1}",
|
|
"timestamp": "${new Date().toISOString()}"
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
await fs.promises.writeFile(filePath, content);
|
|
} else {
|
|
let content = `info:
|
|
name: ${requestName}
|
|
type: http
|
|
seq: ${(indexInFolder % 100) + 1}
|
|
|
|
http:
|
|
method: ${method}
|
|
url: "{{host}}/api/resource/${indexInFolder + 1}"
|
|
`;
|
|
|
|
if (hasBody) {
|
|
content += ` body:
|
|
mode: json
|
|
json: |
|
|
{
|
|
"id": ${indexInFolder + 1},
|
|
"name": "Resource ${indexInFolder + 1}",
|
|
"timestamp": "${new Date().toISOString()}"
|
|
}
|
|
`;
|
|
}
|
|
|
|
await fs.promises.writeFile(filePath, content);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate collections in both formats for comparison testing
|
|
*/
|
|
export async function generateCollectionPair(
|
|
options: Omit<GenerateCollectionOptions, 'format'>
|
|
): Promise<{
|
|
bru: GeneratedCollection;
|
|
yml: GeneratedCollection;
|
|
cleanup: () => Promise<void>;
|
|
}> {
|
|
const bru = await generateCollection({ ...options, format: 'bru' });
|
|
const yml = await generateCollection({ ...options, format: 'yml' }).catch(async (err) => {
|
|
await bru.cleanup();
|
|
throw err;
|
|
});
|
|
|
|
return {
|
|
bru,
|
|
yml,
|
|
cleanup: async () => {
|
|
await Promise.all([bru.cleanup(), yml.cleanup()]);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Item definition for sorting test collections
|
|
*/
|
|
export interface SortingTestItem {
|
|
/** Name of the request */
|
|
name: string;
|
|
/** Sequence number (omit to test alphabetical sorting) */
|
|
seq?: number;
|
|
/** HTTP method (default: GET) */
|
|
method?: typeof HTTP_METHODS[number];
|
|
}
|
|
|
|
/**
|
|
* Options for generating a sorting test collection
|
|
*/
|
|
export interface SortingTestCollectionOptions {
|
|
/** Name of the collection */
|
|
name: string;
|
|
/** Items to generate with specific names and sequences */
|
|
items: SortingTestItem[];
|
|
/** Collection format */
|
|
format: CollectionFormat;
|
|
}
|
|
|
|
/**
|
|
* Generate a collection specifically for sorting tests.
|
|
* Allows specifying exact item names and sequence numbers.
|
|
*/
|
|
export async function generateSortingTestCollection(
|
|
options: SortingTestCollectionOptions
|
|
): Promise<GeneratedCollection> {
|
|
const { name, items, format } = options;
|
|
|
|
// Create temp directory
|
|
const tempDir = await fs.promises.mkdtemp(
|
|
path.join(os.tmpdir(), 'bruno-test-sorting-')
|
|
);
|
|
|
|
try {
|
|
// Generate collection root files
|
|
await generateCollectionRoot(tempDir, name, format);
|
|
|
|
// Create each request with specified name and sequence
|
|
for (const item of items) {
|
|
await createSortingTestRequest(tempDir, item, format);
|
|
}
|
|
|
|
return {
|
|
path: tempDir,
|
|
cleanup: async () => {
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
},
|
|
name,
|
|
requestCount: items.length,
|
|
folderCount: 0,
|
|
format
|
|
};
|
|
} catch (error) {
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a request file for sorting tests with specific name and sequence
|
|
*/
|
|
async function createSortingTestRequest(
|
|
basePath: string,
|
|
item: SortingTestItem,
|
|
format: CollectionFormat
|
|
): Promise<void> {
|
|
const { name, seq, method = 'GET' } = item;
|
|
const ext = format === 'bru' ? 'bru' : 'yml';
|
|
const filePath = path.join(basePath, `${name}.${ext}`);
|
|
|
|
if (format === 'bru') {
|
|
const seqLine = seq !== undefined ? `\n seq: ${seq}` : '';
|
|
const content = `meta {
|
|
name: ${name}
|
|
type: http${seqLine}
|
|
}
|
|
|
|
${method.toLowerCase()} {
|
|
url: {{host}}/api/${name.toLowerCase().replace(/\s+/g, '-')}
|
|
body: none
|
|
auth: none
|
|
}
|
|
`;
|
|
await fs.promises.writeFile(filePath, content);
|
|
} else {
|
|
const seqLine = seq !== undefined ? `\n seq: ${seq}` : '';
|
|
const content = `info:
|
|
name: ${name}
|
|
type: http${seqLine}
|
|
|
|
http:
|
|
method: ${method}
|
|
url: "{{host}}/api/${name.toLowerCase().replace(/\s+/g, '-')}"
|
|
`;
|
|
await fs.promises.writeFile(filePath, content);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up a sorting test fixture with collection and user data directory
|
|
*/
|
|
export async function setupSortingTestFixture(
|
|
options: SortingTestCollectionOptions
|
|
): Promise<TestFixture> {
|
|
const collection = await generateSortingTestCollection(options);
|
|
|
|
const userDataPath = await fs.promises.mkdtemp(
|
|
path.join(os.tmpdir(), 'bruno-test-userdata-')
|
|
);
|
|
|
|
try {
|
|
const preferences = {
|
|
lastOpenedCollections: [collection.path],
|
|
preferences: {
|
|
onboarding: {
|
|
hasLaunchedBefore: true,
|
|
hasSeenWelcomeModal: true
|
|
}
|
|
}
|
|
};
|
|
|
|
await fs.promises.writeFile(
|
|
path.join(userDataPath, 'preferences.json'),
|
|
JSON.stringify(preferences, null, 2)
|
|
);
|
|
|
|
return {
|
|
collection,
|
|
userDataPath,
|
|
cleanup: async () => {
|
|
await Promise.all([
|
|
collection.cleanup(),
|
|
fs.promises.rm(userDataPath, { recursive: true, force: true })
|
|
]);
|
|
}
|
|
};
|
|
} catch (error) {
|
|
await Promise.all([
|
|
collection.cleanup(),
|
|
fs.promises.rm(userDataPath, { recursive: true, force: true })
|
|
]);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Options for setting up a test fixture
|
|
*/
|
|
export interface TestFixtureOptions extends GenerateCollectionOptions {
|
|
/** Additional collections to preload (paths) */
|
|
additionalCollections?: string[];
|
|
}
|
|
|
|
/**
|
|
* Result of setting up a test fixture
|
|
*/
|
|
export interface TestFixture {
|
|
/** Generated collection */
|
|
collection: GeneratedCollection;
|
|
/** Path to user data directory with preferences */
|
|
userDataPath: string;
|
|
/** Cleanup function to remove all generated files */
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Set up a complete test fixture with collection and user data directory.
|
|
* This creates:
|
|
* 1. A generated collection based on the provided options
|
|
* 2. A user data directory with preferences.json configured to preload the collection
|
|
*
|
|
* Use this in beforeAll to set up fixtures, and call cleanup() in afterAll.
|
|
* Note: This does NOT handle app launching or closing - that should be done separately.
|
|
*/
|
|
export async function setupTestFixture(
|
|
options: TestFixtureOptions
|
|
): Promise<TestFixture> {
|
|
const { additionalCollections = [], ...collectionOptions } = options;
|
|
|
|
// Generate the collection
|
|
const collection = await generateCollection(collectionOptions);
|
|
|
|
// Create user data directory
|
|
const userDataPath = await fs.promises.mkdtemp(
|
|
path.join(os.tmpdir(), 'bruno-test-userdata-')
|
|
);
|
|
|
|
try {
|
|
// Create preferences.json with collection preloaded
|
|
const preferences = {
|
|
lastOpenedCollections: [collection.path, ...additionalCollections],
|
|
preferences: {
|
|
onboarding: {
|
|
hasLaunchedBefore: true,
|
|
hasSeenWelcomeModal: true
|
|
}
|
|
}
|
|
};
|
|
|
|
await fs.promises.writeFile(
|
|
path.join(userDataPath, 'preferences.json'),
|
|
JSON.stringify(preferences, null, 2)
|
|
);
|
|
|
|
return {
|
|
collection,
|
|
userDataPath,
|
|
cleanup: async () => {
|
|
await Promise.all([
|
|
collection.cleanup(),
|
|
fs.promises.rm(userDataPath, { recursive: true, force: true })
|
|
]);
|
|
}
|
|
};
|
|
} catch (error) {
|
|
// Cleanup on error
|
|
await Promise.all([
|
|
collection.cleanup(),
|
|
fs.promises.rm(userDataPath, { recursive: true, force: true })
|
|
]);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Result of setting up a static (committed) collection fixture
|
|
*/
|
|
export interface StaticTestFixture {
|
|
/** Path to the temp copy of the collection that is actually mounted */
|
|
collectionPath: string;
|
|
/** Path to user data directory with preferences */
|
|
userDataPath: string;
|
|
/** Cleanup function — removes the temp copy and user data dir; never touches the source */
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Set up a test fixture that preloads a static, committed collection from disk.
|
|
*
|
|
* Unlike setupTestFixture (which generates a throwaway collection), this uses an
|
|
* existing collection directory checked into the repo — for tests that need a
|
|
* fixed, hand-authored layout rather than generated data. The source is copied
|
|
* to a temp dir before mounting so the running app never mutates the committed
|
|
* fixture. cleanup() removes the temp copy and the user data dir.
|
|
*/
|
|
export async function setupStaticFixture(
|
|
sourceCollectionPath: string
|
|
): Promise<StaticTestFixture> {
|
|
const userDataPath = await fs.promises.mkdtemp(
|
|
path.join(os.tmpdir(), 'bruno-test-userdata-')
|
|
);
|
|
const collectionPath = await fs.promises.mkdtemp(
|
|
path.join(os.tmpdir(), 'bruno-test-collection-')
|
|
);
|
|
|
|
const cleanup = async () => {
|
|
await Promise.all([
|
|
fs.promises.rm(userDataPath, { recursive: true, force: true }),
|
|
fs.promises.rm(collectionPath, { recursive: true, force: true })
|
|
]);
|
|
};
|
|
|
|
try {
|
|
// Copy the committed collection into the temp dir so the app mounts the copy.
|
|
await fs.promises.cp(sourceCollectionPath, collectionPath, { recursive: true });
|
|
|
|
const preferences = {
|
|
lastOpenedCollections: [collectionPath],
|
|
preferences: {
|
|
onboarding: {
|
|
hasLaunchedBefore: true,
|
|
hasSeenWelcomeModal: true
|
|
}
|
|
}
|
|
};
|
|
|
|
await fs.promises.writeFile(
|
|
path.join(userDataPath, 'preferences.json'),
|
|
JSON.stringify(preferences, null, 2)
|
|
);
|
|
|
|
return { collectionPath, userDataPath, cleanup };
|
|
} catch (error) {
|
|
await cleanup();
|
|
throw error;
|
|
}
|
|
}
|