implement wp testing

This commit is contained in:
southseact-3d
2026-02-08 19:27:26 +00:00
parent 541b6bc946
commit 39136e863f
16 changed files with 954 additions and 8 deletions

518
chat/external-wp-testing.js Normal file
View File

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

View File

@@ -33,6 +33,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -33,6 +33,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -136,8 +136,9 @@
<a class="ghost" href="/admin/accounts">Accounts</a>
<a class="ghost" href="/admin/affiliates">Affiliates</a>
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost active" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin Panel - External Testing</title>
<link rel="stylesheet" href="/styles.css" />
<!-- PostHog Analytics -->
<script src="/posthog.js"></script>
</head>
<body data-page="external-testing">
<div class="sidebar-overlay"></div>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">A</div>
<div>
<div class="brand-title">Admin</div>
<div class="brand-sub">Site management</div>
</div>
<button id="close-sidebar" class="ghost" style="margin-left: auto; display: none;">&times;</button>
</div>
<div class="sidebar-section">
<div class="section-heading">Navigation</div>
<a class="ghost" href="/admin/build">Build models</a>
<a class="ghost" href="/admin/plan">Plan models</a>
<a class="ghost" href="/admin/plans">Plans</a>
<a class="ghost" href="/admin/accounts">Accounts</a>
<a class="ghost" href="/admin/affiliates">Affiliates</a>
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>
</aside>
<main class="main">
<div class="admin-shell">
<div class="topbar" style="margin-bottom: 12px;">
<button id="menu-toggle">
<span></span><span></span><span></span>
</button>
<div>
<div class="pill">Admin</div>
<div class="title" style="margin-top: 6px;">External WP Testing</div>
<div class="crumb">Run a CLI-only self check without AI calls.</div>
</div>
<div class="admin-actions">
<button id="admin-refresh" class="ghost">Refresh</button>
<button id="admin-logout" class="primary">Logout</button>
</div>
</div>
<div class="admin-card">
<header>
<h3>External testing self-check</h3>
<div class="pill">WP-CLI</div>
</header>
<p class="muted" style="margin-top:0;">Uploads a temporary plugin, activates it, verifies the activation flag, and cleans up. No AI API calls are made.</p>
<div class="admin-actions">
<button id="external-testing-run" class="primary">Run self-test</button>
<div class="status-line" id="external-testing-status"></div>
</div>
<div id="external-testing-output" class="admin-list" style="margin-top: 12px;"></div>
</div>
<div class="admin-card" style="margin-top: 16px;">
<header>
<h3>Current external testing configuration</h3>
<div class="pill">Config</div>
</header>
<div id="external-testing-config" class="admin-list"></div>
</div>
</div>
</main>
</div>
<script src="/admin.js"></script>
</body>
</html>

View File

@@ -31,6 +31,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -31,6 +31,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -224,7 +224,8 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost active" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>
</aside>

View File

@@ -184,6 +184,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -33,6 +33,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -43,6 +43,7 @@
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost" href="/admin/login">Login</a>
</div>

View File

