super-pancake-automation
Version:
A lightweight DOM-based UI automation framework using Chrome DevTools Protocol
356 lines (302 loc) • 10.4 kB
JavaScript
import axios from 'axios';
import Ajv from 'ajv';
import {
SuperPancakeError,
ValidationError,
SecurityError,
validateText
} from './errors.js';
let authToken = null;
// Set a global auth token
export function setAuthToken(token) {
if (token !== null && typeof token !== 'string') {
throw new ValidationError('token', 'string or null', typeof token);
}
authToken = token;
}
// Merge headers with auth token if present
function withAuth(headers) {
return authToken ? { ...headers, Authorization: `Bearer ${authToken}` } : headers;
}
// Build headers with content type and additional custom headers
export function buildHeaders(additionalHeaders = {}) {
return {
'Content-Type': 'application/json',
...additionalHeaders
};
}
// Build URL with query parameters
export function buildUrlWithParams(url, params = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
// Basic URL validation
try {
new URL(url);
} catch {
throw new ValidationError('url', 'valid URL', url);
}
if (params && typeof params !== 'object') {
throw new ValidationError('params', 'object', typeof params);
}
const query = new URLSearchParams(params).toString();
return query ? `${url}?${query}` : url;
}
// Wrap a request to measure duration
export async function timedRequest(fn) {
const start = Date.now();
const response = await fn();
const duration = Date.now() - start;
// Only add duration if response is an object
if (response && typeof response === 'object' && !Array.isArray(response)) {
response.duration = duration;
}
console.log(`🕒 ${fn.name || 'anonymous'} duration: ${duration}ms`);
return response;
}
// Retry a request with exponential backoff
export async function retryRequest(requestFn, retries = 3, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await requestFn();
} catch (err) {
if (attempt === retries) {throw err;}
await new Promise(res => setTimeout(res, delay));
}
}
}
// HTTP Methods
export async function sendGet(url, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.get(url, { headers: withAuth(headers) }));
}
export async function sendPost(url, body = {}, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.post(url, body, { headers: withAuth(headers) }));
}
export async function sendPut(url, body = {}, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.put(url, body, { headers: withAuth(headers) }));
}
export async function sendPatch(url, body = {}, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.patch(url, body, { headers: withAuth(headers) }));
}
export async function sendDelete(url, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.delete(url, { headers: withAuth(headers) }));
}
export async function sendOptions(url, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.options(url, { headers: withAuth(headers) }));
}
// HEAD request
export async function sendHead(url, headers = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('url', 'non-empty string', typeof url);
}
return await timedRequest(() => axios.head(url, { headers: withAuth(headers) }));
}
// Assertions
export function assertStatus(response, expectedStatus) {
if (response.status !== expectedStatus) {
throw new Error(`❌ Expected status ${expectedStatus}, got ${response.status}`);
}
}
export function assertHeader(response, headerName, expectedValue) {
const value = response.headers[headerName.toLowerCase()];
if (value !== expectedValue) {
throw new Error(`❌ Expected header '${headerName}' to be '${expectedValue}', got '${value}'`);
}
}
export function assertBodyContains(response, key, expectedValue) {
const actualValue = response.data[key];
if (actualValue !== expectedValue) {
throw new Error(`❌ Expected response.${key} = '${expectedValue}', got '${actualValue}'`);
}
}
export function assertResponseTime(response, maxMs) {
if (response.duration > maxMs) {
throw new Error(`⏱ Response too slow: ${response.duration}ms (limit: ${maxMs}ms)`);
}
}
// Schema validation
export function validateSchema(responseBody, schema) {
const ajv = new Ajv();
const validate = ajv.compile(schema);
const valid = validate(responseBody);
if (!valid) {
throw new Error(`❌ Schema validation failed:\n${JSON.stringify(validate.errors, null, 2)}`);
}
}
// Log full response data
export function logResponse(response) {
console.log('🔍 Response:', JSON.stringify({
status: response.status,
headers: response.headers,
body: response.data
}, null, 2));
}
// JSONPath assertion (simplified implementation)
export async function assertJsonPath(responseBody, path, expectedValue) {
try {
// Simple path traversal for basic JSONPath expressions like $.address.city
let actual = responseBody;
// Remove leading $ and split by dots
const pathParts = path.replace(/^\$\.?/, '').split('.');
// Traverse the object
for (const part of pathParts) {
if (actual && typeof actual === 'object' && part in actual) {
actual = actual[part];
} else {
throw new Error(`❌ JSONPath ${path} not found in response`);
}
}
// Check if the value matches
if (actual !== expectedValue) {
throw new Error(`❌ JSONPath ${path} expected '${expectedValue}' but got '${actual}'`);
}
console.log(`✅ JSONPath ${path} assertion passed: ${actual}`);
} catch (error) {
throw new Error(`❌ JSONPath assertion failed: ${error.message}`);
}
}
// Assert rate limit headers
export function assertRateLimitHeaders(response) {
['x-ratelimit-limit', 'x-ratelimit-remaining'].forEach(h => {
if (!(h in response.headers)) {
throw new Error(`❌ Missing rate limit header: ${h}`);
}
});
}
import FormData from 'form-data';
import fs from 'fs';
// Multipart/form-data file upload
export async function uploadFile(url, filePath, fieldName = 'file', headers = {}) {
const form = new FormData();
form.append(fieldName, fs.createReadStream(filePath));
const finalHeaders = withAuth({ ...headers, ...form.getHeaders() });
return await timedRequest(() =>
axios.post(url, form, {
headers: finalHeaders,
maxContentLength: Infinity,
maxBodyLength: Infinity
})
);
}
// GraphQL support
export async function sendGraphQL(url, query, variables = {}, headers = {}) {
const body = { query, variables };
return await timedRequest(() =>
axios.post(url, body, {
headers: withAuth({
'Content-Type': 'application/json',
...headers
})
})
);
}
// Token refresh handling (automatic retry on 401)
let refreshingToken = false;
let refreshSubscribers = [];
function subscribeTokenRefresh(callback) {
refreshSubscribers.push(callback);
}
function onRefreshed(newToken) {
refreshSubscribers.forEach(cb => cb(newToken));
refreshSubscribers = [];
}
// Interceptor for refreshing token on 401
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (!refreshingToken) {
refreshingToken = true;
try {
const { data } = await axios.post('/auth/refresh-token');
const newToken = data.token;
setAuthToken(newToken);
onRefreshed(newToken);
} catch (refreshError) {
refreshingToken = false;
return Promise.reject(refreshError);
}
refreshingToken = false;
}
return new Promise(resolve => {
subscribeTokenRefresh(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
originalRequest._retry = true;
resolve(axios(originalRequest));
});
});
}
return Promise.reject(error);
}
);
// Dynamic base URL
export const apiConfig = {
baseUrl: process.env.API_BASE_URL || 'https://default.api.com'
};
// Prepend base URL to relative paths
export function withBaseUrl(path) {
if (!path || typeof path !== 'string') {
throw new ValidationError('path', 'non-empty string', typeof path);
}
return path.startsWith('http') ? path : `${apiConfig.baseUrl}${path}`;
}
// OAuth2 Token Flow (client_credentials)
export async function getOAuth2Token(tokenUrl, clientId, clientSecret, scope = '') {
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', clientId);
params.append('client_secret', clientSecret);
if (scope) {params.append('scope', scope);}
try {
const response = await axios.post(tokenUrl, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const token = response.data.access_token;
setAuthToken(token);
return token;
} catch (err) {
throw new Error(`❌ OAuth2 token fetch failed: ${err.message}`);
}
}
// WebSocket support
import WebSocket from 'ws';
export function createWebSocketConnection(url, onMessage, onOpen = null, onError = null) {
const ws = new WebSocket(url);
ws.on('open', () => {
console.log('🔌 WebSocket connected');
if (onOpen) {onOpen(ws);}
});
ws.on('message', data => {
console.log('📨 WebSocket message received:', data.toString());
if (onMessage) {onMessage(data.toString(), ws);}
});
ws.on('error', err => {
console.error('❌ WebSocket error:', err);
if (onError) {onError(err, ws);}
});
ws.on('close', () => {
console.log('🔌 WebSocket disconnected');
});
return ws;
}