Files
autotest/web/index.html
T
2026-05-26 00:42:42 +02:00

1248 lines
46 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: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.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);
}
.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">
<div class="max-w-[1600px] mx-auto">
<!-- Header -->
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border)] 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">
<div id="status" class="text-xs px-3 py-1 rounded bg-[var(--card)] text-[var(--accent-warm)] font-medium">
<span id="count">0</span> test cases loaded
</div>
<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>
<!-- 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>
<!-- 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-[#94a3b8]"></div>
<div id="modalGroup" class="text-xs text-[#64748b]"></div>
</div>
<div class="flex items-center gap-3">
<select id="modalStatus" class="bg-[var(--input)] border border-[var(--border)] text-sm rounded px-3 py-1">
<option value="automated">Automated</option>
<option value="partial">Partial</option>
<option value="manual">Manual</option>
</select>
<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">
<!-- 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>
<!-- Steps -->
<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>
<!-- Automatable -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="section-title text-emerald-400">Automatable</div>
<button onclick="addListItem('automatable')" class="text-xs px-2.5 py-0.5 bg-emerald-900/60 hover:bg-emerald-800 rounded">+ Add</button>
</div>
<ul id="listAutomatable" class="editable-list text-sm"></ul>
</div>
<!-- Not Yet -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="section-title text-red-400">Not Automatable Yet / Manual</div>
<button onclick="addListItem('notYet')" class="text-xs px-2.5 py-0.5 bg-red-900/60 hover:bg-red-800 rounded">+ Add</button>
</div>
<ul id="listNotYet" class="editable-list text-sm"></ul>
</div>
<!-- Code Generation (for automated tests) -->
<div>
<div class="section-title mb-1.5 text-[#a277ff]">Code Generation (Automated)</div>
<div class="flex items-center gap-2 mb-2">
<input type="checkbox" id="fullyAutomated" class="w-4 h-4">
<label for="fullyAutomated" class="text-sm">Mark as <strong>fullyAutomated</strong> (ready for code generation)</label>
</div>
<div class="mb-2">
<div class="text-xs text-[#64748b] mb-1">Implementation Notes (for generator / LLM)</div>
<textarea id="implementationNotes" rows="2"
class="w-full bg-[var(--input)] border border-[var(--border)] rounded p-2 text-sm"
placeholder="Hints for the code generator..."></textarea>
</div>
<!-- codeSteps editor -->
<div>
<div class="flex justify-between items-center mb-1">
<div class="text-xs text-[#64748b]">Code Steps (for generator)</div>
<button onclick="addCodeStep()" class="text-xs px-2 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded text-[var(--accent-warm)]">+ Add Step</button>
</div>
<div id="codeStepsContainer" class="space-y-2 text-sm"></div>
</div>
</div>
<!-- Dependencies -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="section-title">Dependencies &amp; Prerequisites</div>
<button onclick="addDependency()" class="text-xs px-2.5 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded">+ Add dependency</button>
</div>
<div id="depContainer" class="space-y-1"></div>
</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-6 py-2 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 font-semibold">Save Changes to Test</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();
document.getElementById('count').textContent = allTests.length;
} 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();
}
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` : '';
}
}
// 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 || '';
document.getElementById('modalStatus').value = currentTest.status;
// Code Generation fields (Phase 1)
const fullyAutomatedEl = document.getElementById('fullyAutomated');
fullyAutomatedEl.checked = !!currentTest.fullyAutomated;
const notesEl = document.getElementById('implementationNotes');
notesEl.value = currentTest.implementationNotes || '';
// Code Steps
renderCodeSteps(currentTest.codeSteps || []);
// Render lists
renderRichSteps(currentTest.steps || []);
renderList('listAutomatable', currentTest.automatable || []);
renderList('listNotYet', currentTest.notYet || []);
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);
}
// === Simplified step editor (automation decision moved to testcase level) ===
function renderRichSteps(steps) {
const ul = document.getElementById('listSteps');
ul.innerHTML = '';
const rich = steps.map(s => {
if (typeof s === 'string') {
return { action: s, expected: '', notes: '' };
}
return {
action: s.action || s,
expected: s.expected || '',
notes: s.notes || ''
};
});
rich.forEach((step, idx) => {
const li = document.createElement('li');
li.style.flexDirection = 'column';
li.style.alignItems = 'stretch';
li.style.gap = '4px';
li.innerHTML = `
<div style="display:flex; gap:8px; align-items:center;">
<input value="${(step.action || '').replace(/"/g, '&quot;')}" placeholder="Action" style="flex:1" oninput="updateRichStep(${idx}, 'action', this.value)">
<button onclick="removeRichStep(${idx})" style="color:#f87171;"><i class="fa-solid fa-trash text-xs"></i></button>
</div>
<input value="${(step.expected || '').replace(/"/g, '&quot;')}" placeholder="Expected result (optional)" style="font-size:12px; margin-top:2px;" oninput="updateRichStep(${idx}, 'expected', this.value)">
<input value="${(step.notes || '').replace(/"/g, '&quot;')}" placeholder="Notes (optional)" style="font-size:12px; margin-top:2px; color:#94a3b8;" oninput="updateRichStep(${idx}, 'notes', this.value)">
`;
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: '', notes: '' });
renderRichSteps(steps);
}
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);
}
// === Code Steps Editor (for automated generation) ===
function renderCodeSteps(codeSteps = []) {
const container = document.getElementById('codeStepsContainer');
container.innerHTML = '';
codeSteps.forEach((step, index) => {
const div = document.createElement('div');
div.className = 'flex gap-2 items-center bg-[var(--input)] p-2 rounded';
const paramsValue = step.params ? JSON.stringify(step.params) : '';
div.innerHTML = `
<input placeholder="Step Name" value="${step.stepName || ''}" class="flex-1 text-xs" oninput="updateCodeStep(${index}, 'stepName', this.value)">
<input placeholder="Action Key" value="${step.actionKey || ''}" class="flex-1 text-xs" oninput="updateCodeStep(${index}, 'actionKey', this.value)">
<input placeholder='Params (JSON)' value='${paramsValue}' class="flex-1 text-xs" oninput="updateCodeStepParams(${index}, this.value)">
<button onclick="removeCodeStep(${index})" class="text-red-400 hover:text-red-500 px-1">×</button>
`;
container.appendChild(div);
});
container.dataset.codeSteps = JSON.stringify(codeSteps);
}
function updateCodeStep(index, field, value) {
const container = document.getElementById('codeStepsContainer');
const steps = JSON.parse(container.dataset.codeSteps || '[]');
if (!steps[index]) steps[index] = {};
steps[index][field] = value;
container.dataset.codeSteps = JSON.stringify(steps);
}
function updateCodeStepParams(index, value) {
const container = document.getElementById('codeStepsContainer');
const steps = JSON.parse(container.dataset.codeSteps || '[]');
if (!steps[index]) steps[index] = {};
try {
steps[index].params = value ? JSON.parse(value) : undefined;
} catch (e) {
// keep previous value if JSON is invalid
}
container.dataset.codeSteps = JSON.stringify(steps);
}
function removeCodeStep(index) {
const container = document.getElementById('codeStepsContainer');
const steps = JSON.parse(container.dataset.codeSteps || '[]');
steps.splice(index, 1);
renderCodeSteps(steps);
}
function addCodeStep() {
const container = document.getElementById('codeStepsContainer');
const steps = JSON.parse(container.dataset.codeSteps || '[]');
steps.push({ stepName: '', actionKey: '' });
renderCodeSteps(steps);
}
async function resetToSuggested() {
if (!currentTest) return;
// Build a minimal test object from current modal values
const tempTest = {
id: currentTest.id,
group: currentTest.group,
module: currentTest.module,
action: currentTest.action,
description: document.getElementById('modalDescription').value.trim(),
status: document.getElementById('modalStatus').value,
manualReason: currentTest.manualReason,
partialNotes: currentTest.partialNotes
};
try {
const res = await fetch('/api/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tempTest)
});
const rich = await res.json();
// Repopulate lists with suggested values
currentTest.steps = rich.steps || [];
currentTest.automatable = rich.automatable || [];
currentTest.notYet = rich.notYet || [];
currentTest.dependencies = rich.dependencies || [];
renderList('listSteps', currentTest.steps);
renderList('listAutomatable', currentTest.automatable);
renderList('listNotYet', currentTest.notYet);
renderDependencies(currentTest.dependencies);
} catch (e) {
alert('Could not fetch suggestions from server');
console.error(e);
}
}
function saveCurrentTest() {
if (currentIndex < 0) return;
// Collect values
currentTest.description = document.getElementById('modalDescription').value.trim();
currentTest.status = document.getElementById('modalStatus').value;
// Code Generation fields (Phase 1)
const fullyAutomatedEl = document.getElementById('fullyAutomated');
currentTest.fullyAutomated = fullyAutomatedEl.checked;
const notesEl = document.getElementById('implementationNotes');
currentTest.implementationNotes = notesEl.value.trim() || undefined;
// Code Steps
const codeStepsContainer = document.getElementById('codeStepsContainer');
currentTest.codeSteps = JSON.parse(codeStepsContainer.dataset.codeSteps || '[]')
.filter(s => s.stepName || s.actionKey);
// Collect steps (now simplified: action + expected + optional notes)
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 autoUl = document.getElementById('listAutomatable');
currentTest.automatable = JSON.parse(autoUl.dataset.items || '[]').filter(Boolean);
const notUl = document.getElementById('listNotYet');
currentTest.notYet = JSON.parse(notUl.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();
document.getElementById('status').classList.add('dirty');
document.getElementById('status').innerHTML = '<span class="text-amber-400">Unsaved changes</span>';
}
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;
document.getElementById('status').classList.remove('dirty');
document.getElementById('status').innerHTML = `<span id="count">${allTests.length}</span> test cases saved`;
// Show temporary success feedback
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;
}
}, 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;
document.getElementById('status').classList.remove('dirty');
document.getElementById('status').innerHTML = `<span id="count">${allTests.length}</span> test cases loaded`;
}
// 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>
</body>
</html>