@@ -91,6 +91,10 @@
opencodeBackupForm: document.getElementById('opencode-backup-form'),
opencodeBackup: document.getElementById('opencode-backup'),
opencodeBackupStatus: document.getElementById('opencode-backup-status'),
externalTestingRun: document.getElementById('external-testing-run'),
externalTestingStatus: document.getElementById('external-testing-status'),
externalTestingOutput: document.getElementById('external-testing-output'),
externalTestingConfig: document.getElementById('external-testing-config'),
};
console.log('Element check - opencodeBackupForm:', el.opencodeBackupForm);
console.log('Element check - opencodeBackup:', el.opencodeBackup);
@@ -178,6 +182,128 @@
el.opencodeBackupStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setExternalTestingStatus(msg, isError = false) {
if (!el.externalTestingStatus) return;
el.externalTestingStatus.textContent = msg || '';
el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function renderExternalTestingConfig(config) {
if (!el.externalTestingConfig) return;
el.externalTestingConfig.innerHTML = '';
if (!config) return;
const rows = [
['WP host', config.wpHost || '—'],
['WP path', config.wpPath || '—'],
['Base URL', config.wpBaseUrl || '—'],
['Multisite enabled', config.enableMultisite ? 'Yes' : 'No'],
['Subsite mode', config.subsiteMode || '—'],
['Subsite domain', config.subsiteDomain || '—'],
['Max concurrent tests', String(config.maxConcurrentTests ?? '—')],
['Auto cleanup', config.autoCleanup ? 'Yes' : 'No'],
['Cleanup delay (ms)', String(config.cleanupDelayMs ?? '—')],
['SSH key configured', config.sshKeyConfigured ? 'Yes' : 'No'],
];
rows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = label;
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingConfig.appendChild(row);
});
}
function renderExternalTestingOutput(result) {
if (!el.externalTestingOutput) return;
el.externalTestingOutput.innerHTML = '';
if (!result) return;
const summary = document.createElement('div');
summary.className = 'admin-row';
const summaryLabel = document.createElement('div');
summaryLabel.style.minWidth = '180px';
const summaryStrong = document.createElement('strong');
summaryStrong.textContent = 'Overall result';
summaryLabel.appendChild(summaryStrong);
const summaryValue = document.createElement('div');
summaryValue.textContent = result.ok ? 'Passed' : 'Failed';
summary.appendChild(summaryLabel);
summary.appendChild(summaryValue);
el.externalTestingOutput.appendChild(summary);
const detailRows = [
['Subsite URL', result.subsite_url || '—'],
['Duration', typeof result.duration === 'number' ? `${(result.duration / 1000).toFixed(1)}s` : '—'],
];
detailRows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = label;
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingOutput.appendChild(row);
});
const scenarioResults = result?.test_results?.cli_tests?.results || [];
if (scenarioResults.length) {
scenarioResults.forEach((scenario) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = scenario.name || 'Scenario';
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = scenario.status === 'passed' ? 'Passed' : 'Failed';
if (scenario.status !== 'passed') {
valueWrap.style.color = 'var(--danger)';
}
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingOutput.appendChild(row);
});
}
const errors = Array.isArray(result.errors) ? result.errors : [];
if (errors.length) {
errors.forEach((err) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = 'Error';
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = err;
valueWrap.style.color = 'var(--danger)';
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingOutput.appendChild(row);
});
}
}
async function loadExternalTestingStatus() {
const data = await api('/api/admin/external-testing-status');
renderExternalTestingConfig(data.config || {});
}
async function api(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
@@ -1844,6 +1970,7 @@
() => (el.planTokensTable ? loadPlanTokens() : null),
() => ((el.tokenRateUsd || el.tokenRateGbp || el.tokenRateEur) ? loadTokenRates() : null),
() => ((el.providerUsage || el.providerLimitForm) ? loadProviderLimits() : null),
() => (el.externalTestingConfig ? loadExternalTestingStatus() : null),
];
await Promise.all(loaders.map((fn) => fn()).filter(Boolean));
// Always try to load provider limits if not already loaded (needed for backup dropdown)
@@ -2139,6 +2266,22 @@
});
}
if (el.externalTestingRun) {
el.externalTestingRun.addEventListener('click', async () => {
el.externalTestingRun.disabled = true;
setExternalTestingStatus('Running self-test...');
try {
const data = await api('/api/admin/external-testing-self-test', { method: 'POST' });
renderExternalTestingOutput(data.result || null);
setExternalTestingStatus(data.result && data.result.ok ? 'Self-test passed.' : 'Self-test failed.', !data.result || !data.result.ok);
} catch (err) {
setExternalTestingStatus(err.message || 'Self-test failed.', true);
} finally {
el.externalTestingRun.disabled = false;
}
});
}
if (el.logout) {
el.logout.addEventListener('click', async () => {
await api('/api/admin/logout', { method: 'POST' }).catch(() => { });

View File

@@ -1118,13 +1118,21 @@
</div>
<button class="primary action-link" id="export-zip-btn" title="Download as ZIP">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px;">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"></path>
<polyline points="7,10 12,15 17,10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Download ZIP
</button>
<div style="display:flex; align-items:center; gap:8px;">
<label style="display:inline-flex; align-items:center; gap:8px; font-weight:600; font-size:13px;">
<input type="checkbox" id="external-testing-toggle" style="width:18px; height:18px;" />
<span id="external-testing-label" style="font-weight:600; font-size:13px;">External WP Tests</span>
</label>
<button id="external-testing-info" class="action-link" title="Run the plugin through CLI tests on an external WP site" style="padding:6px 8px; border-radius:8px;">i</button>
<div id="external-testing-usage" style="font-size:12px; color:var(--muted); min-width:140px; text-align:right;"></div>
</div>
</div>
</header>
@@ -1437,6 +1445,21 @@
</div>
<!-- Confirm Build Modal -->
<!-- External Testing Limit Modal -->
<div id="external-testing-limit-modal" class="modal" style="display:none;">
<div class="modal-card" style="max-width:520px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong style="color:var(--ink); font-size:20px;">External Testing Limit Reached</strong>
<button id="external-testing-limit-close" style="background:none; border:none; font-size:22px;">&times;</button>
</div>
<p style="color:var(--muted); margin-top:12px;">Your current plan limits the number of external WP CLI tests you can run per month. To continue using external testing, upgrade your plan or purchase additional test credits.</p>
<div class="admin-actions" style="margin-top:18px; justify-content:flex-end; gap:8px;">
<button id="external-testing-limit-upgrade" class="primary">Upgrade Plan</button>
<button id="external-testing-limit-cancel" class="action-link">Cancel</button>
</div>
</div>
</div>
<div id="confirm-build-modal" class="modal">
<div class="modal-content">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">

View File

@@ -32,7 +32,8 @@ const builderState = savedState || {
lastUserRequest: '',
lastPlanText: '',
pluginPrompt: '',
subsequentPrompt: ''
subsequentPrompt: '',
externalTestingEnabled: false
};
// Auto-save builderState changes to localStorage
@@ -117,6 +118,21 @@ async function proceedWithBuild(planContent) {
async function executeBuild(planContent) {
console.log('executeBuild called with planContent:', planContent ? planContent.substring(0, 100) + '...' : 'null');
// Ensure external testing is still allowed if enabled
if (builderState.externalTestingEnabled) {
await loadUsageSummary();
const summary = state.usageSummary?.externalTesting || null;
const limit = summary ? summary.limit : null;
const used = summary ? summary.used : 0;
if (Number.isFinite(limit) && used >= limit) {
// show modal and abort
const modal = document.getElementById('external-testing-limit-modal');
if (modal) modal.style.display = 'flex';
setStatus('External testing limit reached. Disable external testing or upgrade your plan.');
return;
}
}
builderState.mode = 'build';
builderState.planApproved = true;
updateBuildModeUI();
@@ -160,7 +176,8 @@ async function executeBuild(planContent) {
model: selectedModel,
cli: 'opencode',
isProceedWithBuild: true,
planContent: planContent
planContent: planContent,
externalTestingEnabled: !!builderState.externalTestingEnabled
};
// Preserve opencodeSessionId for session continuity
if (session && session.opencodeSessionId) {
@@ -188,7 +205,16 @@ async function executeBuild(planContent) {
console.log('Build process initiated successfully');
} catch (e) {
console.error('Failed to start build:', e);
alert('Failed to start build: ' + (e.message || 'Unknown error'));
const msg = (e && e.message) ? e.message : 'Unknown error';
if (msg && msg.toLowerCase().includes('external wp cli testing')) {
// Show upgrade modal
const modal = document.getElementById('external-testing-limit-modal');
if (modal) modal.style.display = 'flex';
setStatus(msg, true);
} else {
alert('Failed to start build: ' + msg);
}
builderState.mode = 'plan'; // Revert
updateBuildModeUI();
hideLoadingIndicator();
@@ -603,6 +629,9 @@ async function loadUsageSummary() {
usageMeterTrack: !!el.usageMeterTrack
});
updateUsageProgressBar(state.usageSummary);
if (typeof window.updateExternalTestingUI === 'function') {
try { updateExternalTestingUI(); } catch (e) { console.warn('external testing UI update failed', e); }
}
if (typeof window.checkTokenLimitAndShowModal === 'function') {
setTimeout(() => window.checkTokenLimitAndShowModal(), 500);
}
@@ -618,6 +647,92 @@ async function loadUsageSummary() {
// Expose for builder.html
window.loadUsageSummary = loadUsageSummary;
// --- External testing UI helpers ---
function updateExternalTestingUI() {
const elToggle = document.getElementById('external-testing-toggle');
const elUsage = document.getElementById('external-testing-usage');
const infoBtn = document.getElementById('external-testing-info');
if (!elToggle || !elUsage) return;
const et = state.usageSummary?.externalTesting || null;
if (!et) {
elUsage.textContent = 'Not configured';
elToggle.disabled = true;
elToggle.checked = false;
builderState.externalTestingEnabled = false;
saveBuilderState(builderState);
return;
}
const used = Number(et.used || 0);
const limit = Number.isFinite(Number(et.limit)) ? et.limit : 'unlimited';
elUsage.textContent = Number.isFinite(Number(et.limit)) ? `${used} / ${limit}` : `${used} / ∞`;
if (typeof builderState.externalTestingEnabled === 'boolean') {
elToggle.checked = !!builderState.externalTestingEnabled;
} else {
elToggle.checked = false;
builderState.externalTestingEnabled = false;
saveBuilderState(builderState);
}
if (infoBtn) {
infoBtn.onclick = (e) => {
e.preventDefault();
alert('External WP tests run a series of WP-CLI checks on an external WordPress site. Tests are counted against your monthly allowance.');
};
}
elToggle.addEventListener('change', async (e) => {
const wantOn = e.target.checked === true;
if (!wantOn) {
builderState.externalTestingEnabled = false;
saveBuilderState(builderState);
elToggle.checked = false;
return;
}
// Re-check usage before enabling
await loadUsageSummary();
const summary = state.usageSummary?.externalTesting || null;
const limit = summary ? summary.limit : null;
const used = summary ? summary.used : 0;
if (Number.isFinite(limit) && used >= limit) {
// show modal suggesting upgrade
const modal = document.getElementById('external-testing-limit-modal');
if (modal) modal.style.display = 'flex';
elToggle.checked = false;
return;
}
builderState.externalTestingEnabled = true;
saveBuilderState(builderState);
elToggle.checked = true;
});
}
(function wireExternalTestingModal() {
const modal = document.getElementById('external-testing-limit-modal');
if (!modal) return;
const closeBtn = document.getElementById('external-testing-limit-close');
const cancelBtn = document.getElementById('external-testing-limit-cancel');
const upgradeBtn = document.getElementById('external-testing-limit-upgrade');
const upgradeHeaderBtn = document.getElementById('upgrade-header-btn');
const closeModal = () => { modal.style.display = 'none'; };
if (closeBtn) closeBtn.addEventListener('click', closeModal);
if (cancelBtn) cancelBtn.addEventListener('click', closeModal);
if (upgradeBtn) {
upgradeBtn.addEventListener('click', () => {
closeModal();
if (upgradeHeaderBtn) upgradeHeaderBtn.click(); else window.location.href = '/topup';
});
}
})();
// Expose to global scope
window.updateExternalTestingUI = updateExternalTestingUI;
function checkTokenLimitAndShowModal() {
const remaining = state.usageSummary?.remaining || 0;
if (remaining <= 5000) {
@@ -1273,6 +1388,8 @@ async function hydrateUserIdFromServerSession() {
// Load usage summary on page load
loadUsageSummary().catch(err => {
console.warn('[USAGE] Initial loadUsageSummary failed:', err.message);
}).then(() => {
try { if (typeof window.updateExternalTestingUI === 'function') window.updateExternalTestingUI(); } catch (e) { console.warn('updateExternalTestingUI failed on init', e); }
});
})();
@@ -2065,7 +2182,9 @@ window.renderMessages = renderMessages;
function renderContentWithTodos(text) {
const wrapper = document.createElement('div');
if (!text) return document.createTextNode('');
const processedText = String(text).replace(/([.:])\s+(?=[A-Z])/g, '$1\n\n');
const processedText = String(text)
.replace(/:\s*(?=[A-Z])/g, ':\n\n')
.replace(/([.])\s+(?=[A-Z])/g, '$1\n\n');
const lines = processedText.split(/\r?\n/);
let currentList = null;
let inCodeBlock = false;

View File

@@ -15220,6 +15220,15 @@ async function handleNewMessage(req, res, sessionId, userId) {
const friendlyRemaining = allowance.remaining > 0 ? `${allowance.remaining.toLocaleString()} remaining` : 'no remaining balance';
return sendJson(res, 402, { error: `You have reached your token allowance (${friendlyRemaining}). Upgrade or add a boost.`, allowance });
}
// If this request will trigger an external WP test, enforce plan limits immediately and return a friendly upgrade prompt
if (body.isProceedWithBuild && body.externalTestingEnabled === true) {
const user = findUserById(session.userId);
const limitCheck = canUseExternalTesting(session.userId, userPlan, user?.unlimitedUsage === true);
if (!limitCheck.allowed) {
return sendJson(res, 402, { error: 'External WP CLI testing limit reached for your plan. Upgrade to run more tests.', externalTesting: limitCheck.summary, upgradeUrl: '/topup' });
}
}
const cli = normalizeCli(body.cli || session.cli);
const now = new Date().toISOString();
const message = { id: randomUUID(), role: 'user', content, displayContent, model, cli, status: 'queued', createdAt: now, updatedAt: now, opencodeTokensUsed: null };
@@ -17210,6 +17219,7 @@ async function bootstrap() {
await loadProviderLimits();
await loadProviderUsage();
await loadTokenUsage();
await loadExternalTestUsage();
await loadTopupSessions();
await loadPendingTopups();
await loadPaygSessions();

View File

@@ -0,0 +1,43 @@
{
"required_plugins": [
{ "plugin_slug": "woocommerce", "source": "wordpress.org", "activate": true }
],
"test_scenarios": [
{
"name": "Plugin activates without errors",
"type": "custom",
"wp_cli_command": "plugin activate {plugin_slug}",
"assertions": { "wp_cli_success": true, "not_contains": ["Fatal error", "Parse error"] }
},
{
"name": "Home page responds",
"type": "endpoint",
"url": "/",
"assertions": { "status_code": 200, "contains": ["<html"] }
},
{
"name": "Admin settings page loads",
"type": "admin_page",
"url": "/admin.php?page=example-settings",
"assertions": { "status_code": 200 }
},
{
"name": "AJAX ping",
"type": "ajax",
"ajax_action": "pc_ping",
"assertions": { "contains": ["pong"] }
},
{
"name": "Shortcode renders",
"type": "shortcode",
"shortcode": "[pc_example]",
"assertions": { "contains": ["Example output"] }
},
{
"name": "Activation hook check (logs)",
"type": "custom",
"wp_cli_command": "tail -n 50 /var/log/wp-errors.log || echo 'No errors found'",
"assertions": { "not_contains": ["Fatal error", "Parse error"] }
}
]
}