feat(playwright): isolate collections by copying to a temp directory before launch

This commit is contained in:
Bijin A B
2026-02-14 01:21:26 +05:30
parent e000e377d1
commit f30589adc2
8 changed files with 116 additions and 127 deletions

View File

@@ -1,4 +1,4 @@
import { test as baseTest, BrowserContext, ElectronApplication, Page } from '@playwright/test';
import { test as baseTest, BrowserContext, ElectronApplication, Page, TestInfo } from '@playwright/test';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
@@ -19,12 +19,107 @@ async function recursiveCopy(src: string, dest: string) {
for (const file of files) {
if (!file.isFile()) continue;
const fullPath = path.join(src, file.name);
const fullDestPath = path.join(dest, file.name);
const parentDir = file.parentPath || (file as any).path;
const fullPath = path.join(parentDir, file.name);
const relativePath = path.relative(src, fullPath);
const fullDestPath = path.join(dest, relativePath);
await fs.promises.mkdir(path.dirname(fullDestPath), { recursive: true });
await fs.promises.copyFile(fullPath, fullDestPath);
}
}
async function rewriteCollectionPaths(
userDataPath: string,
fileName: string,
pathKey: string,
pathMap: Map<string, string>
) {
const filePath = path.join(userDataPath, fileName);
if (!await existsAsync(filePath)) return;
const content = await fs.promises.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
if (!Array.isArray(data.collections)) return;
let changed = false;
for (const entry of data.collections) {
const mapped = pathMap.get(entry[pathKey]);
if (mapped) {
entry[pathKey] = mapped;
changed = true;
}
}
if (changed) {
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
}
async function isolateCollections(
userDataPath: string,
createTmpDir: (tag?: string) => Promise<string>
) {
const prefsPath = path.join(userDataPath, 'preferences.json');
if (!await existsAsync(prefsPath)) return;
const content = await fs.promises.readFile(prefsPath, 'utf-8');
const prefs = JSON.parse(content);
if (Array.isArray(prefs.lastOpenedCollections) && prefs.lastOpenedCollections.length > 0) {
const pathMap = new Map<string, string>(); // original -> isolated
const newPaths: string[] = [];
for (const collPath of prefs.lastOpenedCollections) {
if (!await existsAsync(collPath)) {
newPaths.push(collPath);
continue;
}
// Copy the parent directory of the collection so that relative
// paths (e.g. ../protos/, ../client.pfx) still resolve correctly.
// The collection subdirectory itself becomes the isolated copy.
const parentDir = path.dirname(collPath);
const collBaseName = path.basename(collPath);
const tmpParentDir = await createTmpDir('collection');
await recursiveCopy(parentDir, tmpParentDir);
const tmpCollDir = path.join(tmpParentDir, collBaseName);
pathMap.set(collPath, tmpCollDir);
newPaths.push(tmpCollDir);
}
prefs.lastOpenedCollections = newPaths;
await fs.promises.writeFile(prefsPath, JSON.stringify(prefs, null, 2), 'utf-8');
// Rewrite collection paths in user-data JSON files that reference
// collections by their original filesystem path so lookups still
// match after isolation.
if (pathMap.size > 0) {
await rewriteCollectionPaths(userDataPath, 'collection-security.json', 'path', pathMap);
await rewriteCollectionPaths(userDataPath, 'ui-state-snapshot.json', 'pathname', pathMap);
}
}
}
async function withTracing(
context: BrowserContext,
page: Page,
testInfo: TestInfo,
use: (page: Page) => Promise<void>
) {
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) { }
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
}
export const test = baseTest.extend<
{
context: BrowserContext;
@@ -82,6 +177,10 @@ export const test = baseTest.extend<
});
await fs.promises.writeFile(path.join(userDataPath, file), content, 'utf-8');
}
// Copy referenced collections to temp dirs so parallel workers
// never share the same filesystem paths
await isolateCollections(userDataPath, createTmpDir);
}
const app = await playwright._electron.launch({
@@ -128,45 +227,21 @@ export const test = baseTest.extend<
{ scope: 'worker' }
],
context: async ({ electronApp }, use, testInfo) => {
context: async ({ electronApp }, use) => {
const context = await electronApp.context();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) { }
}
await use(context);
},
page: async ({ electronApp, context }, use, testInfo) => {
const page = await electronApp.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
await withTracing(context, page, testInfo, use);
},
newPage: async ({ launchElectronApp }, use, testInfo) => {
const app = await launchElectronApp();
const context = await app.context();
const page = await app.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
await use(page);
await context.tracing.stop({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
await withTracing(context, page, testInfo, use);
},
reuseOrLaunchElectronApp: [
@@ -231,19 +306,7 @@ export const test = baseTest.extend<
const context = await app.context();
const page = await app.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) { }
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
await withTracing(context, page, testInfo, use);
}
});

View File

