implement wp testing
This commit is contained in:
518
chat/external-wp-testing.js
Normal file
518
chat/external-wp-testing.js
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -138,6 +138,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 active" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/login">Login</a>
|
||||
</div>
|
||||
|
||||
81
chat/public/admin-external-testing.html
Normal file
81
chat/public/admin-external-testing.html
Normal 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;">×</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
<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/external-testing">External Testing</a>
|
||||
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
||||
<a class="ghost" href="/admin/login">Login</a>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => { });
|
||||
|
||||
@@ -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;">×</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;">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
43
external-wp-tests.example.json
Normal file
43
external-wp-tests.example.json
Normal 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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user