@xylentis/testgen 0.0.0-stage → 1.1.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/ui/app.js ADDED
@@ -0,0 +1,259 @@
1
+ // testgen UI: drives the local server (src/ui/server.ts) and shows the generated code live.
2
+ const $ = id => document.getElementById(id);
3
+
4
+ const browsers = {
5
+ chrome: { browser: 'chromium', channel: 'chrome' },
6
+ chromium: { browser: 'chromium' },
7
+ msedge: { browser: 'chromium', channel: 'msedge' },
8
+ firefox: { browser: 'firefox' },
9
+ webkit: { browser: 'webkit' },
10
+ };
11
+
12
+ let targets = [];
13
+ let snapshot = { status: 'idle', steps: [], outputs: {}, settings: {} };
14
+ let activeTarget = remember('target') || 'playwright-ts';
15
+ let activeFile = 0;
16
+
17
+ function remember(key, value) {
18
+ try {
19
+ if (value === undefined)
20
+ return localStorage.getItem(`testgen.${key}`);
21
+ localStorage.setItem(`testgen.${key}`, value);
22
+ } catch {
23
+ // Storage can be unavailable (private mode); the UI works without it.
24
+ }
25
+ }
26
+
27
+ async function post(path, body = {}) {
28
+ const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
29
+ const result = await response.json();
30
+ if (!response.ok || result.error)
31
+ throw new Error(result.error || response.statusText);
32
+ return result;
33
+ }
34
+
35
+ function showError(message) {
36
+ $('error').hidden = !message;
37
+ $('error').textContent = message || '';
38
+ }
39
+
40
+ // ---- Rendering --------------------------------------------------------------------------------
41
+
42
+ function render() {
43
+ const recording = snapshot.status === 'recording';
44
+ const count = snapshot.steps.length;
45
+ document.body.classList.toggle('recording', recording);
46
+ $('record').hidden = recording;
47
+ $('stop').hidden = !recording;
48
+ $('status').textContent = recording
49
+ ? `Recording… ${count} step${count === 1 ? '' : 's'}. Close the browser window or press Stop to finish.`
50
+ : snapshot.status === 'stopped' ? `${count} step${count === 1 ? '' : 's'} recorded` : 'Ready';
51
+ showError(snapshot.error || snapshot.parseError);
52
+ $('download-recording').toggleAttribute('hidden', !snapshot.recording);
53
+
54
+ $('step-count').textContent = String(count);
55
+ $('steps-empty').hidden = count > 0;
56
+ $('steps').replaceChildren(...snapshot.steps.map(step => {
57
+ const item = document.createElement('li');
58
+ const page = document.createElement('span');
59
+ page.className = 'page';
60
+ page.textContent = step.page;
61
+ const text = document.createElement('span');
62
+ text.className = 'text';
63
+ text.textContent = step.text;
64
+ item.append(page, text);
65
+ return item;
66
+ }));
67
+ $('steps').lastElementChild?.scrollIntoView({ block: 'nearest' });
68
+
69
+ for (const [key, id] of [['name', 'name'], ['packageName', 'package'], ['testIdAttribute', 'test-id']]) {
70
+ const input = $(id);
71
+ if (document.activeElement !== input && snapshot.settings[key] !== undefined)
72
+ input.value = snapshot.settings[key];
73
+ }
74
+ renderTargets();
75
+ renderCode();
76
+ }
77
+
78
+ function renderTargets() {
79
+ $('targets').replaceChildren(...targets.map(target => {
80
+ const button = document.createElement('button');
81
+ button.type = 'button';
82
+ button.setAttribute('role', 'tab');
83
+ button.setAttribute('aria-selected', String(target.id === activeTarget));
84
+ button.innerHTML = `${escapeHtml(target.framework.split(' ')[0])} · ${escapeHtml(target.language)}<small>${escapeHtml(target.id)}</small>`;
85
+ button.addEventListener('click', () => {
86
+ activeTarget = target.id;
87
+ activeFile = 0;
88
+ remember('target', target.id);
89
+ render();
90
+ });
91
+ return button;
92
+ }));
93
+ $('out-dir').value = remember(`outDir.${activeTarget}`) || `generated/${activeTarget}`;
94
+ }
95
+
96
+ function currentFiles() {
97
+ return snapshot.outputs[activeTarget]?.files ?? [];
98
+ }
99
+
100
+ function renderCode() {
101
+ const files = currentFiles();
102
+ if (activeFile >= files.length)
103
+ activeFile = 0;
104
+ $('files').replaceChildren(...files.map((file, index) => {
105
+ const button = document.createElement('button');
106
+ button.type = 'button';
107
+ button.setAttribute('role', 'tab');
108
+ button.setAttribute('aria-selected', String(index === activeFile));
109
+ button.innerHTML = `${escapeHtml(file.path)}${file.support ? '<span class="tag">support</span>' : ''}`;
110
+ button.addEventListener('click', () => {
111
+ activeFile = index;
112
+ renderCode();
113
+ });
114
+ return button;
115
+ }));
116
+ const file = files[activeFile];
117
+ const error = snapshot.outputs[activeTarget]?.error;
118
+ const pre = $('code');
119
+ const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 4;
120
+ pre.querySelector('code').innerHTML = file ? highlight(file.content) : `<span class="line">${escapeHtml(error ? `// ${error}` : '// The generated test appears here while you record.')}</span>`;
121
+ if (snapshot.status === 'recording' && atBottom)
122
+ pre.scrollTop = pre.scrollHeight;
123
+ for (const id of ['copy', 'download'])
124
+ $(id).disabled = !file;
125
+ $('save-form').querySelector('button').disabled = !files.length;
126
+ }
127
+
128
+ // ---- Syntax highlighting (strings, comments, numbers, keywords, calls) ----------------------
129
+
130
+ const keywords = new Set(('abstract async await boolean break case catch class const continue default defer do else enum export ' +
131
+ 'extends false final finally for func function go if implements import in instanceof int interface let long map new nil ' +
132
+ 'null package private protected public range return static struct super switch this throw throws true try type typeof ' +
133
+ 'undefined var void while').split(' '));
134
+
135
+ const tokenizer = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("""[\s\S]*?"""|`[^`]*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)/g;
136
+
137
+ function highlight(code) {
138
+ const lines = [''];
139
+ const push = (className, text) => {
140
+ text.split('\n').forEach((part, index) => {
141
+ if (index > 0)
142
+ lines.push('');
143
+ if (part)
144
+ lines[lines.length - 1] += className ? `<span class="tok-${className}">${escapeHtml(part)}</span>` : escapeHtml(part);
145
+ });
146
+ };
147
+ let last = 0;
148
+ for (const match of code.matchAll(tokenizer)) {
149
+ push('', code.slice(last, match.index));
150
+ const [text, comment, string, number, call, word] = match;
151
+ if (comment)
152
+ push('comment', text);
153
+ else if (string)
154
+ push('string', text);
155
+ else if (number)
156
+ push('number', text);
157
+ else if (call)
158
+ push(keywords.has(call) ? 'keyword' : 'call', text);
159
+ else
160
+ push(keywords.has(word) ? 'keyword' : '', text);
161
+ last = match.index + text.length;
162
+ }
163
+ push('', code.slice(last));
164
+ if (lines.length > 1 && lines[lines.length - 1] === '')
165
+ lines.pop();
166
+ return lines.map(line => `<span class="line">${line}</span>`).join('');
167
+ }
168
+
169
+ function escapeHtml(text) {
170
+ return String(text).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' })[c]);
171
+ }
172
+
173
+ // ---- Actions -------------------------------------------------------------------------------
174
+
175
+ $('url').value = remember('url') || '';
176
+ $('browser').value = remember('browser') || 'chrome';
177
+
178
+ $('record-form').addEventListener('submit', async event => {
179
+ event.preventDefault();
180
+ remember('url', $('url').value);
181
+ remember('browser', $('browser').value);
182
+ try {
183
+ await post('/api/record', { url: $('url').value, ...browsers[$('browser').value], testIdAttribute: $('test-id').value });
184
+ } catch (error) {
185
+ showError(error.message);
186
+ }
187
+ });
188
+
189
+ $('stop').addEventListener('click', () => post('/api/stop').catch(error => showError(error.message)));
190
+
191
+ let settingsTimer;
192
+ for (const id of ['name', 'package', 'test-id']) {
193
+ $(id).addEventListener('input', () => {
194
+ clearTimeout(settingsTimer);
195
+ settingsTimer = setTimeout(() => {
196
+ post('/api/settings', { name: $('name').value, packageName: $('package').value, testIdAttribute: $('test-id').value })
197
+ .catch(error => showError(error.message));
198
+ }, 300);
199
+ });
200
+ }
201
+
202
+ $('open').addEventListener('change', async () => {
203
+ const file = $('open').files[0];
204
+ if (!file)
205
+ return;
206
+ try {
207
+ await post('/api/load', { recording: await file.text() });
208
+ } catch (error) {
209
+ showError(error.message);
210
+ }
211
+ $('open').value = '';
212
+ });
213
+
214
+ $('copy').addEventListener('click', async () => {
215
+ const file = currentFiles()[activeFile];
216
+ if (!file)
217
+ return;
218
+ await navigator.clipboard.writeText(file.content);
219
+ $('copy').textContent = 'Copied';
220
+ setTimeout(() => $('copy').textContent = 'Copy', 1200);
221
+ });
222
+
223
+ $('download').addEventListener('click', () => {
224
+ const file = currentFiles()[activeFile];
225
+ if (!file)
226
+ return;
227
+ const link = document.createElement('a');
228
+ link.href = URL.createObjectURL(new Blob([file.content], { type: 'text/plain' }));
229
+ link.download = file.path;
230
+ link.click();
231
+ URL.revokeObjectURL(link.href);
232
+ });
233
+
234
+ $('out-dir').addEventListener('change', () => remember(`outDir.${activeTarget}`, $('out-dir').value));
235
+
236
+ $('save-form').addEventListener('submit', async event => {
237
+ event.preventDefault();
238
+ $('save-result').textContent = 'Saving…';
239
+ try {
240
+ const { written } = await post('/api/save', { target: activeTarget, outDir: $('out-dir').value });
241
+ $('save-result').textContent = `Saved ${written.join(', ')}`;
242
+ } catch (error) {
243
+ $('save-result').textContent = '';
244
+ showError(error.message);
245
+ }
246
+ });
247
+
248
+ // ---- Start ---------------------------------------------------------------------------------
249
+
250
+ targets = await (await fetch('/api/targets')).json();
251
+ if (!targets.some(t => t.id === activeTarget))
252
+ activeTarget = targets[0].id;
253
+ render();
254
+ const events = new EventSource('/api/events');
255
+ events.addEventListener('message', event => {
256
+ snapshot = JSON.parse(event.data);
257
+ render();
258
+ });
259
+ events.addEventListener('error', () => $('status').textContent = 'Disconnected from testgen; is `testgen ui` still running?');
package/ui/index.html ADDED
@@ -0,0 +1,65 @@
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">
6
+ <title>testgen</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <header class="toolbar">
11
+ <div class="brand"><span class="dot"></span>testgen</div>
12
+ <form id="record-form" class="record">
13
+ <input id="url" type="url" placeholder="https://your-app.example" aria-label="URL to record" autocomplete="url">
14
+ <select id="browser" aria-label="Browser">
15
+ <option value="chrome">Google Chrome</option>
16
+ <option value="chromium">Chromium (Playwright)</option>
17
+ <option value="msedge">Microsoft Edge</option>
18
+ <option value="firefox">Firefox (Playwright)</option>
19
+ <option value="webkit">WebKit (Playwright)</option>
20
+ </select>
21
+ <button id="record" type="submit" class="primary">Record</button>
22
+ <button id="stop" type="button" class="danger" hidden>Stop</button>
23
+ </form>
24
+ <div id="status" class="status" role="status">Ready</div>
25
+ </header>
26
+
27
+ <section class="options" aria-label="Generation options">
28
+ <label>Test name <input id="name" value="recorded" spellcheck="false"></label>
29
+ <label>Package <input id="package" placeholder="e.g. com.acme.e2e" spellcheck="false"></label>
30
+ <label>Test id attribute <input id="test-id" value="data-testid" spellcheck="false"></label>
31
+ <span class="spacer"></span>
32
+ <label class="file-button">Open recording… <input id="open" type="file" accept=".jsonl,.json,.txt"></label>
33
+ <a id="download-recording" class="button" href="/api/recording" download="recording.jsonl">Download recording</a>
34
+ </section>
35
+
36
+ <div id="error" class="error" hidden></div>
37
+
38
+ <main class="workspace">
39
+ <aside class="steps">
40
+ <h2>Steps <span id="step-count" class="count">0</span></h2>
41
+ <ol id="steps"></ol>
42
+ <p id="steps-empty" class="empty">Enter a URL and press <strong>Record</strong>. A browser opens; click and type as a user would.
43
+ Use the recorder toolbar in the page to add assertions. Close the browser or press <strong>Stop</strong> when done.</p>
44
+ </aside>
45
+
46
+ <section class="code" aria-label="Generated code">
47
+ <nav id="targets" class="tabs" role="tablist"></nav>
48
+ <div class="file-bar">
49
+ <nav id="files" class="file-tabs" role="tablist"></nav>
50
+ <span class="spacer"></span>
51
+ <button id="copy" type="button">Copy</button>
52
+ <button id="download" type="button">Download</button>
53
+ </div>
54
+ <pre id="code" class="source" tabindex="0"><code></code></pre>
55
+ <form id="save-form" class="save">
56
+ <label>Save to <input id="out-dir" spellcheck="false"></label>
57
+ <button type="submit">Save files</button>
58
+ <span id="save-result" class="save-result" role="status"></span>
59
+ </form>
60
+ </section>
61
+ </main>
62
+
63
+ <script src="/app.js" type="module"></script>
64
+ </body>
65
+ </html>
package/ui/style.css ADDED
@@ -0,0 +1,189 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #f6f7f9;
4
+ --panel: #ffffff;
5
+ --border: #dde1e6;
6
+ --text: #1f2328;
7
+ --muted: #5f6b7a;
8
+ --accent: #2d6ae3;
9
+ --accent-text: #ffffff;
10
+ --danger: #c93c37;
11
+ --recording: #d93025;
12
+ --code-bg: #fbfbfc;
13
+ --tok-keyword: #8a3ffc;
14
+ --tok-string: #0b7a3b;
15
+ --tok-comment: #7b8794;
16
+ --tok-number: #b35c00;
17
+ --tok-call: #1d5fc2;
18
+ --selection: #dbe7ff;
19
+ --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
20
+ --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
21
+ }
22
+
23
+ @media (prefers-color-scheme: dark) {
24
+ :root {
25
+ --bg: #15181d;
26
+ --panel: #1c2027;
27
+ --border: #2f3540;
28
+ --text: #e6e9ee;
29
+ --muted: #98a2b3;
30
+ --accent: #6b9cff;
31
+ --accent-text: #0d1117;
32
+ --danger: #f07167;
33
+ --recording: #ff6b5f;
34
+ --code-bg: #171a20;
35
+ --tok-keyword: #c792ea;
36
+ --tok-string: #8bd49c;
37
+ --tok-comment: #7f8a99;
38
+ --tok-number: #f0a35e;
39
+ --tok-call: #82aaff;
40
+ --selection: #26385a;
41
+ }
42
+ }
43
+
44
+ * { box-sizing: border-box; }
45
+
46
+ html, body { height: 100%; }
47
+
48
+ body {
49
+ margin: 0;
50
+ display: flex;
51
+ flex-direction: column;
52
+ background: var(--bg);
53
+ color: var(--text);
54
+ font: 14px/1.45 var(--font);
55
+ }
56
+
57
+ input, select, button, .button {
58
+ font: inherit;
59
+ color: inherit;
60
+ border: 1px solid var(--border);
61
+ border-radius: 6px;
62
+ background: var(--panel);
63
+ padding: 6px 10px;
64
+ }
65
+
66
+ input:focus-visible, select:focus-visible, button:focus-visible, .button:focus-visible, pre:focus-visible {
67
+ outline: 2px solid var(--accent);
68
+ outline-offset: 1px;
69
+ }
70
+
71
+ button, .button { cursor: pointer; text-decoration: none; white-space: nowrap; }
72
+ button:hover, .button:hover { border-color: var(--muted); }
73
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
74
+ button.danger { background: var(--danger); border-color: var(--danger); color: #fff; font-weight: 600; }
75
+ button:disabled { opacity: .5; cursor: default; }
76
+
77
+ .toolbar, .options {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 12px;
81
+ padding: 10px 16px;
82
+ background: var(--panel);
83
+ border-bottom: 1px solid var(--border);
84
+ flex-wrap: wrap;
85
+ }
86
+
87
+ .brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 8px; }
88
+ .brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
89
+ body.recording .brand .dot { background: var(--recording); animation: pulse 1.2s infinite; }
90
+ @keyframes pulse { 50% { opacity: .3; } }
91
+
92
+ .record { display: flex; gap: 8px; flex: 1; min-width: 280px; }
93
+ .record input { flex: 1; min-width: 0; }
94
+
95
+ .status { color: var(--muted); font-size: 13px; }
96
+ body.recording .status { color: var(--recording); font-weight: 600; }
97
+
98
+ .options { font-size: 13px; color: var(--muted); padding-top: 8px; padding-bottom: 8px; }
99
+ .options label { display: flex; align-items: center; gap: 6px; }
100
+ .options input { width: 160px; padding: 4px 8px; }
101
+ .spacer { flex: 1; }
102
+ .file-button { border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer; color: var(--text); }
103
+ .file-button input { display: none; }
104
+ .options .button { padding: 4px 10px; }
105
+
106
+ .error {
107
+ margin: 12px 16px 0;
108
+ padding: 10px 12px;
109
+ border: 1px solid var(--danger);
110
+ border-radius: 6px;
111
+ color: var(--danger);
112
+ white-space: pre-wrap;
113
+ font-family: var(--mono);
114
+ font-size: 12px;
115
+ max-height: 160px;
116
+ overflow: auto;
117
+ }
118
+
119
+ .workspace {
120
+ flex: 1;
121
+ min-height: 0;
122
+ display: grid;
123
+ grid-template-columns: minmax(240px, 32%) 1fr;
124
+ gap: 12px;
125
+ padding: 12px 16px 16px;
126
+ }
127
+
128
+ .steps, .code {
129
+ min-height: 0;
130
+ display: flex;
131
+ flex-direction: column;
132
+ background: var(--panel);
133
+ border: 1px solid var(--border);
134
+ border-radius: 8px;
135
+ overflow: hidden;
136
+ }
137
+
138
+ .steps h2 { margin: 0; padding: 10px 12px; font-size: 13px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: center; }
139
+ .count { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 0 8px; font-size: 12px; color: var(--muted); }
140
+ .steps ol { margin: 0; padding: 6px 0; overflow: auto; flex: 1; list-style: none; counter-reset: step; }
141
+ .steps li { counter-increment: step; display: grid; grid-template-columns: 2.2em 3.6em 1fr; gap: 8px; padding: 4px 12px; font-family: var(--mono); font-size: 12px; }
142
+ .steps li::before { content: counter(step); color: var(--muted); text-align: right; }
143
+ .steps li .page { color: var(--muted); }
144
+ .steps li .text { overflow-wrap: anywhere; }
145
+ .steps li:last-child { background: var(--selection); }
146
+ .empty { color: var(--muted); padding: 0 12px; }
147
+
148
+ .tabs { display: flex; gap: 2px; padding: 6px 6px 0; border-bottom: 1px solid var(--border); overflow-x: auto; }
149
+ .tabs button { border: none; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--muted); }
150
+ .tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
151
+ .tabs button small { display: block; font-weight: 400; font-size: 11px; color: var(--muted); }
152
+
153
+ .file-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
154
+ .file-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
155
+ .file-tabs button { font-family: var(--mono); font-size: 12px; padding: 3px 8px; }
156
+ .file-tabs button[aria-selected="true"] { border-color: var(--accent); color: var(--accent); }
157
+ .file-tabs button .tag { color: var(--muted); font-family: var(--font); margin-left: 6px; }
158
+ .file-bar > button { padding: 3px 10px; font-size: 13px; }
159
+
160
+ .source {
161
+ flex: 1;
162
+ margin: 0;
163
+ overflow: auto;
164
+ background: var(--code-bg);
165
+ font: 12.5px/1.55 var(--mono);
166
+ padding: 10px 0;
167
+ tab-size: 4;
168
+ }
169
+ .source code { display: block; counter-reset: line; }
170
+ .source .line { display: block; padding-right: 16px; white-space: pre; }
171
+ .source .line::before { counter-increment: line; content: counter(line); display: inline-block; width: 3.5em; padding-right: 1em; text-align: right; color: var(--muted); opacity: .6; user-select: none; }
172
+ .tok-keyword { color: var(--tok-keyword); }
173
+ .tok-string { color: var(--tok-string); }
174
+ .tok-comment { color: var(--tok-comment); font-style: italic; }
175
+ .tok-number { color: var(--tok-number); }
176
+ .tok-call { color: var(--tok-call); }
177
+
178
+ .save { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border); font-size: 13px; flex-wrap: wrap; }
179
+ .save label { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 220px; color: var(--muted); }
180
+ .save input { flex: 1; font-family: var(--mono); font-size: 12px; }
181
+ .save-result { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
182
+
183
+ @media (max-width: 760px) {
184
+ .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 30%) 1fr; }
185
+ .record { flex-wrap: wrap; }
186
+ .record input { flex-basis: 100%; }
187
+ .record select { flex: 1; }
188
+ .options input { width: 120px; }
189
+ }