añadido karpathy y refactorizacion del proyecto
This commit is contained in:
+254
@@ -0,0 +1,254 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const { enrichTestCase } = require('./lib/enrich');
|
||||
const { generateReport } = require('./scripts/generate-report');
|
||||
const { generateTestPlan } = require('./scripts/generate-test-plan');
|
||||
const { loadTestcases } = require('./lib/load-testcases');
|
||||
const { saveTestcases } = require('./lib/save-testcases');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4321;
|
||||
|
||||
const DATA_FILE = path.join(__dirname, '..', 'editor', 'data', 'testcases.json'); // legacy transition only
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.static(path.join(__dirname, '..', 'web')));
|
||||
|
||||
// Get all testcases - primary source is now the YAML tree under testcases/
|
||||
app.get('/api/testcases', (req, res) => {
|
||||
try {
|
||||
const testcases = loadTestcases();
|
||||
res.json(testcases);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Could not read testcases data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save all testcases - writes to YAML files (source of truth) + legacy JSON during transition
|
||||
app.post('/api/testcases', (req, res) => {
|
||||
try {
|
||||
const testcases = req.body;
|
||||
if (!Array.isArray(testcases)) {
|
||||
return res.status(400).json({ error: 'Body must be an array of testcases' });
|
||||
}
|
||||
|
||||
// Primary: persist to YAML tree
|
||||
saveTestcases(testcases);
|
||||
|
||||
// Transition: still write the legacy JSON so other tools don't break yet
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(testcases, null, 2), 'utf8');
|
||||
|
||||
res.json({ success: true, count: testcases.length, source: 'yaml+json' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to save data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Enrich a single test case with smart defaults (used by the editor "Reset suggestions" button)
|
||||
app.post('/api/enrich', (req, res) => {
|
||||
try {
|
||||
const test = req.body;
|
||||
if (!test || !test.id) {
|
||||
return res.status(400).json({ error: 'Test object with id is required' });
|
||||
}
|
||||
const enriched = enrichTestCase(test);
|
||||
res.json(enriched);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Enrichment failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== EXPORT ENDPOINTS ====================
|
||||
|
||||
// Full automation report (timestamped qa_automation.html)
|
||||
app.get('/api/export/report', (req, res) => {
|
||||
try {
|
||||
const generatedPath = generateReport();
|
||||
const html = fs.readFileSync(generatedPath, 'utf8');
|
||||
|
||||
const filename = path.basename(generatedPath);
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(html);
|
||||
} catch (e) {
|
||||
res.status(500).send('Error generating report: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple summary HTML (lightweight)
|
||||
app.get('/api/export/summary', (req, res) => {
|
||||
try {
|
||||
const tests = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||
const total = tests.length;
|
||||
const automated = tests.filter(t => t.status === 'automated').length;
|
||||
const autoPct = ((automated / total) * 100).toFixed(1);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Test Plan Summary</title>
|
||||
<style>body{font-family:system-ui;background:#0b1120;color:#e2e8f0;padding:40px} .card{background:#1e2937;padding:24px;border-radius:12px}</style>
|
||||
</head><body>
|
||||
<h1>Test Automation Summary</h1>
|
||||
<div class="card">
|
||||
<h2>${total} Test Cases</h2>
|
||||
<h3 style="color:#4ade80">${automated} Automated (${autoPct}%)</h3>
|
||||
<p>Generated on ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="QA-Automation-Summary-Report.html"');
|
||||
res.send(html);
|
||||
} catch (e) {
|
||||
res.status(500).send('Error: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== TEST PLANS (Persistent & Editable) ====================
|
||||
|
||||
const PLANS_DIR = path.join(__dirname, '..', 'output', 'testplans', 'plans');
|
||||
|
||||
// Ensure plans dir
|
||||
if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
|
||||
|
||||
function getPlanPath(planId) {
|
||||
return path.join(PLANS_DIR, `${planId}.json`);
|
||||
}
|
||||
|
||||
// Create a new Test Plan from selected IDs
|
||||
app.post('/api/test-plans', (req, res) => {
|
||||
try {
|
||||
const { ids = [], name = 'Manual Test Plan' } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'No test IDs provided' });
|
||||
}
|
||||
|
||||
const allTests = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||
const selectedTests = allTests.filter(t => ids.includes(t.id));
|
||||
|
||||
if (selectedTests.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid tests found' });
|
||||
}
|
||||
|
||||
const planId = Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const plan = {
|
||||
id: planId,
|
||||
name: name || 'Manual Test Plan',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tests: selectedTests.map(t => ({
|
||||
...t,
|
||||
severity: 'High',
|
||||
expectedResult: t.description || '',
|
||||
actualResult: '',
|
||||
testerStatus: 'N/A',
|
||||
comments: ''
|
||||
}))
|
||||
};
|
||||
|
||||
fs.writeFileSync(getPlanPath(planId), JSON.stringify(plan, null, 2));
|
||||
|
||||
res.json({ success: true, planId, name: plan.name });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to create Test Plan: ' + e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a plan
|
||||
app.get('/api/test-plans/:id', (req, res) => {
|
||||
try {
|
||||
const planPath = getPlanPath(req.params.id);
|
||||
if (!fs.existsSync(planPath)) return res.status(404).json({ error: 'Plan not found' });
|
||||
|
||||
const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'));
|
||||
res.json(plan);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error loading plan' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save/update a plan (for auto-save from tester editor)
|
||||
app.post('/api/test-plans/:id', (req, res) => {
|
||||
try {
|
||||
const planPath = getPlanPath(req.params.id);
|
||||
if (!fs.existsSync(planPath)) return res.status(404).json({ error: 'Plan not found' });
|
||||
|
||||
const existing = JSON.parse(fs.readFileSync(planPath, 'utf8'));
|
||||
const updated = {
|
||||
...existing,
|
||||
...req.body,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
fs.writeFileSync(planPath, JSON.stringify(updated, null, 2));
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to save plan' });
|
||||
}
|
||||
});
|
||||
|
||||
// List all plans (for future tester portal)
|
||||
app.get('/api/test-plans', (req, res) => {
|
||||
try {
|
||||
const files = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.json'));
|
||||
const plans = files.map(file => {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(PLANS_DIR, file), 'utf8'));
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
testCount: data.tests ? data.tests.length : 0
|
||||
};
|
||||
}).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
|
||||
res.json(plans);
|
||||
} catch (e) {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Raw JSON
|
||||
app.get('/api/export/json', (req, res) => {
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="testcases.json"');
|
||||
res.sendFile(DATA_FILE);
|
||||
});
|
||||
|
||||
// Export Automated Playwright Tests (uses the generator script)
|
||||
app.post('/api/export/automated-tests', (req, res) => {
|
||||
try {
|
||||
const { group } = req.body;
|
||||
|
||||
if (!group) {
|
||||
return res.status(400).json({ error: 'Group is required for now' });
|
||||
}
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, 'scripts', 'generate-playwright-tests.js');
|
||||
|
||||
// Generate for the specific group
|
||||
execSync(`node ${scriptPath} --group ${group}`, { stdio: 'inherit' });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Automated tests generated for group ${group}`,
|
||||
group
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'Failed to export automated tests: ' + e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Health
|
||||
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n🚀 Autotest Editor running at http://localhost:${PORT}`);
|
||||
console.log(` Primary data source: YAML files under testcases/ and testplans/\n`);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* enrich.js
|
||||
* Shared logic to generate rich metadata (steps, automatable, notYet, dependencies)
|
||||
* for a test case. Used by both the editor and (optionally) the report.
|
||||
*/
|
||||
|
||||
function enrichTestCase(t) {
|
||||
const id = t.id || '';
|
||||
const group = t.group || '';
|
||||
const isCore = t.module === 'core';
|
||||
const isPbx = t.module === 'pbx';
|
||||
const status = t.status || 'manual';
|
||||
|
||||
const isAutomated = status === 'automated';
|
||||
const isPartial = status === 'partial';
|
||||
const isManual = status === 'manual';
|
||||
|
||||
let steps = [];
|
||||
let automatable = [];
|
||||
let notYet = [];
|
||||
let dependencies = [];
|
||||
|
||||
// === STEPS ===
|
||||
steps.push('Open admin portal or repository URL in browser context');
|
||||
|
||||
if (id.includes('repository') || id.includes('documentation')) {
|
||||
steps.push('Navigate to the download / documentation portal');
|
||||
steps.push('Search or locate the relevant file/link by filename pattern');
|
||||
steps.push('Assert presence and visibility of the expected document');
|
||||
} else if (group.includes('security') || group.includes('configuration')) {
|
||||
steps.push('Log in to the admin portal (if required)');
|
||||
steps.push('Navigate to the relevant settings section');
|
||||
steps.push('Read current configuration values via UI or API');
|
||||
} else if (group.includes('calls') || group.includes('codecs')) {
|
||||
steps.push('Ensure SIP endpoints / softphones are registered');
|
||||
steps.push('Initiate or receive call using the required scenario');
|
||||
steps.push('Verify audio path, signaling, and media parameters');
|
||||
} else if (group.includes('upgrade') || group.includes('installation')) {
|
||||
steps.push('Prepare clean or previous-version VM/image');
|
||||
steps.push('Perform installation or upgrade procedure');
|
||||
steps.push('Post-install verification via portal + external tools');
|
||||
} else {
|
||||
steps.push('Execute the main verification or configuration action described in the test');
|
||||
steps.push('Capture evidence (screenshot, log, API response)');
|
||||
steps.push('Assert expected outcome');
|
||||
}
|
||||
|
||||
// === AUTOMATION BREAKDOWN ===
|
||||
if (isAutomated) {
|
||||
automatable.push('Full end-to-end execution via Playwright against the admin portal UI');
|
||||
automatable.push('Navigation, form interaction, and assertions are scriptable');
|
||||
} else if (isPartial) {
|
||||
automatable.push('Portal UI configuration and verification steps (already partially automated)');
|
||||
automatable.push('Data extraction and reporting of current settings');
|
||||
}
|
||||
|
||||
if (isManual || isPartial) {
|
||||
if (t.manualReason) notYet.push(t.manualReason);
|
||||
if (t.partialNotes) notYet.push(t.partialNotes);
|
||||
}
|
||||
|
||||
if (!isAutomated) {
|
||||
notYet.push('Requires external SIP endpoints or physical phones for media/audio validation');
|
||||
}
|
||||
|
||||
if (group.includes('virtualization') || group.includes('availability') || group.includes('performance')) {
|
||||
notYet.push('Hypervisor-level operations (vCenter, libvirt, vSphere) not reachable from browser automation');
|
||||
notYet.push('Long-running load or HA failover scenarios need dedicated lab infrastructure');
|
||||
}
|
||||
|
||||
if (group.includes('calls') || group.includes('codecs')) {
|
||||
notYet.push('Real RTP/SRTP media path and audio quality verification (human ear or PESQ/MOS tools)');
|
||||
notYet.push('External SIP trunk / PSTN connectivity and DID numbers');
|
||||
}
|
||||
|
||||
if (id.includes('security') && (id.includes('cve') || id.includes('vulnerab'))) {
|
||||
notYet.push('Full CVE / dependency scanning (Trivy, Grype, OWASP Dependency-Check) in CI pipeline');
|
||||
}
|
||||
|
||||
// === DEPENDENCIES ===
|
||||
dependencies.push({ label: 'Admin Portal', value: 'https://pbx.local:4443 (configure via $BASE_URL)' });
|
||||
dependencies.push({ label: 'Repository URL', value: '${BASE_URL}/downloads or $REPO_URL' });
|
||||
|
||||
if (isCore) dependencies.push({ label: 'Protocol', value: 'HTTPS (admin portal) + optional SSH/CLI' });
|
||||
if (isPbx) dependencies.push({ label: 'Protocol', value: 'HTTPS + SIP (UDP/TCP/TLS) + RTP/SRTP' });
|
||||
|
||||
if (group.includes('calls') || group.includes('codecs') || group.includes('extensions')) {
|
||||
dependencies.push({ label: 'SIP Endpoints', value: 'At least 2 registered SIP clients (deskphone or softphone: Zoiper, MicroSIP, Yealink, etc.)' });
|
||||
dependencies.push({ label: 'Network', value: 'SIP trunk or local Asterisk/FreeSWITCH simulator for PSTN simulation' });
|
||||
}
|
||||
|
||||
if (group.includes('virtualization')) {
|
||||
dependencies.push({ label: 'Hypervisor Access', value: 'vCenter, Hyper-V Manager, Proxmox, or virsh (SSH/API)' });
|
||||
}
|
||||
|
||||
if (group.includes('performance') || group.includes('availability')) {
|
||||
dependencies.push({ label: 'Load Gen', value: 'SIPp or custom load generator + monitoring (Prometheus/Grafana recommended)' });
|
||||
}
|
||||
|
||||
if (id.includes('siem') || id.includes('addm')) {
|
||||
dependencies.push({ label: 'External Systems', value: 'SIEM server (Splunk, ELK, QRadar) and/or ADDM/ADDM collector' });
|
||||
}
|
||||
|
||||
dependencies.push({ label: 'Credentials', value: 'Admin user with sufficient privileges (portal + SSH where needed)' });
|
||||
dependencies.push({ label: 'Test Data', value: 'Clean test tenant or lab PBX instance (recommended isolated environment)' });
|
||||
|
||||
// Cleanup: remove duplicates and empty
|
||||
const seen = new Set();
|
||||
notYet = notYet.filter(item => {
|
||||
const key = item.trim();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
automatable = [...new Set(automatable.filter(Boolean))];
|
||||
steps = [...new Set(steps.filter(Boolean))];
|
||||
|
||||
// Fallbacks
|
||||
if (automatable.length === 0) {
|
||||
automatable.push('Most UI-driven configuration and verification steps via Playwright');
|
||||
}
|
||||
if (notYet.length === 0 && !isAutomated) {
|
||||
notYet.push('No major blockers identified — can be fully automated with current tooling');
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
automatable,
|
||||
notYet,
|
||||
dependencies
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { enrichTestCase };
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* load-testcases.js
|
||||
*
|
||||
* ONE-TIME TRANSITION HELPER
|
||||
* Loads all testcases from the new YAML tree (testcases/) and converts them
|
||||
* to the legacy JSON shape that the editor frontend currently expects.
|
||||
*
|
||||
* This allows the UI to keep working unchanged while we migrate.
|
||||
*
|
||||
* TODO: Once the editor is updated to work natively with YAML, this can be removed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const TESTCASES_ROOT = path.join(__dirname, '..', '..', 'testcases');
|
||||
|
||||
/**
|
||||
* Recursively find all .yaml files under a directory (excluding SCHEMA.md etc.)
|
||||
*/
|
||||
function findYamlFiles(dir) {
|
||||
const results = [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...findYamlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.yaml') && !entry.name.startsWith('SCHEMA')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single YAML testcase object back to the legacy editor shape.
|
||||
*/
|
||||
function yamlToLegacy(yamlTest) {
|
||||
const cf = yamlTest.custom_fields || {};
|
||||
|
||||
const legacy = {
|
||||
id: yamlTest.id,
|
||||
group: yamlTest.group,
|
||||
module: yamlTest.module,
|
||||
action: cf.original_action || 'check',
|
||||
description: yamlTest.description || yamlTest.title || '',
|
||||
status: yamlTest.automation_status || 'manual',
|
||||
file: yamlTest.file || '',
|
||||
steps: (yamlTest.steps || []).map(s => s.action || s),
|
||||
automatable: cf.original_automatable || [],
|
||||
notYet: cf.original_notYet || [],
|
||||
dependencies: cf.original_dependencies || [],
|
||||
};
|
||||
|
||||
// Preserve extra fields that the old data sometimes had
|
||||
if (cf.manualReason) legacy.manualReason = cf.manualReason;
|
||||
if (cf.partialNotes) legacy.partialNotes = cf.partialNotes;
|
||||
|
||||
return legacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main loader. Returns array of testcases in the old JSON format.
|
||||
*/
|
||||
function loadTestcases() {
|
||||
if (!fs.existsSync(TESTCASES_ROOT)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const yamlFiles = findYamlFiles(TESTCASES_ROOT);
|
||||
const testcases = [];
|
||||
|
||||
for (const filePath of yamlFiles) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const doc = yaml.load(content);
|
||||
if (doc && doc.id) {
|
||||
testcases.push(yamlToLegacy(doc));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load YAML:', filePath, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for stable output (same order every time)
|
||||
testcases.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return testcases;
|
||||
}
|
||||
|
||||
module.exports = { loadTestcases };
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* save-testcases.js
|
||||
*
|
||||
* Writes an array of legacy-shaped testcases back to the individual YAML files.
|
||||
* Tries to preserve existing per-step automation flags and custom data.
|
||||
*
|
||||
* This is part of the transition so that the editor can save directly to YAML.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const TESTCASES_ROOT = path.join(__dirname, '..', '..', 'testcases');
|
||||
|
||||
function getYamlPath(legacyTest) {
|
||||
const project = 'pbx'; // transition: all current data lives under pbx project
|
||||
const module = legacyTest.module || 'unknown';
|
||||
const rawGroup = legacyTest.group || 'unknown';
|
||||
const group = rawGroup.replace(/^core-|^pbx-/, '');
|
||||
return path.join(TESTCASES_ROOT, project, module, group, `${legacyTest.id}.yaml`);
|
||||
}
|
||||
|
||||
function buildCanonicalDoc(legacy, existing = {}) {
|
||||
const cf = legacy.custom_fields || existing.custom_fields || {};
|
||||
|
||||
// Support rich steps from new per-step editor (Fase B+)
|
||||
let steps = [];
|
||||
const incomingSteps = legacy.steps || [];
|
||||
|
||||
if (incomingSteps.length > 0 && typeof incomingSteps[0] === 'object' && incomingSteps[0] !== null) {
|
||||
// Rich steps sent from frontend
|
||||
steps = incomingSteps.map(s => ({
|
||||
action: s.action || '',
|
||||
expected: s.expected || '',
|
||||
automatable: s.automatable !== undefined ? !!s.automatable : (legacy.status !== 'manual'),
|
||||
...(s.reason_not_automatable ? { reason_not_automatable: s.reason_not_automatable } : {})
|
||||
}));
|
||||
} else {
|
||||
// Legacy flat strings
|
||||
steps = incomingSteps.map((action, i) => {
|
||||
const prev = (existing.steps && existing.steps[i]) || {};
|
||||
return {
|
||||
action: action,
|
||||
expected: prev.expected || '',
|
||||
automatable: prev.automatable !== undefined ? prev.automatable : (legacy.status !== 'manual'),
|
||||
...(prev.reason_not_automatable ? { reason_not_automatable: prev.reason_not_automatable } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// If existing had more detailed steps, prefer their automatable flags
|
||||
if (existing.steps && existing.steps.length === steps.length) {
|
||||
steps = existing.steps.map((prev, i) => ({
|
||||
...prev,
|
||||
action: steps[i].action
|
||||
}));
|
||||
}
|
||||
|
||||
const doc = {
|
||||
id: legacy.id,
|
||||
project: 'pbx',
|
||||
module: legacy.module,
|
||||
group: legacy.group,
|
||||
title: legacy.description || legacy.title,
|
||||
description: legacy.description || legacy.title,
|
||||
priority: existing.priority || 'Medium',
|
||||
tags: existing.tags || [],
|
||||
references: existing.references || [],
|
||||
dependencies: legacy.dependencies || existing.dependencies || [],
|
||||
preconditions: existing.preconditions || [],
|
||||
postconditions: existing.postconditions || [],
|
||||
steps,
|
||||
automation_status: legacy.status,
|
||||
file: legacy.file || existing.file || null,
|
||||
custom_fields: {
|
||||
...cf,
|
||||
original_action: legacy.action,
|
||||
}
|
||||
};
|
||||
|
||||
// Carry over extra legacy fields the UI might send
|
||||
if (legacy.manualReason) doc.custom_fields.manualReason = legacy.manualReason;
|
||||
if (legacy.partialNotes) doc.custom_fields.partialNotes = legacy.partialNotes;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
function saveTestcases(legacyArray) {
|
||||
for (const test of legacyArray) {
|
||||
if (!test || !test.id) continue;
|
||||
|
||||
const filePath = getYamlPath(test);
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let existing = {};
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
existing = yaml.load(raw) || {};
|
||||
} catch (e) {
|
||||
// ignore, we'll overwrite
|
||||
}
|
||||
}
|
||||
|
||||
const doc = buildCanonicalDoc(test, existing);
|
||||
const header = `# Updated via Autotest Editor - ${new Date().toISOString()}\n# Source of truth: this YAML file\n\n`;
|
||||
const content = header + yaml.dump(doc, { lineWidth: 100, noRefs: true });
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { saveTestcases, getYamlPath };
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_comment": "Action Catalog for generating Playwright + MCP tests from editor data.",
|
||||
"_version": "0.1.0",
|
||||
"_targetGroup": "core-documentation",
|
||||
|
||||
"actions": {
|
||||
"open_admin_portal": {
|
||||
"template": "await page.goto(process.env.BASE_URL ?? 'https://pbx.local:4443');"
|
||||
},
|
||||
|
||||
"navigate_to_downloads_portal": {
|
||||
"template": "await page.goto(REPO_URL);"
|
||||
},
|
||||
|
||||
"search_for_document": {
|
||||
"template": "const link = page.locator('a, li, td').filter({ hasText: /{{pattern}}/i });\nawait expect(link.first()).toBeVisible();",
|
||||
"params": ["pattern"]
|
||||
},
|
||||
|
||||
"assert_document_visible": {
|
||||
"template": "const el = page.locator('a, li, td').filter({ hasText: /{{pattern}}/i });\nawait expect(el.first()).toBeVisible();",
|
||||
"params": ["pattern"]
|
||||
},
|
||||
|
||||
"extract_text_content": {
|
||||
"template": "const content = await page.locator('body').innerText();\n// TODO: Parse and save the relevant section (Release Notes, Changelog, etc.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CATALOG_PATH = path.join(__dirname, 'catalog.json');
|
||||
|
||||
let cachedCatalog = null;
|
||||
|
||||
function loadActionCatalog(forceReload = false) {
|
||||
if (cachedCatalog && !forceReload) {
|
||||
return cachedCatalog;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CATALOG_PATH, 'utf-8');
|
||||
cachedCatalog = JSON.parse(raw);
|
||||
return cachedCatalog;
|
||||
}
|
||||
|
||||
function getAction(actionKey) {
|
||||
const catalog = loadActionCatalog();
|
||||
return catalog.actions ? catalog.actions[actionKey] : undefined;
|
||||
}
|
||||
|
||||
module.exports = { loadActionCatalog, getAction };
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface ActionDefinition {
|
||||
template: string;
|
||||
params?: string[];
|
||||
}
|
||||
|
||||
export interface ActionCatalog {
|
||||
_comment?: string;
|
||||
_version?: string;
|
||||
_targetGroup?: string;
|
||||
actions: Record<string, ActionDefinition>;
|
||||
}
|
||||
|
||||
const CATALOG_PATH = path.join(__dirname, 'catalog.json');
|
||||
|
||||
let cachedCatalog: ActionCatalog | null = null;
|
||||
|
||||
export function loadActionCatalog(forceReload = false): ActionCatalog {
|
||||
if (cachedCatalog && !forceReload) {
|
||||
return cachedCatalog;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CATALOG_PATH, 'utf-8');
|
||||
cachedCatalog = JSON.parse(raw);
|
||||
return cachedCatalog!;
|
||||
}
|
||||
|
||||
export function getAction(actionKey: string): ActionDefinition | undefined {
|
||||
const catalog = loadActionCatalog();
|
||||
return catalog.actions[actionKey];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* generate-playwright-tests.js
|
||||
*
|
||||
* Generates Playwright + MCP spec files from the editor's test data.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadActionCatalog, getAction } = require('./action-catalog/index.js');
|
||||
|
||||
const DATA_FILE = path.join(__dirname, '..', '..', 'legacy', 'data', 'testcases.json'); // legacy JSON (for export scripts)
|
||||
const OUTPUT_BASE = path.join(__dirname, '..', '..', 'tests', 'generated');
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--group' && args[i + 1]) options.group = args[++i];
|
||||
if (args[i] === '--dry-run') options.dryRun = true;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function loadTests() {
|
||||
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
|
||||
}
|
||||
|
||||
function getTestsToGenerate(tests, options) {
|
||||
let filtered = tests.filter(t => t.fullyAutomated === true || t.status === 'automated');
|
||||
|
||||
if (options.group) {
|
||||
filtered = filtered.filter(t => t.group === options.group);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function generateSpecFile(group, tests, catalog) {
|
||||
let code = `import { test, expect } from '@playwright/test';\n\n`;
|
||||
code += `// === AUTOGENERATED BY AUTOTEST EDITOR ===\n`;
|
||||
code += `// Group: ${group}\n`;
|
||||
code += `// Generated: ${new Date().toISOString()}\n\n`;
|
||||
|
||||
code += `test.describe('${group}', () => {\n`;
|
||||
code += ` test.use({ ignoreHTTPSErrors: true });\n\n`;
|
||||
|
||||
for (const test of tests) {
|
||||
code += generateSingleTest(test, catalog);
|
||||
}
|
||||
|
||||
code += `});\n`;
|
||||
return code;
|
||||
}
|
||||
|
||||
function generateSingleTest(test, catalog) {
|
||||
let body = '';
|
||||
|
||||
const steps = test.codeSteps && test.codeSteps.length > 0
|
||||
? test.codeSteps
|
||||
: (test.steps || []).map(s => ({ stepName: s, actionKey: 'search_for_document' }));
|
||||
|
||||
for (const step of steps) {
|
||||
const action = getAction(step.actionKey);
|
||||
if (action) {
|
||||
let rendered = action.template;
|
||||
if (step.params) {
|
||||
Object.keys(step.params).forEach(key => {
|
||||
rendered = rendered.replace(new RegExp(`{{${key}}}`, 'g'), step.params[key]);
|
||||
});
|
||||
}
|
||||
body += ` await test.step('${step.stepName}', async () => {\n`;
|
||||
body += ` ${rendered}\n`;
|
||||
body += ` });\n\n`;
|
||||
} else {
|
||||
body += ` await test.step('${step.stepName}', async () => {\n`;
|
||||
body += ` // TODO: Implement action "${step.actionKey}"\n`;
|
||||
body += ` });\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
let testCode = ` test('${test.id}', async ({ page }, testInfo) => {\n`;
|
||||
testCode += ` testInfo.annotations.push({ type: 'automationStatus', description: '${test.status}' });\n`;
|
||||
testCode += ` testInfo.annotations.push({ type: 'testType', description: '${test.action}' });\n`;
|
||||
testCode += ` testInfo.annotations.push({ type: 'description', description: '${test.description}' });\n\n`;
|
||||
testCode += body;
|
||||
testCode += ` });\n\n`;
|
||||
|
||||
return testCode;
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs();
|
||||
const tests = loadTests();
|
||||
const catalog = loadActionCatalog();
|
||||
|
||||
const toGenerate = getTestsToGenerate(tests, options);
|
||||
|
||||
if (toGenerate.length === 0) {
|
||||
console.log('No automated tests found for the given filters.');
|
||||
return;
|
||||
}
|
||||
|
||||
const byGroup = {};
|
||||
toGenerate.forEach(t => {
|
||||
if (!byGroup[t.group]) byGroup[t.group] = [];
|
||||
byGroup[t.group].push(t);
|
||||
});
|
||||
|
||||
for (const group of Object.keys(byGroup)) {
|
||||
const groupTests = byGroup[group];
|
||||
const moduleFolder = group.startsWith('core-') ? 'core' : 'pbx';
|
||||
const outputDir = path.join(OUTPUT_BASE, moduleFolder);
|
||||
ensureDir(outputDir);
|
||||
|
||||
const fileName = group.replace('core-', '').replace('pbx-', '') + '.spec.ts';
|
||||
const outputPath = path.join(outputDir, fileName);
|
||||
|
||||
const fileContent = generateSpecFile(group, groupTests, catalog);
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`\n=== DRY RUN: ${outputPath} ===\n`);
|
||||
console.log(fileContent);
|
||||
} else {
|
||||
fs.writeFileSync(outputPath, fileContent, 'utf-8');
|
||||
console.log(`✅ Generated: ${outputPath} (${groupTests.length} tests)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* generate-report.js
|
||||
* Generates the automation analysis report (qa_automation.html) with timestamp.
|
||||
* Output location: editor/output/reports/
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_FILE = path.join(__dirname, '..', '..', 'legacy', 'data', 'testcases.json'); // legacy JSON (for export scripts)
|
||||
const OUTPUT_DIR = path.join(__dirname, '..', '..', 'output', 'reports');
|
||||
|
||||
function generateReport() {
|
||||
const tests = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||
|
||||
// Calculate statistics
|
||||
const total = tests.length;
|
||||
const automated = tests.filter(t => t.status === 'automated').length;
|
||||
const partial = tests.filter(t => t.status === 'partial').length;
|
||||
const manual = tests.filter(t => t.status === 'manual').length;
|
||||
|
||||
const autoPct = total > 0 ? ((automated / total) * 100).toFixed(1) : 0;
|
||||
const partialPct = total > 0 ? ((partial / total) * 100).toFixed(1) : 0;
|
||||
const manualPct = total > 0 ? ((manual / total) * 100).toFixed(1) : 0;
|
||||
|
||||
// Group counts for the bar chart
|
||||
const groups = [...new Set(tests.map(t => t.group))].sort();
|
||||
const groupStats = {};
|
||||
groups.forEach(g => {
|
||||
groupStats[g] = {
|
||||
automated: tests.filter(t => t.group === g && t.status === 'automated').length,
|
||||
partial: tests.filter(t => t.group === g && t.status === 'partial').length,
|
||||
manual: tests.filter(t => t.group === g && t.status === 'manual').length
|
||||
};
|
||||
});
|
||||
|
||||
// Embed data
|
||||
const embeddedData = JSON.stringify(tests);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QA Automation Test Plan Report</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body { background: #0b1120; color: #e2e8f0; font-family: system-ui, -apple-system, sans-serif; }
|
||||
.card { background: #1e2937; border: 1px solid #334155; }
|
||||
.status-automated { background:#052e16; color:#4ade80; }
|
||||
.status-partial { background:#451a03; color:#fbbf24; }
|
||||
.status-manual { background:#450a0a; color:#f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-sm">
|
||||
<div class="max-w-[1500px] mx-auto p-8">
|
||||
<div class="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">QA Automation Test Plan Report</h1>
|
||||
<p class="text-[#64748b]">Generated from Test Case Editor • ${new Date().toISOString().slice(0,10)}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-[#64748b]">
|
||||
${total} test cases • ${autoPct}% automated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-8">
|
||||
<div class="card p-5 rounded-2xl">
|
||||
<div class="text-[#64748b] text-xs">TOTAL TESTS</div>
|
||||
<div class="text-5xl font-bold mt-1">${total}</div>
|
||||
</div>
|
||||
<div class="card p-5 rounded-2xl border-l-4 border-emerald-500">
|
||||
<div class="text-emerald-400 text-xs">AUTOMATED</div>
|
||||
<div class="text-5xl font-bold text-emerald-400 mt-1">${automated}</div>
|
||||
<div class="text-xs mt-1">${autoPct}%</div>
|
||||
</div>
|
||||
<div class="card p-5 rounded-2xl border-l-4 border-amber-500">
|
||||
<div class="text-amber-400 text-xs">PARTIAL</div>
|
||||
<div class="text-5xl font-bold text-amber-400 mt-1">${partial}</div>
|
||||
<div class="text-xs mt-1">${partialPct}%</div>
|
||||
</div>
|
||||
<div class="card p-5 rounded-2xl border-l-4 border-red-500">
|
||||
<div class="text-red-400 text-xs">MANUAL</div>
|
||||
<div class="text-5xl font-bold text-red-400 mt-1">${manual}</div>
|
||||
<div class="text-xs mt-1">${manualPct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 text-[#64748b] text-xs font-semibold">TEST CASES</div>
|
||||
|
||||
<div class="card rounded-2xl overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-[#0f172a] text-[#64748b] text-xs">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">#</th>
|
||||
<th class="px-4 py-3 text-left">ID</th>
|
||||
<th class="px-4 py-3 text-left">Group</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3 text-left">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[#334155]">
|
||||
${tests.map((t, i) => `
|
||||
<tr class="hover:bg-[#162032]">
|
||||
<td class="px-4 py-3 text-[#64748b]">${i+1}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">${t.id}</td>
|
||||
<td class="px-4 py-3">${t.group}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-3 py-0.5 rounded-full text-xs font-semibold status-${t.status}">${t.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[#cbd5e1]">${t.description}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center text-xs text-[#64748b]">
|
||||
Generated by Autotest • Data source: YAML under testcases/ (legacy JSON during transition)
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate timestamped filename
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-').replace('T', '_');
|
||||
const finalFilename = `${timestamp}_qa_automation.html`;
|
||||
const finalPath = path.join(OUTPUT_DIR, finalFilename);
|
||||
|
||||
fs.writeFileSync(finalPath, html);
|
||||
|
||||
console.log(`✅ Automation report generated: ${finalPath}`);
|
||||
console.log(` ${total} tests | ${automated} automated (${autoPct}%)`);
|
||||
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
generateReport();
|
||||
}
|
||||
|
||||
module.exports = { generateReport };
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* generate-test-plan.js
|
||||
* Generates a self-contained, fully inline-styled HTML Test Plan
|
||||
* for manual execution by testers.
|
||||
*
|
||||
* Output: output/testplans/YYYY-MM-DD_HH-MM-SS_{baseName}.html
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUTPUT_DIR = path.join(__dirname, '..', '..', 'output', 'testplans');
|
||||
|
||||
function generateTestPlan(selectedTests, baseName = 'Manual-Test-Plan') {
|
||||
if (!Array.isArray(selectedTests) || selectedTests.length === 0) {
|
||||
throw new Error('No test cases selected for the Test Plan');
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate timestamped filename
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-').replace('T', '_');
|
||||
const safeBase = baseName.replace(/[^a-zA-Z0-9-_]/g, '').slice(0, 60) || 'Manual-Test-Plan';
|
||||
const filename = `${timestamp}_${safeBase}.html`;
|
||||
const finalPath = path.join(OUTPUT_DIR, filename);
|
||||
|
||||
// Group tests by group
|
||||
const grouped = {};
|
||||
selectedTests.forEach(test => {
|
||||
const g = test.group || 'Other';
|
||||
if (!grouped[g]) grouped[g] = [];
|
||||
grouped[g].push(test);
|
||||
});
|
||||
|
||||
const groups = Object.keys(grouped).sort();
|
||||
|
||||
// Build HTML
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Manual Test Plan - ${safeBase}</title>
|
||||
<style>
|
||||
body { font-family: Arial, Helvetica, sans-serif; font-size: 13px; line-height: 1.45; color: #222; margin: 20px; background: #fff; }
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
h2 { font-size: 16px; margin-top: 24px; margin-bottom: 8px; border-bottom: 2px solid #333; padding-bottom: 4px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
||||
th, td { border: 1px solid #999; padding: 6px 8px; vertical-align: top; }
|
||||
th { background: #f0f0f0; text-align: left; font-weight: 600; }
|
||||
.meta { margin-bottom: 16px; color: #555; }
|
||||
.test-id { font-family: monospace; font-weight: 600; }
|
||||
.steps { margin: 4px 0; padding-left: 18px; }
|
||||
.field-label { font-weight: 600; font-size: 11px; color: #444; margin-top: 6px; }
|
||||
textarea, select { width: 100%; box-sizing: border-box; font-size: 12px; }
|
||||
textarea { min-height: 50px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 12px; }
|
||||
.generated { font-size: 11px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Manual Test Plan</h1>
|
||||
<div class="meta"><strong>${safeBase}</strong> — Generated on ${now.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="generated">Total cases: ${selectedTests.length}</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th style="width: 18%;">Test ID</th>
|
||||
<th style="width: 28%;">Description & Expected Result</th>
|
||||
<th style="width: 22%;">Steps</th>
|
||||
<th style="width: 12%;">Severity</th>
|
||||
<th style="width: 20%;">Result / Comments</th>
|
||||
</tr>
|
||||
|
||||
${groups.map(group => `
|
||||
<tr>
|
||||
<td colspan="5" style="background:#e8e8e8; font-weight:700; padding:8px 10px;">${group}</td>
|
||||
</tr>
|
||||
${grouped[group].map(test => {
|
||||
const expected = test.description || '';
|
||||
const stepsHtml = (test.steps || []).map((s, i) => `<li>${escapeHtml(s)}</li>`).join('');
|
||||
const depsHtml = (test.dependencies || []).map(d => `<div>${escapeHtml(d.label)}: ${escapeHtml(d.value)}</div>`).join('');
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="test-id">${escapeHtml(test.id)}</td>
|
||||
<td>
|
||||
<strong>${escapeHtml(test.description || '')}</strong>
|
||||
<div class="field-label">Expected Result</div>
|
||||
<textarea>${escapeHtml(expected)}</textarea>
|
||||
</td>
|
||||
<td>
|
||||
<ol class="steps">${stepsHtml}</ol>
|
||||
${depsHtml ? `<div class="field-label">Dependencies</div><div style="font-size:11px;">${depsHtml}</div>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<select>
|
||||
<option>Low</option>
|
||||
<option>Medium</option>
|
||||
<option selected>High</option>
|
||||
<option>Critical</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div class="field-label">Status</div>
|
||||
<select>
|
||||
<option>Pass</option>
|
||||
<option>Fail</option>
|
||||
<option>Blocked</option>
|
||||
<option selected>N/A</option>
|
||||
</select>
|
||||
<div class="field-label" style="margin-top:8px;">Actual Result / Comments</div>
|
||||
<textarea></textarea>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
`).join('')}
|
||||
|
||||
</table>
|
||||
|
||||
<p style="font-size:11px; color:#666; margin-top:30px;">
|
||||
Generated by QA Automation Test Plan Editor • All results and comments should be filled by the tester.
|
||||
</p>
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(finalPath, html);
|
||||
console.log(`✅ Manual Test Plan generated: ${finalPath}`);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
module.exports = { generateTestPlan };
|
||||
Reference in New Issue
Block a user