qa-deck-backend 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/README.md +126 -0
- package/bin/cli.js +36 -0
- package/dashboard/index.html +1222 -0
- package/dashboard/recorder.html +1359 -0
- package/package.json +23 -0
- package/recorder/cicd.js +760 -0
- package/recorder/converter.js +539 -0
- package/recorder/recorder.js +294 -0
- package/server.js +4616 -0
|
@@ -0,0 +1,1359 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
6
|
+
<title>QA Deck — Capture Workspace</title>
|
|
7
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
|
|
8
|
+
<style>
|
|
9
|
+
:root{
|
|
10
|
+
--green:#1D9E75;--green-dim:#0F6E56;--green-glow:rgba(29,158,117,.12);--green-line:rgba(29,158,117,.3);
|
|
11
|
+
--bg:#0c0d0e;--bg-1:#111213;--bg-2:#161718;--bg-3:#1c1d1f;
|
|
12
|
+
--border:rgba(255,255,255,.07);--border-md:rgba(255,255,255,.12);
|
|
13
|
+
--text-1:#f0f0ee;--text-2:#9a9a96;--text-3:#5a5a58;
|
|
14
|
+
--red:#E24B4A;--blue:#378ADD;--amber:#d97706;--purple:#a78bfa;
|
|
15
|
+
--mono:'IBM Plex Mono',monospace;--sans:'IBM Plex Sans',sans-serif;
|
|
16
|
+
}
|
|
17
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
18
|
+
html,body{height:100%}
|
|
19
|
+
body{background:var(--bg);color:var(--text-1);font-family:var(--sans);font-size:13px;display:flex;flex-direction:column;overflow:hidden}
|
|
20
|
+
|
|
21
|
+
/* ── Top bar ── */
|
|
22
|
+
.topbar{display:flex;align-items:center;justify-content:space-between;padding:0 18px;height:46px;border-bottom:1px solid var(--border);background:var(--bg-1);flex-shrink:0}
|
|
23
|
+
.logo{display:flex;align-items:center;gap:8px;text-decoration:none}
|
|
24
|
+
.logo-mark{width:22px;height:22px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center}
|
|
25
|
+
.logo-name{font-family:var(--mono);font-size:12px;color:var(--text-1)}.logo-name span{color:var(--green)}
|
|
26
|
+
.btn{display:inline-flex;align-items:center;gap:5px;padding:6px 13px;border-radius:5px;font-size:12px;cursor:pointer;border:1px solid var(--border-md);background:var(--bg-2);color:var(--text-2);transition:all .15s;font-family:var(--sans);white-space:nowrap}
|
|
27
|
+
.btn:hover:not(:disabled){background:var(--bg-3);color:var(--text-1)}
|
|
28
|
+
.btn:disabled{opacity:.4;cursor:not-allowed}
|
|
29
|
+
.btn-green{background:var(--green);color:#fff;border-color:var(--green)}.btn-green:hover:not(:disabled){background:var(--green-dim)!important;color:#fff!important}
|
|
30
|
+
.btn-red{background:var(--red);color:#fff;border-color:var(--red)}.btn-red:hover:not(:disabled){background:#b83737!important;color:#fff!important}
|
|
31
|
+
|
|
32
|
+
/* ── URL bar ── */
|
|
33
|
+
.urlbar{display:flex;align-items:center;gap:8px;padding:9px 14px;background:var(--bg-1);border-bottom:1px solid var(--border);flex-shrink:0}
|
|
34
|
+
.url-input{flex:1;background:var(--bg-2);border:1px solid var(--border);border-radius:5px;padding:7px 11px;font-family:var(--mono);font-size:12px;color:var(--text-1);outline:none;transition:border-color .15s}
|
|
35
|
+
.url-input:focus{border-color:var(--green-line)}
|
|
36
|
+
.rec-dot{width:8px;height:8px;border-radius:50%;background:var(--text-3);flex-shrink:0;transition:all .3s}
|
|
37
|
+
.rec-dot.on{background:var(--red);box-shadow:0 0 7px var(--red);animation:rpulse 1s ease-in-out infinite}
|
|
38
|
+
@keyframes rpulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
39
|
+
.counter{font-family:var(--mono);font-size:11px;color:var(--text-3);min-width:72px}
|
|
40
|
+
.counter .n{color:var(--green);font-weight:500}
|
|
41
|
+
|
|
42
|
+
/* ── Main layout ── */
|
|
43
|
+
.main{display:flex;flex:1;min-height:0;overflow:hidden}
|
|
44
|
+
|
|
45
|
+
/* ── Preview ── */
|
|
46
|
+
.preview{flex:1;display:flex;flex-direction:column;border-right:1px solid var(--border);min-width:0;overflow:hidden}
|
|
47
|
+
.preview-bar{display:flex;align-items:center;gap:8px;padding:7px 12px;background:var(--bg-1);border-bottom:1px solid var(--border);flex-shrink:0}
|
|
48
|
+
.preview-url{font-family:var(--mono);font-size:10px;color:var(--text-3);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
49
|
+
.preview-badge{font-family:var(--mono);font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--green-line);background:var(--green-glow);color:var(--green)}
|
|
50
|
+
.iframe-wrap{flex:1;position:relative;background:#fff;overflow:hidden}
|
|
51
|
+
.iframe-wrap iframe{width:100%;height:100%;border:none;display:block}
|
|
52
|
+
.rec-border{position:absolute;inset:0;pointer-events:none;border:2px solid transparent;transition:border-color .3s}
|
|
53
|
+
.rec-border.on{border-color:var(--red)}
|
|
54
|
+
.start-screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--bg-1);gap:14px;padding:40px;text-align:center}
|
|
55
|
+
.start-icon{font-size:40px;filter:grayscale(1)opacity(.25)}
|
|
56
|
+
|
|
57
|
+
/* ── Right panel ── */
|
|
58
|
+
.panel{width:380px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg-1)}
|
|
59
|
+
|
|
60
|
+
/* ── Panel tabs ── */
|
|
61
|
+
.ptabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto}
|
|
62
|
+
.ptabs::-webkit-scrollbar{display:none}
|
|
63
|
+
.ptab{padding:10px 13px;font-family:var(--mono);font-size:9px;color:var(--text-3);cursor:pointer;border-bottom:2px solid transparent;text-transform:uppercase;letter-spacing:.5px;transition:all .15s;white-space:nowrap;flex-shrink:0}
|
|
64
|
+
.ptab:hover{color:var(--text-2)}
|
|
65
|
+
.ptab.active{color:var(--green);border-bottom-color:var(--green)}
|
|
66
|
+
.ptab .badge{display:inline-block;background:var(--bg-3);border:1px solid var(--border);color:var(--text-3);font-size:9px;padding:0 5px;border-radius:99px;margin-left:4px;min-width:16px;text-align:center}
|
|
67
|
+
.ptab.active .badge{background:var(--green-glow);border-color:var(--green-line);color:var(--green)}
|
|
68
|
+
|
|
69
|
+
/* ── Panel bodies ── */
|
|
70
|
+
.pbody{flex:1;overflow-y:auto;padding:12px;display:none}
|
|
71
|
+
.pbody.active{display:block}
|
|
72
|
+
.pbody::-webkit-scrollbar{width:4px}.pbody::-webkit-scrollbar-track{background:transparent}.pbody::-webkit-scrollbar-thumb{background:var(--bg-3);border-radius:99px}
|
|
73
|
+
|
|
74
|
+
/* ── Action rows ── */
|
|
75
|
+
.arow{display:flex;align-items:flex-start;gap:7px;padding:7px 9px;border-radius:5px;border:1px solid var(--border);background:var(--bg-2);margin-bottom:4px;animation:ain .18s ease}
|
|
76
|
+
@keyframes ain{from{opacity:0;transform:translateX(-4px)}to{opacity:1;transform:translateX(0)}}
|
|
77
|
+
.arow:last-child{border-color:var(--green-line)}
|
|
78
|
+
.atype{font-family:var(--mono);font-size:9px;padding:2px 6px;border-radius:3px;flex-shrink:0;min-width:48px;text-align:center;margin-top:1px}
|
|
79
|
+
.t-navigate{background:rgba(55,138,221,.12);color:var(--blue);border:1px solid rgba(55,138,221,.2)}
|
|
80
|
+
.t-click{background:rgba(217,119,6,.12);color:var(--amber);border:1px solid rgba(217,119,6,.2)}
|
|
81
|
+
.t-fill{background:var(--green-glow);color:var(--green);border:1px solid var(--green-line)}
|
|
82
|
+
.t-select,.t-check,.t-radio{background:rgba(167,139,250,.12);color:var(--purple);border:1px solid rgba(167,139,250,.2)}
|
|
83
|
+
.t-press,.t-submit{background:rgba(255,255,255,.04);color:var(--text-2);border:1px solid var(--border)}
|
|
84
|
+
.abody{flex:1;min-width:0}
|
|
85
|
+
.amain{font-size:11px;color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
86
|
+
.aloc{font-family:var(--mono);font-size:9px;color:var(--text-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
|
|
87
|
+
.adel{width:16px;height:16px;border:none;background:transparent;color:var(--text-3);cursor:pointer;border-radius:3px;opacity:0;font-size:13px;flex-shrink:0;display:flex;align-items:center;justify-content:center;padding:0;margin-top:1px}
|
|
88
|
+
.arow:hover .adel{opacity:1}.adel:hover{color:var(--red)}
|
|
89
|
+
|
|
90
|
+
/* ── Steps ── */
|
|
91
|
+
.srow{display:flex;gap:9px;padding:8px 10px;border-radius:5px;border:1px solid var(--border);background:var(--bg-2);margin-bottom:4px}
|
|
92
|
+
.snum{font-family:var(--mono);font-size:10px;color:var(--text-3);min-width:18px;padding-top:1px}
|
|
93
|
+
.stxt{font-size:12px;color:var(--text-1);flex:1;outline:none;line-height:1.6;cursor:text}
|
|
94
|
+
.stxt:focus{color:var(--text-1)}
|
|
95
|
+
|
|
96
|
+
/* ── Code panel ── */
|
|
97
|
+
.fw-switcher{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:10px}
|
|
98
|
+
.fw-btn{padding:8px 6px;border-radius:5px;border:1px solid var(--border);background:var(--bg-2);color:var(--text-3);cursor:pointer;text-align:center;font-size:11px;line-height:1.4;transition:all .15s}
|
|
99
|
+
.fw-btn:hover{border-color:var(--border-md);color:var(--text-2)}
|
|
100
|
+
.fw-btn.active{border-color:var(--green-line);background:var(--green-glow);color:var(--green)}
|
|
101
|
+
.fw-btn strong{display:block;font-family:var(--mono);font-size:11px;margin-bottom:1px}
|
|
102
|
+
.code-wrap{border:1px solid var(--border);border-radius:5px;overflow:hidden}
|
|
103
|
+
.code-hdr{display:flex;align-items:center;justify-content:space-between;padding:6px 11px;background:var(--bg-3);border-bottom:1px solid var(--border)}
|
|
104
|
+
.code-fn{font-family:var(--mono);font-size:10px;color:var(--text-2)}
|
|
105
|
+
.copybtn{font-family:var(--mono);font-size:9px;padding:2px 8px;border-radius:3px;border:1px solid var(--border);background:var(--bg-2);color:var(--text-3);cursor:pointer;transition:all .15s}
|
|
106
|
+
.copybtn:hover{color:var(--green);border-color:var(--green-line)}
|
|
107
|
+
.code-pre{padding:11px;font-family:var(--mono);font-size:10px;color:var(--text-2);line-height:1.75;overflow-x:auto;white-space:pre;max-height:420px;overflow-y:auto}
|
|
108
|
+
.code-spin{text-align:center;padding:30px;color:var(--text-3);font-size:12px}
|
|
109
|
+
.code-spin::before{content:'';display:block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--green);border-radius:50%;animation:spin .7s linear infinite;margin:0 auto 10px}
|
|
110
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
111
|
+
|
|
112
|
+
/* ── Test case panel ── */
|
|
113
|
+
.tc-toolbar{display:flex;flex-direction:column;gap:10px}
|
|
114
|
+
.tc-summary{display:flex;justify-content:space-between;align-items:center;font-family:var(--mono);font-size:10px;color:var(--text-3)}
|
|
115
|
+
.tc-mini-label{font-family:var(--mono);font-size:9px;color:var(--text-3);text-transform:uppercase;letter-spacing:.6px;min-width:52px}
|
|
116
|
+
.tc-chip-row{display:flex;gap:6px;flex-wrap:wrap}
|
|
117
|
+
.tc-chip{padding:6px 9px;border-radius:999px;border:1px solid var(--border);background:var(--bg-2);color:var(--text-3);cursor:pointer;font-family:var(--mono);font-size:10px;transition:all .15s}
|
|
118
|
+
.tc-chip:hover{border-color:var(--border-md);color:var(--text-2)}
|
|
119
|
+
.tc-chip.active{background:var(--green-glow);border-color:var(--green-line);color:var(--green)}
|
|
120
|
+
.tc-action-row{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
|
|
121
|
+
.tc-filter-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
|
122
|
+
.tc-action-btn{padding:7px 8px;border-radius:5px;border:1px solid var(--border);background:var(--bg-2);color:var(--text-2);font-size:11px;cursor:pointer;transition:all .15s}
|
|
123
|
+
.tc-action-btn:hover:not(:disabled){border-color:var(--border-md);background:var(--bg-3);color:var(--text-1)}
|
|
124
|
+
.tc-action-btn:disabled{opacity:.45;cursor:not-allowed}
|
|
125
|
+
.tc-action-btn.primary{background:var(--green);border-color:var(--green);color:#fff}
|
|
126
|
+
.tc-action-btn.primary:hover:not(:disabled){background:var(--green-dim);border-color:var(--green-dim)}
|
|
127
|
+
.tc-status{font-size:11px;color:var(--text-3);padding:8px 0}
|
|
128
|
+
.tc-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
|
129
|
+
.tc-card{border:1px solid var(--border);border-radius:7px;background:var(--bg-2);overflow:hidden}
|
|
130
|
+
.tc-card.expanded{border-color:var(--green-line)}
|
|
131
|
+
.tc-card-top{display:flex;align-items:flex-start;gap:8px;padding:10px}
|
|
132
|
+
.tc-select{width:18px;height:18px;border-radius:4px;border:1px solid var(--border-md);background:var(--bg-1);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:11px;color:transparent;flex-shrink:0;margin-top:2px}
|
|
133
|
+
.tc-select.selected{background:var(--green);border-color:var(--green);color:#fff}
|
|
134
|
+
.tc-main{flex:1;min-width:0;cursor:pointer}
|
|
135
|
+
.tc-title-line{font-size:12px;color:var(--text-1);line-height:1.5}
|
|
136
|
+
.tc-meta{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
|
|
137
|
+
.badge{font-family:var(--mono);font-size:9px;padding:2px 7px;border-radius:3px}
|
|
138
|
+
.badge-high{background:rgba(226,75,74,.1);color:#f87171;border:1px solid rgba(226,75,74,.2)}
|
|
139
|
+
.badge-medium{background:rgba(217,119,6,.1);color:#fbbf24;border:1px solid rgba(217,119,6,.2)}
|
|
140
|
+
.badge-low{background:var(--green-glow);color:var(--green);border:1px solid var(--green-line)}
|
|
141
|
+
.badge-cat,.badge-source{background:var(--bg-3);color:var(--text-2);border:1px solid var(--border)}
|
|
142
|
+
.tc-actions{display:flex;gap:4px;flex-shrink:0}
|
|
143
|
+
.tc-icon-btn{padding:5px 7px;border-radius:4px;border:1px solid var(--border);background:var(--bg-1);color:var(--text-3);cursor:pointer;font-size:10px;transition:all .15s}
|
|
144
|
+
.tc-icon-btn:hover{border-color:var(--border-md);background:var(--bg-3);color:var(--text-1)}
|
|
145
|
+
.tc-icon-btn.danger:hover{color:#f87171;border-color:rgba(226,75,74,.3)}
|
|
146
|
+
.tc-card-body{border-top:1px solid var(--border);padding:10px;display:flex;flex-direction:column;gap:10px}
|
|
147
|
+
.tc-field{display:flex;flex-direction:column;gap:5px}
|
|
148
|
+
.tc-label{font-family:var(--mono);font-size:9px;color:var(--text-3);text-transform:uppercase;letter-spacing:.6px}
|
|
149
|
+
.tc-val{font-size:12px;color:var(--text-1);line-height:1.6}
|
|
150
|
+
.tc-val.mono{font-family:var(--mono);font-size:10px;white-space:pre-wrap}
|
|
151
|
+
.tc-step-list{display:flex;flex-direction:column;gap:5px}
|
|
152
|
+
.tc-step-row{display:flex;gap:7px;font-size:11px;color:var(--text-2);padding:4px 0;border-bottom:1px solid var(--border)}
|
|
153
|
+
.tc-step-row:last-child{border-bottom:none}
|
|
154
|
+
.tc-step-num{color:var(--text-3);font-family:var(--mono);font-size:10px;min-width:16px}
|
|
155
|
+
.tc-edit-grid{display:flex;flex-direction:column;gap:8px}
|
|
156
|
+
.tc-input,.tc-textarea,.tc-select-input{width:100%;background:var(--bg-1);border:1px solid var(--border);border-radius:5px;padding:8px 10px;font-size:11px;color:var(--text-1);outline:none;font-family:var(--sans)}
|
|
157
|
+
.tc-textarea{resize:vertical;min-height:70px;line-height:1.6}
|
|
158
|
+
.tc-input:focus,.tc-textarea:focus,.tc-select-input:focus{border-color:var(--green-line)}
|
|
159
|
+
.tc-edit-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
160
|
+
.tc-body-actions{display:flex;gap:6px;justify-content:flex-end}
|
|
161
|
+
.tc-empty{padding:34px 16px;text-align:center;color:var(--text-3);font-size:12px;line-height:1.8;border:1px dashed var(--border);border-radius:7px;margin-top:10px}
|
|
162
|
+
.tc-generating{text-align:center;padding:30px;color:var(--text-3);font-size:12px}
|
|
163
|
+
.tc-generating::before{content:'';display:block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--green);border-radius:50%;animation:spin .7s linear infinite;margin:0 auto 10px}
|
|
164
|
+
|
|
165
|
+
/* ── Settings ── */
|
|
166
|
+
.setting-row{margin-bottom:14px}
|
|
167
|
+
.setting-label{font-family:var(--mono);font-size:9px;color:var(--text-3);text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px}
|
|
168
|
+
.setting-input{width:100%;background:var(--bg-2);border:1px solid var(--border);border-radius:4px;padding:7px 10px;font-family:var(--mono);font-size:11px;color:var(--text-1);outline:none;transition:border-color .15s}
|
|
169
|
+
.setting-input:focus{border-color:var(--green-line)}
|
|
170
|
+
.setting-hint{font-size:10px;color:var(--text-3);margin-top:4px;line-height:1.6}
|
|
171
|
+
|
|
172
|
+
/* ── Bottom bar ── */
|
|
173
|
+
.bottombar{display:flex;align-items:center;gap:7px;padding:9px 14px;border-top:1px solid var(--border);background:var(--bg-1);flex-shrink:0}
|
|
174
|
+
.bottombar .btn{flex:1;justify-content:center}
|
|
175
|
+
|
|
176
|
+
/* ── Empty states ── */
|
|
177
|
+
.empty{text-align:center;padding:36px 16px;color:var(--text-3);font-size:12px;line-height:1.8}
|
|
178
|
+
.empty .ei{font-size:28px;margin-bottom:10px;filter:grayscale(1)opacity(.3)}
|
|
179
|
+
|
|
180
|
+
/* ── Toast ── */
|
|
181
|
+
.toasts{position:fixed;bottom:14px;right:14px;z-index:999;display:flex;flex-direction:column;gap:6px}
|
|
182
|
+
.toast{padding:9px 13px;border-radius:5px;font-size:12px;border:1px solid;animation:tin .18s ease;white-space:nowrap}
|
|
183
|
+
@keyframes tin{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:translateX(0)}}
|
|
184
|
+
.ts{background:rgba(29,158,117,.12);border-color:var(--green-line);color:var(--green)}
|
|
185
|
+
.te{background:rgba(226,75,74,.12);border-color:rgba(226,75,74,.3);color:#f87171}
|
|
186
|
+
.ti{background:rgba(55,138,221,.12);border-color:rgba(55,138,221,.3);color:var(--blue)}
|
|
187
|
+
</style>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
|
|
191
|
+
<!-- Top bar -->
|
|
192
|
+
<header class="topbar">
|
|
193
|
+
<a href="/" class="logo">
|
|
194
|
+
<div class="logo-mark"><svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M2 6.5h9M2 4h5.5M2 9h7" stroke="white" stroke-width="1.3" stroke-linecap="round"/></svg></div>
|
|
195
|
+
<span class="logo-name">QA <span>Deck</span> / capture</span>
|
|
196
|
+
</a>
|
|
197
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
198
|
+
<span id="hdr-status" style="font-family:var(--mono);font-size:10px;color:var(--text-3)">idle</span>
|
|
199
|
+
<a href="/" class="btn">← Local Engine</a>
|
|
200
|
+
</div>
|
|
201
|
+
</header>
|
|
202
|
+
|
|
203
|
+
<!-- URL bar -->
|
|
204
|
+
<div class="urlbar">
|
|
205
|
+
<div class="rec-dot" id="rec-dot"></div>
|
|
206
|
+
<input class="url-input" id="url-input" type="url" placeholder="https://example.com/login"/>
|
|
207
|
+
<button class="btn btn-green" id="load-btn" onclick="loadPage()">Load</button>
|
|
208
|
+
<button class="btn btn-green" id="start-btn" onclick="startRecording()" disabled>⏺ Start Capture</button>
|
|
209
|
+
<button class="btn btn-red" id="stop-btn" onclick="stopRecording()" disabled>⏹ Stop</button>
|
|
210
|
+
<div class="counter"><span class="n" id="act-count">0</span> actions</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Main -->
|
|
214
|
+
<div class="main">
|
|
215
|
+
|
|
216
|
+
<!-- Left: embedded browser -->
|
|
217
|
+
<div class="preview">
|
|
218
|
+
<div class="preview-bar">
|
|
219
|
+
<div class="preview-url" id="preview-url">Enter a URL above and click Load</div>
|
|
220
|
+
<span class="preview-badge" id="preview-badge" style="display:none">✓ injected</span>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="iframe-wrap">
|
|
223
|
+
<div class="start-screen" id="start-screen">
|
|
224
|
+
<div class="start-icon">🌐</div>
|
|
225
|
+
<div style="font-size:15px;font-weight:500;color:var(--text-2)">Embedded Browser</div>
|
|
226
|
+
<div style="font-size:12px;color:var(--text-3);max-width:360px;line-height:1.8">
|
|
227
|
+
Enter any URL and click <strong style="color:var(--text-2)">Load</strong>. The page opens here.<br>
|
|
228
|
+
Then click <strong style="color:var(--red)">⏺ Start Capture</strong> and interact normally.<br>
|
|
229
|
+
Your clicks and inputs are captured in real time.
|
|
230
|
+
</div>
|
|
231
|
+
<div style="font-size:11px;color:var(--text-3);background:var(--bg-2);border:1px solid var(--border);border-radius:5px;padding:10px 14px;max-width:360px;line-height:1.8;margin-top:4px">
|
|
232
|
+
⚠ Sites that block iframes (e.g. Google, Facebook) won't load here.<br>
|
|
233
|
+
Use a local or staging URL instead.
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
<iframe id="preview-frame" style="display:none"></iframe>
|
|
237
|
+
<div class="rec-border" id="rec-border"></div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<!-- Right: panel -->
|
|
242
|
+
<div class="panel">
|
|
243
|
+
|
|
244
|
+
<!-- Tabs -->
|
|
245
|
+
<div class="ptabs">
|
|
246
|
+
<div class="ptab active" data-p="actions" onclick="switchPanel('actions',this)">Actions <span class="badge" id="b-actions">0</span></div>
|
|
247
|
+
<div class="ptab" data-p="steps" onclick="switchPanel('steps',this)">Steps <span class="badge" id="b-steps">0</span></div>
|
|
248
|
+
<div class="ptab" data-p="code" onclick="switchPanel('code',this)">Code</div>
|
|
249
|
+
<div class="ptab" data-p="testcase" onclick="switchPanel('testcase',this)">Test Case</div>
|
|
250
|
+
<div class="ptab" data-p="settings" onclick="switchPanel('settings',this)">Settings</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<!-- Actions -->
|
|
254
|
+
<div class="pbody active" id="pbody-actions">
|
|
255
|
+
<div class="empty" id="empty-actions"><div class="ei">⏺</div>Load a page, click ⏺ Start Capture,<br>then interact with the page.</div>
|
|
256
|
+
<div id="actions-list"></div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<!-- Steps -->
|
|
260
|
+
<div class="pbody" id="pbody-steps">
|
|
261
|
+
<div class="empty" id="empty-steps"><div class="ei">📋</div>Steps appear after stopping recording.</div>
|
|
262
|
+
<div id="steps-list"></div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<!-- Code -->
|
|
266
|
+
<div class="pbody" id="pbody-code" style="padding:10px">
|
|
267
|
+
<!-- Framework switcher -->
|
|
268
|
+
<div class="fw-switcher">
|
|
269
|
+
<div class="fw-btn active" data-fw="playwright-python" onclick="switchFw(this)"><strong>Playwright</strong>Python</div>
|
|
270
|
+
<div class="fw-btn" data-fw="playwright-typescript" onclick="switchFw(this)"><strong>Playwright</strong>TypeScript</div>
|
|
271
|
+
<div class="fw-btn" data-fw="selenium-python" onclick="switchFw(this)"><strong>Selenium</strong>Python</div>
|
|
272
|
+
<div class="fw-btn" data-fw="selenium-java" onclick="switchFw(this)"><strong>Selenium</strong>Java</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="code-wrap">
|
|
275
|
+
<div class="code-hdr">
|
|
276
|
+
<span class="code-fn" id="code-fn">—</span>
|
|
277
|
+
<button class="copybtn" onclick="copyCode()">Copy</button>
|
|
278
|
+
</div>
|
|
279
|
+
<div id="code-body">
|
|
280
|
+
<pre class="code-pre" style="color:var(--text-3);font-style:italic">Click Generate after recording.</pre>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<!-- Test Case -->
|
|
286
|
+
<div class="pbody" id="pbody-testcase" style="padding:10px">
|
|
287
|
+
<div class="tc-toolbar">
|
|
288
|
+
<div class="tc-summary">
|
|
289
|
+
<span id="tc-summary-label">0 test cases</span>
|
|
290
|
+
<span id="tc-selection-label">0 selected</span>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="tc-filter-row">
|
|
293
|
+
<span class="tc-mini-label">Focus</span>
|
|
294
|
+
<button class="tc-chip active" data-suite="page" onclick="toggleSuiteType('page', this)">Page</button>
|
|
295
|
+
<button class="tc-chip" data-suite="e2e" onclick="toggleSuiteType('e2e', this)">E2E</button>
|
|
296
|
+
<button class="tc-chip" data-suite="regression" onclick="toggleSuiteType('regression', this)">Regression</button>
|
|
297
|
+
<button class="tc-chip" data-suite="smoke" onclick="toggleSuiteType('smoke', this)">Smoke</button>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="tc-chip-row">
|
|
300
|
+
<button class="tc-chip active" data-scenario="positive" onclick="toggleScenarioType('positive', this)">Positive</button>
|
|
301
|
+
<button class="tc-chip" data-scenario="negative" onclick="toggleScenarioType('negative', this)">Negative</button>
|
|
302
|
+
<button class="tc-chip" data-scenario="boundary" onclick="toggleScenarioType('boundary', this)">Boundary</button>
|
|
303
|
+
<button class="tc-chip" data-scenario="navigation" onclick="toggleScenarioType('navigation', this)">Navigation</button>
|
|
304
|
+
<button class="tc-chip" data-scenario="ui" onclick="toggleScenarioType('ui', this)">UI</button>
|
|
305
|
+
<button class="tc-chip" data-scenario="accessibility" onclick="toggleScenarioType('accessibility', this)">Accessibility</button>
|
|
306
|
+
<button class="tc-chip" data-scenario="all_possible" onclick="toggleScenarioType('all_possible', this)">All Possible</button>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="tc-action-row">
|
|
309
|
+
<button class="tc-action-btn primary" id="tc-generate-btn" onclick="generateScenarioTestCases(false)">Generate</button>
|
|
310
|
+
<button class="tc-action-btn" id="tc-add-all-btn" onclick="generateScenarioTestCases(true)">Add All Possible</button>
|
|
311
|
+
<button class="tc-action-btn" id="tc-add-manual-btn" onclick="addManualTestCase()">Add Manual</button>
|
|
312
|
+
<button class="tc-action-btn" id="tc-copy-selected-btn" onclick="copySelectedTestCases()" disabled>Copy Selected</button>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="tc-filter-row">
|
|
315
|
+
<span class="tc-mini-label">List</span>
|
|
316
|
+
<button class="tc-chip active" data-list-filter="all" onclick="setCaseListFilter('all', this)">All</button>
|
|
317
|
+
<button class="tc-chip" data-list-filter="selected" onclick="setCaseListFilter('selected', this)">Selected</button>
|
|
318
|
+
<button class="tc-chip" data-list-filter="page" onclick="setCaseListFilter('page', this)">Page</button>
|
|
319
|
+
<button class="tc-chip" data-list-filter="e2e" onclick="setCaseListFilter('e2e', this)">E2E</button>
|
|
320
|
+
<button class="tc-chip" data-list-filter="regression" onclick="setCaseListFilter('regression', this)">Regression</button>
|
|
321
|
+
<button class="tc-chip" data-list-filter="smoke" onclick="setCaseListFilter('smoke', this)">Smoke</button>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="tc-status" id="tc-generation-status">Choose scenario types and generate QA-ready test cases from the page or recorded flow.</div>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="tc-list" id="tc-case-list"></div>
|
|
326
|
+
<div class="tc-empty" id="tc-case-empty">
|
|
327
|
+
Capture a flow or load a page, then generate QA test cases from the selected scenario types.
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<!-- Settings -->
|
|
332
|
+
<div class="pbody" id="pbody-settings" style="padding:14px">
|
|
333
|
+
|
|
334
|
+
<!-- AI Provider -->
|
|
335
|
+
<div class="setting-row">
|
|
336
|
+
<div class="setting-label">AI Provider</div>
|
|
337
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;margin-bottom:8px">
|
|
338
|
+
<div class="fw-btn active" id="prov-claude" onclick="selectProvider('claude',this)" style="font-size:10px;padding:7px 4px"><strong style="font-size:11px">Claude</strong>Anthropic</div>
|
|
339
|
+
<div class="fw-btn" id="prov-gemini" onclick="selectProvider('gemini',this)" style="font-size:10px;padding:7px 4px"><strong style="font-size:11px">Gemini</strong>Google</div>
|
|
340
|
+
<div class="fw-btn" id="prov-openai" onclick="selectProvider('openai',this)" style="font-size:10px;padding:7px 4px"><strong style="font-size:11px">GPT-4o</strong>OpenAI</div>
|
|
341
|
+
</div>
|
|
342
|
+
<div id="provider-hints" style="font-size:10px;color:var(--text-3);background:var(--bg-2);border:1px solid var(--border);border-radius:4px;padding:8px 10px;line-height:1.8"></div>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<!-- API Key -->
|
|
346
|
+
<div class="setting-row">
|
|
347
|
+
<div class="setting-label">API Key</div>
|
|
348
|
+
<input class="setting-input" id="settings-api-key" type="password" autocomplete="off"
|
|
349
|
+
placeholder="Paste your API key here..."/>
|
|
350
|
+
<div class="setting-hint" id="key-hint" style="margin-top:4px">Stored in your browser only. Never sent to anyone except the selected AI provider.</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<!-- Framework -->
|
|
354
|
+
<div class="setting-row">
|
|
355
|
+
<div class="setting-label">Default framework</div>
|
|
356
|
+
<select class="setting-input" id="settings-fw">
|
|
357
|
+
<option value="playwright-python">Playwright Python</option>
|
|
358
|
+
<option value="playwright-typescript">Playwright TypeScript</option>
|
|
359
|
+
<option value="selenium-python">Selenium Python</option>
|
|
360
|
+
<option value="selenium-java">Selenium Java</option>
|
|
361
|
+
</select>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<!-- Panel width -->
|
|
365
|
+
<div class="setting-row">
|
|
366
|
+
<div class="setting-label">Panel width</div>
|
|
367
|
+
<input class="setting-input" id="settings-width" type="number" value="380" min="280" max="600" step="10"/>
|
|
368
|
+
<div class="setting-hint">Right panel width in pixels.</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<button class="btn btn-green" onclick="saveSettings()" style="width:100%;justify-content:center;margin-top:4px">Save Settings</button>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
</div><!-- /.panel -->
|
|
375
|
+
</div><!-- /.main -->
|
|
376
|
+
|
|
377
|
+
<!-- Bottom bar -->
|
|
378
|
+
<div class="bottombar">
|
|
379
|
+
<button class="btn btn-green" id="gen-btn" onclick="generateAll()" disabled>→ Generate</button>
|
|
380
|
+
<button class="btn" id="dl-btn" onclick="downloadZip()" disabled>↓ ZIP</button>
|
|
381
|
+
<button class="btn" id="save-btn" onclick="saveToProject()" disabled>Save Project</button>
|
|
382
|
+
<button class="btn" onclick="clearAll()" style="color:var(--text-3)">Clear</button>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div class="toasts" id="toasts"></div>
|
|
386
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
|
387
|
+
|
|
388
|
+
<script>
|
|
389
|
+
const API = 'http://localhost:3747';
|
|
390
|
+
|
|
391
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
392
|
+
let S = {
|
|
393
|
+
recording: false,
|
|
394
|
+
frameReady: false,
|
|
395
|
+
actions: [],
|
|
396
|
+
steps: [],
|
|
397
|
+
codeCache: { 'playwright-python':'', 'playwright-typescript':'', 'selenium-python':'', 'selenium-java':'' },
|
|
398
|
+
fw: 'playwright-python',
|
|
399
|
+
testCases: [],
|
|
400
|
+
selectedCaseIds: [],
|
|
401
|
+
activeSuiteTypes: ['page'],
|
|
402
|
+
activeScenarioTypes: ['positive'],
|
|
403
|
+
caseListFilter: 'all',
|
|
404
|
+
expandedCaseIds: [],
|
|
405
|
+
editingCaseId: null,
|
|
406
|
+
pollTimer: null,
|
|
407
|
+
apiKey: '',
|
|
408
|
+
provider: 'claude', // 'claude' | 'gemini' | 'openai'
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
412
|
+
// ── Provider config ───────────────────────────────────────────────────────────
|
|
413
|
+
const PROVIDERS = {
|
|
414
|
+
claude: {
|
|
415
|
+
name: 'Claude (Anthropic)',
|
|
416
|
+
placeholder: 'sk-ant-api03-...',
|
|
417
|
+
hint: '🔑 Get free $5 credit at console.anthropic.com → API Keys',
|
|
418
|
+
detect: k => k.startsWith('sk-ant'),
|
|
419
|
+
},
|
|
420
|
+
gemini: {
|
|
421
|
+
name: 'Gemini (Google)',
|
|
422
|
+
placeholder: 'AIzaSy...',
|
|
423
|
+
hint: '🆓 Free tier: 1,500 requests/day. Get key at aistudio.google.com → Get API Key',
|
|
424
|
+
detect: k => k.startsWith('AIza'),
|
|
425
|
+
},
|
|
426
|
+
openai: {
|
|
427
|
+
name: 'GPT-4o-mini (OpenAI)',
|
|
428
|
+
placeholder: 'sk-proj-... or sk-...',
|
|
429
|
+
hint: '🔑 Get key at platform.openai.com → API Keys. Uses gpt-4o-mini (cheapest model)',
|
|
430
|
+
detect: k => k.startsWith('sk-') && !k.startsWith('sk-ant'),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
435
|
+
const saved = localStorage.getItem('qa_settings');
|
|
436
|
+
if (saved) {
|
|
437
|
+
try {
|
|
438
|
+
const s = JSON.parse(saved);
|
|
439
|
+
if (s.apiKey) { S.apiKey = s.apiKey; document.getElementById('settings-api-key').value = s.apiKey; }
|
|
440
|
+
if (s.fw) { S.fw = s.fw; document.getElementById('settings-fw').value = s.fw; activateFwBtn(s.fw); }
|
|
441
|
+
if (s.width) { document.querySelector('.panel').style.width = s.width+'px'; document.getElementById('settings-width').value = s.width; }
|
|
442
|
+
if (s.provider) { selectProvider(s.provider, document.getElementById('prov-'+s.provider)); }
|
|
443
|
+
} catch(e) {}
|
|
444
|
+
}
|
|
445
|
+
updateProviderHint();
|
|
446
|
+
initTestCaseWorkspace();
|
|
447
|
+
|
|
448
|
+
// Auto-detect provider when user pastes a key
|
|
449
|
+
document.getElementById('settings-api-key').addEventListener('input', function() {
|
|
450
|
+
const k = this.value.trim();
|
|
451
|
+
for (const [id, p] of Object.entries(PROVIDERS)) {
|
|
452
|
+
if (p.detect(k)) { selectProvider(id, document.getElementById('prov-'+id)); break; }
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
function selectProvider(id, el) {
|
|
458
|
+
S.provider = id;
|
|
459
|
+
document.querySelectorAll('#prov-claude,#prov-gemini,#prov-openai').forEach(b => b.classList.remove('active'));
|
|
460
|
+
if (el) el.classList.add('active');
|
|
461
|
+
updateProviderHint();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function updateProviderHint() {
|
|
465
|
+
const p = PROVIDERS[S.provider || 'claude'];
|
|
466
|
+
const hintEl = document.getElementById('provider-hints');
|
|
467
|
+
const inputEl = document.getElementById('settings-api-key');
|
|
468
|
+
if (hintEl) hintEl.textContent = p.hint;
|
|
469
|
+
if (inputEl) inputEl.placeholder = p.placeholder;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function saveSettings() {
|
|
473
|
+
S.apiKey = document.getElementById('settings-api-key').value.trim();
|
|
474
|
+
S.fw = document.getElementById('settings-fw').value;
|
|
475
|
+
const w = parseInt(document.getElementById('settings-width').value) || 380;
|
|
476
|
+
document.querySelector('.panel').style.width = w+'px';
|
|
477
|
+
activateFwBtn(S.fw);
|
|
478
|
+
localStorage.setItem('qa_settings', JSON.stringify({ apiKey: S.apiKey, fw: S.fw, width: w, provider: S.provider || 'claude' }));
|
|
479
|
+
toast('Settings saved ✓', 's');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Load page (with automatic proxy fallback) ────────────────────────────────
|
|
483
|
+
async function loadPage() {
|
|
484
|
+
let url = document.getElementById('url-input').value.trim();
|
|
485
|
+
if (!url) { toast('Enter a URL', 'e'); return; }
|
|
486
|
+
if (!url.startsWith('http')) url = 'https://' + url;
|
|
487
|
+
document.getElementById('url-input').value = url;
|
|
488
|
+
|
|
489
|
+
document.getElementById('start-screen').style.display = 'none';
|
|
490
|
+
document.getElementById('preview-url').textContent = 'Loading…';
|
|
491
|
+
document.getElementById('preview-badge').style.display = 'none';
|
|
492
|
+
document.getElementById('load-btn').disabled = true;
|
|
493
|
+
S.frameReady = false;
|
|
494
|
+
|
|
495
|
+
// Same-origin pages can be loaded directly. External sites should go
|
|
496
|
+
// straight to proxy mode to avoid a flash followed by a disappearing page.
|
|
497
|
+
const targetOrigin = new URL(url).origin;
|
|
498
|
+
if (targetOrigin === window.location.origin) {
|
|
499
|
+
await tryLoadDirect(url);
|
|
500
|
+
} else {
|
|
501
|
+
useProxy(url);
|
|
502
|
+
}
|
|
503
|
+
document.getElementById('load-btn').disabled = false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function tryLoadDirect(url) {
|
|
507
|
+
return new Promise((resolve) => {
|
|
508
|
+
const frame = document.getElementById('preview-frame');
|
|
509
|
+
frame.style.display = 'block';
|
|
510
|
+
frame.src = url;
|
|
511
|
+
|
|
512
|
+
const timeout = setTimeout(() => {
|
|
513
|
+
// Timeout — fall back to proxy
|
|
514
|
+
useProxy(url);
|
|
515
|
+
resolve();
|
|
516
|
+
}, 6000);
|
|
517
|
+
|
|
518
|
+
frame.onload = () => {
|
|
519
|
+
clearTimeout(timeout);
|
|
520
|
+
// Try injection — if it throws, site is cross-origin, use proxy
|
|
521
|
+
try {
|
|
522
|
+
frame.contentWindow.document; // throws if cross-origin
|
|
523
|
+
injectCapture(frame);
|
|
524
|
+
resolve();
|
|
525
|
+
} catch(e) {
|
|
526
|
+
useProxy(url);
|
|
527
|
+
resolve();
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function useProxy(url) {
|
|
534
|
+
const proxyUrl = API + '/api/proxy?url=' + encodeURIComponent(url);
|
|
535
|
+
const frame = document.getElementById('preview-frame');
|
|
536
|
+
S.proxyUrl = proxyUrl;
|
|
537
|
+
S.originalUrl = url;
|
|
538
|
+
document.getElementById('preview-url').textContent = 'Loading via proxy…';
|
|
539
|
+
frame.style.display = 'block';
|
|
540
|
+
frame.src = proxyUrl;
|
|
541
|
+
|
|
542
|
+
frame.onload = () => {
|
|
543
|
+
S.frameReady = true;
|
|
544
|
+
document.getElementById('preview-url').textContent = '🔀 ' + url + ' (proxy)';
|
|
545
|
+
document.getElementById('start-btn').disabled = false;
|
|
546
|
+
document.getElementById('preview-badge').style.display = '';
|
|
547
|
+
toast('Loaded via proxy ✓ — capture ready', 's');
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Inject capture script (for same-origin pages) ─────────────────────────────
|
|
552
|
+
function injectCapture(frame) {
|
|
553
|
+
try {
|
|
554
|
+
frame.contentWindow.eval(`
|
|
555
|
+
if(!window.__QA_ACTIVE){
|
|
556
|
+
window.__QA_ACTIVE=true;window.__QA_Q=[];
|
|
557
|
+
function _loc(e){if(!e)return null;var t=e.dataset&&(e.dataset.testid||e.dataset.test||e.dataset.cy);if(t)return'[data-testid="'+t+'"]';if(e.id)return'#'+e.id;var a=e.getAttribute('aria-label');if(a)return'[aria-label="'+a+'"]';if(e.name)return'[name="'+e.name+'"]';var g=(e.tagName||'').toLowerCase(),x=(e.textContent||e.value||'').trim().slice(0,40);return x?(g+':has-text("'+x+'")'):g;}
|
|
558
|
+
function _lbl(e){if(e.getAttribute('aria-label'))return e.getAttribute('aria-label');if(e.id){var l=document.querySelector('label[for="'+e.id+'"]');if(l)return l.textContent.trim();}return e.placeholder||null;}
|
|
559
|
+
function _push(o){o.ts=Date.now();window.__QA_Q.push(o);}
|
|
560
|
+
document.addEventListener('click',function(e){var el=e.target.closest('button,a,[role="button"],input[type="checkbox"],input[type="radio"]');if(!el)return;if(el.type==='checkbox')_push({type:'check',locator:_loc(el),checked:el.checked,label:_lbl(el)});else if(el.type==='radio')_push({type:'radio',locator:_loc(el),value:el.value,label:_lbl(el)});else _push({type:'click',locator:_loc(el),text:(el.textContent||el.value||'').trim().slice(0,60),tag:(el.tagName||'').toLowerCase()});},true);
|
|
561
|
+
document.addEventListener('blur',function(e){var el=e.target;if(!el||!['INPUT','TEXTAREA'].includes(el.tagName))return;if(['checkbox','radio','submit','button'].includes(el.type))return;if(!el.value)return;var last=window.__QA_Q[window.__QA_Q.length-1];if(last&&last.type==='fill'&&last.locator===_loc(el)&&last.value===el.value)return;_push({type:'fill',locator:_loc(el),value:el.value,inputType:el.type||'text',label:_lbl(el)});},true);
|
|
562
|
+
document.addEventListener('change',function(e){var el=e.target;if(!el||el.tagName!=='SELECT')return;var o=el.options[el.selectedIndex];_push({type:'select',locator:_loc(el),value:el.value,optionText:o?o.text:el.value,label:_lbl(el)});},true);
|
|
563
|
+
document.addEventListener('submit',function(e){_push({type:'submit',locator:_loc(e.target)});},true);
|
|
564
|
+
document.addEventListener('keydown',function(e){if(e.key==='Enter'&&!['BUTTON','A'].includes(e.target.tagName))_push({type:'press',key:'Enter',locator:_loc(e.target)});if(e.key==='Escape')_push({type:'press',key:'Escape'});},true);
|
|
565
|
+
}
|
|
566
|
+
`);
|
|
567
|
+
S.frameReady = true;
|
|
568
|
+
document.getElementById('preview-url').textContent = document.getElementById('url-input').value;
|
|
569
|
+
document.getElementById('preview-badge').style.display = '';
|
|
570
|
+
document.getElementById('start-btn').disabled = false;
|
|
571
|
+
toast('Page loaded — capture ready ✓', 's');
|
|
572
|
+
} catch(e) {
|
|
573
|
+
// Cross-origin — will be handled by tryLoadDirect
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ── Recording ─────────────────────────────────────────────────────────────────
|
|
578
|
+
function startRecording() {
|
|
579
|
+
if (!S.frameReady) { toast('Load a page first', 'e'); return; }
|
|
580
|
+
S.recording = true;
|
|
581
|
+
S.actions = [{ type:'navigate', url: document.getElementById('url-input').value, sessionTime:0, ts:Date.now(), id:'a0' }];
|
|
582
|
+
setRecUI(true);
|
|
583
|
+
renderActions();
|
|
584
|
+
S.pollTimer = setInterval(drainIframe, 350);
|
|
585
|
+
toast('Recording — interact with the page', 's');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function stopRecording() {
|
|
589
|
+
clearInterval(S.pollTimer); S.pollTimer = null;
|
|
590
|
+
drainIframe();
|
|
591
|
+
S.recording = false;
|
|
592
|
+
setRecUI(false);
|
|
593
|
+
document.getElementById('gen-btn').disabled = S.actions.length < 2;
|
|
594
|
+
buildSteps();
|
|
595
|
+
toast(`Stopped · ${S.actions.length} actions captured`, 's');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function setRecUI(on) {
|
|
599
|
+
document.getElementById('rec-dot').classList.toggle('on', on);
|
|
600
|
+
document.getElementById('rec-border').classList.toggle('on', on);
|
|
601
|
+
document.getElementById('hdr-status').textContent = on ? '● REC' : 'done';
|
|
602
|
+
document.getElementById('hdr-status').style.color = on ? 'var(--red)' : 'var(--text-3)';
|
|
603
|
+
document.getElementById('start-btn').disabled = on;
|
|
604
|
+
document.getElementById('stop-btn').disabled = !on;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── Drain iframe queue ────────────────────────────────────────────────────────
|
|
608
|
+
function drainIframe() {
|
|
609
|
+
try {
|
|
610
|
+
const win = document.getElementById('preview-frame').contentWindow;
|
|
611
|
+
if (!win || !win.__QA_Q) return;
|
|
612
|
+
const raw = win.__QA_Q.splice(0);
|
|
613
|
+
if (!raw.length) return;
|
|
614
|
+
const t0 = S.actions[0]?.ts || Date.now();
|
|
615
|
+
let changed = false;
|
|
616
|
+
for (let i = 0; i < raw.length; i++) {
|
|
617
|
+
const a = raw[i], nx = raw[i+1];
|
|
618
|
+
if (a.type==='fill' && nx && nx.type==='fill' && a.locator===nx.locator) continue;
|
|
619
|
+
S.actions.push({ ...a, id:'a'+(S.actions.length+1), sessionTime: a.ts - t0 });
|
|
620
|
+
changed = true;
|
|
621
|
+
}
|
|
622
|
+
if (changed) { renderActions(); document.getElementById('act-count').textContent = S.actions.length; document.getElementById('b-actions').textContent = S.actions.length; }
|
|
623
|
+
} catch(e) { clearInterval(S.pollTimer); }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ── Build steps ───────────────────────────────────────────────────────────────
|
|
627
|
+
function buildSteps() {
|
|
628
|
+
S.steps = S.actions.map((a,i) => ({ num:i+1, text: stepText(a) })).filter(s=>s.text);
|
|
629
|
+
renderSteps();
|
|
630
|
+
document.getElementById('b-steps').textContent = S.steps.length;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Generate everything ───────────────────────────────────────────────────────
|
|
634
|
+
async function generateAll() {
|
|
635
|
+
if (!S.actions.length) { toast('No actions to generate from','e'); return; }
|
|
636
|
+
const btn = document.getElementById('gen-btn');
|
|
637
|
+
btn.disabled = true; btn.textContent = '…';
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
// Generate code for ALL 4 frameworks in parallel
|
|
641
|
+
document.getElementById('code-body').innerHTML = '<div class="code-spin">Generating code…</div>';
|
|
642
|
+
switchPanel('code', document.querySelector('.ptab[data-p="code"]'));
|
|
643
|
+
|
|
644
|
+
const fws = ['playwright-python','playwright-typescript','selenium-python','selenium-java'];
|
|
645
|
+
const results = await Promise.all(fws.map(f =>
|
|
646
|
+
fetch(`${API}/api/record/offline/convert`, {
|
|
647
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
648
|
+
body: JSON.stringify({ framework:f, className: guessClass(), actions: S.actions })
|
|
649
|
+
}).then(r=>r.json()).catch(()=>({success:false}))
|
|
650
|
+
));
|
|
651
|
+
|
|
652
|
+
fws.forEach((f,i) => {
|
|
653
|
+
if (results[i]?.success) S.codeCache[f] = results[i].code || '';
|
|
654
|
+
if (results[i]?.steps && f === S.fw) { S.steps = results[i].steps; renderSteps(); document.getElementById('b-steps').textContent = S.steps.length; }
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
renderCode();
|
|
658
|
+
syncRecorderButtons();
|
|
659
|
+
toast('Generated all 4 frameworks ✓', 's');
|
|
660
|
+
} catch(e) {
|
|
661
|
+
toast('Error: ' + e.message, 'e');
|
|
662
|
+
} finally {
|
|
663
|
+
btn.disabled = false; btn.textContent = '→ Generate';
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Framework switcher (instant — uses cache) ─────────────────────────────────
|
|
668
|
+
function switchFw(el) {
|
|
669
|
+
S.fw = el.dataset.fw;
|
|
670
|
+
activateFwBtn(S.fw);
|
|
671
|
+
renderCode();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function activateFwBtn(fw) {
|
|
675
|
+
document.querySelectorAll('.fw-btn').forEach(b => b.classList.toggle('active', b.dataset.fw === fw));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderCode() {
|
|
679
|
+
const src = S.codeCache[S.fw];
|
|
680
|
+
const ext = S.fw.includes('typescript') ? 'ts' : S.fw.includes('java') ? 'java' : 'py';
|
|
681
|
+
const fn = guessClass().toLowerCase().replace('page','_page') + '.' + ext;
|
|
682
|
+
document.getElementById('code-fn').textContent = fn;
|
|
683
|
+
document.getElementById('code-body').innerHTML = src
|
|
684
|
+
? `<pre class="code-pre">${esc(src)}</pre>`
|
|
685
|
+
: `<pre class="code-pre" style="color:var(--text-3);font-style:italic">Click Generate first.</pre>`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function copyCode() {
|
|
689
|
+
const pre = document.querySelector('#code-body pre');
|
|
690
|
+
if (!pre) return;
|
|
691
|
+
await navigator.clipboard.writeText(pre.textContent).catch(()=>{});
|
|
692
|
+
const btn = document.querySelector('.copybtn');
|
|
693
|
+
btn.textContent='Copied!'; btn.style.color='var(--green)';
|
|
694
|
+
setTimeout(()=>{btn.textContent='Copy';btn.style.color='';},1500);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Test case panel ───────────────────────────────────────────────────────────
|
|
698
|
+
function initTestCaseWorkspace() {
|
|
699
|
+
syncTestCaseWorkspaceControls();
|
|
700
|
+
renderTestCaseWorkspace();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function toggleScenarioType(type, el) {
|
|
704
|
+
const active = new Set(S.activeScenarioTypes);
|
|
705
|
+
if (active.has(type)) active.delete(type);
|
|
706
|
+
else active.add(type);
|
|
707
|
+
if (!active.size) active.add('positive');
|
|
708
|
+
if (type === 'all_possible' && active.has('all_possible')) {
|
|
709
|
+
active.clear();
|
|
710
|
+
active.add('all_possible');
|
|
711
|
+
} else if (type !== 'all_possible') {
|
|
712
|
+
active.delete('all_possible');
|
|
713
|
+
}
|
|
714
|
+
S.activeScenarioTypes = Array.from(active);
|
|
715
|
+
syncTestCaseWorkspaceControls();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function toggleSuiteType(type, el) {
|
|
719
|
+
const active = new Set(S.activeSuiteTypes);
|
|
720
|
+
if (active.has(type)) active.delete(type);
|
|
721
|
+
else active.add(type);
|
|
722
|
+
if (!active.size) active.add('page');
|
|
723
|
+
S.activeSuiteTypes = Array.from(active);
|
|
724
|
+
syncTestCaseWorkspaceControls();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function setCaseListFilter(filter, el) {
|
|
728
|
+
S.caseListFilter = filter || 'all';
|
|
729
|
+
syncTestCaseWorkspaceControls();
|
|
730
|
+
renderTestCaseWorkspace();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function syncTestCaseWorkspaceControls() {
|
|
734
|
+
document.querySelectorAll('.tc-chip[data-scenario]').forEach((chip) => {
|
|
735
|
+
chip.classList.toggle('active', S.activeScenarioTypes.includes(chip.dataset.scenario));
|
|
736
|
+
});
|
|
737
|
+
document.querySelectorAll('.tc-chip[data-suite]').forEach((chip) => {
|
|
738
|
+
chip.classList.toggle('active', S.activeSuiteTypes.includes(chip.dataset.suite));
|
|
739
|
+
});
|
|
740
|
+
document.querySelectorAll('.tc-chip[data-list-filter]').forEach((chip) => {
|
|
741
|
+
chip.classList.toggle('active', chip.dataset.listFilter === S.caseListFilter);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function syncRecorderButtons() {
|
|
746
|
+
const hasCode = Object.values(S.codeCache).some(Boolean);
|
|
747
|
+
const hasCases = S.testCases.length > 0;
|
|
748
|
+
document.getElementById('dl-btn').disabled = !(hasCode || hasCases);
|
|
749
|
+
document.getElementById('save-btn').disabled = !(hasCode || hasCases);
|
|
750
|
+
document.getElementById('tc-copy-selected-btn').disabled = S.selectedCaseIds.length === 0;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function getNormalizedSelectedTypes(forceAll) {
|
|
754
|
+
if (forceAll || S.activeScenarioTypes.includes('all_possible')) {
|
|
755
|
+
return ['positive','negative','boundary','navigation','ui','accessibility','all_possible'];
|
|
756
|
+
}
|
|
757
|
+
return S.activeScenarioTypes.length ? [...S.activeScenarioTypes] : ['positive'];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function getNormalizedSelectedSuites() {
|
|
761
|
+
return S.activeSuiteTypes.length ? [...S.activeSuiteTypes] : ['page'];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function generateScenarioTestCases(appendAll) {
|
|
765
|
+
const pageContext = extractPageContextFromIframe();
|
|
766
|
+
const scenarioTypes = getNormalizedSelectedTypes(appendAll);
|
|
767
|
+
const suiteTypes = getNormalizedSelectedSuites();
|
|
768
|
+
if (!pageContext && !S.actions.length) {
|
|
769
|
+
toast('Load a page or record a flow first', 'e');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const list = document.getElementById('tc-case-list');
|
|
774
|
+
const empty = document.getElementById('tc-case-empty');
|
|
775
|
+
empty.style.display = 'none';
|
|
776
|
+
list.innerHTML = '<div class="tc-generating">Generating QA test cases…</div>';
|
|
777
|
+
document.getElementById('tc-generation-status').textContent = appendAll
|
|
778
|
+
? `Adding all possible ${suiteTypes.join(', ')} scenarios based on the current page and flow…`
|
|
779
|
+
: `Generating ${suiteTypes.join(', ')} test cases for ${scenarioTypes.join(', ')} coverage…`;
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
const res = await fetch(`${API}/api/record/offline/testcases`, {
|
|
783
|
+
method: 'POST',
|
|
784
|
+
headers: { 'Content-Type': 'application/json' },
|
|
785
|
+
body: JSON.stringify({
|
|
786
|
+
pageContext,
|
|
787
|
+
actions: S.actions,
|
|
788
|
+
steps: S.steps,
|
|
789
|
+
scenarioTypes,
|
|
790
|
+
suiteTypes,
|
|
791
|
+
sourceMode: 'hybrid',
|
|
792
|
+
apiKey: S.apiKey || '',
|
|
793
|
+
provider: S.provider || 'claude',
|
|
794
|
+
}),
|
|
795
|
+
});
|
|
796
|
+
const data = await res.json();
|
|
797
|
+
if (!data.success) throw new Error(data.error || 'Failed to generate test cases');
|
|
798
|
+
|
|
799
|
+
const incoming = dedupeTestCases((data.testCases || []).map(normalizeTestCaseShape));
|
|
800
|
+
const manualCases = S.testCases.filter(tc => tc.source === 'manual');
|
|
801
|
+
const preservedGenerated = appendAll ? S.testCases.filter(tc => tc.source !== 'manual') : [];
|
|
802
|
+
const mergedBase = appendAll ? [...manualCases, ...preservedGenerated] : [...manualCases];
|
|
803
|
+
const merged = mergeTestCases(mergedBase, incoming);
|
|
804
|
+
const preservedSelected = new Set(manualCases.filter(tc => S.selectedCaseIds.includes(tc.id)).map(tc => tc.id));
|
|
805
|
+
S.testCases = merged;
|
|
806
|
+
S.selectedCaseIds = Array.from(new Set([...preservedSelected, ...incoming.map(tc => tc.id)]));
|
|
807
|
+
S.expandedCaseIds = incoming.length ? [incoming[0].id] : S.expandedCaseIds;
|
|
808
|
+
S.editingCaseId = null;
|
|
809
|
+
document.getElementById('tc-generation-status').textContent = `${incoming.length} scenarios ready. Review, edit, copy, or add more.`;
|
|
810
|
+
renderTestCaseWorkspace();
|
|
811
|
+
toast(`${incoming.length} test cases generated`, 's');
|
|
812
|
+
} catch (err) {
|
|
813
|
+
list.innerHTML = '';
|
|
814
|
+
empty.style.display = '';
|
|
815
|
+
document.getElementById('tc-generation-status').textContent = 'Generation failed. Adjust the scenario types and try again.';
|
|
816
|
+
toast(err.message, 'e');
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function extractPageContextFromIframe() {
|
|
821
|
+
try {
|
|
822
|
+
const doc = document.getElementById('preview-frame').contentDocument;
|
|
823
|
+
if (!doc) return null;
|
|
824
|
+
const isVisible = (el) => {
|
|
825
|
+
if (!el) return false;
|
|
826
|
+
const style = window.getComputedStyle(el);
|
|
827
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && (el.offsetWidth > 0 || el.offsetHeight > 0);
|
|
828
|
+
};
|
|
829
|
+
const text = (el) => (el?.textContent || '').replace(/\s+/g, ' ').trim();
|
|
830
|
+
const locator = (el) => {
|
|
831
|
+
if (!el) return '';
|
|
832
|
+
const tid = el.dataset && (el.dataset.testid || el.dataset.test || el.dataset.cy || el.dataset.qa);
|
|
833
|
+
if (tid) return `[data-testid="${tid}"]`;
|
|
834
|
+
if (el.id) return `#${el.id}`;
|
|
835
|
+
if (el.name) return `[name="${el.name}"]`;
|
|
836
|
+
if (el.getAttribute('aria-label')) return `[aria-label="${el.getAttribute('aria-label')}"]`;
|
|
837
|
+
return el.tagName ? el.tagName.toLowerCase() : '';
|
|
838
|
+
};
|
|
839
|
+
const forms = Array.from(doc.querySelectorAll('form')).slice(0, 8).map((form) => ({
|
|
840
|
+
locator: locator(form),
|
|
841
|
+
fields: Array.from(form.querySelectorAll('input, select, textarea')).filter(isVisible).slice(0, 12).map((field) => ({
|
|
842
|
+
label: field.getAttribute('aria-label') || field.placeholder || field.name || field.id || field.type || field.tagName.toLowerCase(),
|
|
843
|
+
type: field.type || field.tagName.toLowerCase(),
|
|
844
|
+
required: !!field.required,
|
|
845
|
+
placeholder: field.placeholder || '',
|
|
846
|
+
locator: locator(field),
|
|
847
|
+
})),
|
|
848
|
+
buttons: Array.from(form.querySelectorAll('button, input[type="submit"]')).filter(isVisible).slice(0, 6).map((btn) => ({
|
|
849
|
+
text: text(btn) || btn.value || btn.name || 'Submit',
|
|
850
|
+
locator: locator(btn),
|
|
851
|
+
})),
|
|
852
|
+
}));
|
|
853
|
+
return {
|
|
854
|
+
url: doc.location?.href || document.getElementById('url-input').value || '',
|
|
855
|
+
title: doc.title || '',
|
|
856
|
+
headings: Array.from(doc.querySelectorAll('h1,h2,h3')).filter(isVisible).slice(0, 8).map(text).filter(Boolean),
|
|
857
|
+
forms,
|
|
858
|
+
inputs: Array.from(doc.querySelectorAll('input, select, textarea')).filter(isVisible).slice(0, 20).map((field) => ({
|
|
859
|
+
label: field.getAttribute('aria-label') || field.placeholder || field.name || field.id || field.type || field.tagName.toLowerCase(),
|
|
860
|
+
type: field.type || field.tagName.toLowerCase(),
|
|
861
|
+
required: !!field.required,
|
|
862
|
+
placeholder: field.placeholder || '',
|
|
863
|
+
locator: locator(field),
|
|
864
|
+
})),
|
|
865
|
+
buttons: Array.from(doc.querySelectorAll('button, [role="button"], input[type="submit"]')).filter(isVisible).slice(0, 20).map((btn) => ({
|
|
866
|
+
text: text(btn) || btn.value || btn.name || 'Action',
|
|
867
|
+
locator: locator(btn),
|
|
868
|
+
})),
|
|
869
|
+
links: Array.from(doc.querySelectorAll('a[href]')).filter(isVisible).slice(0, 20).map((link) => ({
|
|
870
|
+
text: text(link) || link.href,
|
|
871
|
+
href: link.getAttribute('href') || '',
|
|
872
|
+
locator: locator(link),
|
|
873
|
+
})),
|
|
874
|
+
tables: Array.from(doc.querySelectorAll('table')).slice(0, 5).map((table) => ({
|
|
875
|
+
locator: locator(table),
|
|
876
|
+
headers: Array.from(table.querySelectorAll('th')).slice(0, 8).map(text).filter(Boolean),
|
|
877
|
+
})),
|
|
878
|
+
alerts: Array.from(doc.querySelectorAll('[role="alert"], .error, .alert, .toast')).filter(isVisible).slice(0, 8).map((el) => ({
|
|
879
|
+
text: text(el),
|
|
880
|
+
locator: locator(el),
|
|
881
|
+
})).filter(item => item.text),
|
|
882
|
+
};
|
|
883
|
+
} catch (_) {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function normalizeTestCaseShape(tc, index) {
|
|
889
|
+
const expected = (tc.expectedResult || tc.expected_result || '').trim();
|
|
890
|
+
const steps = Array.isArray(tc.steps) ? tc.steps.map(s => String(s).trim()).filter(Boolean) : [];
|
|
891
|
+
const title = ensureVerifyTitle(tc.title || '', expected || 'the expected result is displayed');
|
|
892
|
+
return {
|
|
893
|
+
id: String(tc.id || `TC${String(index + 1).padStart(3, '0')}`),
|
|
894
|
+
title,
|
|
895
|
+
category: (tc.category || 'functional').toLowerCase(),
|
|
896
|
+
priority: (tc.priority || 'medium').toLowerCase(),
|
|
897
|
+
preconditions: String(tc.preconditions || '').trim(),
|
|
898
|
+
steps,
|
|
899
|
+
expectedResult: expected,
|
|
900
|
+
locators: tc.locators && typeof tc.locators === 'object' ? tc.locators : {},
|
|
901
|
+
testData: tc.testData && typeof tc.testData === 'object' ? tc.testData : {},
|
|
902
|
+
tags: Array.isArray(tc.tags) ? tc.tags : [],
|
|
903
|
+
suite: inferTestCaseSuite(tc),
|
|
904
|
+
source: tc.source || 'hybrid',
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function inferTestCaseSuite(tc) {
|
|
909
|
+
const explicit = String(tc.suite || '').toLowerCase().trim();
|
|
910
|
+
if (explicit) return explicit;
|
|
911
|
+
const tags = Array.isArray(tc.tags) ? tc.tags.map(tag => String(tag).toLowerCase()) : [];
|
|
912
|
+
if (tags.includes('regression')) return 'regression';
|
|
913
|
+
if (tags.includes('smoke')) return 'smoke';
|
|
914
|
+
if (String(tc.category || '').toLowerCase() === 'e2e' || tags.includes('e2e') || tags.includes('flow')) return 'e2e';
|
|
915
|
+
return 'page';
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function ensureVerifyTitle(title, expected) {
|
|
919
|
+
const cleaned = String(title || '').replace(/\s+/g, ' ').trim();
|
|
920
|
+
if (cleaned && /^verify\b/i.test(cleaned)) return cleaned.endsWith('.') ? cleaned : `${cleaned}.`;
|
|
921
|
+
const expectation = String(expected || 'the expected result is shown').replace(/\s+/g, ' ').trim();
|
|
922
|
+
const subject = cleaned || 'the selected scenario behaves correctly';
|
|
923
|
+
const sentence = `Verify ${subject.replace(/^verify\s+/i, '')} and expect ${expectation.replace(/\.$/, '')}.`;
|
|
924
|
+
return sentence.replace(/\s+/g, ' ').trim();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function dedupeTestCases(cases) {
|
|
928
|
+
const seen = new Set();
|
|
929
|
+
return cases.filter((tc) => {
|
|
930
|
+
const key = tc.title.toLowerCase().trim();
|
|
931
|
+
if (seen.has(key)) return false;
|
|
932
|
+
seen.add(key);
|
|
933
|
+
return true;
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function mergeTestCases(baseCases, incoming) {
|
|
938
|
+
const seen = new Set(baseCases.map(tc => tc.title.toLowerCase().trim()));
|
|
939
|
+
const result = [...baseCases];
|
|
940
|
+
incoming.forEach((tc) => {
|
|
941
|
+
const key = tc.title.toLowerCase().trim();
|
|
942
|
+
if (!seen.has(key)) {
|
|
943
|
+
seen.add(key);
|
|
944
|
+
result.push(tc);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
return result;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function addManualTestCase() {
|
|
951
|
+
const manual = normalizeTestCaseShape({
|
|
952
|
+
id: nextTestCaseId(),
|
|
953
|
+
title: 'Verify the manually added scenario and expect the intended result.',
|
|
954
|
+
category: 'functional',
|
|
955
|
+
priority: 'medium',
|
|
956
|
+
preconditions: '',
|
|
957
|
+
steps: ['Document the QA action to perform'],
|
|
958
|
+
expectedResult: 'The intended result is displayed.',
|
|
959
|
+
locators: {},
|
|
960
|
+
testData: {},
|
|
961
|
+
tags: ['manual'],
|
|
962
|
+
suite: 'page',
|
|
963
|
+
source: 'manual',
|
|
964
|
+
}, S.testCases.length);
|
|
965
|
+
S.testCases.unshift(manual);
|
|
966
|
+
S.selectedCaseIds = Array.from(new Set([manual.id, ...S.selectedCaseIds]));
|
|
967
|
+
S.expandedCaseIds = [manual.id];
|
|
968
|
+
S.editingCaseId = manual.id;
|
|
969
|
+
document.getElementById('tc-generation-status').textContent = 'Manual test case added. Update the details and save it inline.';
|
|
970
|
+
renderTestCaseWorkspace();
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function nextTestCaseId() {
|
|
974
|
+
const max = S.testCases.reduce((acc, tc) => {
|
|
975
|
+
const match = String(tc.id || '').match(/(\d+)$/);
|
|
976
|
+
return Math.max(acc, match ? parseInt(match[1], 10) : 0);
|
|
977
|
+
}, 0);
|
|
978
|
+
return `TC${String(max + 1).padStart(3, '0')}`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function renderTestCaseWorkspace() {
|
|
982
|
+
const list = document.getElementById('tc-case-list');
|
|
983
|
+
const empty = document.getElementById('tc-case-empty');
|
|
984
|
+
const validIds = new Set(S.testCases.map(tc => tc.id));
|
|
985
|
+
S.selectedCaseIds = S.selectedCaseIds.filter(id => validIds.has(id));
|
|
986
|
+
S.expandedCaseIds = S.expandedCaseIds.filter(id => validIds.has(id));
|
|
987
|
+
if (S.editingCaseId && !validIds.has(S.editingCaseId)) S.editingCaseId = null;
|
|
988
|
+
document.getElementById('tc-summary-label').textContent = `${S.testCases.length} test cases`;
|
|
989
|
+
document.getElementById('tc-selection-label').textContent = `${S.selectedCaseIds.length} selected`;
|
|
990
|
+
syncRecorderButtons();
|
|
991
|
+
syncTestCaseWorkspaceControls();
|
|
992
|
+
|
|
993
|
+
const visibleCases = S.testCases.filter((tc) => {
|
|
994
|
+
if (S.caseListFilter === 'selected') return S.selectedCaseIds.includes(tc.id);
|
|
995
|
+
if (S.caseListFilter === 'all') return true;
|
|
996
|
+
return (tc.suite || 'page') === S.caseListFilter;
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
if (!visibleCases.length) {
|
|
1000
|
+
list.innerHTML = '';
|
|
1001
|
+
empty.style.display = '';
|
|
1002
|
+
empty.textContent = S.testCases.length
|
|
1003
|
+
? 'No test cases match the current list filter.'
|
|
1004
|
+
: 'Capture a flow or load a page, then generate QA test cases from the selected scenario types.';
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
empty.style.display = 'none';
|
|
1009
|
+
list.innerHTML = visibleCases.map((tc) => renderTestCaseCard(tc)).join('');
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function renderTestCaseCard(tc) {
|
|
1013
|
+
const expanded = S.expandedCaseIds.includes(tc.id);
|
|
1014
|
+
const editing = S.editingCaseId === tc.id;
|
|
1015
|
+
const selected = S.selectedCaseIds.includes(tc.id);
|
|
1016
|
+
const fullText = buildDetailedTestCaseText(tc);
|
|
1017
|
+
return `
|
|
1018
|
+
<div class="tc-card ${expanded ? 'expanded' : ''}">
|
|
1019
|
+
<div class="tc-card-top">
|
|
1020
|
+
<button class="tc-select ${selected ? 'selected' : ''}" onclick="toggleTestCaseSelection('${escAttr(tc.id)}')">${selected ? '✓' : ''}</button>
|
|
1021
|
+
<div class="tc-main" onclick="toggleTestCaseExpanded('${escAttr(tc.id)}')">
|
|
1022
|
+
<div class="tc-title-line">${esc(tc.title)}</div>
|
|
1023
|
+
<div class="tc-meta">
|
|
1024
|
+
<span class="badge badge-${escAttr(tc.priority || 'medium')}">${esc(tc.priority)}</span>
|
|
1025
|
+
<span class="badge badge-cat">${esc(tc.category)}</span>
|
|
1026
|
+
<span class="badge badge-source">${esc(tc.suite || 'page')}</span>
|
|
1027
|
+
<span class="badge badge-source">${esc(tc.source || 'hybrid')}</span>
|
|
1028
|
+
</div>
|
|
1029
|
+
</div>
|
|
1030
|
+
<div class="tc-actions">
|
|
1031
|
+
<button class="tc-icon-btn" onclick="copyTestCaseTitle('${escAttr(tc.id)}')">Copy</button>
|
|
1032
|
+
<button class="tc-icon-btn" onclick="startEditTestCase('${escAttr(tc.id)}')">${editing ? 'Editing' : 'Edit'}</button>
|
|
1033
|
+
<button class="tc-icon-btn danger" onclick="deleteTestCase('${escAttr(tc.id)}')">Delete</button>
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
${expanded ? `
|
|
1037
|
+
<div class="tc-card-body">
|
|
1038
|
+
${editing ? renderTestCaseEditor(tc) : `
|
|
1039
|
+
<div class="tc-field"><div class="tc-label">ID</div><div class="tc-val mono">${esc(tc.id)}</div></div>
|
|
1040
|
+
<div class="tc-field"><div class="tc-label">Suite</div><div class="tc-val">${esc(tc.suite || 'page')}</div></div>
|
|
1041
|
+
<div class="tc-field"><div class="tc-label">Preconditions</div><div class="tc-val">${esc(tc.preconditions || 'None')}</div></div>
|
|
1042
|
+
<div class="tc-field"><div class="tc-label">Expected Result</div><div class="tc-val">${esc(tc.expectedResult || 'Not specified')}</div></div>
|
|
1043
|
+
<div class="tc-field">
|
|
1044
|
+
<div class="tc-label">Steps</div>
|
|
1045
|
+
<div class="tc-step-list">${(tc.steps || []).map((step, idx) => `<div class="tc-step-row"><span class="tc-step-num">${idx + 1}.</span><span>${esc(step)}</span></div>`).join('')}</div>
|
|
1046
|
+
</div>
|
|
1047
|
+
<div class="tc-field"><div class="tc-label">Locators</div><div class="tc-val mono">${esc(formatKeyValueBlock(tc.locators) || 'None')}</div></div>
|
|
1048
|
+
<div class="tc-field"><div class="tc-label">Test Data</div><div class="tc-val mono">${esc(formatKeyValueBlock(tc.testData) || 'None')}</div></div>
|
|
1049
|
+
<div class="tc-body-actions">
|
|
1050
|
+
<button class="tc-icon-btn" onclick="copyDetailedTestCase('${escAttr(tc.id)}')">Copy Full</button>
|
|
1051
|
+
</div>
|
|
1052
|
+
`}
|
|
1053
|
+
</div>
|
|
1054
|
+
` : ''}
|
|
1055
|
+
<div style="display:none" data-copy-detail="${escAttr(tc.id)}">${esc(fullText)}</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function renderTestCaseEditor(tc) {
|
|
1061
|
+
return `
|
|
1062
|
+
<div class="tc-edit-grid">
|
|
1063
|
+
<div class="tc-field">
|
|
1064
|
+
<div class="tc-label">Title</div>
|
|
1065
|
+
<input class="tc-input" id="edit-title-${escAttr(tc.id)}" value="${escAttr(tc.title)}"/>
|
|
1066
|
+
</div>
|
|
1067
|
+
<div class="tc-edit-row">
|
|
1068
|
+
<div class="tc-field">
|
|
1069
|
+
<div class="tc-label">Priority</div>
|
|
1070
|
+
<select class="tc-select-input" id="edit-priority-${escAttr(tc.id)}">
|
|
1071
|
+
${['high','medium','low'].map(opt => `<option value="${opt}" ${tc.priority === opt ? 'selected' : ''}>${opt}</option>`).join('')}
|
|
1072
|
+
</select>
|
|
1073
|
+
</div>
|
|
1074
|
+
<div class="tc-field">
|
|
1075
|
+
<div class="tc-label">Category</div>
|
|
1076
|
+
<select class="tc-select-input" id="edit-category-${escAttr(tc.id)}">
|
|
1077
|
+
${['functional','negative','boundary','navigation','ui','accessibility','e2e'].map(opt => `<option value="${opt}" ${tc.category === opt ? 'selected' : ''}>${opt}</option>`).join('')}
|
|
1078
|
+
</select>
|
|
1079
|
+
</div>
|
|
1080
|
+
</div>
|
|
1081
|
+
<div class="tc-field">
|
|
1082
|
+
<div class="tc-label">Suite</div>
|
|
1083
|
+
<select class="tc-select-input" id="edit-suite-${escAttr(tc.id)}">
|
|
1084
|
+
${['page','e2e','regression','smoke'].map(opt => `<option value="${opt}" ${(tc.suite || 'page') === opt ? 'selected' : ''}>${opt}</option>`).join('')}
|
|
1085
|
+
</select>
|
|
1086
|
+
</div>
|
|
1087
|
+
<div class="tc-field">
|
|
1088
|
+
<div class="tc-label">Preconditions</div>
|
|
1089
|
+
<textarea class="tc-textarea" id="edit-preconditions-${escAttr(tc.id)}">${esc(tc.preconditions || '')}</textarea>
|
|
1090
|
+
</div>
|
|
1091
|
+
<div class="tc-field">
|
|
1092
|
+
<div class="tc-label">Expected Result</div>
|
|
1093
|
+
<textarea class="tc-textarea" id="edit-expected-${escAttr(tc.id)}">${esc(tc.expectedResult || '')}</textarea>
|
|
1094
|
+
</div>
|
|
1095
|
+
<div class="tc-field">
|
|
1096
|
+
<div class="tc-label">Steps (one per line)</div>
|
|
1097
|
+
<textarea class="tc-textarea" id="edit-steps-${escAttr(tc.id)}">${esc((tc.steps || []).join('\n'))}</textarea>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div class="tc-body-actions">
|
|
1100
|
+
<button class="tc-icon-btn" onclick="cancelEditTestCase()">Cancel</button>
|
|
1101
|
+
<button class="tc-icon-btn" onclick="saveEditedTestCase('${escAttr(tc.id)}')">Save</button>
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
`;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function toggleTestCaseSelection(id) {
|
|
1108
|
+
const selected = new Set(S.selectedCaseIds);
|
|
1109
|
+
if (selected.has(id)) selected.delete(id);
|
|
1110
|
+
else selected.add(id);
|
|
1111
|
+
S.selectedCaseIds = Array.from(selected);
|
|
1112
|
+
renderTestCaseWorkspace();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function toggleTestCaseExpanded(id) {
|
|
1116
|
+
const expanded = new Set(S.expandedCaseIds);
|
|
1117
|
+
if (expanded.has(id)) expanded.delete(id);
|
|
1118
|
+
else expanded.add(id);
|
|
1119
|
+
S.expandedCaseIds = Array.from(expanded);
|
|
1120
|
+
renderTestCaseWorkspace();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function startEditTestCase(id) {
|
|
1124
|
+
S.expandedCaseIds = Array.from(new Set([...S.expandedCaseIds, id]));
|
|
1125
|
+
S.editingCaseId = id;
|
|
1126
|
+
renderTestCaseWorkspace();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function cancelEditTestCase() {
|
|
1130
|
+
S.editingCaseId = null;
|
|
1131
|
+
renderTestCaseWorkspace();
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function saveEditedTestCase(id) {
|
|
1135
|
+
const tc = S.testCases.find(item => item.id === id);
|
|
1136
|
+
if (!tc) return;
|
|
1137
|
+
const expected = document.getElementById(`edit-expected-${id}`).value.trim();
|
|
1138
|
+
const rawTitle = document.getElementById(`edit-title-${id}`).value.trim();
|
|
1139
|
+
tc.title = ensureVerifyTitle(rawTitle, expected || 'the expected result is displayed');
|
|
1140
|
+
tc.priority = document.getElementById(`edit-priority-${id}`).value;
|
|
1141
|
+
tc.category = document.getElementById(`edit-category-${id}`).value;
|
|
1142
|
+
tc.suite = document.getElementById(`edit-suite-${id}`).value;
|
|
1143
|
+
tc.preconditions = document.getElementById(`edit-preconditions-${id}`).value.trim();
|
|
1144
|
+
tc.expectedResult = expected;
|
|
1145
|
+
tc.steps = document.getElementById(`edit-steps-${id}`).value.split('\n').map(s => s.trim()).filter(Boolean);
|
|
1146
|
+
S.editingCaseId = null;
|
|
1147
|
+
renderTestCaseWorkspace();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function deleteTestCase(id) {
|
|
1151
|
+
S.testCases = S.testCases.filter(tc => tc.id !== id);
|
|
1152
|
+
S.selectedCaseIds = S.selectedCaseIds.filter(caseId => caseId !== id);
|
|
1153
|
+
S.expandedCaseIds = S.expandedCaseIds.filter(caseId => caseId !== id);
|
|
1154
|
+
if (S.editingCaseId === id) S.editingCaseId = null;
|
|
1155
|
+
renderTestCaseWorkspace();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function copyTestCaseTitle(id) {
|
|
1159
|
+
const tc = S.testCases.find(item => item.id === id);
|
|
1160
|
+
if (!tc) return;
|
|
1161
|
+
navigator.clipboard.writeText(tc.title).then(() => toast('Test case copied', 's')).catch(() => {});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function copyDetailedTestCase(id) {
|
|
1165
|
+
const tc = S.testCases.find(item => item.id === id);
|
|
1166
|
+
if (!tc) return;
|
|
1167
|
+
navigator.clipboard.writeText(buildDetailedTestCaseText(tc)).then(() => toast('Detailed test case copied', 's')).catch(() => {});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function copySelectedTestCases() {
|
|
1171
|
+
const lines = S.testCases.filter(tc => S.selectedCaseIds.includes(tc.id)).map(tc => tc.title);
|
|
1172
|
+
if (!lines.length) return;
|
|
1173
|
+
navigator.clipboard.writeText(lines.join('\n')).then(() => toast('Selected test cases copied', 's')).catch(() => {});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function buildDetailedTestCaseText(tc) {
|
|
1177
|
+
return [
|
|
1178
|
+
tc.title,
|
|
1179
|
+
'',
|
|
1180
|
+
`ID: ${tc.id}`,
|
|
1181
|
+
`Priority: ${tc.priority}`,
|
|
1182
|
+
`Category: ${tc.category}`,
|
|
1183
|
+
`Suite: ${tc.suite || 'page'}`,
|
|
1184
|
+
`Source: ${tc.source || 'hybrid'}`,
|
|
1185
|
+
`Preconditions: ${tc.preconditions || 'None'}`,
|
|
1186
|
+
'',
|
|
1187
|
+
'Steps:',
|
|
1188
|
+
...(tc.steps || []).map((step, idx) => `${idx + 1}. ${step}`),
|
|
1189
|
+
'',
|
|
1190
|
+
`Expected Result: ${tc.expectedResult || 'Not specified'}`,
|
|
1191
|
+
'',
|
|
1192
|
+
`Locators: ${formatKeyValueBlock(tc.locators) || 'None'}`,
|
|
1193
|
+
`Test Data: ${formatKeyValueBlock(tc.testData) || 'None'}`,
|
|
1194
|
+
].join('\n');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function formatKeyValueBlock(obj) {
|
|
1198
|
+
if (!obj || typeof obj !== 'object' || !Object.keys(obj).length) return '';
|
|
1199
|
+
return Object.entries(obj).map(([key, value]) => `${key}: ${value}`).join('\n');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ── Download ZIP ──────────────────────────────────────────────────────────────
|
|
1203
|
+
async function downloadZip() {
|
|
1204
|
+
const zip = new JSZip();
|
|
1205
|
+
const fws = { 'playwright-python':'py', 'playwright-typescript':'ts', 'selenium-python':'py', 'selenium-java':'java' };
|
|
1206
|
+
let hasFiles = false;
|
|
1207
|
+
Object.entries(S.codeCache).forEach(([f,src]) => {
|
|
1208
|
+
if (!src) return;
|
|
1209
|
+
const ext = fws[f];
|
|
1210
|
+
const folder = f.includes('playwright') ? 'playwright' : 'selenium';
|
|
1211
|
+
const lang = f.includes('typescript') ? 'ts' : f.includes('java') ? 'java' : 'py';
|
|
1212
|
+
zip.file(`${folder}/${lang}/${guessClass().toLowerCase().replace('page','_page')}.${ext}`, src);
|
|
1213
|
+
hasFiles = true;
|
|
1214
|
+
});
|
|
1215
|
+
if (!hasFiles && !S.testCases.length) { toast('Generate code or test cases first','e'); return; }
|
|
1216
|
+
|
|
1217
|
+
if (S.testCases.length) {
|
|
1218
|
+
zip.file('test_cases.json', JSON.stringify(S.testCases, null, 2));
|
|
1219
|
+
const selectedLines = S.testCases.filter(tc => S.selectedCaseIds.includes(tc.id)).map(tc => tc.title).join('\n');
|
|
1220
|
+
zip.file('selected_test_cases.txt', selectedLines);
|
|
1221
|
+
}
|
|
1222
|
+
if (S.steps.length) zip.file('STEPS.md', '# Test Steps\n\n' + S.steps.map(s=>`${s.num}. ${s.text}`).join('\n'));
|
|
1223
|
+
|
|
1224
|
+
const blob = await zip.generateAsync({ type:'blob', compression:'DEFLATE' });
|
|
1225
|
+
const url = URL.createObjectURL(blob);
|
|
1226
|
+
const a = document.createElement('a'); a.href=url; a.download=`qa_recorded_${Date.now()}.zip`; a.click();
|
|
1227
|
+
URL.revokeObjectURL(url);
|
|
1228
|
+
toast('ZIP downloaded','s');
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ── Save to project ───────────────────────────────────────────────────────────
|
|
1232
|
+
async function saveToProject() {
|
|
1233
|
+
const url = document.getElementById('url-input').value;
|
|
1234
|
+
const project = {
|
|
1235
|
+
name: url, url, pageType: guessPageType(url),
|
|
1236
|
+
pages: [{ url, testCases: S.testCases, selectedTestCaseIds: S.selectedCaseIds, scripts: Object.fromEntries(
|
|
1237
|
+
Object.entries(S.codeCache).filter(([,v])=>v).map(([k,v])=>[k,{filename:k+'.txt',content:v}])
|
|
1238
|
+
)}]
|
|
1239
|
+
};
|
|
1240
|
+
try {
|
|
1241
|
+
const r = await fetch(`${API}/api/save-project`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({project})});
|
|
1242
|
+
const d = await r.json();
|
|
1243
|
+
if (d.success) { toast('Saved! Redirecting…','s'); setTimeout(()=>window.location='/',1400); }
|
|
1244
|
+
else throw new Error(d.error);
|
|
1245
|
+
} catch(e) { toast('Save failed: '+e.message,'e'); }
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
1249
|
+
function renderActions() {
|
|
1250
|
+
const list = document.getElementById('actions-list');
|
|
1251
|
+
const empty = document.getElementById('empty-actions');
|
|
1252
|
+
if (!S.actions.length) { empty.style.display='block'; list.innerHTML=''; return; }
|
|
1253
|
+
empty.style.display='none';
|
|
1254
|
+
list.innerHTML = S.actions.map((a,i)=>`
|
|
1255
|
+
<div class="arow">
|
|
1256
|
+
<span class="atype t-${a.type}">${a.type}</span>
|
|
1257
|
+
<div class="abody">
|
|
1258
|
+
<div class="amain">${esc(actionText(a))}</div>
|
|
1259
|
+
${(a.locator||a.url)?`<div class="aloc">${esc(a.locator||a.url)}</div>`:''}
|
|
1260
|
+
</div>
|
|
1261
|
+
<button class="adel" onclick="delAction(${i})">×</button>
|
|
1262
|
+
</div>`).join('');
|
|
1263
|
+
list.scrollTop = list.scrollHeight;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function renderSteps() {
|
|
1267
|
+
const list = document.getElementById('steps-list');
|
|
1268
|
+
const empty = document.getElementById('empty-steps');
|
|
1269
|
+
if (!S.steps.length) { empty.style.display='block'; list.innerHTML=''; return; }
|
|
1270
|
+
empty.style.display='none';
|
|
1271
|
+
list.innerHTML = S.steps.map((s,i)=>`
|
|
1272
|
+
<div class="srow">
|
|
1273
|
+
<span class="snum">${s.num}.</span>
|
|
1274
|
+
<div class="stxt" contenteditable="true" spellcheck="false"
|
|
1275
|
+
onblur="S.steps[${i}].text=this.textContent.trim()"
|
|
1276
|
+
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()}">${esc(s.text)}</div>
|
|
1277
|
+
</div>`).join('');
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ── Panel switching ───────────────────────────────────────────────────────────
|
|
1281
|
+
function switchPanel(name, el) {
|
|
1282
|
+
document.querySelectorAll('.pbody').forEach(p => p.classList.remove('active'));
|
|
1283
|
+
document.getElementById('pbody-'+name).classList.add('active');
|
|
1284
|
+
document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
|
|
1285
|
+
el.classList.add('active');
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
1289
|
+
function delAction(i) { S.actions.splice(i,1); renderActions(); document.getElementById('act-count').textContent=S.actions.length; document.getElementById('b-actions').textContent=S.actions.length; }
|
|
1290
|
+
|
|
1291
|
+
function clearAll() {
|
|
1292
|
+
clearInterval(S.pollTimer); S.pollTimer=null; S.recording=false;
|
|
1293
|
+
S.actions=[]; S.steps=[]; S.codeCache={'playwright-python':'','playwright-typescript':'','selenium-python':'','selenium-java':''};
|
|
1294
|
+
S.testCases=[]; S.selectedCaseIds=[]; S.activeSuiteTypes=['page']; S.activeScenarioTypes=['positive']; S.caseListFilter='all'; S.expandedCaseIds=[]; S.editingCaseId=null;
|
|
1295
|
+
setRecUI(false);
|
|
1296
|
+
document.getElementById('start-btn').disabled=true;
|
|
1297
|
+
['gen-btn','dl-btn','save-btn'].forEach(id=>document.getElementById(id).disabled=true);
|
|
1298
|
+
document.getElementById('act-count').textContent='0';
|
|
1299
|
+
['b-actions','b-steps'].forEach(id=>document.getElementById(id).textContent='0');
|
|
1300
|
+
renderActions(); renderSteps(); renderCode(); renderTestCaseWorkspace();
|
|
1301
|
+
syncTestCaseWorkspaceControls();
|
|
1302
|
+
document.getElementById('tc-generation-status').textContent = 'Choose scenario types and generate QA-ready test cases from the page or recorded flow.';
|
|
1303
|
+
document.getElementById('preview-frame').style.display='none';
|
|
1304
|
+
document.getElementById('start-screen').style.display='flex';
|
|
1305
|
+
document.getElementById('preview-badge').style.display='none';
|
|
1306
|
+
S.frameReady=false;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function actionText(a) {
|
|
1310
|
+
if(a.type==='navigate') return a.url;
|
|
1311
|
+
if(a.type==='click') return 'Click "'+(a.text||a.locator)+'"';
|
|
1312
|
+
if(a.type==='fill') return 'Fill "'+a.value+'" → '+(a.label||a.locator);
|
|
1313
|
+
if(a.type==='select') return 'Select "'+(a.optionText||a.value)+'" in '+(a.label||a.locator);
|
|
1314
|
+
if(a.type==='check') return (a.checked?'Check':'Uncheck')+' "'+(a.label||a.locator)+'"';
|
|
1315
|
+
if(a.type==='press') return 'Press '+a.key;
|
|
1316
|
+
if(a.type==='submit') return 'Submit form';
|
|
1317
|
+
return a.type;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function stepText(a) {
|
|
1321
|
+
if(a.type==='navigate') return 'Navigate to '+a.url;
|
|
1322
|
+
if(a.type==='click') return 'Click '+(a.text?'"'+a.text+'"':a.locator);
|
|
1323
|
+
if(a.type==='fill') return 'Enter "'+(a.value||'')+'" in the '+(a.label||a.locator)+' field';
|
|
1324
|
+
if(a.type==='select') return 'Select "'+(a.optionText||a.value)+'" from '+(a.label||a.locator);
|
|
1325
|
+
if(a.type==='check') return (a.checked?'Check':'Uncheck')+' "'+(a.label||a.locator)+'"';
|
|
1326
|
+
if(a.type==='press') return 'Press '+a.key+' key';
|
|
1327
|
+
if(a.type==='submit') return 'Submit the form';
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function guessClass() {
|
|
1332
|
+
try{ const p=new URL(document.getElementById('url-input').value||'http://x/page').pathname.split('/').filter(Boolean); const l=p[p.length-1]||'page'; return l.charAt(0).toUpperCase()+l.slice(1).replace(/[-_](.)/g,(_,c)=>c.toUpperCase())+'Page'; }catch{return 'RecordedPage';}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function guessPageType(url) {
|
|
1336
|
+
const u=(url||'').toLowerCase();
|
|
1337
|
+
return /login|signin/.test(u)?'login':/register|signup/.test(u)?'registration':/checkout/.test(u)?'checkout':'page';
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
1341
|
+
function escAttr(s){
|
|
1342
|
+
return String(s||'')
|
|
1343
|
+
.replace(/&/g,'&')
|
|
1344
|
+
.replace(/"/g,'"')
|
|
1345
|
+
.replace(/'/g,''')
|
|
1346
|
+
.replace(/</g,'<')
|
|
1347
|
+
.replace(/>/g,'>');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function toast(msg, type='i') {
|
|
1351
|
+
const c=document.getElementById('toasts'),el=document.createElement('div');
|
|
1352
|
+
el.className='toast t'+type; el.textContent=msg; c.appendChild(el);
|
|
1353
|
+
setTimeout(()=>{el.style.opacity='0';el.style.transition='opacity .3s';setTimeout(()=>el.remove(),300);},3000);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
document.getElementById('url-input').addEventListener('keydown',e=>{if(e.key==='Enter')loadPage();});
|
|
1357
|
+
</script>
|
|
1358
|
+
</body>
|
|
1359
|
+
</html>
|