v1.1: 实时日志系统 + SSE流式输出 + 上传进度 + 平台展示

- 新增 SSE 流式接口 /api/test/stream,测试过程实时推送日志
- 前端日志面板:终端风格,每步骤实时输出(info/success/warn/error)
- 文件上传进度条:逐文件读取显示进度百分比
- 平台展示卡片:点击测试时显示所有支持的平台信息
- 新增 /api/platforms 接口返回三大平台完整信息
- 增量渲染:每个插件测试完成后立即显示结果卡片
- 日志支持清空操作
- 六级音质:128k/192k/320k/FLAC/Hi-Res/Master
This commit is contained in:
Koneko Tester
2026-08-04 14:41:21 +08:00
commit 99d813cedf
9 changed files with 2683 additions and 0 deletions

198
lib/detector.js Normal file
View File

@@ -0,0 +1,198 @@
/**
* 插件格式自动检测模块
* 支持检测: Lx Music / QZV2 / MusicFree 三种插件格式
*/
const PLATFORMS = {
LX: 'lx',
QZV2: 'qzv2',
MUSICFREE: 'musicfree',
UNKNOWN: 'unknown'
};
const PLATFORM_LABELS = {
lx: '落雪音乐 (Lx Music)',
qzv2: '清泽音乐 (QZV2)',
musicfree: 'MusicFree',
unknown: '未知格式'
};
/** 所有支持的平台信息(用于前端展示) */
const ALL_PLATFORMS = [
{
id: 'lx',
name: '落雪音乐',
fullName: 'Lx Music',
format: 'JavaScript 脚本',
qualities: ['128k', '192k', '320k', 'flac', 'flac24bit', 'master'],
features: ['musicUrl', 'lyric', 'pic'],
sources: ['kw', 'kg', 'tx', 'wy', 'mg', 'local'],
detectionHints: ['globalThis.lx', 'EVENT_NAMES', '@name 注释头'],
docUrl: 'https://lxmusic.toside.cn/desktop/custom-source'
},
{
id: 'qzv2',
name: '清泽音乐',
fullName: 'QZ Music v2',
format: 'JS 模块 (ZIP/JS)',
qualities: ['low', 'standard', 'exhigh', 'high', 'lossless', 'hires', 'master'],
features: ['search', 'getUrl', 'getLyric', 'getPlaylist', 'getAlbum'],
sources: ['自定义'],
detectionHints: ['pluginInfo', 'getUrl', 'supportFunc'],
docUrl: 'https://music.qz.shiqianjiang.cn/'
},
{
id: 'musicfree',
name: 'MusicFree',
fullName: 'MusicFree',
format: 'CommonJS 模块',
qualities: ['low', 'standard', 'high', 'super', 'hires', 'master'],
features: ['search', 'getMediaSource', 'getMusicInfo', 'getLyric', 'getAlbumInfo', 'getMusicSheetInfo', 'getArtistWorks', 'getTopLists', 'getRecommendSheetTags', 'getMusicComments'],
sources: ['自定义'],
detectionHints: ['platform:', 'getMediaSource', 'supportedSearchType'],
docUrl: 'https://musicfree.catcat.work/plugin/protocol.html'
}
];
/**
* 检测插件格式
*/
function detectPlatform(code, filename = '') {
const scores = { lx: 0, qzv2: 0, musicfree: 0 };
// === Lx Music ===
if (/\/\*\*[\s\S]*?@name\s+/m.test(code)) scores.lx += 2;
if (/globalThis\.lx\b/.test(code)) scores.lx += 3;
if (/EVENT_NAMES/.test(code)) scores.lx += 2;
if (/send\s*\(\s*EVENT_NAMES\.inited/.test(code)) scores.lx += 3;
if (/lx\.(request|on|send)\b/.test(code)) scores.lx += 2;
if (/['"`]128k['"`]|['"`]320k['"`]|['"`]flac24bit['"`]/.test(code)) scores.lx += 1;
if (/sources\s*:\s*\{/.test(code) && /qualitys/.test(code)) scores.lx += 2;
// === QZV2 ===
if (/pluginInfo\s*[:?]/.test(code)) scores.qzv2 += 3;
if (/getUrl\s*[:(]/.test(code)) scores.qzv2 += 2;
if (/supportFunc/.test(code)) scores.qzv2 += 2;
if (/getPlay(list|List)\s*[:(]/.test(code)) scores.qzv2 += 2;
if (/musicSearch/.test(code)) scores.qzv2 += 2;
if (/info\s*:\s*\{[\s\S]*?id\s*:/.test(code) && /info\s*:\s*\{[\s\S]*?name\s*:/.test(code)) scores.qzv2 += 1;
if (/allPage/.test(code)) scores.qzv2 += 1;
// === MusicFree ===
if (/platform\s*:\s*["'`]/.test(code)) scores.musicfree += 2;
if (/getMediaSource\s*[:(]/.test(code)) scores.musicfree += 3;
if (/getMusicInfo\s*[:(]/.test(code)) scores.musicfree += 2;
if (/getAlbumInfo|getMusicSheetInfo/.test(code)) scores.musicfree += 2;
if (/supportedSearchType/.test(code)) scores.musicfree += 2;
if (/cacheControl/.test(code)) scores.musicfree += 1;
if (/srcUrl/.test(code)) scores.musicfree += 1;
if (/primaryKey/.test(code)) scores.musicfree += 1;
if (/getTopLists|getRecommendSheetTags/.test(code)) scores.musicfree += 2;
// 排除规则
if (scores.musicfree >= 3 && scores.qzv2 > 0) {
if (/getMediaSource/.test(code) && !/pluginInfo/.test(code)) {
scores.qzv2 = Math.max(0, scores.qzv2 - 2);
}
}
let bestPlatform = PLATFORMS.UNKNOWN;
let bestScore = 0;
for (const [platform, score] of Object.entries(scores)) {
if (score > bestScore) { bestScore = score; bestPlatform = platform; }
}
if (bestScore < 2) bestPlatform = PLATFORMS.UNKNOWN;
const metadata = extractMetadata(code, bestPlatform, filename);
return { platform: bestPlatform, platformLabel: PLATFORM_LABELS[bestPlatform], metadata, confidence: bestScore, scores };
}
function extractMetadata(code, platform, filename) {
const metadata = {
name: '', version: '', author: '', description: '', homepage: '',
quality: [], features: [], sources: [], apiUrls: []
};
const urlMatches = code.match(/https?:\/\/[^\s"'`<>)}\]]+/g) || [];
metadata.apiUrls = [...new Set(urlMatches)].slice(0, 20);
if (platform === 'lx') {
const nameMatch = code.match(/@name\s+(.+)/); if (nameMatch) metadata.name = nameMatch[1].trim();
const descMatch = code.match(/@description\s+(.+)/); if (descMatch) metadata.description = descMatch[1].trim();
const versionMatch = code.match(/@version\s+(.+)/); if (versionMatch) metadata.version = versionMatch[1].trim();
const authorMatch = code.match(/@author\s+(.+)/); if (authorMatch) metadata.author = authorMatch[1].trim();
const homeMatch = code.match(/@homepage\s+(.+)/); if (homeMatch) metadata.homepage = homeMatch[1].trim();
const qualitySet = new Set();
if (/['"`]128k['"`]/.test(code)) qualitySet.add('128k');
if (/['"`]192k['"`]/.test(code)) qualitySet.add('192k');
if (/['"`]320k['"`]/.test(code)) qualitySet.add('320k');
if (/['"`]flac24bit['"`]/.test(code)) qualitySet.add('flac24bit');
if (/['"`]flac['"`]/.test(code)) qualitySet.add('flac');
if (/['"`]hires['"`]/.test(code)) qualitySet.add('hires');
if (/['"`]master['"`]/.test(code)) qualitySet.add('master');
metadata.quality = [...qualitySet];
if (/musicUrl/.test(code)) metadata.features.push('musicUrl');
if (/lyric/.test(code)) metadata.features.push('lyric');
if (/pic/.test(code)) metadata.features.push('pic');
const sourcePattern = /\b(kw|kg|tx|wy|mg|local)\b\s*:/g;
let match; const sourceSet = new Set();
while ((match = sourcePattern.exec(code)) !== null) sourceSet.add(match[1]);
metadata.sources = [...sourceSet];
} else if (platform === 'qzv2') {
const nameMatch = code.match(/name\s*:\s*["'`]([^"'`]+)["'`]/); if (nameMatch) metadata.name = nameMatch[1];
const idMatch = code.match(/id\s*:\s*["'`]([^"'`]+)["'`]/); if (idMatch) metadata.description = `ID: ${idMatch[1]}`;
const descMatch = code.match(/description\s*:\s*["'`]([^"'`]+)["'`]/); if (descMatch) metadata.description = descMatch[1];
const versionMatch = code.match(/version\s*:\s*["'`]?(["'`]?)([\d.]+)/); if (versionMatch) metadata.version = versionMatch[2];
const qualityMatch = code.match(/quality\s*:\s*\[([^\]]+)\]/);
if (qualityMatch) {
const qItems = qualityMatch[1].match(/["'`]([^"'`]+)["'`]/g);
if (qItems) metadata.quality = qItems.map(q => q.replace(/["'`]/g, ''));
}
const supportFuncMatch = code.match(/supportFunc\s*:\s*\[([^\]]+)\]/);
if (supportFuncMatch) {
const fItems = supportFuncMatch[1].match(/["'`]([^"'`]+)["'`]/g);
if (fItems) metadata.features = fItems.map(f => f.replace(/["'`]/g, ''));
}
if (/getUrl/.test(code) && !metadata.features.includes('getUrl')) metadata.features.push('getUrl');
if (/getLyric/.test(code) && !metadata.features.includes('getLyric')) metadata.features.push('getLyric');
if (/search/.test(code) && !metadata.features.includes('search')) metadata.features.push('search');
} else if (platform === 'musicfree') {
const platformMatch = code.match(/platform\s*:\s*["'`]([^"'`]+)["'`]/); if (platformMatch) metadata.name = platformMatch[1];
const versionMatch = code.match(/version\s*:\s*["'`]([^"'`]+)["'`]/); if (versionMatch) metadata.version = versionMatch[1];
const authorMatch = code.match(/author\s*:\s*["'`]([^"'`]+)["'`]/); if (authorMatch) metadata.author = authorMatch[1];
const srcUrlMatch = code.match(/srcUrl\s*:\s*["'`]([^"'`]+)["'`]/); if (srcUrlMatch) metadata.homepage = srcUrlMatch[1];
const qualitySet = new Set();
if (/['"`]low['"`]/.test(code)) qualitySet.add('low');
if (/['"`]standard['"`]/.test(code)) qualitySet.add('standard');
if (/['"`]high['"`]/.test(code)) qualitySet.add('high');
if (/['"`]super['"`]/.test(code)) qualitySet.add('super');
if (/['"`]hires['"`]/.test(code)) qualitySet.add('hires');
if (/['"`]master['"`]/.test(code)) qualitySet.add('master');
metadata.quality = [...qualitySet];
const featureMap = [
['search', 'search'], ['getMediaSource', 'getMediaSource'], ['getMusicInfo', 'getMusicInfo'],
['getLyric', 'getLyric'], ['getAlbumInfo', 'getAlbumInfo'], ['getMusicSheetInfo', 'getMusicSheetInfo'],
['getArtistWorks', 'getArtistWorks'], ['importMusicItem', 'importMusicItem'],
['importMusicSheet', 'importMusicSheet'], ['getTopLists', 'getTopLists'],
['getTopListDetail', 'getTopListDetail'], ['getRecommendSheetTags', 'getRecommendSheetTags'],
['getRecommendSheetsByTag', 'getRecommendSheetsByTag'], ['getMusicComments', 'getMusicComments']
];
for (const [pattern, label] of featureMap) {
if (new RegExp(pattern).test(code)) metadata.features.push(label);
}
}
if (!metadata.name && filename) metadata.name = filename.replace(/\.(js|zip)$/i, '');
return metadata;
}
module.exports = { detectPlatform, PLATFORMS, PLATFORM_LABELS, ALL_PLATFORMS };

263
lib/tester.js Normal file
View File

@@ -0,0 +1,263 @@
/**
* 插件测试模块 - 支持日志回调
* 每个步骤都会通过 logger 回调实时输出
*/
const http = require('http');
const https = require('https');
const { URL } = require('url');
const { detectPlatform, PLATFORMS } = require('./detector');
function fetchPluginCode(url, timeout = 15000) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const client = parsedUrl.protocol === 'https:' ? https : http;
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: 'GET',
headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/1.1', 'Accept': '*/*' },
timeout
};
const req = client.request(options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return fetchPluginCode(new URL(res.headers.location, url).href, timeout).then(resolve).catch(reject);
}
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
let data = ''; let dataBytes = Buffer.alloc(0);
res.on('data', (chunk) => { typeof chunk === 'string' ? data += chunk : dataBytes = Buffer.concat([dataBytes, chunk]); });
res.on('end', () => {
const content = data || dataBytes.toString('utf-8');
resolve({ code: content, statusCode: res.statusCode, headers: res.headers, size: Buffer.byteLength(content) });
});
});
req.on('timeout', () => { req.destroy(); reject(new Error('下载超时')); });
req.on('error', reject);
req.end();
});
}
function testUrlLatency(url, timeout = 10000) {
return new Promise((resolve) => {
const startTime = Date.now(); let resolved = false;
const finish = (result) => { if (!resolved) { resolved = true; resolve(result); } };
try {
const parsedUrl = new URL(url);
const client = parsedUrl.protocol === 'https:' ? https : http;
const options = {
hostname: parsedUrl.hostname, port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search, method: 'GET',
headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/1.1', 'Accept': '*/*', 'Range': 'bytes=0-1023' },
timeout
};
const req = client.request(options, (res) => {
const latency = Date.now() - startTime;
let bodySize = 0;
res.on('data', (chunk) => { bodySize += chunk.length; if (bodySize > 4096) res.destroy(); });
res.on('end', () => finish({ url, status: res.statusCode, latency, success: res.statusCode >= 200 && res.statusCode < 400, contentType: res.headers['content-type'] || '', contentLength: parseInt(res.headers['content-length'] || '0', 10), error: null }));
res.on('error', () => finish({ url, status: res.statusCode, latency, success: false, contentType: '', contentLength: 0, error: '响应读取错误' }));
});
req.on('timeout', () => { req.destroy(); finish({ url, status: 0, latency: timeout, success: false, contentType: '', contentLength: 0, error: '请求超时' }); });
req.on('error', (err) => finish({ url, status: 0, latency: Date.now() - startTime, success: false, contentType: '', contentLength: 0, error: err.message }));
req.end();
} catch (err) {
finish({ url, status: 0, latency: 0, success: false, contentType: '', contentLength: 0, error: err.message });
}
});
}
/**
* 测试插件主入口 - 带日志回调
* @param {string} url
* @param {string} customCode
* @param {string} filename
* @param {function} logger - 日志回调 (level, message, data)
*/
async function testPlugin(url, customCode = null, filename = '', logger = null) {
const log = (level, message, data) => { if (logger) logger(level, message, data); };
const result = {
url: url || filename, filename,
platform: 'unknown', platformLabel: '未知格式',
metadata: {}, downloadInfo: null, latencyTests: [], qualityTests: [],
overallStatus: 'pending', summary: {}, testedAt: new Date().toISOString()
};
let code = customCode;
// 步骤1: 下载/读取
log('info', `开始处理: ${filename || url || '未知'}`);
if (!code && url) {
log('info', `正在下载插件文件...`);
try {
const downloadStart = Date.now();
const response = await fetchPluginCode(url);
const downloadLatency = Date.now() - downloadStart;
code = response.code;
result.downloadInfo = { success: true, latency: downloadLatency, size: response.size, statusCode: response.statusCode, contentType: response.headers['content-type'] || '' };
log('success', `下载完成 | 大小: ${formatSize(response.size)} | 耗时: ${downloadLatency}ms`);
} catch (err) {
result.downloadInfo = { success: false, latency: 0, error: err.message };
result.overallStatus = 'failed';
result.summary = { error: `插件下载失败: ${err.message}`, score: 0 };
log('error', `下载失败: ${err.message}`);
return result;
}
} else if (code) {
result.downloadInfo = { success: true, latency: 0, size: Buffer.byteLength(code), statusCode: 0, contentType: 'text/plain' };
log('success', `文件已读取 | 大小: ${formatSize(Buffer.byteLength(code))}`);
}
if (!code) {
result.overallStatus = 'failed';
result.summary = { error: '无插件代码', score: 0 };
log('error', '无插件代码');
return result;
}
// 步骤2: 格式检测
log('info', `正在识别插件格式...`);
const detection = detectPlatform(code, filename);
result.platform = detection.platform;
result.platformLabel = detection.platformLabel;
result.metadata = detection.metadata;
result.detectionScores = detection.scores;
if (detection.platform !== 'unknown') {
log('success', `格式识别: ${detection.platformLabel} (置信度: ${detection.confidence})`);
if (detection.metadata.name) log('info', `插件名称: ${detection.metadata.name}`);
if (detection.metadata.version) log('info', `版本: ${detection.metadata.version}`);
if (detection.metadata.author) log('info', `作者: ${detection.metadata.author}`);
} else {
log('warn', `未能识别插件格式,得分: lx=${detection.scores.lx}, qzv2=${detection.scores.qzv2}, musicfree=${detection.scores.musicfree}`);
}
// 步骤3: 功能分析
if (detection.metadata.features && detection.metadata.features.length > 0) {
log('info', `功能列表: ${detection.metadata.features.join(', ')}`);
}
if (detection.metadata.sources && detection.metadata.sources.length > 0) {
log('info', `音源: ${detection.metadata.sources.join(', ').toUpperCase()}`);
}
// 步骤4: 延时测试
const apiUrls = detection.metadata.apiUrls || [];
const testUrls = apiUrls
.filter(u => !u.match(/\.(png|jpg|jpeg|gif|svg|ico|css|woff|ttf)$/i))
.filter(u => !u.includes('catcat.work'))
.filter(u => !u.includes('w3.org'))
.filter(u => !u.includes('schema.org'))
.slice(0, 5);
if (url && !customCode) {
log('info', `测试插件源 URL 可达性...`);
const pluginUrlTest = await testUrlLatency(url);
result.latencyTests.push({ ...pluginUrlTest, isPluginUrl: true });
log(pluginUrlTest.success ? 'success' : 'warn', `插件源 URL | 状态: ${pluginUrlTest.status} | 延时: ${pluginUrlTest.latency}ms`);
}
if (testUrls.length > 0) {
log('info', `开始延时测试,共 ${testUrls.length} 个 API 端点...`);
for (let i = 0; i < testUrls.length; i++) {
const testUrl = testUrls[i];
log('info', `[${i + 1}/${testUrls.length}] 测试: ${truncate(testUrl, 60)}`);
}
const latencyPromises = testUrls.map(u => testUrlLatency(u));
const latencyResults = await Promise.all(latencyPromises);
result.latencyTests.push(...latencyResults);
const successCount = latencyResults.filter(t => t.success).length;
if (successCount > 0) {
const avgLatency = Math.round(latencyResults.filter(t => t.success).reduce((sum, t) => sum + t.latency, 0) / successCount);
log('success', `延时测试完成 | 成功: ${successCount}/${testUrls.length} | 平均延时: ${avgLatency}ms`);
} else {
log('warn', `延时测试完成 | 所有端点均不可达`);
}
} else if (!url || customCode) {
log('info', `未发现可测试的 API 端点`);
}
// 步骤5: 音质分析
const declaredQualities = detection.metadata.quality || [];
const qualityMap = getQualityMap(detection.platform);
result.qualityTests = declaredQualities.map(q => ({ quality: q, label: qualityMap[q] || q, declared: true, testable: testUrls.length > 0 }));
if (result.qualityTests.length === 0 && detection.platform !== 'unknown') {
const defaults = getDefaultQualities(detection.platform);
result.qualityTests = defaults.map(q => ({ quality: q, label: qualityMap[q] || q, declared: false, testable: testUrls.length > 0 }));
log('info', `未声明音质,使用默认音质列表: ${defaults.join(', ')}`);
} else if (declaredQualities.length > 0) {
log('success', `支持音质: ${declaredQualities.map(q => `${q}(${qualityMap[q] || q})`).join(', ')}`);
}
// 步骤6: 评分
log('info', `正在计算综合评分...`);
result.summary = calculateSummary(result);
result.overallStatus = result.summary.score >= 60 ? 'passed' : (result.summary.score > 0 ? 'warning' : 'failed');
log('success', `测试完成 | 评分: ${result.summary.score}/100 | 等级: ${result.summary.details?.grade || 'D'} | 状态: ${result.overallStatus === 'passed' ? '通过' : (result.overallStatus === 'warning' ? '警告' : '失败')}`);
return result;
}
function getQualityMap(platform) {
const maps = {
lx: { '128k': '标准 128kbps', '192k': '标准 192kbps', '320k': '高品质 320kbps', 'flac': '无损 FLAC', 'flac24bit': 'Hi-Res FLAC 24bit', 'hires': 'Hi-Res', 'master': '母带 Master' },
qzv2: { 'low': '低音质', 'standard': '标准音质', 'exhigh': '极高音质', 'high': '高音质', 'lossless': '无损音质', 'hires': 'Hi-Res', 'master': '母带 Master' },
musicfree: { 'low': '低音质', 'standard': '标准音质', 'high': '高音质', 'super': '超高音质', 'hires': 'Hi-Res', 'master': '母带 Master' }
};
return maps[platform] || {};
}
function getDefaultQualities(platform) {
const defaults = {
lx: ['128k', '192k', '320k', 'flac', 'flac24bit', 'master'],
qzv2: ['standard', 'exhigh', 'high', 'lossless', 'hires', 'master'],
musicfree: ['low', 'standard', 'high', 'super', 'hires', 'master']
};
return defaults[platform] || [];
}
function calculateSummary(result) {
let score = 0; const details = {};
if (result.downloadInfo && result.downloadInfo.success) { score += 20; details.download = '成功'; }
else if (result.downloadInfo && !result.downloadInfo.success) { details.download = '失败'; return { score: 0, details, error: result.downloadInfo.error }; }
else { score += 20; details.download = '文件上传'; }
if (result.platform !== 'unknown') { score += 20; details.format = result.platformLabel; } else { details.format = '未识别'; }
const successLatency = result.latencyTests.filter(t => t.success);
if (result.latencyTests.length > 0) {
if (successLatency.length > 0) {
const avgLatency = successLatency.reduce((sum, t) => sum + t.latency, 0) / successLatency.length;
details.avgLatency = Math.round(avgLatency); details.successRate = `${successLatency.length}/${result.latencyTests.length}`;
if (avgLatency < 300) score += 30; else if (avgLatency < 800) score += 25; else if (avgLatency < 1500) score += 18; else if (avgLatency < 3000) score += 10; else score += 5;
} else { details.avgLatency = -1; details.successRate = `0/${result.latencyTests.length}`; }
} else { score += 15; details.avgLatency = -1; details.successRate = 'N/A'; }
if (result.qualityTests.length > 0) {
const declaredCount = result.qualityTests.filter(q => q.declared).length;
score += Math.min(15, declaredCount * 5);
details.qualityCount = result.qualityTests.length; details.qualities = result.qualityTests.map(q => q.quality).join(', ');
} else { details.qualityCount = 0; }
if (result.metadata.features && result.metadata.features.length > 0) {
score += Math.min(15, result.metadata.features.length * 3);
details.features = result.metadata.features.join(', ');
}
details.score = Math.min(100, score);
if (details.score >= 85) details.grade = 'S'; else if (details.score >= 70) details.grade = 'A';
else if (details.score >= 55) details.grade = 'B'; else if (details.score >= 40) details.grade = 'C'; else details.grade = 'D';
return { score: Math.min(100, score), details };
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + 'B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB';
return (bytes / 1048576).toFixed(1) + 'MB';
}
function truncate(str, max) {
if (!str) return ''; if (str.length <= max) return str; return str.substring(0, max - 3) + '...';
}
module.exports = { testPlugin, fetchPluginCode, testUrlLatency };