Files
shopify-ai-backup/chat/external-wp-testing.js
2026-02-08 19:27:26 +00:00

519 lines
19 KiB
JavaScript

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,
};