@demon-utils/playwright 0.1.3 → 0.1.6

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.
@@ -0,0 +1,461 @@
1
+ import type { ReviewMetadata, CodeReview } from "./review-types.ts";
2
+
3
+ export interface GenerateReviewHtmlOptions {
4
+ metadata: ReviewMetadata;
5
+ title?: string;
6
+ }
7
+
8
+ function escapeHtml(s: string): string {
9
+ return s
10
+ .replace(/&/g, "&")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+
17
+ function escapeAttr(s: string): string {
18
+ return escapeHtml(s);
19
+ }
20
+
21
+ function renderReviewSection(review: CodeReview): string {
22
+ const bannerClass = review.verdict === "approve" ? "approve" : "request-changes";
23
+ const verdictLabel = review.verdict === "approve" ? "Approved" : "Changes Requested";
24
+
25
+ const highlightsHtml = review.highlights
26
+ .map((h) => `<li>${escapeHtml(h)}</li>`)
27
+ .join("\n ");
28
+
29
+ const issuesHtml = review.issues.length > 0
30
+ ? review.issues
31
+ .map((issue) => {
32
+ const badgeLabel = issue.severity.toUpperCase();
33
+ return `<div class="issue ${issue.severity}"><span class="severity-badge">${badgeLabel}</span> <span class="issue-text">${escapeHtml(issue.description)}</span><button class="feedback-add-issue" data-issue="${escapeAttr(issue.description)}">+</button></div>`;
34
+ })
35
+ .join("\n ")
36
+ : '<p class="no-issues">No issues found.</p>';
37
+
38
+ return `<section class="review-section">
39
+ <div class="verdict-banner ${bannerClass}">
40
+ <strong>${verdictLabel}</strong>: ${escapeHtml(review.verdictReason)}
41
+ </div>
42
+ <div class="review-body">
43
+ <h2>Summary</h2>
44
+ <p>${escapeHtml(review.summary)}</p>
45
+ <h2>Highlights</h2>
46
+ <ul class="highlights-list">
47
+ ${highlightsHtml}
48
+ </ul>
49
+ <h2>Issues</h2>
50
+ ${issuesHtml}
51
+ </div>
52
+ </section>`;
53
+ }
54
+
55
+ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
56
+ const { metadata, title = "Demo Review" } = options;
57
+
58
+ if (metadata.demos.length === 0) {
59
+ throw new Error("metadata.demos must not be empty");
60
+ }
61
+
62
+ const firstDemo = metadata.demos[0];
63
+
64
+ const demoButtons = metadata.demos
65
+ .map((demo, i) => {
66
+ const activeClass = i === 0 ? ' class="active"' : "";
67
+ return `<li><button data-index="${i}"${activeClass}>${escapeHtml(demo.file)}</button></li>`;
68
+ })
69
+ .join("\n ");
70
+
71
+ const metadataJson = JSON.stringify(metadata).replace(/<\//g, "<\\/");
72
+
73
+ const reviewHtml = metadata.review ? renderReviewSection(metadata.review) : "";
74
+ const hasReview = !!metadata.review;
75
+ const defaultTab = hasReview ? "summary" : "demos";
76
+
77
+ return `<!DOCTYPE html>
78
+ <html lang="en">
79
+ <head>
80
+ <meta charset="UTF-8">
81
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
82
+ <title>${escapeHtml(title)}</title>
83
+ <style>
84
+ * { margin: 0; padding: 0; box-sizing: border-box; }
85
+ body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
86
+ header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }
87
+ header h1 { font-size: 1.4rem; color: #e94560; }
88
+ .tab-bar { display: flex; gap: 0; background: #16213e; border-bottom: 2px solid #0f3460; padding: 0 2rem; }
89
+ .tab-btn { padding: 0.7rem 1.5rem; background: none; border: none; border-bottom: 3px solid transparent; color: #999; font-size: 0.95rem; cursor: pointer; font-family: inherit; transition: all 0.15s; margin-bottom: -2px; }
90
+ .tab-btn:hover { color: #e0e0e0; }
91
+ .tab-btn.active { color: #e94560; border-bottom-color: #e94560; }
92
+ .tab-panel { display: none; }
93
+ .tab-panel.active { display: block; }
94
+ .review-section { padding: 1.5rem 2rem; }
95
+ .verdict-banner { padding: 1rem 1.5rem; border-radius: 6px; font-size: 1rem; margin-bottom: 1.5rem; }
96
+ .verdict-banner.approve { background: #1b4332; border: 1px solid #2d6a4f; color: #95d5b2; }
97
+ .verdict-banner.request-changes { background: #4a1520; border: 1px solid #842029; color: #f5c6cb; }
98
+ .review-body { max-width: 900px; }
99
+ .review-body h2 { font-size: 1.1rem; color: #e94560; margin: 1.2rem 0 0.5rem; }
100
+ .review-body p { font-size: 0.95rem; line-height: 1.6; color: #ccc; }
101
+ .highlights-list { list-style: disc; padding-left: 1.5rem; margin-bottom: 0.5rem; }
102
+ .highlights-list li { font-size: 0.95rem; line-height: 1.5; color: #95d5b2; margin-bottom: 0.3rem; }
103
+ .issue { padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; border-radius: 4px; font-size: 0.9rem; line-height: 1.4; }
104
+ .issue.major { background: rgba(132, 32, 41, 0.3); border-left: 4px solid #dc3545; }
105
+ .issue.minor { background: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; }
106
+ .issue.nit { background: rgba(108, 117, 125, 0.2); border-left: 4px solid #6c757d; }
107
+ .severity-badge { display: inline-block; font-size: 0.7rem; font-weight: bold; padding: 0.15rem 0.4rem; border-radius: 3px; margin-right: 0.5rem; vertical-align: middle; }
108
+ .issue.major .severity-badge { background: #dc3545; color: #fff; }
109
+ .issue.minor .severity-badge { background: #ffc107; color: #000; }
110
+ .issue.nit .severity-badge { background: #6c757d; color: #fff; }
111
+ .no-issues { color: #95d5b2; font-style: italic; }
112
+ .demos-section { padding: 1rem 0; }
113
+ .review-layout { display: flex; height: 600px; }
114
+ .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }
115
+ .video-wrapper { position: relative; width: 100%; max-height: 100%; display: flex; flex-direction: column; }
116
+ .video-wrapper video { width: 100%; max-height: calc(100% - 36px); border-radius: 4px 4px 0 0; display: block; cursor: pointer; }
117
+ .video-controls { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #16213e; border-radius: 0 0 4px 4px; }
118
+ .video-controls button { background: none; border: none; color: #e0e0e0; cursor: pointer; font-size: 1rem; padding: 0; width: 20px; display: flex; align-items: center; justify-content: center; }
119
+ .video-controls button:hover { color: #e94560; }
120
+ .video-controls input[type="range"] { flex: 1; height: 4px; accent-color: #e94560; cursor: pointer; }
121
+ .video-controls .vc-time { font-size: 0.75rem; color: #999; white-space: nowrap; font-variant-numeric: tabular-nums; }
122
+ .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }
123
+ .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }
124
+ .side-panel section { margin-bottom: 1.5rem; }
125
+ #demo-list { list-style: none; }
126
+ #demo-list li { margin-bottom: 0.25rem; }
127
+ #demo-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
128
+ #demo-list button:hover { background: #0f3460; }
129
+ #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }
130
+ #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }
131
+ #steps-list { list-style: none; }
132
+ #steps-list li { margin-bottom: 0.3rem; }
133
+ #steps-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: transparent; color: #53a8b6; border: none; border-left: 3px solid transparent; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
134
+ #steps-list button:hover { color: #e94560; }
135
+ #steps-list button.step-active { background: rgba(233, 69, 96, 0.15); color: #e94560; border-left-color: #e94560; }
136
+ .timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }
137
+ .issue { position: relative; }
138
+ .feedback-add-issue { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); background: none; border: 1px solid #53a8b6; color: #53a8b6; border-radius: 4px; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.45rem; line-height: 1; }
139
+ .feedback-add-issue:hover { background: #53a8b6; color: #1a1a2e; }
140
+ #feedback-selection-btn { display: none; position: absolute; z-index: 1000; padding: 0.35rem 0.7rem; background: #e94560; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.8rem; white-space: nowrap; }
141
+ .feedback-layout { display: flex; gap: 1.5rem; padding: 1.5rem 2rem; }
142
+ .feedback-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
143
+ .feedback-right { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.5rem; }
144
+ #feedback-list { list-style: none; padding: 0; }
145
+ #feedback-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; background: #16213e; border: 1px solid #0f3460; border-radius: 4px; margin-bottom: 0.4rem; font-size: 0.9rem; }
146
+ #feedback-list li span { flex: 1; }
147
+ .feedback-remove { background: none; border: none; color: #dc3545; cursor: pointer; font-size: 0.9rem; padding: 0 0.3rem; }
148
+ .feedback-remove:hover { color: #ff6b7a; }
149
+ #feedback-general { width: 100%; min-height: 100px; background: #16213e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; padding: 0.6rem; font-family: inherit; font-size: 0.9rem; resize: vertical; }
150
+ #feedback-preview { background: #0f0f23; color: #ccc; border: 1px solid #0f3460; border-radius: 4px; padding: 1rem; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; flex: 1; min-height: 200px; overflow-y: auto; }
151
+ #feedback-copy { align-self: flex-end; padding: 0.5rem 1rem; background: none; border: 1px solid #53a8b6; color: #53a8b6; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
152
+ #feedback-copy:hover { background: #53a8b6; color: #1a1a2e; }
153
+ </style>
154
+ </head>
155
+ <body>
156
+ <header>
157
+ <h1>${escapeHtml(title)}</h1>
158
+ </header>
159
+ <nav class="tab-bar">
160
+ ${hasReview ? `<button class="tab-btn${defaultTab === "summary" ? " active" : ""}" data-tab="summary">Summary</button>` : ""}
161
+ <button class="tab-btn${defaultTab === "demos" ? " active" : ""}" data-tab="demos">Demos</button>
162
+ ${hasReview ? `<button class="tab-btn" data-tab="feedback">Feedback</button>` : ""}
163
+ </nav>
164
+ <main>
165
+ ${hasReview ? `<div id="tab-summary" class="tab-panel${defaultTab === "summary" ? " active" : ""}">
166
+ ${reviewHtml}
167
+ </div>` : ""}
168
+ <div id="tab-demos" class="tab-panel${defaultTab === "demos" ? " active" : ""}">
169
+ <section class="demos-section">
170
+ <div class="review-layout">
171
+ <div class="video-panel">
172
+ <div class="video-wrapper">
173
+ <video id="review-video" src="${escapeAttr(firstDemo.file)}"></video>
174
+ <div class="video-controls">
175
+ <button id="vc-play" aria-label="Play">&#9654;</button>
176
+ <input id="vc-seek" type="range" min="0" max="100" value="0" step="0.1">
177
+ <span class="vc-time" id="vc-time">0:00 / 0:00</span>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ <div class="side-panel">
182
+ <section>
183
+ <h2>Demos</h2>
184
+ <ul id="demo-list">
185
+ ${demoButtons}
186
+ </ul>
187
+ </section>
188
+ <section>
189
+ <h2>Summary</h2>
190
+ <p id="summary-text"></p>
191
+ </section>
192
+ <section id="steps-section">
193
+ <h2>Steps</h2>
194
+ <ul id="steps-list"></ul>
195
+ </section>
196
+ </div>
197
+ </div>
198
+ </section>
199
+ </div>
200
+ ${hasReview ? `<div id="tab-feedback" class="tab-panel">
201
+ <div class="feedback-layout">
202
+ <div class="feedback-left">
203
+ <h2>Feedback Items</h2>
204
+ <ul id="feedback-list"></ul>
205
+ <h2>General Feedback</h2>
206
+ <textarea id="feedback-general" placeholder="Add general feedback here..."></textarea>
207
+ </div>
208
+ <div class="feedback-right">
209
+ <h2>Preview</h2>
210
+ <pre id="feedback-preview"></pre>
211
+ <button id="feedback-copy">Copy to clipboard</button>
212
+ </div>
213
+ </div>
214
+ </div>` : ""}
215
+ </main>
216
+ ${hasReview ? `<button id="feedback-selection-btn">Add to feedback</button>` : ""}
217
+ <script>
218
+ (function() {
219
+ // Tab switching
220
+ var tabBtns = document.querySelectorAll(".tab-btn");
221
+ var tabPanels = document.querySelectorAll(".tab-panel");
222
+ tabBtns.forEach(function(btn) {
223
+ btn.addEventListener("click", function() {
224
+ var target = btn.getAttribute("data-tab");
225
+ tabBtns.forEach(function(b) { b.classList.toggle("active", b === btn); });
226
+ tabPanels.forEach(function(p) { p.classList.toggle("active", p.id === "tab-" + target); });
227
+ });
228
+ });
229
+
230
+ var metadata = ${metadataJson};
231
+ var video = document.getElementById("review-video");
232
+ var summaryText = document.getElementById("summary-text");
233
+ var stepsList = document.getElementById("steps-list");
234
+ var demoButtons = document.querySelectorAll("#demo-list button");
235
+
236
+ function esc(s) {
237
+ var d = document.createElement("div");
238
+ d.appendChild(document.createTextNode(s));
239
+ return d.innerHTML;
240
+ }
241
+
242
+ function formatTime(seconds) {
243
+ var m = Math.floor(seconds / 60);
244
+ var s = Math.floor(seconds % 60);
245
+ return m + ":" + (s < 10 ? "0" : "") + s;
246
+ }
247
+
248
+ function selectDemo(index) {
249
+ var demo = metadata.demos[index];
250
+ video.src = demo.file;
251
+ video.load();
252
+ summaryText.textContent = demo.summary;
253
+
254
+ demoButtons.forEach(function(btn, i) {
255
+ btn.classList.toggle("active", i === index);
256
+ });
257
+
258
+ var stepsHtml = "";
259
+ demo.steps.forEach(function(step) {
260
+ stepsHtml += '<li><button data-time="' + step.timestampSeconds + '">' +
261
+ '<span class="timestamp">' + esc(formatTime(step.timestampSeconds)) + '</span>' +
262
+ esc(step.text) + '</button></li>';
263
+ });
264
+ stepsList.innerHTML = stepsHtml;
265
+ }
266
+
267
+ demoButtons.forEach(function(btn) {
268
+ btn.addEventListener("click", function() {
269
+ selectDemo(parseInt(btn.getAttribute("data-index"), 10));
270
+ });
271
+ });
272
+
273
+ stepsList.addEventListener("click", function(e) {
274
+ var btn = e.target.closest("button[data-time]");
275
+ if (btn) {
276
+ video.currentTime = parseFloat(btn.getAttribute("data-time"));
277
+ video.play();
278
+ }
279
+ });
280
+
281
+ video.addEventListener("timeupdate", function() {
282
+ var buttons = document.querySelectorAll("#steps-list button[data-time]");
283
+ var ct = video.currentTime;
284
+ var activeIdx = -1;
285
+ buttons.forEach(function(btn, i) {
286
+ if (parseFloat(btn.getAttribute("data-time")) <= ct) activeIdx = i;
287
+ btn.classList.remove("step-active");
288
+ });
289
+ if (activeIdx >= 0) buttons[activeIdx].classList.add("step-active");
290
+ });
291
+
292
+ selectDemo(0);
293
+
294
+ // Custom video controls
295
+ var playBtn = document.getElementById("vc-play");
296
+ var seekBar = document.getElementById("vc-seek");
297
+ var timeDisplay = document.getElementById("vc-time");
298
+ var seeking = false;
299
+
300
+ function fmtTime(sec) {
301
+ var m = Math.floor(sec / 60);
302
+ var s = Math.floor(sec % 60);
303
+ return m + ":" + (s < 10 ? "0" : "") + s;
304
+ }
305
+
306
+ function updateTime() {
307
+ var cur = video.currentTime || 0;
308
+ var dur = video.duration || 0;
309
+ timeDisplay.textContent = fmtTime(cur) + " / " + fmtTime(dur);
310
+ if (!seeking && dur) seekBar.value = (cur / dur) * 100;
311
+ }
312
+
313
+ function updatePlayBtn() {
314
+ playBtn.innerHTML = video.paused ? "&#9654;" : "&#9646;&#9646;";
315
+ }
316
+
317
+ playBtn.addEventListener("click", function() {
318
+ video.paused ? video.play() : video.pause();
319
+ });
320
+ video.addEventListener("click", function() {
321
+ video.paused ? video.play() : video.pause();
322
+ });
323
+ video.addEventListener("play", updatePlayBtn);
324
+ video.addEventListener("pause", updatePlayBtn);
325
+ video.addEventListener("ended", updatePlayBtn);
326
+ video.addEventListener("timeupdate", updateTime);
327
+ video.addEventListener("loadedmetadata", updateTime);
328
+
329
+ seekBar.addEventListener("input", function() {
330
+ seeking = true;
331
+ if (video.duration) {
332
+ video.currentTime = (seekBar.value / 100) * video.duration;
333
+ }
334
+ });
335
+ seekBar.addEventListener("change", function() { seeking = false; });
336
+
337
+ // Feedback tab logic
338
+ if (document.getElementById("tab-feedback")) {
339
+ var feedbackItems = [];
340
+ var feedbackList = document.getElementById("feedback-list");
341
+ var feedbackGeneral = document.getElementById("feedback-general");
342
+ var feedbackPreview = document.getElementById("feedback-preview");
343
+ var feedbackCopy = document.getElementById("feedback-copy");
344
+ var selectionBtn = document.getElementById("feedback-selection-btn");
345
+
346
+ function addFeedbackItem(text) {
347
+ var trimmed = text.trim();
348
+ if (!trimmed) return;
349
+ for (var i = 0; i < feedbackItems.length; i++) {
350
+ if (feedbackItems[i] === trimmed) return;
351
+ }
352
+ feedbackItems.push(trimmed);
353
+ renderFeedback();
354
+ }
355
+
356
+ function removeFeedbackItem(index) {
357
+ feedbackItems.splice(index, 1);
358
+ renderFeedback();
359
+ }
360
+
361
+ function renderFeedback() {
362
+ var html = "";
363
+ feedbackItems.forEach(function(item, i) {
364
+ html += '<li><span>' + esc(item) + '</span><button class="feedback-remove" data-index="' + i + '">X</button></li>';
365
+ });
366
+ feedbackList.innerHTML = html;
367
+ updatePreview();
368
+ }
369
+
370
+ function updatePreview() {
371
+ var lines = "";
372
+ feedbackItems.forEach(function(item, i) {
373
+ lines += (i + 1) + ". Address: " + item + "\\n";
374
+ });
375
+ var general = feedbackGeneral.value.trim();
376
+ if (general) {
377
+ lines += "\\nGeneral feedback:\\n" + general;
378
+ }
379
+ feedbackPreview.textContent = lines;
380
+ }
381
+
382
+ // Issue "+" buttons
383
+ var summaryTab = document.getElementById("tab-summary");
384
+ if (summaryTab) {
385
+ summaryTab.addEventListener("click", function(e) {
386
+ var btn = e.target.closest(".feedback-add-issue");
387
+ if (btn) {
388
+ addFeedbackItem(btn.getAttribute("data-issue"));
389
+ }
390
+ });
391
+ }
392
+
393
+ // Text selection floating button
394
+ var selectionTimeout;
395
+ document.addEventListener("mouseup", function(e) {
396
+ clearTimeout(selectionTimeout);
397
+ selectionTimeout = setTimeout(function() {
398
+ var sel = window.getSelection();
399
+ var text = sel ? sel.toString().trim() : "";
400
+ if (!text) return;
401
+ var anchor = sel.anchorNode;
402
+ var inSummary = false;
403
+ var node = anchor;
404
+ while (node) {
405
+ if (node.id === "tab-summary") { inSummary = true; break; }
406
+ node = node.parentNode;
407
+ }
408
+ if (!inSummary) return;
409
+ selectionBtn.style.display = "block";
410
+ selectionBtn.style.left = e.pageX + "px";
411
+ selectionBtn.style.top = (e.pageY - 35) + "px";
412
+ selectionBtn._selectedText = text;
413
+ }, 100);
414
+ });
415
+
416
+ selectionBtn.addEventListener("click", function() {
417
+ if (selectionBtn._selectedText) {
418
+ addFeedbackItem(selectionBtn._selectedText);
419
+ }
420
+ selectionBtn.style.display = "none";
421
+ window.getSelection().removeAllRanges();
422
+ });
423
+
424
+ document.addEventListener("mousedown", function(e) {
425
+ if (e.target !== selectionBtn) {
426
+ selectionBtn.style.display = "none";
427
+ }
428
+ });
429
+
430
+ // Remove buttons
431
+ feedbackList.addEventListener("click", function(e) {
432
+ var btn = e.target.closest(".feedback-remove");
433
+ if (btn) {
434
+ removeFeedbackItem(parseInt(btn.getAttribute("data-index"), 10));
435
+ }
436
+ });
437
+
438
+ // Textarea input
439
+ feedbackGeneral.addEventListener("input", updatePreview);
440
+
441
+ // Copy button
442
+ feedbackCopy.addEventListener("click", function() {
443
+ var text = feedbackPreview.textContent;
444
+ function onCopied() {
445
+ feedbackCopy.textContent = "Copied!";
446
+ setTimeout(function() { feedbackCopy.textContent = "Copy to clipboard"; }, 1500);
447
+ }
448
+ if (navigator.clipboard && navigator.clipboard.writeText) {
449
+ navigator.clipboard.writeText(text).then(onCopied, onCopied);
450
+ } else {
451
+ onCopied();
452
+ }
453
+ });
454
+
455
+ renderFeedback();
456
+ }
457
+ })();
458
+ </script>
459
+ </body>
460
+ </html>`;
461
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,15 @@
1
1
  export { showCommentary, hideCommentary } from "./commentary.ts";
2
2
  export type { ShowCommentaryOptions } from "./commentary.ts";
3
+
4
+ export type { DemoMetadata, ReviewMetadata, IssueSeverity, ReviewIssue, ReviewVerdict, CodeReview } from "./review-types.ts";
5
+ export { buildReviewPrompt, extractJson, invokeClaude, parseLlmResponse } from "./review.ts";
6
+ export type { InvokeClaudeOptions, SpawnFn, LlmReviewResponse, BuildReviewPromptOptions } from "./review.ts";
7
+
8
+ export { getRepoContext } from "./git-context.ts";
9
+ export type { ExecFn, ReadFileFn, RepoContext, GetRepoContextOptions } from "./git-context.ts";
10
+
11
+ export { generateReviewHtml } from "./html-generator.ts";
12
+ export type { GenerateReviewHtmlOptions } from "./html-generator.ts";
13
+
14
+ export { DemoRecorder } from "./recorder.ts";
15
+ export type { DemoStep } from "./recorder.ts";
@@ -0,0 +1,161 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ import type { DemoStep } from "./recorder.ts";
7
+ import { DemoRecorder } from "./recorder.ts";
8
+
9
+ function createMockShowCommentary() {
10
+ const calls: Array<{ selector: string; text: string }> = [];
11
+ const fn = mock(async (_page: unknown, options: { selector: string; text: string }) => {
12
+ calls.push({ selector: options.selector, text: options.text });
13
+ });
14
+ return { fn, calls };
15
+ }
16
+
17
+ function createMockPage(): unknown {
18
+ return { _mock: true };
19
+ }
20
+
21
+ describe("DemoRecorder", () => {
22
+ describe("step()", () => {
23
+ test("records timestampSeconds relative to construction time", async () => {
24
+ const { fn: mockShow } = createMockShowCommentary();
25
+ const demo = new DemoRecorder({ showCommentary: mockShow as never });
26
+
27
+ await demo.step(createMockPage() as never, "First step", { selector: "#a" });
28
+ const steps = demo.getSteps();
29
+
30
+ expect(steps).toHaveLength(1);
31
+ expect(steps[0]!.text).toBe("First step");
32
+ expect(steps[0]!.timestampSeconds).toBeGreaterThanOrEqual(0);
33
+ expect(steps[0]!.timestampSeconds).toBeLessThan(2);
34
+ });
35
+
36
+ test("calls showCommentary with correct args", async () => {
37
+ const { fn: mockShow, calls } = createMockShowCommentary();
38
+ const page = createMockPage();
39
+ const demo = new DemoRecorder({ showCommentary: mockShow as never });
40
+
41
+ await demo.step(page as never, "Click button", { selector: "#btn" });
42
+
43
+ expect(calls).toHaveLength(1);
44
+ expect(calls[0]).toEqual({ selector: "#btn", text: "Click button" });
45
+ });
46
+
47
+ test("wraps in testStep when provided", async () => {
48
+ const { fn: mockShow } = createMockShowCommentary();
49
+ const testStepCalls: string[] = [];
50
+ const mockTestStep = async (title: string, body: () => Promise<void>) => {
51
+ testStepCalls.push(title);
52
+ await body();
53
+ };
54
+
55
+ const demo = new DemoRecorder({
56
+ showCommentary: mockShow as never,
57
+ testStep: mockTestStep,
58
+ });
59
+
60
+ await demo.step(createMockPage() as never, "Navigate to page", { selector: "body" });
61
+
62
+ expect(testStepCalls).toEqual(["Navigate to page"]);
63
+ expect(demo.getSteps()).toHaveLength(1);
64
+ });
65
+
66
+ test("multiple steps accumulate in order", async () => {
67
+ const { fn: mockShow } = createMockShowCommentary();
68
+ const demo = new DemoRecorder({ showCommentary: mockShow as never });
69
+ const page = createMockPage();
70
+
71
+ await demo.step(page as never, "Step 1", { selector: "#a" });
72
+ await demo.step(page as never, "Step 2", { selector: "#b" });
73
+ await demo.step(page as never, "Step 3", { selector: "#c" });
74
+
75
+ const steps = demo.getSteps();
76
+ expect(steps).toHaveLength(3);
77
+ expect(steps.map((s) => s.text)).toEqual(["Step 1", "Step 2", "Step 3"]);
78
+ expect(steps[0]!.timestampSeconds).toBeLessThanOrEqual(steps[1]!.timestampSeconds);
79
+ expect(steps[1]!.timestampSeconds).toBeLessThanOrEqual(steps[2]!.timestampSeconds);
80
+ });
81
+
82
+ test("step not recorded if showCommentary throws", async () => {
83
+ const failingShow = async () => {
84
+ throw new Error("Element not found");
85
+ };
86
+ const demo = new DemoRecorder({ showCommentary: failingShow as never });
87
+
88
+ await expect(
89
+ demo.step(createMockPage() as never, "Bad step", { selector: "#missing" }),
90
+ ).rejects.toThrow("Element not found");
91
+
92
+ expect(demo.getSteps()).toHaveLength(0);
93
+ });
94
+ });
95
+
96
+ describe("getSteps()", () => {
97
+ test("returns a copy (mutation safety)", async () => {
98
+ const { fn: mockShow } = createMockShowCommentary();
99
+ const demo = new DemoRecorder({ showCommentary: mockShow as never });
100
+
101
+ await demo.step(createMockPage() as never, "Step 1", { selector: "#a" });
102
+
103
+ const steps1 = demo.getSteps();
104
+ const steps2 = demo.getSteps();
105
+ expect(steps1).toEqual(steps2);
106
+ expect(steps1).not.toBe(steps2);
107
+
108
+ steps1.push({ text: "fake", timestampSeconds: 99 });
109
+ expect(demo.getSteps()).toHaveLength(1);
110
+ });
111
+ });
112
+
113
+ describe("save()", () => {
114
+ let tmpDir: string;
115
+
116
+ beforeEach(() => {
117
+ tmpDir = join("/tmp", "demon", "tests", `recorder-${randomUUID()}`);
118
+ mkdirSync(tmpDir, { recursive: true });
119
+ });
120
+
121
+ test("creates output directory if it does not exist", async () => {
122
+ const { fn: mockShow } = createMockShowCommentary();
123
+ const demo = new DemoRecorder({ showCommentary: mockShow as never });
124
+ const page = createMockPage();
125
+
126
+ await demo.step(page as never, "Step 1", { selector: "#a" });
127
+
128
+ const nestedDir = join(tmpDir, "nested", "deep");
129
+ expect(existsSync(nestedDir)).toBe(false);
130
+
131
+ await demo.save(nestedDir);
132
+
133
+ expect(existsSync(nestedDir)).toBe(true);
134
+ const content = JSON.parse(readFileSync(join(nestedDir, "demo-steps.json"), "utf-8"));
135
+ expect(content).toHaveLength(1);
136
+ expect(content[0].text).toBe("Step 1");
137
+ });
138
+
139
+ test("writes correct JSON to demo-steps.json", async () => {
140
+ const { fn: mockShow } = createMockShowCommentary();
141
+ const demo = new DemoRecorder({ showCommentary: mockShow as never });
142
+ const page = createMockPage();
143
+
144
+ await demo.step(page as never, "Navigate to page", { selector: "body" });
145
+ await demo.step(page as never, "Click button", { selector: "#btn" });
146
+
147
+ await demo.save(tmpDir);
148
+
149
+ const filePath = join(tmpDir, "demo-steps.json");
150
+ const content = JSON.parse(readFileSync(filePath, "utf-8")) as DemoStep[];
151
+
152
+ expect(content).toHaveLength(2);
153
+ expect(content[0]!.text).toBe("Navigate to page");
154
+ expect(content[1]!.text).toBe("Click button");
155
+ expect(typeof content[0]!.timestampSeconds).toBe("number");
156
+ expect(typeof content[1]!.timestampSeconds).toBe("number");
157
+
158
+ rmSync(tmpDir, { recursive: true, force: true });
159
+ });
160
+ });
161
+ });