Files
autotest/web/index.html
T

1242 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Case Editor • PBX Automation</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
/* =========================================
AURA THEME by Dalton Menezes
https://github.com/daltonmenezes/aura-theme
========================================= */
:root {
/* Backgrounds */
--bg: #21202e;
--bg-secondary: #1c1b29;
--card: #27263a;
--card-hover: #2a293a;
--input: #1f1e2e;
/* Borders & Lines */
--border: #3a3849;
--border-light: #4a4859;
/* Text */
--text: #edecee;
--text-muted: #a0a0b0;
--text-secondary: #8a8899;
/* Accents (Aura signature) */
--accent: #a277ff; /* Purple */
--accent-hover: #b38aff;
--accent-warm: #ffca85; /* Warm orange/yellow */
--green: #6cc76f;
--red: #ff6767;
--orange: #ffca85;
}
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--card);
border: 1px solid var(--border);
}
.row-hover:hover {
background: var(--card-hover);
}
.section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: .6px;
text-transform: uppercase;
color: var(--text-secondary);
}
.modal {
animation: fadeInScale .15s ease-out;
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.editable-list li {
display: block;
margin-bottom: 0;
}
#listSteps li {
margin-bottom: 0;
}
.editable-list input {
flex: 1;
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
}
.editable-list button {
color: var(--text-secondary);
}
.editable-list button:hover {
color: var(--red);
}
/* Step cards for better visual grouping in the modal */
.step-card {
background: var(--input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 8px;
}
.step-card input {
background: var(--bg);
border: 1px solid var(--border);
}
.step-header {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.step-number {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
min-width: 20px;
text-align: center;
}
.step-label {
font-size: 10px;
color: var(--text-secondary);
margin-bottom: 2px;
display: block;
letter-spacing: 0.3px;
}
.step-expected input {
font-size: 12px;
}
.dep-row {
display: flex;
gap: 8px;
margin-bottom: 6px;
}
.dep-row input {
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
padding: 5px 8px;
border-radius: 4px;
font-size: 13px;
}
/* Status colors */
.status-automated {
background: #1a3c2e;
color: var(--green);
}
.status-partial {
background: #3f2f1f;
color: var(--orange);
}
.status-manual {
background: #3f1f28;
color: var(--red);
}
.dirty {
color: var(--orange);
}
/* Aura accent buttons */
.btn-accent {
background: var(--input);
color: var(--accent);
border: 1px solid var(--accent);
}
.btn-accent:hover {
background: #2f2a3f;
}
/* Component styles - Aura Theme */
.group-card {
background: var(--card);
border: 1px solid var(--border);
}
.group-card:hover {
border-color: var(--accent);
}
.group-card.active {
background: var(--card);
border: 2px solid var(--accent);
box-shadow: 0 0 0 1px rgba(162, 119, 255, 0.3);
}
.module-card {
background: var(--card);
border: 1px solid var(--border);
}
.module-card:hover {
border-color: var(--accent);
}
.module-card.active {
background: var(--card);
border: 2px solid var(--accent);
box-shadow: 0 0 0 1px rgba(162, 119, 255, 0.3);
}
.nav-button {
background: var(--input);
color: var(--accent);
border: 1px solid var(--accent);
}
.nav-button:hover {
background: #2f2a3f;
}
</style>
</head>
<body class="text-sm">
<!-- Full-width Navbar -->
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border)]">
<div class="max-w-[1600px] mx-auto px-8 py-5 flex items-center justify-between">
<div>
<h1 onclick="resetToHome()"
class="text-2xl font-bold flex items-center gap-3 cursor-pointer hover:text-[var(--accent)] transition-colors">
<i class="fa-solid fa-robot text-[var(--accent)]"></i>
Autotest
</h1>
<p class="text-[#a0a0b0] text-xs mt-0.5">Test Case Manager &amp; Planning Tool</p>
</div>
<div class="flex items-center gap-3">
<button onclick="saveAll()"
class="h-10 px-4 nav-button rounded-lg font-semibold flex items-center gap-2 text-sm">
<i class="fa-solid fa-save"></i>
<span>Save</span>
</button>
<!-- Unified Plans button (Create + Load) -->
<button onclick="openPlansModal()"
class="h-10 px-4 nav-button rounded-lg font-semibold flex items-center gap-2 text-sm">
<i class="fa-solid fa-list-check"></i>
<span>Plans</span>
</button>
<button onclick="reloadData()"
class="h-10 px-4 bg-[var(--card)] hover:bg-[var(--border)] rounded-lg text-sm flex items-center gap-2">
<i class="fa-solid fa-sync"></i>
<span>Reload</span>
</button>
</div>
</div>
</div>
<!-- Constrained content -->
<div class="max-w-[1600px] mx-auto">
<!-- Groups Overview (high-level bird's eye view) -->
<div class="px-8 pt-2">
<div class="flex items-center justify-between mb-2 px-1">
<div class="text-sm font-semibold text-[#a0a0b0]">Groups Overview</div>
<button onclick="resetModuleView()" class="text-xs text-[#a0a0b0] hover:text-[#edecee]">Reset view</button>
</div>
<div id="groupsOverview" class="min-h-[120px]"></div>
</div>
<!-- Tests overview -->
<div class="px-8 pt-3 pb-1 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-[#a0a0b0]">Tests overview</span>
<span id="visibleCount" class="text-xs text-[var(--accent-warm)] font-medium">0 tests</span>
</div>
<span id="saveFeedback" class="text-xs text-amber-400"></span>
</div>
<!-- Table -->
<div class="px-8 pt-4 pb-12">
<!-- Selection Toolbar -->
<div class="flex items-center gap-2 mb-2 px-1 text-xs">
<span class="text-[#64748b]">Selection:</span>
<button onclick="selectOnlyManualAndPartial()" class="px-2 py-1 bg-[#27263a] hover:bg-[#3f2f1f] rounded text-[#ffca85]">Manual + Partial</button>
<button onclick="selectAllCore()" class="px-2 py-1 bg-[#27263a] hover:bg-[#2f2a3f] rounded">All Core</button>
<button onclick="selectAllPbx()" class="px-2 py-1 bg-[#27263a] hover:bg-[#2f2a3f] rounded">All PBX</button>
<button onclick="clearSelection()" class="px-2 py-1 bg-[#27263a] hover:bg-[#3f1f28] rounded text-[#ff6767]">Clear</button>
<button onclick="selectOnlyFullyAutomated()" class="px-2 py-1 bg-[#27263a] hover:bg-[#3f2f1f] rounded text-[#ffca85]">Only Fully Automated</button>
<span class="ml-3 text-amber-300 font-medium" id="selectionCount"></span>
</div>
<div class="card rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-[var(--input)] text-[var(--text-secondary)] text-xs">
<th class="w-8 py-3 px-2"></th>
<th class="w-10 py-3 px-2 text-left">#</th>
<th class="py-3 px-3 text-left">Test ID</th>
<th class="w-16 py-3 px-2 text-left">Module</th>
<th class="w-16 py-3 px-2 text-left">Action</th>
<th class="py-3 px-3 text-left">Description</th>
<th class="w-24 py-3 px-2 text-left">Status</th>
</tr>
</thead>
<tbody id="tableBody" class="divide-y divide-[#334155]"></tbody>
</table>
</div>
<div id="noResults" class="hidden text-center py-8 text-[#64748b]">No tests match the current filters.</div>
</div>
</div>
<!-- EDIT MODAL -->
<div id="editModal" class="hidden fixed inset-0 bg-black/70 flex items-center justify-center z-50" onclick="if (event.target.id === 'editModal') closeModal()">
<div class="modal bg-[var(--card)] border border-[var(--border)] rounded-2xl w-full max-w-4xl max-h-[92vh] flex flex-col mx-4" onclick="event.stopImmediatePropagation()">
<!-- Modal Header -->
<div class="px-6 py-4 border-b border-[var(--border)] flex items-center justify-between bg-[var(--input)] rounded-t-2xl">
<div>
<div id="modalId" class="font-mono text-sm text-[var(--accent-warm)]"></div>
<div id="modalGroup" class="text-xs text-[#64748b]"></div>
</div>
<div class="flex items-center gap-3">
<button onclick="resetToSuggested()"
class="text-xs px-3 py-1 bg-[var(--input)] text-[var(--accent)] border border-[var(--accent)] hover:bg-[#2f2a3f] rounded flex items-center gap-1 whitespace-nowrap">
<i class="fa-solid fa-magic"></i>
<span>Reset to suggested</span>
</button>
<button onclick="closeModal()" class="text-[#64748b] hover:text-white px-2 text-xl">×</button>
</div>
</div>
<div class="p-6 overflow-auto flex-1 space-y-6 text-sm">
<!-- 1. Description -->
<div>
<div class="section-title mb-1.5">Description</div>
<textarea id="modalDescription" rows="2"
class="w-full bg-[var(--input)] border border-[var(--border)] rounded-lg p-3 text-sm"></textarea>
</div>
<!-- 2. Automatable -->
<div>
<div class="flex items-center gap-2">
<input type="checkbox" id="automatableCheckbox" class="w-4 h-4" onchange="toggleAutomationTool()">
<label for="automatableCheckbox" class="section-title text-emerald-400 cursor-pointer">Automatable</label>
</div>
<!-- Automation tool selector (shown only when Automatable is checked) -->
<div id="automationToolContainer" class="mt-2 hidden">
<div class="text-xs text-[#64748b] mb-1">Automation tool</div>
<select id="automationTool" class="bg-[var(--input)] border border-[var(--border)] rounded px-3 py-1 text-sm w-full">
<option value="">Select tool...</option>
<option value="agent-ai">Agent AI</option>
<option value="playwright">Playwright</option>
<option value="paramiko">Paramiko</option>
</select>
</div>
</div>
<!-- 3. Dependencies -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="section-title">Dependencies</div>
<button onclick="addDependency()" class="text-xs px-2.5 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded">+ Add</button>
</div>
<div id="depContainer" class="space-y-1"></div>
</div>
<!-- 4. Test Steps (action + expected result only) -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="section-title">Test Steps</div>
<button onclick="addRichStep()" class="text-xs px-2.5 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded">+ Add step</button>
</div>
<ul id="listSteps" class="editable-list text-sm"></ul>
</div>
</div>
<div class="px-6 py-4 border-t border-[var(--border)] bg-[var(--input)] rounded-b-2xl flex justify-end gap-3">
<button onclick="closeModal()" class="px-5 py-2 text-sm rounded-lg bg-[var(--border)] hover:bg-[var(--border-light)]">Cancel</button>
<button onclick="saveCurrentTest()"
class="px-5 py-2 text-sm rounded-lg bg-[var(--input)] text-[var(--accent)] border border-[var(--accent)] hover:bg-[#2f2a3f] font-semibold">
Save
</button>
</div>
</div>
</div>
<script>
let allTests = [];
let filteredTests = [];
let currentTest = null;
let currentIndex = -1;
let isDirty = false;
let selectedIds = new Set(); // For Generate Test Plan selection
async function loadData() {
try {
const res = await fetch('/api/testcases');
allTests = await res.json();
renderTable();
renderGroupsOverview();
updateSelectionCount();
} catch (e) {
alert('Could not load test cases. Make sure the server is running and data file exists.');
console.error(e);
}
}
let currentModuleView = null; // null | 'core' | 'pbx'
let currentGroupView = null; // null | groupName (only valid when currentModuleView is set)
function renderGroupsOverview() {
const container = document.getElementById('groupsOverview');
if (!container) return;
const byModule = { core: {}, pbx: {} };
allTests.forEach(t => {
const mod = t.module || 'unknown';
const grp = t.group || 'unknown';
if (!byModule[mod]) byModule[mod] = {};
if (!byModule[mod][grp]) {
byModule[mod][grp] = { total: 0, automated: 0, partial: 0, manual: 0 };
}
const g = byModule[mod][grp];
g.total++;
const status = t.status || 'manual';
if (status === 'automated') g.automated++;
else if (status === 'partial') g.partial++;
else g.manual++;
});
let html = '';
if (!currentModuleView) {
// Level 1: Choose module (bird's eye)
html += `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
${['core', 'pbx'].map(mod => {
const groups = byModule[mod] || {};
const totalTests = Object.values(groups).reduce((sum, g) => sum + g.total, 0);
const totalAutomated = Object.values(groups).reduce((sum, g) => sum + g.automated, 0);
const autoPct = totalTests > 0 ? Math.round((totalAutomated / totalTests) * 100) : 0;
const isActiveModule = currentModuleView === mod;
return `
<div onclick="selectModule('${mod}')"
class="module-card ${isActiveModule ? 'active' : ''} rounded-2xl p-6 cursor-pointer transition-all hover:scale-[1.01]">
<div class="flex items-center justify-between">
<div>
<div class="text-2xl font-bold">${mod.toUpperCase()}</div>
<div class="text-[#64748b] mt-1">${Object.keys(groups).length} groups ${totalTests} test cases</div>
</div>
<div class="text-right">
<div class="text-3xl font-semibold" style="color:#4ade80">${autoPct}<span class="text-base align-super">%</span></div>
<div class="text-xs text-[#64748b]">automated</div>
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
} else {
// Level 2: Show groups for the selected module
const mod = currentModuleView;
const groups = byModule[mod] || {};
const groupNames = Object.keys(groups).sort();
html += `
<div class="mb-2 flex items-center gap-2 text-sm">
<button onclick="resetModuleView()" class="text-[var(--accent)] hover:underline">← Back to modules</button>
<span class="text-[var(--text-muted)]">/</span>
<span class="font-semibold text-[var(--accent)]">${mod.toUpperCase()}</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
`;
groupNames.forEach(grp => {
const stats = groups[grp];
const autoPct = stats.total > 0 ? Math.round((stats.automated / stats.total) * 100) : 0;
const isActiveGroup = currentGroupView === grp;
html += `
<div onclick="filterByGroupAndModule('${mod}', '${grp}')"
class="group-card ${isActiveGroup ? 'active' : ''} rounded-xl p-3 cursor-pointer transition-colors">
<div class="flex justify-between items-start">
<div>
<div class="font-semibold text-sm">${grp}</div>
<div class="text-[11px] text-[var(--text-muted)] mt-0.5">${stats.total} tests</div>
</div>
<div class="text-right">
<div class="text-xs font-mono" style="color:#4ade80">${stats.automated}</div>
<div class="text-xs font-mono" style="color:#fbbf24">${stats.partial}</div>
<div class="text-xs font-mono" style="color:#f87171">${stats.manual}</div>
</div>
</div>
<div class="mt-2 h-1.5 bg-[var(--input)] rounded-full overflow-hidden">
<div class="h-1.5 bg-emerald-500" style="width:${autoPct}%"></div>
</div>
<div class="text-[10px] text-right mt-0.5 text-[var(--text-muted)]">${autoPct}% automated</div>
</div>
`;
});
html += `</div>`;
}
container.innerHTML = html;
}
function selectModule(mod) {
currentModuleView = mod;
currentGroupView = null; // mostrar todos los del módulo
renderGroupsOverview();
renderTable();
}
function resetModuleView() {
currentModuleView = null;
currentGroupView = null;
renderGroupsOverview();
renderTable();
}
function resetToHome() {
currentModuleView = null;
currentGroupView = null;
renderGroupsOverview();
renderTable();
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function filterByGroupAndModule(module, groupName) {
const wasAlreadySelected = (currentModuleView === module && currentGroupView === groupName);
if (wasAlreadySelected) {
// Deselecting the group → show all of the parent module, no scroll
currentGroupView = null;
} else {
currentModuleView = module;
currentGroupView = groupName;
}
renderGroupsOverview();
renderTable();
// Only scroll when selecting a new group (not when deselecting)
if (!wasAlreadySelected) {
document.querySelector('#tableBody')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
function getFiltered() {
return allTests.filter(t => {
if (currentModuleView && t.module !== currentModuleView) return false;
if (currentGroupView && t.group !== currentGroupView) return false;
return true;
});
}
function renderTable() {
filteredTests = getFiltered();
const tbody = document.getElementById('tableBody');
const noRes = document.getElementById('noResults');
if (filteredTests.length === 0) {
tbody.innerHTML = '';
noRes.classList.remove('hidden');
return;
}
noRes.classList.add('hidden');
// Group by 'group' field (e.g. core-repository, pbx-calls, etc.)
const grouped = {};
filteredTests.forEach(t => {
if (!grouped[t.group]) grouped[t.group] = [];
grouped[t.group].push(t);
});
const groupNames = Object.keys(grouped).sort();
let html = '';
groupNames.forEach(groupName => {
const testsInGroup = grouped[groupName];
const allSelectedInGroup = testsInGroup.every(t => selectedIds.has(t.id));
// Group header
html += `
<tr class="bg-[var(--input)]">
<td colspan="7" style="padding: 5px 10px; border-bottom: 1px solid #334155;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; color: #a277ff; font-weight: 600; font-size: 13px;">
<input type="checkbox" ${allSelectedInGroup ? 'checked' : ''}
onclick="event.stopImmediatePropagation(); toggleGroupSelection('${groupName}', this.checked)">
<span>${groupName}</span>
<span style="font-size: 11px; color: #64748b; font-weight: normal;">(${testsInGroup.length} tests)</span>
</label>
</td>
</tr>
`;
testsInGroup.forEach((t, idx) => {
const statusClass = `status-${t.status}`;
const shortId = t.id.length > 52 ? t.id.slice(0, 49) + '…' : t.id;
const isChecked = selectedIds.has(t.id) ? 'checked' : '';
html += `
<tr class="row-hover">
<td class="py-1.5 px-2">
<input type="checkbox" ${isChecked}
onclick="event.stopImmediatePropagation(); toggleSelection('${t.id}', this.checked)">
</td>
<td class="py-1.5 px-2 text-[#64748b] cursor-pointer text-xs" onclick="openEditor(${allTests.indexOf(t)})">${idx + 1}</td>
<td class="py-1.5 px-2 font-mono text-xs text-[#94a3b8] cursor-pointer" onclick="openEditor(${allTests.indexOf(t)})">${shortId}</td>
<td class="py-1.5 px-2 cursor-pointer" onclick="openEditor(${allTests.indexOf(t)})">
<span class="px-2 py-0.5 text-xs rounded ${t.module === 'core' ? 'bg-blue-900 text-blue-300' : 'bg-violet-900 text-violet-300'}">${t.module}</span>
</td>
<td class="py-1.5 px-2 text-[#94a3b8] cursor-pointer text-xs" onclick="openEditor(${allTests.indexOf(t)})">${t.action}</td>
<td class="py-1.5 px-2 text-[#e2e8f0] cursor-pointer text-sm" onclick="openEditor(${allTests.indexOf(t)})">${t.description}</td>
<td class="py-1.5 px-2 cursor-pointer" onclick="openEditor(${allTests.indexOf(t)})">
<span class="px-2.5 py-0.5 text-xs font-semibold rounded-full ${statusClass}">${t.status}</span>
</td>
</tr>
`;
});
});
tbody.innerHTML = html;
updateSelectionCount();
updateVisibleCount();
}
function resetFilters() {
currentModuleView = null;
currentGroupView = null;
renderGroupsOverview();
renderTable();
}
function toggleSelection(id, checked) {
if (checked) {
selectedIds.add(id);
} else {
selectedIds.delete(id);
}
renderTable();
updateSelectionCount();
}
function toggleGroupSelection(groupName, checked) {
const testsInGroup = allTests.filter(t => t.group === groupName);
testsInGroup.forEach(t => {
if (checked) {
selectedIds.add(t.id);
} else {
selectedIds.delete(t.id);
}
});
renderTable();
updateSelectionCount();
}
function toggleGroupSelection(groupName, checked) {
const testsInGroup = allTests.filter(t => t.group === groupName);
testsInGroup.forEach(t => {
if (checked) {
selectedIds.add(t.id);
} else {
selectedIds.delete(t.id);
}
});
renderTable();
updateSelectionCount();
}
// Bulk selection helpers
function selectOnlyManualAndPartial() {
selectedIds.clear();
allTests.forEach(t => {
if (t.status === 'manual' || t.status === 'partial') selectedIds.add(t.id);
});
renderTable();
updateSelectionCount();
}
function selectAllCore() {
selectedIds.clear();
allTests.forEach(t => {
if (t.module === 'core') selectedIds.add(t.id);
});
renderTable();
updateSelectionCount();
}
function selectAllPbx() {
selectedIds.clear();
allTests.forEach(t => {
if (t.module === 'pbx') selectedIds.add(t.id);
});
renderTable();
updateSelectionCount();
}
function selectOnlyFullyAutomated() {
selectedIds.clear();
allTests.forEach(t => {
if (t.fullyAutomated === true) selectedIds.add(t.id);
});
renderTable();
updateSelectionCount();
}
function clearSelection() {
selectedIds.clear();
renderTable();
updateSelectionCount();
}
function updateSelectionCount() {
const el = document.getElementById('selectionCount');
if (el) {
el.textContent = selectedIds.size > 0 ? `${selectedIds.size} selected` : '';
}
}
function updateVisibleCount() {
const el = document.getElementById('visibleCount');
if (el) {
const n = filteredTests.length;
el.textContent = `${n} test${n === 1 ? '' : 's'}`;
}
}
// Unified Plans modal (Create + Load)
async function openPlansModal() {
const modal = document.createElement('div');
modal.id = 'plansModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999';
const hasSelection = selectedIds.size > 0;
modal.innerHTML = `
<div style="background:#1e2937; border:1px solid #334155; border-radius:12px; width:90%; max-width:620px; padding:24px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 style="margin:0; color:var(--accent); font-size:18px;">Test Plans</h3>
<button onclick="closePlansModal()" style="background:none; border:none; color:#64748b; font-size:22px; cursor:pointer; line-height:1;">×</button>
</div>
<!-- Create new plan -->
<div style="margin-bottom:24px;">
<div style="font-size:13px; color:#94a3b8; margin-bottom:6px;">Create new plan from selection</div>
<div style="display:flex; gap:8px;">
<input id="newPlanName" type="text" value="Manual Test Plan"
style="flex:1; background:#0f172a; border:1px solid #334155; color:#e2e8f0; padding:8px 12px; border-radius:6px;"
${!hasSelection ? 'disabled' : ''}>
<button onclick="createPlanFromModal()"
style="padding:8px 16px; background:var(--accent); color:var(--bg); border:none; border-radius:6px; font-weight:600; cursor:pointer;"
${!hasSelection ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>
Create
</button>
</div>
${!hasSelection ? '<div style="font-size:12px; color:#f87171; margin-top:4px;">Select one or more test cases first</div>' : ''}
</div>
<!-- Load existing plans -->
<div>
<div style="font-size:13px; color:#94a3b8; margin-bottom:8px;">Load existing plan</div>
<div id="plansList" style="max-height:320px; overflow:auto; border:1px solid #334155; border-radius:8px;">
<div style="padding:16px; color:#64748b; text-align:center;">Loading plans...</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Load existing plans
try {
const res = await fetch('/api/test-plans');
const plans = await res.json();
const container = document.getElementById('plansList');
if (!plans || plans.length === 0) {
container.innerHTML = `<div style="padding:16px; color:#64748b; text-align:center;">No saved plans yet.</div>`;
return;
}
let html = '';
plans.forEach(p => {
const date = new Date(p.updatedAt || p.createdAt).toLocaleString();
html += `
<div onclick="openPlan('${p.id}'); closePlansModal();"
style="padding:12px 16px; border-bottom:1px solid #334155; cursor:pointer; display:flex; justify-content:space-between; align-items:center;">
<div>
<strong>${p.name}</strong><br>
<small style="color:#64748b">${p.testCount} tests • ${date}</small>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<div style="color:var(--accent); font-size:13px;">Open →</div>
<button onclick="event.stopImmediatePropagation(); deletePlan('${p.id}', this);"
style="background:none; border:none; color:#f87171; cursor:pointer; padding:4px 6px; font-size:14px;"
title="Delete plan">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
});
container.innerHTML = html;
} catch (e) {
document.getElementById('plansList').innerHTML =
`<div style="padding:16px; color:#f87171;">Error loading plans.</div>`;
}
}
function closePlansModal() {
const m = document.getElementById('plansModal');
if (m) m.remove();
}
async function deletePlan(planId, buttonElement) {
if (!confirm('Are you sure you want to delete this plan? This cannot be undone.')) {
return;
}
const row = buttonElement.closest('div[onclick]');
if (row) row.style.opacity = '0.5';
try {
const res = await fetch(`/api/test-plans/${planId}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
// Remove the row from the list
if (row) row.remove();
} else {
alert('Failed to delete plan: ' + (data.error || 'Unknown error'));
if (row) row.style.opacity = '1';
}
} catch (e) {
alert('Error deleting plan: ' + e.message);
if (row) row.style.opacity = '1';
}
}
async function createPlanFromModal() {
const nameInput = document.getElementById('newPlanName');
const name = nameInput ? nameInput.value.trim() : 'Manual Test Plan';
if (!name) return;
const ids = Array.from(selectedIds);
try {
const res = await fetch('/api/test-plans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, name })
});
const data = await res.json();
if (data.success && data.planId) {
closePlansModal();
window.open(`/tester-plan.html?plan=${data.planId}`, '_blank');
alert(`✅ Test Plan created!\n\nOpened in a new tab.`);
} else {
alert('Error creating plan: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to create Test Plan: ' + e.message);
}
}
function openPlan(planId) {
window.open(`/tester-plan.html?plan=${planId}`, '_blank');
closePlansModal();
}
function openEditor(globalIndex) {
currentIndex = globalIndex;
currentTest = JSON.parse(JSON.stringify(allTests[globalIndex])); // deep clone
document.getElementById('modalId').textContent = currentTest.id;
document.getElementById('modalGroup').textContent = `${currentTest.group}${currentTest.file}`;
document.getElementById('modalDescription').value = currentTest.description || '';
// Automatable checkbox + tool selector
const autoCb = document.getElementById('automatableCheckbox');
if (typeof currentTest.automatable === 'boolean') {
autoCb.checked = currentTest.automatable;
} else {
autoCb.checked = currentTest.status === 'automated';
}
const toolSelect = document.getElementById('automationTool');
if (toolSelect) {
toolSelect.value = currentTest.automationTool || '';
}
toggleAutomationTool();
// Render the 4 definition fields
renderRichSteps(currentTest.steps || []);
renderDependencies(currentTest.dependencies || []);
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
}
function renderList(containerId, items) {
const ul = document.getElementById(containerId);
ul.innerHTML = items.map((item, idx) => `
<li>
<input value="${item.replace(/"/g, '&quot;')}" oninput="updateListItem('${containerId}', ${idx}, this.value)">
<button onclick="removeListItem('${containerId}', ${idx})"><i class="fa-solid fa-trash text-xs"></i></button>
</li>
`).join('');
ul.dataset.items = JSON.stringify(items);
}
function updateListItem(containerId, idx, value) {
const ul = document.getElementById(containerId);
const items = JSON.parse(ul.dataset.items || '[]');
items[idx] = value;
ul.dataset.items = JSON.stringify(items);
}
function removeListItem(containerId, idx) {
const ul = document.getElementById(containerId);
const items = JSON.parse(ul.dataset.items || '[]');
items.splice(idx, 1);
renderList(containerId, items);
}
function addListItem(type) {
const map = { steps: 'listSteps', automatable: 'listAutomatable', notYet: 'listNotYet' };
const id = map[type];
const ul = document.getElementById(id);
const items = JSON.parse(ul.dataset.items || '[]');
items.push('');
renderList(id, items);
}
// === Step editor: action + expected result only (notes removed per definition rules) ===
function renderRichSteps(steps) {
const ul = document.getElementById('listSteps');
ul.innerHTML = '';
const rich = steps.map(s => {
if (typeof s === 'string') {
return { action: s, expected: '' };
}
return {
action: s.action || s,
expected: s.expected || ''
};
});
rich.forEach((step, idx) => {
const li = document.createElement('li');
li.innerHTML = `
<div class="step-card">
<div class="step-header">
<span class="step-number">${idx + 1}</span>
<input
value="${(step.action || '').replace(/"/g, '&quot;')}"
placeholder="Action / Description"
style="flex:1; font-size:13px;"
oninput="updateRichStep(${idx}, 'action', this.value)">
<button onclick="removeRichStep(${idx})" style="color:#f87171; padding: 2px 4px;">
<i class="fa-solid fa-trash text-xs"></i>
</button>
</div>
<div class="step-expected">
<span class="step-label">Expected result</span>
<input
value="${(step.expected || '').replace(/"/g, '&quot;')}"
placeholder="What should happen after this step"
style="width:100%;"
oninput="updateRichStep(${idx}, 'expected', this.value)">
</div>
</div>
`;
ul.appendChild(li);
});
ul.dataset.richSteps = JSON.stringify(rich);
}
function updateRichStep(idx, field, value) {
const ul = document.getElementById('listSteps');
const steps = JSON.parse(ul.dataset.richSteps || '[]');
if (!steps[idx]) steps[idx] = {};
steps[idx][field] = value;
}
function removeRichStep(idx) {
const ul = document.getElementById('listSteps');
const steps = JSON.parse(ul.dataset.richSteps || '[]');
steps.splice(idx, 1);
renderRichSteps(steps);
}
function addRichStep() {
const ul = document.getElementById('listSteps');
const steps = JSON.parse(ul.dataset.richSteps || '[]');
steps.push({ action: '', expected: '' });
renderRichSteps(steps);
}
function toggleAutomationTool() {
const cb = document.getElementById('automatableCheckbox');
const container = document.getElementById('automationToolContainer');
if (container) {
container.classList.toggle('hidden', !cb.checked);
}
}
function renderDependencies(deps) {
const container = document.getElementById('depContainer');
container.innerHTML = deps.map((d, i) => `
<div class="dep-row">
<input value="${d.label || ''}" placeholder="Label" style="width:160px" oninput="updateDep(${i}, 'label', this.value)">
<input value="${d.value || ''}" placeholder="Value / URL / Requirement" style="flex:1" oninput="updateDep(${i}, 'value', this.value)">
<button onclick="removeDep(${i})" class="px-2 text-red-400"><i class="fa-solid fa-trash"></i></button>
</div>
`).join('');
container.dataset.deps = JSON.stringify(deps);
}
function updateDep(idx, field, val) {
const container = document.getElementById('depContainer');
const deps = JSON.parse(container.dataset.deps || '[]');
deps[idx][field] = val;
container.dataset.deps = JSON.stringify(deps);
}
function removeDep(idx) {
const container = document.getElementById('depContainer');
const deps = JSON.parse(container.dataset.deps || '[]');
deps.splice(idx, 1);
renderDependencies(deps);
}
function addDependency() {
const container = document.getElementById('depContainer');
const deps = JSON.parse(container.dataset.deps || '[]');
deps.push({ label: '', value: '' });
renderDependencies(deps);
}
async function resetToSuggested() {
if (!currentTest) return;
// Build a minimal test object from current modal values (no status select anymore)
const tempTest = {
id: currentTest.id,
group: currentTest.group,
module: currentTest.module,
action: currentTest.action,
description: document.getElementById('modalDescription').value.trim()
};
try {
const res = await fetch('/api/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tempTest)
});
const rich = await res.json();
// Repopulate only the fields the modal still edits
currentTest.steps = rich.steps || [];
currentTest.dependencies = rich.dependencies || [];
// Set the simple automatable checkbox from the suggestion if possible
const autoCb = document.getElementById('automatableCheckbox');
if (rich.automatable && rich.automatable.length > 0) {
autoCb.checked = true;
}
toggleAutomationTool();
renderRichSteps(currentTest.steps);
renderDependencies(currentTest.dependencies);
} catch (e) {
alert('Could not fetch suggestions from server');
console.error(e);
}
}
function saveCurrentTest() {
if (currentIndex < 0) return;
// Collect the 4 definition fields (aligned with YAML)
currentTest.description = document.getElementById('modalDescription').value.trim();
// Automatable as boolean + keep status in sync for the rest of the system
const autoChecked = document.getElementById('automatableCheckbox').checked;
currentTest.automatable = autoChecked;
currentTest.status = autoChecked ? 'automated' : 'manual';
// Save selected automation tool (only meaningful when automatable)
const toolSelect = document.getElementById('automationTool');
if (autoChecked && toolSelect) {
currentTest.automationTool = toolSelect.value || undefined;
} else {
currentTest.automationTool = undefined;
}
// Steps: only action + expected (notes are never written from the modal anymore)
const stepsUl = document.getElementById('listSteps');
const richFromUI = JSON.parse(stepsUl.dataset.richSteps || '[]');
currentTest.steps = richFromUI.length > 0 ? richFromUI : (JSON.parse(stepsUl.dataset.items || '[]').filter(Boolean));
const depContainer = document.getElementById('depContainer');
currentTest.dependencies = JSON.parse(depContainer.dataset.deps || '[]')
.filter(d => d.label || d.value);
// Update master array
allTests[currentIndex] = currentTest;
isDirty = true;
closeModal();
renderTable();
const fb = document.getElementById('saveFeedback');
if (fb) fb.textContent = 'Unsaved changes';
}
function closeModal() {
document.getElementById('editModal').classList.remove('flex');
document.getElementById('editModal').classList.add('hidden');
currentTest = null;
currentIndex = -1;
}
async function saveAll() {
const saveBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Save'));
const originalText = saveBtn ? saveBtn.innerHTML : '';
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> <span>Saving...</span>`;
}
try {
const res = await fetch('/api/testcases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(allTests)
});
const json = await res.json();
if (json.success) {
isDirty = false;
const fb = document.getElementById('saveFeedback');
if (fb) fb.textContent = 'Saved!';
// Show temporary success feedback on the button
if (saveBtn) {
saveBtn.innerHTML = `<i class="fa-solid fa-check"></i> <span>Saved!</span>`;
saveBtn.style.borderColor = '#6cc76f';
saveBtn.style.color = '#6cc76f';
}
setTimeout(() => {
if (saveBtn) {
saveBtn.innerHTML = originalText;
saveBtn.style.borderColor = '';
saveBtn.style.color = '';
saveBtn.disabled = false;
}
const fb = document.getElementById('saveFeedback');
if (fb) fb.textContent = '';
}, 1800);
} else {
throw new Error(json.error || 'Unknown error');
}
} catch (e) {
if (saveBtn) {
saveBtn.innerHTML = `<i class="fa-solid fa-exclamation-triangle"></i> <span>Error</span>`;
saveBtn.style.borderColor = '#ff6767';
saveBtn.style.color = '#ff6767';
}
alert('Error saving data: ' + e.message);
setTimeout(() => {
if (saveBtn) {
saveBtn.innerHTML = originalText;
saveBtn.style.borderColor = '';
saveBtn.style.color = '';
saveBtn.disabled = false;
}
}, 2000);
}
}
async function reloadData() {
if (isDirty && !confirm('You have unsaved changes. Reload anyway?')) return;
await loadData();
isDirty = false;
const fb = document.getElementById('saveFeedback');
if (fb) fb.textContent = '';
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('editModal');
if (!modal.classList.contains('hidden')) closeModal();
}
if (e.metaKey && e.key === 's') {
e.preventDefault();
saveAll();
}
});
// Init
window.onload = () => {
loadData();
};
</script>
</div> <!-- close max-w content container -->
</body>
</html>