Files
openobserve/tests/ui-testing/playwright-tests/utils/sourcemap-helpers.js
T
b1fe77871a test: add E2E tests for RUM sourcemap feature (#11014)
## Summary
- Add comprehensive E2E test coverage for RUM sourcemap functionality
(26 total tests)
- API tests: 20 pytest tests (upload/list/delete/resolve operations +
RBAC)
- UI tests: 6 Playwright tests for error tracking UI
- Helper scripts for RUM data ingestion

## Implementation Details

### API Tests (pytest)
- **test_sourcemap_api.py**: 15 tests covering:
  - Sourcemap upload (multipart/form-data)
  - List sourcemaps
  - Delete sourcemaps
  - Stacktrace resolution with sourcemaps
- **test_sourcemap_rbac.py**: 5 tests for role-based access control
- **ingest_rum_errors.py**: Helper to inject synthetic RUM error data
for UI tests
- **ingest_rum_performance.py**: Helper to enable RUM feature in UI

### UI Tests (Playwright)
- **sourcemap-ui.spec.js**: 6 tests validating:
  - Error tracking page loads
  - Query execution
  - Error list display
  - Error detail navigation
  - Metadata display
  - Stack trace display
- **rumPage.js**: Updated page object with API response interception
- Workaround for Vue 3 @click handlers not responding to Playwright
clicks
  - Navigate via URL using error IDs extracted from API responses
- Tests run in **parallel mode** with proper assertions (no graceful
skips)

## Test Execution
```bash
# API tests (pytest)
cd tests/api-testing
pytest tests/test_sourcemap_api.py -v        # 15 tests
pytest tests/test_sourcemap_rbac.py -v       # 5 tests

# UI tests (Playwright)
cd tests/ui-testing
npx playwright test playwright-tests/RUM/sourcemap-ui.spec.js --workers=3  # 6 tests
```

## Technical Notes
- Vue 3 production builds don't respond to Playwright's synthetic click
events on @click handlers
- Solution: Intercept API responses to extract error IDs, then navigate
directly via URL
- All tests use hard assertions - tests fail if data doesn't exist (no
graceful skips)

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Shrinath Rao <undefined@users.noreply.github.com>
2026-03-28 20:57:48 +05:30

565 lines
16 KiB
JavaScript

