Files
bruno/playwright/index.ts
lohit 95de14adcb feat: add OAuth 1.0 authentication support (#7482)
* feat: add OAuth 1.0 authentication support

Add full OAuth 1.0 (RFC 5849) authentication with support for
HMAC-SHA1/256/512, RSA-SHA1/256/512, and PLAINTEXT signature methods.
Includes UI components, bru/yml serialization, Postman import, code
generation, CLI support, and comprehensive playwright and unit tests.

* test: replace real-looking PEM literals with fake markers in oauth1 tests

Avoid tripping secret scanners by using obviously fake BEGIN/END markers
and non-sensitive base64 content in serialization and round-trip tests.

* fix: remove invalid OAuth1 placeholder header from code generator

OAuth1 requires runtime-computed nonce, timestamp, and signature that
cannot be pre-computed for a static code snippet. Return an empty array
instead of emitting an Authorization header with literal <signature>,
<timestamp>, <nonce> placeholders.

* fix: remove unreachable oauth1 case from WSAuth component

The oauth1 switch branch was dead code since it was not in
supportedAuthModes and the useEffect would reset it to 'none'
before it could render.

* fix: remove unused collectionPath param and use path.basename for filename extraction

* refactor: rename OAuth1 fields for clarity

- tokenSecret → accessTokenSecret
- signatureMethod → signatureEncoding
- addParamsTo value 'queryparams' → 'query'

* refactor: rename addParamsTo to placement in OAuth1 auth

* fix: add missing oauth1: null in buildOAuth2Config and upgrade @opencollection/types to 0.9.0

* test: add oauth1 import tests and fix missing oauth1: null in auth assertions

* ci: add auth playwright tests workflow for Linux, macOS, and Windows

* refactor: rename signatureEncoding to signatureMethod and fix timeline race condition

- Rename OAuth1 signatureEncoding to signatureMethod across all packages
- Fix timeline showing "No Headers/Body found" when request-sent IPC event
  arrives after response by retroactively updating the timeline entry
- Store requestUid in timeline entries for precise matching
- Correct timeline entry timestamp on retroactive update for proper sort order

* ci: add OAuth1 CLI tests and reorganize auth actions under oauth1/

- Add CLI tests that run full BRU and YML collections via bru run
- Add start-test-server actions for Linux, macOS, and Windows
- Move auth e2e and setup actions under auth/oauth1/ directory
- Fix Windows Playwright failures caused by unescaped backslashes in collectionPath template variable

* ci: reorder auth tests to run E2E tests before CLI tests

* ci: start test server after E2E tests to fix port 8081 conflict
2026-03-27 18:59:42 +05:30

356 lines
12 KiB
TypeScript

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';
const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
const existsAsync = (filepath: string) => fs.promises.access(filepath).then(() => true).catch(() => false);
async function recursiveCopy(src: string, dest: string) {
if (!await existsAsync(src)) {
throw new Error(`${src} doesn't exist`);
}
const files = await fs.promises.readdir(src, {
recursive: true,
withFileTypes: true
});
for (const file of files) {
if (!file.isFile()) continue;
const fullPath = path.join(src, file.name);
const fullDestPath = path.join(dest, file.name);
await fs.promises.copyFile(fullPath, fullDestPath);
}
}
const TRACING_OPTIONS = { screenshots: true, snapshots: true, sources: true };
function isTracingEnabled(testInfo: TestInfo): boolean {
return !!(testInfo as any)._tracing.traceOptions();
}
async function usePageWithTracing(
context: BrowserContext,
page: Page,
testInfo: TestInfo,
use: (page: Page) => Promise<void>,
options: { initTracing?: boolean; useChunks?: boolean } = {}
) {
const { initTracing = false, useChunks = true } = options;
if (!isTracingEnabled(testInfo)) {
await use(page);
return;
}
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
if (initTracing) {
try {
await context.tracing.start(TRACING_OPTIONS);
} catch (e) { }
}
if (useChunks) {
await context.tracing.startChunk();
await use(page);
try { await context.tracing.stopChunk({ path: tracePath }); } catch { }
} else {
await use(page);
try { await context.tracing.stop({ path: tracePath }); } catch { }
}
try { await testInfo.attach('trace', { path: tracePath }); } catch { }
}
/**
* Gracefully close an Electron app by telling it to exit with code 0.
* This avoids the macOS "quit unexpectedly" crash dialog that appears when
* app.context().close() kills subprocesses (renderer/GPU) abruptly before
* the main process can shut down cleanly.
*
* Emits 'before-quit' first so cleanup handlers run (e.g., saving cookies to disk),
* since app.exit() bypasses all lifecycle events.
*/
export async function closeElectronApp(app: ElectronApplication) {
try {
await app.evaluate(async ({ app }) => {
app.emit('before-quit');
// Add a delay to ensure the app is fully closed
await new Promise((resolve) => setTimeout(resolve, 250));
app.exit(0);
});
} catch {
// Expected: process exited before the CDP response was sent
}
try {
await app.close();
} catch {
// Process already exited
}
}
export const test = baseTest.extend<
{
context: BrowserContext;
page: Page;
newPage: Page;
pageWithUserData: Page;
collectionFixturePath: string | null;
restartApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
},
{
createTmpDir: (tag?: string) => Promise<string>;
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string>; templateVars?: Record<string, string> }) => Promise<ElectronApplication>;
electronApp: ElectronApplication;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; testFile?: string; userDataPath?: string; dotEnv?: Record<string, string>; templateVars?: Record<string, string>; closePrevious?: boolean }) => Promise<ElectronApplication>;
}
>({
createTmpDir: [
async ({ }, use) => {
const dirs: string[] = [];
await use(async (tag?: string) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `pw-${tag || ''}-`));
dirs.push(dir);
return dir;
});
await Promise.all(
dirs.map((dir) => fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch((e) => e))
);
},
{ scope: 'worker' }
],
collectionFixturePath: async ({ createTmpDir }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const fixturesDir = path.join(testDir, 'fixtures');
// fixtures/collections — multiple named collections (subdirs with bruno.json/opencollection.yml)
// fixtures/collection — single collection (single dir with bruno.json/opencollection.yml)
const srcPath = [path.join(fixturesDir, 'collections'), path.join(fixturesDir, 'collection')]
.find((p) => fs.existsSync(p));
if (srcPath) {
const tmpDir = await createTmpDir(path.basename(srcPath));
await fs.promises.cp(srcPath, tmpDir, { recursive: true });
await use(tmpDir);
} else {
await use(null);
}
},
launchElectronApp: [
async ({ playwright, createTmpDir }, use, workerInfo) => {
const apps: ElectronApplication[] = [];
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath, dotEnv = {}, templateVars = {} } = {}) => {
const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata'));
// Ensure dir exists when caller supplies their own path
if (providedUserDataPath) {
await fs.promises.mkdir(userDataPath, { recursive: true });
}
if (initUserDataPath) {
const replacements: Record<string, string> = {
projectRoot: path.posix.join(__dirname, '..'),
...templateVars
};
for (const file of await fs.promises.readdir(initUserDataPath)) {
let content = await fs.promises.readFile(path.join(initUserDataPath, file), 'utf-8');
content = content.replace(/{{(\w+)}}/g, (_, key) => {
if (replacements[key]) {
return replacements[key];
} else {
throw new Error(`\tNo replacement for {{${key}}} in ${path.join(initUserDataPath, file)}`);
}
});
await fs.promises.writeFile(path.join(userDataPath, file), content, 'utf-8');
}
} else {
// No initUserDataPath provided: create default preferences to skip onboarding
// BUT only if preferences.json doesn't already exist
const prefsPath = path.join(userDataPath, 'preferences.json');
const prefsExist = await existsAsync(prefsPath);
if (!prefsExist) {
const defaultPreferences = {
preferences: {
onboarding: {
hasLaunchedBefore: true,
hasSeenWelcomeModal: true
}
}
};
await fs.promises.writeFile(
prefsPath,
JSON.stringify(defaultPreferences, null, 2),
'utf-8'
);
}
}
const app = await playwright._electron.launch({
args: [electronAppPath, '--disable-gpu'],
env: {
...process.env,
ELECTRON_USER_DATA_PATH: userDataPath,
DISABLE_SAMPLE_COLLECTION_IMPORT: 'true',
PLAYWRIGHT: 'true',
DISABLE_SINGLE_INSTANCE: 'true',
...dotEnv
}
});
const { workerIndex } = workerInfo;
const electronProcess = app.process();
if (electronProcess?.stdout) {
electronProcess.stdout.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
}
if (electronProcess?.stderr) {
electronProcess.stderr.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
}
apps.push(app);
return app;
});
for (const app of apps) {
await closeElectronApp(app);
}
},
{ scope: 'worker' }
],
electronApp: [
async ({ launchElectronApp }, use) => {
const app = await launchElectronApp();
await use(app);
},
{ scope: 'worker' }
],
context: async ({ electronApp }, use, testInfo) => {
const context = await electronApp.context();
if (isTracingEnabled(testInfo)) {
try {
await context.tracing.start(TRACING_OPTIONS);
} catch (e) { }
}
await use(context);
},
page: async ({ electronApp, context }, use, testInfo) => {
const page = await electronApp.firstWindow();
await usePageWithTracing(context, page, testInfo, use);
},
newPage: async ({ launchElectronApp }, use, testInfo) => {
const app = await launchElectronApp();
const context = await app.context();
const page = await app.firstWindow();
await usePageWithTracing(context, page, testInfo, use, { initTracing: true, useChunks: false });
},
reuseOrLaunchElectronApp: [
async ({ launchElectronApp }, use, testInfo) => {
const apps: Record<string, ElectronApplication> = {};
await use(async ({ initUserDataPath, testFile, userDataPath, dotEnv = {}, templateVars = {}, closePrevious = false } = {}) => {
const key = testFile || userDataPath || initUserDataPath;
if (key && apps[key]) {
if (closePrevious) {
await closeElectronApp(apps[key]);
delete apps[key];
} else {
return apps[key];
}
}
// Close other cached apps to prevent resource accumulation across test files
for (const existingKey of Object.keys(apps)) {
if (existingKey !== key) {
await closeElectronApp(apps[existingKey]);
delete apps[existingKey];
}
}
const app = await launchElectronApp({ initUserDataPath, userDataPath, dotEnv, templateVars });
if (key) {
apps[key] = app;
}
return app;
});
},
{ scope: 'worker' }
],
restartApp: async ({ reuseOrLaunchElectronApp, createTmpDir, collectionFixturePath }, use, testInfo) => {
await use(async ({ initUserDataPath } = {}) => {
const testDir = path.dirname(testInfo.file);
const defaultInitUserDataPath = path.join(testDir, 'init-user-data');
let srcUserDataPath = initUserDataPath;
if (!srcUserDataPath) {
const hasInitUserData = await fs.promises.stat(defaultInitUserDataPath).catch(() => false);
srcUserDataPath = hasInitUserData ? defaultInitUserDataPath : undefined;
}
// Copy init-user-data to a fresh tmp dir (same as pageWithUserData)
const tmpAppDataDir = await createTmpDir();
if (srcUserDataPath) {
await recursiveCopy(srcUserDataPath, tmpAppDataDir);
}
const templateVars: Record<string, string> = {};
if (collectionFixturePath) {
templateVars.collectionPath = collectionFixturePath.split(path.sep).join('/');
}
// Close the previous app (from pageWithUserData) before launching a new one
return await reuseOrLaunchElectronApp({
initUserDataPath: tmpAppDataDir,
testFile: testInfo.file,
templateVars,
closePrevious: true
});
});
},
pageWithUserData: async ({ reuseOrLaunchElectronApp, createTmpDir, collectionFixturePath }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const initUserDataPath = path.join(testDir, 'init-user-data');
const tmpAppDataDir = await createTmpDir();
try {
await recursiveCopy(initUserDataPath, tmpAppDataDir);
} catch (err) {
if (err instanceof Error && err.message.includes('doesn\'t exist')) {
throw new Error(`${initUserDataPath} doesn't exist, either add one or if you don't need an initial state then use the \`page\` fixture instead of \`pageWithUserData\`.`);
}
throw err;
}
const templateVars: Record<string, string> = {};
if (collectionFixturePath) {
templateVars.collectionPath = collectionFixturePath.split(path.sep).join('/');
}
const app = await reuseOrLaunchElectronApp({ initUserDataPath: tmpAppDataDir, testFile: testInfo.file, templateVars });
const context = await app.context();
const page = await app.firstWindow();
// Wait for app to be ready
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await usePageWithTracing(context, page, testInfo, use, { initTracing: true });
}
});
export * from '@playwright/test';