@@ -1,7 +1,5 @@
import { execSync } from 'child_process';
import { test, expect } from '../../../playwright';
import { Page, ElectronApplication } from '@playwright/test';
import path from 'path';
import { openCollection } from '../../utils/page/actions';
import { buildCommonLocators } from '../../utils/page/locators';
@@ -20,11 +18,6 @@ const restartAppAndGetLocators = async (restartApp: (options?: { initUserDataPat
// The CollectionsHeader component (with collections-header-actions-menu-close-all) is not rendered in workspace mode
// The "Remove from workspace" flow is different from the old "Close collection" flow
test.describe.skip('Close All Collections', () => {
test.afterAll(async () => {
// Reset the request file to the original state after saving changes
execSync(`git checkout -- "${path.join(__dirname, 'fixtures', 'collections', 'collection 1', 'test-request.bru')}"`);
});
test('should show/hide close all icon based on hover state', async ({ pageWithUserData: page }) => {
const locators = buildCommonLocators(page);

View File

@@ -1,19 +1,13 @@
import { test, expect } from '../../../../../playwright';
import path from 'path';
import fs from 'fs';
test.describe.serial('Collection Environment Import Tests', () => {
test('should import single collection environment', async ({ pageWithUserData: page }) => {
test('should import single collection environment', async ({ restartApp }) => {
const app = await restartApp();
const page = await app.firstWindow();
const singleEnvFile = path.join(__dirname, '../../../fixtures/environment-exports/local.json');
const collectionPath = path.join(__dirname, 'fixtures/collection');
const environmentsPath = path.join(collectionPath, 'environments');
await test.step('Clean up existing environments and open collection', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
await test.step('Open collection', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Import Test Collection' }).click();
});
@@ -49,26 +43,14 @@ test.describe.serial('Collection Environment Import Tests', () => {
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Clean up after test', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
});
});
test('should import multiple collection environments', async ({ pageWithUserData: page }) => {
test('should import multiple collection environments', async ({ restartApp }) => {
const app = await restartApp();
const page = await app.firstWindow();
const multiEnvFile = path.join(__dirname, '../../../fixtures/environment-exports/bruno-collection-environments.json');
const collectionPath = path.join(__dirname, 'fixtures/collection');
const environmentsPath = path.join(collectionPath, 'environments');
await test.step('Clean up existing environments and open collection', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
await test.step('Open collection', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Import Test Collection' }).click();
});
@@ -130,12 +112,5 @@ test.describe.serial('Collection Environment Import Tests', () => {
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Clean up after test', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
});
});
});

View File

@@ -1,27 +1,6 @@
import path from 'path';
import { test, expect } from '../../playwright';
import { closeAllCollections } from '../utils/page';
import fs from 'fs';
const COLLECTION_PATH = path.join(__dirname, 'collection', 'bruno.json');
const BACKUP_PATH = path.join(__dirname, 'collection', 'bruno.json.backup');
import { execSync } from 'child_process';
test.describe('manage protofile', () => {
test.beforeAll(async () => {
// Backup original file
if (fs.existsSync(COLLECTION_PATH)) {
fs.copyFileSync(COLLECTION_PATH, BACKUP_PATH);
}
});
test.afterAll(async ({ pageWithUserData: page }) => {
// Close all collections
await closeAllCollections(page);
// Reset the collection request file to the original state
execSync(`git checkout -- ${path.join(__dirname, 'collection', 'bruno.json')}`);
});
test('protofiles, import paths from bruno.json are visible in the protobuf settings', async ({ pageWithUserData: page }) => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'Grpcbin' }).click();

View File

@@ -51,9 +51,8 @@ test.describe.serial('Header Validation', () => {
const headerRow = page.locator('table tbody tr').first();
const nameCell = getTableCell(headerRow, 0);
// Clear and enter a valid header name
await nameCell.locator('.CodeMirror').click();
await page.keyboard.press('Meta+a');
// Clear and enter a valid header name - use triple-click to select all (works cross-platform)
await nameCell.locator('.CodeMirror').click({ clickCount: 3 });
await nameCell.locator('textarea').fill('Valid-Header');
// Verify the error icon is not visible

View File

@@ -1,14 +1,7 @@
import { execSync } from 'child_process';
import { test, expect } from '../../playwright';
import path from 'path';
import { clickResponseAction } from '../utils/page/actions';
test.describe.serial('Create and Delete Response Examples', () => {
test.afterAll(async () => {
// Reset the collection request file to the original state
execSync(`git checkout -- ${path.join(__dirname, 'fixtures', 'collection', 'create-example.bru')}`);
});
test('should create a response example from response bookmark', async ({ pageWithUserData: page }) => {
await test.step('Open collection and request', async () => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'collection' }).click();

View File

@@ -1,14 +1,7 @@
import { test, expect } from '../../playwright';
import { execSync } from 'child_process';
import path from 'path';
import { clickResponseAction } from '../utils/page/actions';
test.describe.serial('Edit Response Examples', () => {
test.afterAll(async () => {
// Reset the collection request file to the original state
execSync(`git checkout -- ${path.join(__dirname, 'fixtures', 'collection', 'edit-example.bru')}`);
});
test('should enter edit mode and show editable fields when edit button is clicked', async ({ pageWithUserData: page }) => {
await test.step('Open collection and request', async () => {
await page.locator('#sidebar-collection-name').getByText('collection').click();

View File

@@ -1,14 +1,8 @@
import { test, expect } from '../../playwright';
import { execSync } from 'child_process';
import path from 'path';
import { clickResponseAction } from '../utils/page/actions';
test.describe.serial('Response Example Menu Operations', () => {
test.setTimeout(1 * 60 * 1000); // 1 minute for all tests in this describe block, default is 30 seconds.
test.afterAll(async () => {
// Reset the collection request file to the original state
execSync(`git checkout -- ${path.join(__dirname, 'fixtures', 'collection', 'menu-operations.bru')}`);
});
test('should clone a response example via three dots menu', async ({ pageWithUserData: page }) => {
await test.step('Open collection and request', async () => {