/**
* Sourcemap Test Helpers
*
* Shared utility functions for sourcemap E2E testing.
* Handles test app building, sourcemap upload/delete, stacktrace resolution, and user management.
*/
const fs = require('fs').promises;
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const execAsync = promisify(exec);
const testLogger = require('./test-logger');
/**
* Build test app with updated RUM SDK configuration
* @param {string} rumToken - RUM token from API
* @param {Object} options - Build options
* @returns {Promise<Object>} Build result with hashes and paths
*/
async function buildTestApp(rumToken, options = {}) {
const {
site = 'localhost:5080',
insecureHTTP = true,
service = 'o2-sourcemap-test-app',
env = 'testing',
version = '1.0.0-automation',
org = 'default'
} = options;
testLogger.info('Building test app with RUM SDK configuration');
const appDir = path.join(__dirname, '../../../MD_Files/Sourcemaps/o2-sourcemap-test-app');
const configPath = path.join(appDir, 'src/index.js');
try {
// Read current config
let configContent = await fs.readFile(configPath, 'utf8');
// Update SDK config
configContent = configContent.replace(
/clientToken:\s*['"][^'"]*['"]/,
`clientToken: '${rumToken}'`
);
configContent = configContent.replace(
/site:\s*['"][^'"]*['"]/,
`site: '${site}'`
);
configContent = configContent.replace(
/insecureHTTP:\s*(?:true|false)/,
`insecureHTTP: ${insecureHTTP}`
);
configContent = configContent.replace(
/service:\s*['"][^'"]*['"]/,
`service: '${service}'`
);
configContent = configContent.replace(
/env:\s*['"][^'"]*['"]/,
`env: '${env}'`
);
configContent = configContent.replace(
/version:\s*['"][^'"]*['"]/,
`version: '${version}'`
);
configContent = configContent.replace(
/organizationIdentifier:\s*['"][^'"]*['"]/,
`organizationIdentifier: '${org}'`
);
// Write updated config
await fs.writeFile(configPath, configContent, 'utf8');
testLogger.info('Updated SDK configuration');
// Build app
testLogger.info('Running npm run build...');
const { stdout, stderr } = await execAsync('npm run build', {
cwd: appDir,
timeout: 60000 // 60 second timeout
});
if (stderr && !stderr.includes('webpack')) {
testLogger.warn(`Build warnings: ${stderr}`);
}
testLogger.info('Build completed successfully');
// Extract content hashes from dist/
const distPath = path.join(appDir, 'dist');
// Validate distPath to prevent path traversal
const resolvedDistPath = path.resolve(distPath);
const resolvedAppDir = path.resolve(appDir);
if (!resolvedDistPath.startsWith(resolvedAppDir)) {
throw new Error(`Invalid distPath: ${distPath} is outside appDir`);
}
const distFiles = await fs.readdir(distPath);
const hashes = {
main: extractHash(distFiles, 'main', '.js'),
lazyModule: extractHash(distFiles, 'lazy-module', '.js'),
profiler: extractHash(distFiles, 'profiler', '.js'),
recorder: extractHash(distFiles, 'recorder', '.js')
};
testLogger.info(`Extracted hashes: ${JSON.stringify(hashes)}`);
// Create sourcemaps ZIP
const sourcemapsZipPath = path.join(distPath, 'sourcemaps.zip');
// Remove old ZIP if exists
try {
await fs.unlink(sourcemapsZipPath);
} catch (err) {
// Ignore if doesn't exist
}
await execAsync('zip sourcemaps.zip *.map', { cwd: distPath });
testLogger.info(`Created sourcemaps ZIP at ${sourcemapsZipPath}`);
return {
appDir,
distPath,
hashes,
sourcemapsZipPath,
config: { service, env, version, org }
};
} catch (error) {
testLogger.error(`Failed to build test app: ${error.message}`);
throw error;
}
}
/**
* Extract content hash from filename
* @param {string[]} files - List of filenames
* @param {string} prefix - File prefix (e.g., 'main')
* @param {string} ext - File extension (e.g., '.js')
* @returns {string} Extracted hash
*/
function extractHash(files, prefix, ext) {
const pattern = new RegExp(`${prefix}\\.(.*?)\\${ext}`);
const file = files.find(f => f.startsWith(prefix) && f.endsWith(ext) && !f.endsWith('.map'));
if (!file) {
throw new Error(`Could not find file matching ${prefix}*${ext}`);
}
const match = file.match(pattern);
if (!match || !match[1]) {
throw new Error(`Could not extract hash from ${file}`);
}
return match[1];
}
/**
* Serve test app on specified port
* @param {string} distPath - Path to dist directory
* @param {number} port - Port number (default: 8089)
* @returns {Promise<Object>} Server process info
*/
async function serveTestApp(distPath, port = 8089) {
// Validate port before use
const portNum = parseInt(port, 10);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
throw new Error(`Invalid port number: ${port}`);
}
testLogger.info(`Starting HTTP server on port ${portNum}...`);
const { spawn } = require('child_process');
const serverProcess = spawn('python3', ['-m', 'http.server', portNum.toString()], {
cwd: distPath,
detached: true,
stdio: 'ignore'
});
serverProcess.unref();
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
testLogger.info(`HTTP server started (PID: ${serverProcess.pid})`);
return {
process: serverProcess,
pid: serverProcess.pid,
port: portNum,
url: `http://localhost:${portNum}`
};
}
/**
* Stop test app HTTP server
* @param {Object} serverInfo - Server info from serveTestApp
*/
async function stopTestAppServer(serverInfo) {
if (!serverInfo || !serverInfo.pid) {
testLogger.warn('No server PID provided, skipping stop');
return;
}
testLogger.info(`Stopping HTTP server (PID: ${serverInfo.pid})...`);
try {
// Kill process group
process.kill(-serverInfo.pid, 'SIGTERM');
testLogger.info('HTTP server stopped');
} catch (error) {
testLogger.warn(`Failed to stop server: ${error.message}`);
// Try direct kill
try {
process.kill(serverInfo.pid, 'SIGKILL');
} catch (err) {
testLogger.error(`Could not kill server process: ${err.message}`);
}
}
// Also try to kill any python processes on the port
try {
// Validate port is a safe integer before interpolating into shell command
const port = parseInt(serverInfo.port, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid port number: ${serverInfo.port}`);
}
await execAsync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`);
} catch (err) {
// Ignore errors (port validation errors will be logged, kill errors are expected)
}
}
/**
* Upload sourcemaps to OpenObserve
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - Upload configuration
* @param {string} zipPath - Path to sourcemaps ZIP
* @returns {Promise<Object>} Response
*/
async function uploadSourcemaps(request, config, zipPath) {
const { baseUrl, org, auth, service, env, version } = config;
testLogger.info(`Uploading sourcemaps: service=${service}, env=${env}, version=${version}`);
const FormData = require('form-data');
const form = new FormData();
// Read ZIP file
const zipBuffer = await fs.readFile(zipPath);
form.append('file', zipBuffer, {
filename: 'sourcemaps.zip',
contentType: 'application/zip'
});
form.append('service', service);
form.append('env', env);
form.append('version', version);
const response = await request.post(`${baseUrl}/api/${org}/sourcemaps`, {
headers: {
'Authorization': `Basic ${auth}`,
...form.getHeaders()
},
data: form.getBuffer()
});
const status = response.status();
testLogger.info(`Upload response: ${status}`);
if (status !== 201 && status !== 200) {
const body = await response.text();
testLogger.error(`Upload failed: ${body}`);
throw new Error(`Upload failed with status ${status}: ${body}`);
}
return response;
}
/**
* List sourcemaps from OpenObserve
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - List configuration
* @returns {Promise<Array>} List of sourcemaps
*/
async function listSourcemaps(request, config) {
const { baseUrl, org, auth, service, env, version } = config;
const params = new URLSearchParams();
if (service) params.append('service', service);
if (env) params.append('env', env);
if (version) params.append('version', version);
const url = `${baseUrl}/api/${org}/sourcemaps?${params}`;
testLogger.info(`Listing sourcemaps: ${url}`);
const response = await request.get(url, {
headers: { 'Authorization': `Basic ${auth}` }
});
if (response.status() !== 200) {
const body = await response.text();
testLogger.error(`List failed: ${body}`);
throw new Error(`List failed with status ${response.status()}`);
}
return await response.json();
}
/**
* Delete sourcemaps from OpenObserve
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - Delete configuration
* @returns {Promise<Object>} Response
*/
async function deleteSourcemaps(request, config) {
const { baseUrl, org, auth, service, env, version } = config;
const params = new URLSearchParams({ service, env, version });
const url = `${baseUrl}/api/${org}/sourcemaps?${params}`;
testLogger.info(`Deleting sourcemaps: ${url}`);
const response = await request.delete(url, {
headers: { 'Authorization': `Basic ${auth}` }
});
const status = response.status();
testLogger.info(`Delete response: ${status}`);
if (status !== 200) {
const body = await response.text();
testLogger.warn(`Delete returned non-200: ${body}`);
}
return response;
}
/**
* Resolve stacktrace via sourcemap API
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - Resolution configuration
* @param {string} stacktrace - Minified stacktrace
* @returns {Promise<Object>} Resolved stacktrace
*/
async function resolveStacktrace(request, config, stacktrace) {
const { baseUrl, org, auth, service, env, version } = config;
testLogger.info(`Resolving stacktrace for ${service}:${version}`);
const response = await request.post(`${baseUrl}/api/${org}/sourcemaps/stacktrace`, {
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json'
},
data: {
service,
env,
version,
stacktrace
}
});
if (response.status() !== 200) {
const body = await response.text();
testLogger.error(`Resolution failed: ${body}`);
throw new Error(`Resolution failed with status ${response.status()}`);
}
return await response.json();
}
/**
* Get RUM token from OpenObserve
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - Configuration
* @returns {Promise<string>} RUM token
*/
async function getRumToken(request, config) {
const { baseUrl, org, auth } = config;
testLogger.info('Fetching RUM token...');
const response = await request.get(`${baseUrl}/api/${org}/rumtoken`, {
headers: { 'Authorization': `Basic ${auth}` }
});
if (response.status() !== 200) {
const body = await response.text();
testLogger.error(`Failed to get RUM token: ${body}`);
throw new Error(`Failed to get RUM token: ${response.status()}`);
}
const data = await response.json();
const token = data.data?.rum_token || data.rum_token;
testLogger.info(`RUM token retrieved`);
return token;
}
/**
* Generate expected stacktrace with actual content hashes
* @param {string} errorType - Error type (e.g., 'typeError')
* @param {Object} hashes - Content hashes from build
* @returns {string} Stacktrace with actual hashes
*/
function getExpectedStacktrace(errorType, hashes) {
const templates = {
typeError: `TypeError: Cannot read properties of undefined (reading 'profile')
at Ht @ http://localhost:8089/main.${hashes.main}.js:1:186484
at http://localhost:8089/main.${hashes.main}.js:1:186818`,
referenceError: `ReferenceError: undeclaredVariableThatDoesNotExist is not defined
at Zt @ http://localhost:8089/main.${hashes.main}.js:1:186511`,
rangeError: `RangeError: Invalid array length
at Kt @ http://localhost:8089/main.${hashes.main}.js:1:186576`,
customError: `PaymentError: Insufficient funds in account
at Jt @ http://localhost:8089/main.${hashes.main}.js:1:186730`,
crossChunk: `TypeError: Cannot read properties of null (reading 'timestamp')
at u @ http://localhost:8089/lazy-module.${hashes.lazyModule}.js:1:131
at http://localhost:8089/main.${hashes.main}.js:1:186963`
};
if (!templates[errorType]) {
throw new Error(`Unknown error type: ${errorType}`);
}
return templates[errorType];
}
/**
* Trigger errors in test app via Playwright
* @param {Object} page - Playwright page
* @param {string} testAppUrl - Test app URL
* @returns {Promise<void>}
*/
async function triggerErrors(page, testAppUrl) {
testLogger.info(`Navigating to test app: ${testAppUrl}`);
await page.goto(testAppUrl);
await page.waitForLoadState('networkidle', { timeout: 10000 });
// Wait for SDK to initialize
await page.waitForTimeout(2000);
// Click all error buttons
const errorButtons = [
'#btn-type-error',
'#btn-ref-error',
'#btn-range-error',
'#btn-custom-error',
'#btn-nested-error'
// Note: promise-error, async-error, and lazy-error are caught by .catch() handlers
];
for (const buttonId of errorButtons) {
testLogger.info(`Clicking ${buttonId}...`);
await page.locator(buttonId).click();
await page.waitForTimeout(500); // Small delay between clicks
}
testLogger.info('All errors triggered');
// Wait for SDK to flush (30 seconds)
testLogger.info('Waiting 30 seconds for SDK to flush events...');
await page.waitForTimeout(30000);
testLogger.info('Error ingestion complete');
}
/**
* Create test user for RBAC testing
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - Configuration
* @param {string} role - User role (viewer, editor, admin)
* @returns {Promise<Object>} User info
*/
async function createTestUser(request, config, role) {
const { baseUrl, org, auth } = config;
const email = `test-${role}-${Date.now()}@sourcemap-test.local`;
const password = 'TestPass123!';
testLogger.info(`Creating test user: ${email} with role: ${role}`);
const response = await request.post(`${baseUrl}/api/${org}/users`, {
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json'
},
data: {
email,
password,
first_name: 'Test',
last_name: role.charAt(0).toUpperCase() + role.slice(1),
role
}
});
const status = response.status();
if (status !== 200 && status !== 201) {
const body = await response.text();
testLogger.error(`Failed to create user: ${body}`);
throw new Error(`Failed to create user: ${status}`);
}
testLogger.info(`User created successfully: ${email}`);
return {
email,
password,
role,
auth: Buffer.from(`${email}:${password}`).toString('base64')
};
}
/**
* Delete test user
* @param {Object} request - Playwright APIRequestContext
* @param {Object} config - Configuration
* @param {string} email - User email
* @returns {Promise<void>}
*/
async function deleteTestUser(request, config, email) {
const { baseUrl, org, auth } = config;
testLogger.info(`Deleting test user: ${email}`);
const response = await request.delete(
`${baseUrl}/api/${org}/users/${encodeURIComponent(email)}`,
{
headers: { 'Authorization': `Basic ${auth}` }
}
);
const status = response.status();
if (status !== 200) {
const body = await response.text();
testLogger.warn(`Delete user returned ${status}: ${body}`);
} else {
testLogger.info(`User deleted: ${email}`);
}
}
module.exports = {
buildTestApp,
serveTestApp,
stopTestAppServer,
uploadSourcemaps,
listSourcemaps,
deleteSourcemaps,
resolveStacktrace,
getRumToken,
getExpectedStacktrace,
triggerErrors,
createTestUser,
deleteTestUser
};