const fs = require('fs/promises'); const fsSync = require('fs'); const path = require('path'); const os = require('os'); const { spawn } = require('child_process'); const { randomUUID } = require('crypto'); const DEFAULT_CONFIG = { wpHost: process.env.TEST_WP_HOST || process.env.EXTERNAL_WP_HOST || '', wpSshUser: process.env.TEST_WP_SSH_USER || process.env.EXTERNAL_WP_SSH_USER || 'wordpress', wpSshKey: process.env.TEST_WP_SSH_KEY || process.env.TEST_WP_SSH_KEY_PATH || process.env.EXTERNAL_WP_SSH_KEY || '', wpPath: process.env.TEST_WP_PATH || process.env.EXTERNAL_WP_PATH || '/var/www/html', wpBaseUrl: process.env.TEST_WP_BASE_URL || process.env.EXTERNAL_WP_BASE_URL || '', enableMultisite: (process.env.TEST_WP_MULTISITE || process.env.EXTERNAL_WP_MULTISITE || 'true') !== 'false', subsitePrefix: process.env.TEST_WP_SUBSITE_PREFIX || process.env.EXTERNAL_WP_SUBSITE_PREFIX || 'test', subsiteDomain: process.env.TEST_WP_SUBSITE_DOMAIN || process.env.EXTERNAL_WP_SUBSITE_DOMAIN || '', subsiteMode: (process.env.TEST_WP_SUBSITE_MODE || process.env.EXTERNAL_WP_SUBSITE_MODE || 'subdirectory').toLowerCase(), maxConcurrentTests: Number(process.env.TEST_MAX_CONCURRENT || process.env.EXTERNAL_WP_MAX_CONCURRENT || 20), queueTimeoutMs: Number(process.env.TEST_QUEUE_TIMEOUT || process.env.EXTERNAL_WP_QUEUE_TIMEOUT || 300000), testTimeoutMs: Number(process.env.TEST_TIMEOUT || process.env.EXTERNAL_WP_TEST_TIMEOUT || 600000), autoCleanup: (process.env.TEST_AUTO_CLEANUP || process.env.EXTERNAL_WP_AUTO_CLEANUP || 'true') !== 'false', cleanupDelayMs: Number(process.env.TEST_CLEANUP_DELAY || process.env.EXTERNAL_WP_CLEANUP_DELAY || 3600000), errorLogPath: process.env.TEST_WP_ERROR_LOG || process.env.EXTERNAL_WP_ERROR_LOG || '/var/log/wp-errors.log', strictHostKeyChecking: (process.env.TEST_SSH_STRICT || process.env.EXTERNAL_WP_SSH_STRICT || 'false') === 'true', }; function expandHome(p) { if (!p || typeof p !== 'string') return p; if (p.startsWith('~')) { return path.join(os.homedir(), p.slice(1)); } return p; } function getExternalTestingConfig(overrides = {}) { const config = { ...DEFAULT_CONFIG, ...(overrides || {}) }; config.wpSshKey = expandHome(config.wpSshKey); config.wpBaseUrl = (config.wpBaseUrl || '').trim(); if (!config.wpBaseUrl && config.wpHost) { config.wpBaseUrl = `https://${config.wpHost}`; } config.maxConcurrentTests = Number.isFinite(config.maxConcurrentTests) && config.maxConcurrentTests > 0 ? Math.floor(config.maxConcurrentTests) : 1; config.queueTimeoutMs = Number.isFinite(config.queueTimeoutMs) && config.queueTimeoutMs > 0 ? Math.floor(config.queueTimeoutMs) : 300000; config.testTimeoutMs = Number.isFinite(config.testTimeoutMs) && config.testTimeoutMs > 0 ? Math.floor(config.testTimeoutMs) : 600000; config.cleanupDelayMs = Number.isFinite(config.cleanupDelayMs) && config.cleanupDelayMs >= 0 ? Math.floor(config.cleanupDelayMs) : 3600000; config.subsiteMode = config.subsiteMode === 'subdomain' ? 'subdomain' : 'subdirectory'; return config; } function spawnCommand(command, args, options = {}) { return new Promise((resolve, reject) => { const start = Date.now(); const child = spawn(command, args, { ...options, windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', (error) => { reject(error); }); child.on('close', (code) => { resolve({ stdout, stderr, code: code ?? 0, durationMs: Date.now() - start }); }); }); } function buildSshBaseArgs(config) { const args = []; if (config.wpSshKey) { args.push('-i', config.wpSshKey); } if (!config.strictHostKeyChecking) { args.push('-o', 'StrictHostKeyChecking=no'); } args.push('-o', 'BatchMode=yes'); return args; } async function runSshCommand(config, command, options = {}) { const target = `${config.wpSshUser}@${config.wpHost}`; const args = [...buildSshBaseArgs(config), target, command]; return spawnCommand('ssh', args, { timeout: options.timeout || config.testTimeoutMs }); } async function runScpUpload(config, localPath, remotePath) { const args = [...buildSshBaseArgs(config), '-r', localPath, `${config.wpSshUser}@${config.wpHost}:${remotePath}`]; return spawnCommand('scp', args, { timeout: config.testTimeoutMs }); } function buildWpCliCommand(config, url, command) { return `wp --path=${config.wpPath} --url=${url} ${command}`; } function resolveSubsiteUrl(config, slug) { if (!config.enableMultisite) return config.wpBaseUrl; const domain = config.subsiteDomain || config.wpHost; if (config.subsiteMode === 'subdomain') { return `https://${slug}.${domain}`; } return `${config.wpBaseUrl.replace(/\/$/, '')}/${slug}`; } async function createSubsite(config, sessionId) { const slug = `${config.subsitePrefix}-${sessionId.slice(0, 8)}`; const title = `Test Environment ${sessionId}`; const adminEmail = 'test@example.com'; const createCmd = buildWpCliCommand( config, config.wpBaseUrl, `site create --slug=${slug} --title="${title}" --email=${adminEmail}` ); const createRes = await runSshCommand(config, createCmd, { timeout: config.testTimeoutMs }); if (createRes.code !== 0) { throw new Error(`Failed to create subsite: ${createRes.stderr || createRes.stdout}`); } const url = resolveSubsiteUrl(config, slug); // Configure permalink structure const permalinkCmd = buildWpCliCommand( config, url, 'option update permalink_structure "/%postname%/"' ); await runSshCommand(config, permalinkCmd, { timeout: config.testTimeoutMs }); // Flush rewrite rules const rewriteCmd = buildWpCliCommand( config, url, 'rewrite flush' ); await runSshCommand(config, rewriteCmd, { timeout: config.testTimeoutMs }); return { slug, url, adminUrl: `${url.replace(/\/$/, '')}/wp-admin` }; } async function deleteSubsite(config, slug) { if (!slug) return; const deleteCmd = buildWpCliCommand( config, config.wpBaseUrl, `site delete --slug=${slug} --yes` ); await runSshCommand(config, deleteCmd, { timeout: config.testTimeoutMs }); } async function installPluginFromLocal(config, pluginPath, pluginSlug, sessionId) { if (!pluginPath || !pluginSlug) throw new Error('Plugin path and slug are required'); const safeSlug = pluginSlug.replace(/[^a-zA-Z0-9\-_]/g, ''); const remoteBase = path.posix.join(config.wpPath, 'wp-content', 'plugins'); const tmpDir = path.posix.join(remoteBase, `${safeSlug}__tmp_${sessionId}`); const finalDir = path.posix.join(remoteBase, safeSlug); const cleanupTmp = async () => { const cmd = `rm -rf ${tmpDir}`; await runSshCommand(config, cmd, { timeout: config.testTimeoutMs }).catch(() => {}); }; await cleanupTmp(); const scpResult = await runScpUpload(config, pluginPath, tmpDir); if (scpResult.code !== 0) { throw new Error(`Failed to upload plugin: ${scpResult.stderr || scpResult.stdout}`); } const moveCmd = `rm -rf ${finalDir} && mv ${tmpDir} ${finalDir}`; const moveRes = await runSshCommand(config, moveCmd, { timeout: config.testTimeoutMs }); if (moveRes.code !== 0) { throw new Error(`Failed to install plugin: ${moveRes.stderr || moveRes.stdout}`); } } async function installRequiredPlugins(config, subsiteUrl, plugins) { if (!Array.isArray(plugins)) return []; const installed = []; for (const spec of plugins) { if (!spec || !spec.plugin_slug) continue; const slug = spec.plugin_slug; const source = (spec.source || 'wordpress.org').toLowerCase(); const activate = spec.activate !== false; let command; if (source === 'url' && spec.source_url) { command = `plugin install ${spec.source_url} ${activate ? '--activate' : ''}`; } else if (source === 'local' && spec.source_url) { await installPluginFromLocal(config, spec.source_url, slug, randomUUID()); command = activate ? `plugin activate ${slug}` : `plugin deactivate ${slug}`; } else { command = `plugin install ${slug} ${activate ? '--activate' : ''}`; } const res = await runSshCommand(config, buildWpCliCommand(config, subsiteUrl, command)); installed.push({ slug, status: res.code === 0 ? (activate ? 'active' : 'installed') : 'error', output: res.stdout || res.stderr }); } return installed; } function normalizeScenarioAssertions(assertions = {}) { return { status_code: Number.isFinite(assertions.status_code) ? assertions.status_code : null, contains: Array.isArray(assertions.contains) ? assertions.contains : [], not_contains: Array.isArray(assertions.not_contains) ? assertions.not_contains : [], wp_cli_success: assertions.wp_cli_success === true, }; } function normalizeTestScenarios(spec, pluginSlug, config) { const scenarios = Array.isArray(spec?.test_scenarios) ? spec.test_scenarios : []; const normalized = scenarios.map((s) => ({ name: String(s?.name || 'Unnamed test'), type: String(s?.type || 'custom'), url: s?.url || '', selector: s?.selector || '', expected_text: s?.expected_text || '', wp_cli_command: s?.wp_cli_command || '', shortcode: s?.shortcode || '', hook: s?.hook || '', ajax_action: s?.ajax_action || '', method: s?.method || '', body: s?.body || '', assertions: normalizeScenarioAssertions(s?.assertions || {}) })); if (!normalized.length && pluginSlug) { normalized.push({ name: 'Plugin activates without errors', type: 'custom', wp_cli_command: `plugin activate ${pluginSlug}`, assertions: { wp_cli_success: true, contains: [], not_contains: ['Fatal error', 'Parse error'] } }); normalized.push({ name: 'Plugin is listed as active', type: 'custom', wp_cli_command: `plugin status ${pluginSlug}`, assertions: { contains: ['Active'] } }); } if (spec?.auto_error_log_check !== false && config?.errorLogPath) { const safePath = config.errorLogPath.replace(/"/g, '').trim(); const grepSlug = pluginSlug ? ` | grep -i "${pluginSlug}"` : ''; normalized.push({ name: 'Scan error log for PHP errors', type: 'custom', wp_cli_command: `eval "echo shell_exec('tail -n 200 ${safePath.replace(/'/g, "'\\''")}${grepSlug} || echo No errors found');"`, assertions: { not_contains: ['Fatal error', 'Parse error'] } }); } return normalized; } async function runHttpScenario(config, subsiteUrl, scenario, baseFn) { const url = scenario.url || '/'; const method = String(scenario.method || '').toUpperCase() || 'GET'; const body = scenario.body || ''; const bodyArg = body ? `, ["body" => ${JSON.stringify(body)}]` : ''; const requestFn = method === 'POST' ? 'wp_remote_post' : 'wp_remote_get'; const phpUrl = `${baseFn}("${url}")`; const command = buildWpCliCommand( config, subsiteUrl, `eval ' $res = ${requestFn}(${phpUrl}${bodyArg}); echo json_encode(["status" => wp_remote_retrieve_response_code($res), "body" => wp_remote_retrieve_body($res)]);'` ); const result = await runSshCommand(config, command, { timeout: config.testTimeoutMs }); let statusCode = null; let bodyOut = result.stdout || ''; try { const parsed = JSON.parse((result.stdout || '').trim()); statusCode = parsed.status; bodyOut = parsed.body || ''; } catch (_) { bodyOut = result.stdout || result.stderr || ''; } return { result, statusCode, body: bodyOut }; } function evaluateAssertions(output, statusCode, assertions) { const failures = []; if (Number.isFinite(assertions.status_code) && statusCode !== assertions.status_code) { failures.push(`status_code expected ${assertions.status_code} but got ${statusCode}`); } for (const needle of assertions.contains || []) { if (!output.includes(needle)) { failures.push(`missing text: ${needle}`); } } for (const needle of assertions.not_contains || []) { if (output.includes(needle)) { failures.push(`unexpected text: ${needle}`); } } return failures; } async function runTestScenario(config, subsiteUrl, scenario) { const assertions = normalizeScenarioAssertions(scenario.assertions || {}); let output = ''; let statusCode = null; let command = scenario.wp_cli_command || ''; let result; if (scenario.type === 'endpoint' && !command) { const endpointRes = await runHttpScenario(config, subsiteUrl, scenario, 'home_url'); result = endpointRes.result; statusCode = endpointRes.statusCode; output = endpointRes.body || ''; } else if (scenario.type === 'admin_page' && !command) { const adminUrl = scenario.url || 'admin.php'; const adminScenario = { ...scenario, url: adminUrl }; const adminRes = await runHttpScenario(config, subsiteUrl, adminScenario, 'admin_url'); result = adminRes.result; statusCode = adminRes.statusCode; output = adminRes.body || ''; } else if (scenario.type === 'ajax' && !command) { const action = scenario.ajax_action || (scenario.url || '').replace(/^\/?/, ''); const ajaxUrl = action.includes('admin-ajax.php') || action.includes('action=') ? action : `admin-ajax.php?action=${action || 'ping'}`; const ajaxScenario = { ...scenario, url: ajaxUrl, method: scenario.method || 'POST' }; const ajaxRes = await runHttpScenario(config, subsiteUrl, ajaxScenario, 'admin_url'); result = ajaxRes.result; statusCode = ajaxRes.statusCode; output = ajaxRes.body || ''; } else if (scenario.type === 'shortcode' && !command) { const shortcode = scenario.shortcode || scenario.url || ''; const shortcodeInput = shortcode.includes('[') ? shortcode : `[${shortcode}]`; command = `eval "echo do_shortcode('${shortcodeInput.replace(/'/g, "'\\''")}');"`; result = await runSshCommand(config, buildWpCliCommand(config, subsiteUrl, command)); output = (result.stdout || result.stderr || '').trim(); } else if (scenario.type === 'hook' && !command) { const hook = scenario.hook || scenario.url || ''; command = `eval "echo json_encode([\"action\" => has_action('${hook.replace(/'/g, "'\\''")}') ? 1 : 0, \"filter\" => has_filter('${hook.replace(/'/g, "'\\''")}') ? 1 : 0]);"`; result = await runSshCommand(config, buildWpCliCommand(config, subsiteUrl, command)); output = (result.stdout || result.stderr || '').trim(); } else { if (!command) { command = 'eval "echo \'No command provided\';"'; } result = await runSshCommand(config, buildWpCliCommand(config, subsiteUrl, command)); output = (result.stdout || result.stderr || '').trim(); } const failures = evaluateAssertions(output, statusCode, assertions); if (assertions.wp_cli_success && result.code !== 0) { failures.push(`wp_cli_command exited with ${result.code}`); } return { name: scenario.name, status: failures.length ? 'failed' : 'passed', command, output, statusCode, duration: result.durationMs || 0, failures, }; } async function runTestScenarios(config, subsiteUrl, scenarios) { const results = []; let passed = 0; let failed = 0; for (const scenario of scenarios) { const result = await runTestScenario(config, subsiteUrl, scenario); results.push(result); if (result.status === 'passed') { passed += 1; } else { failed += 1; } } return { passed, failed, results }; } class ExternalTestQueue { constructor(maxConcurrent, queueTimeoutMs) { this.maxConcurrent = maxConcurrent; this.queueTimeoutMs = queueTimeoutMs; this.active = 0; this.queue = []; } enqueue(task) { return new Promise((resolve) => { const entry = { task, resolve, enqueuedAt: Date.now() }; this.queue.push(entry); this.process(); }); } process() { if (this.active >= this.maxConcurrent) return; const entry = this.queue.shift(); if (!entry) return; if (Date.now() - entry.enqueuedAt > this.queueTimeoutMs) { entry.resolve({ ok: false, error: 'Test queue timeout - too many concurrent tests' }); this.process(); return; } this.active += 1; Promise.resolve() .then(entry.task) .then((result) => entry.resolve(result)) .catch((error) => entry.resolve({ ok: false, error: error.message || String(error) })) .finally(() => { this.active -= 1; this.process(); }); } } function createExternalWpTester(options = {}) { const logger = typeof options.logger === 'function' ? options.logger : null; const queue = new ExternalTestQueue( DEFAULT_CONFIG.maxConcurrentTests, DEFAULT_CONFIG.queueTimeoutMs ); const log = (msg, meta) => { if (logger) logger(msg, meta || {}); }; async function runTest(input = {}, opts = {}) { const config = getExternalTestingConfig(opts.configOverrides); if (!config.wpHost || !config.wpSshUser) { return { ok: false, error: 'External WordPress test host is not configured' }; } if (!config.wpSshKey || !fsSync.existsSync(config.wpSshKey)) { return { ok: false, error: 'SSH key for external WordPress testing is missing or invalid' }; } const sessionId = input.session_id || randomUUID(); const pluginPath = input.plugin_path; const pluginSlug = input.plugin_slug || path.basename(pluginPath || '').replace(/\.zip$/i, ''); const requiredPlugins = input.required_plugins || []; return queue.enqueue(async () => { const start = Date.now(); let subsite = { slug: null, url: config.wpBaseUrl, adminUrl: `${config.wpBaseUrl.replace(/\/$/, '')}/wp-admin` }; const warnings = []; const errors = []; try { if (config.enableMultisite) { subsite = await createSubsite(config, sessionId); } if (pluginPath && pluginSlug) { await installPluginFromLocal(config, pluginPath, pluginSlug, sessionId); } else { warnings.push('Plugin path or slug missing; skipping plugin upload'); } const installedPlugins = await installRequiredPlugins(config, subsite.url, requiredPlugins); if (pluginSlug) { const activateCmd = buildWpCliCommand(config, subsite.url, `plugin activate ${pluginSlug}`); const activateRes = await runSshCommand(config, activateCmd, { timeout: config.testTimeoutMs }); if (activateRes.code !== 0) { errors.push(`Failed to activate plugin ${pluginSlug}: ${activateRes.stderr || activateRes.stdout}`); } } const scenarios = normalizeTestScenarios(input, pluginSlug, config); const cliTests = await runTestScenarios(config, subsite.url, scenarios); const result = { ok: cliTests.failed === 0, session_id: sessionId, subsite_url: subsite.url, test_results: { mode: 'cli', cli_tests: cliTests, }, installed_plugins: installedPlugins, errors, warnings, duration: Date.now() - start, }; if (config.autoCleanup && subsite.slug) { setTimeout(() => { deleteSubsite(config, subsite.slug).catch(() => {}); }, config.cleanupDelayMs); result.cleanup_scheduled = new Date(Date.now() + config.cleanupDelayMs).toISOString(); } return result; } catch (error) { errors.push(error.message || String(error)); if (config.autoCleanup && subsite.slug) { setTimeout(() => { deleteSubsite(config, subsite.slug).catch(() => {}); }, config.cleanupDelayMs); } return { ok: false, session_id: sessionId, subsite_url: subsite.url, test_results: { mode: 'cli', cli_tests: { passed: 0, failed: 1, results: [] } }, installed_plugins: [], errors, warnings, duration: Date.now() - start, }; } }); } return { runTest, getConfig: () => getExternalTestingConfig() }; } module.exports = { createExternalWpTester, getExternalTestingConfig, };