External testing self-check
+Uploads a temporary plugin, activates it, verifies the activation flag, and cleans up. No AI API calls are made.
+diff --git a/chat/external-wp-testing.js b/chat/external-wp-testing.js new file mode 100644 index 0000000..98d09ad --- /dev/null +++ b/chat/external-wp-testing.js @@ -0,0 +1,518 @@ +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); + const configureCmd = buildWpCliCommand( + config, + url, + 'option update permalink_structure "/%postname%/" && wp rewrite flush' + ); + await runSshCommand(config, configureCmd, { 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) { + await runSshCommand(config, buildWpCliCommand(config, subsite.url, `plugin activate ${pluginSlug}`)); + } + + 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, +}; diff --git a/chat/public/admin-accounts.html b/chat/public/admin-accounts.html index c76298b..b83d873 100644 --- a/chat/public/admin-accounts.html +++ b/chat/public/admin-accounts.html @@ -33,6 +33,7 @@ Withdrawals Tracking Resources + External Testing Contact Messages Login diff --git a/chat/public/admin-affiliates.html b/chat/public/admin-affiliates.html index a3c2efa..c9381b3 100644 --- a/chat/public/admin-affiliates.html +++ b/chat/public/admin-affiliates.html @@ -33,6 +33,7 @@ Withdrawals Tracking Resources + External Testing Contact Messages Login diff --git a/chat/public/admin-contact-messages.html b/chat/public/admin-contact-messages.html index 47cd03b..887584e 100644 --- a/chat/public/admin-contact-messages.html +++ b/chat/public/admin-contact-messages.html @@ -136,8 +136,9 @@ Accounts Affiliates Withdrawals - Tracking - Resources + Tracking + Resources + External Testing Contact Messages Login diff --git a/chat/public/admin-external-testing.html b/chat/public/admin-external-testing.html new file mode 100644 index 0000000..9212651 --- /dev/null +++ b/chat/public/admin-external-testing.html @@ -0,0 +1,81 @@ + + +
+ + +Uploads a temporary plugin, activates it, verifies the activation flag, and cleans up. No AI API calls are made.
+