@demon-utils/playwright 0.1.5 → 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.
@@ -1,4 +1,4 @@
1
- import type { ReviewMetadata } from "./review-types.ts";
1
+ import type { ReviewMetadata, CodeReview } from "./review-types.ts";
2
2
 
3
3
  export interface GenerateReviewHtmlOptions {
4
4
  metadata: ReviewMetadata;
@@ -18,6 +18,40 @@ function escapeAttr(s: string): string {
18
18
  return escapeHtml(s);
19
19
  }
20
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
+
21
55
  export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
22
56
  const { metadata, title = "Demo Review" } = options;
23
57
 
@@ -36,6 +70,10 @@ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
36
70
 
37
71
  const metadataJson = JSON.stringify(metadata).replace(/<\//g, "<\\/");
38
72
 
73
+ const reviewHtml = metadata.review ? renderReviewSection(metadata.review) : "";
74
+ const hasReview = !!metadata.review;
75
+ const defaultTab = hasReview ? "summary" : "demos";
76
+
39
77
  return `<!DOCTYPE html>
40
78
  <html lang="en">
41
79
  <head>
@@ -47,9 +85,40 @@ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
47
85
  body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
48
86
  header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }
49
87
  header h1 { font-size: 1.4rem; color: #e94560; }
50
- .review-layout { display: flex; height: calc(100vh - 60px); }
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; }
51
114
  .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }
52
- .video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }
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; }
53
122
  .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }
54
123
  .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }
55
124
  .side-panel section { margin-bottom: 1.5rem; }
@@ -59,44 +128,109 @@ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
59
128
  #demo-list button:hover { background: #0f3460; }
60
129
  #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }
61
130
  #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }
62
- #annotations-list { list-style: none; }
63
- #annotations-list li { margin-bottom: 0.3rem; }
64
- #annotations-list button { width: 100%; text-align: left; padding: 0.3rem 0.5rem; background: transparent; color: #53a8b6; border: none; cursor: pointer; font-size: 0.85rem; }
65
- #annotations-list button:hover { color: #e94560; text-decoration: underline; }
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; }
66
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; }
67
153
  </style>
68
154
  </head>
69
155
  <body>
70
156
  <header>
71
157
  <h1>${escapeHtml(title)}</h1>
72
158
  </header>
73
- <main class="review-layout">
74
- <div class="video-panel">
75
- <video id="review-video" controls src="${escapeAttr(firstDemo.file)}"></video>
76
- </div>
77
- <div class="side-panel">
78
- <section>
79
- <h2>Demos</h2>
80
- <ul id="demo-list">
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">
81
185
  ${demoButtons}
82
- </ul>
83
- </section>
84
- <section>
85
- <h2>Summary</h2>
86
- <p id="summary-text"></p>
87
- </section>
88
- <section>
89
- <h2>Annotations</h2>
90
- <ul id="annotations-list"></ul>
91
- </section>
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>
92
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>` : ""}
93
215
  </main>
216
+ ${hasReview ? `<button id="feedback-selection-btn">Add to feedback</button>` : ""}
94
217
  <script>
95
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
+
96
230
  var metadata = ${metadataJson};
97
231
  var video = document.getElementById("review-video");
98
232
  var summaryText = document.getElementById("summary-text");
99
- var annotationsList = document.getElementById("annotations-list");
233
+ var stepsList = document.getElementById("steps-list");
100
234
  var demoButtons = document.querySelectorAll("#demo-list button");
101
235
 
102
236
  function esc(s) {
@@ -121,13 +255,13 @@ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
121
255
  btn.classList.toggle("active", i === index);
122
256
  });
123
257
 
124
- var html = "";
125
- demo.annotations.forEach(function(ann) {
126
- html += '<li><button data-time="' + ann.timestampSeconds + '">' +
127
- '<span class="timestamp">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +
128
- esc(ann.text) + '</button></li>';
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>';
129
263
  });
130
- annotationsList.innerHTML = html;
264
+ stepsList.innerHTML = stepsHtml;
131
265
  }
132
266
 
133
267
  demoButtons.forEach(function(btn) {
@@ -136,7 +270,7 @@ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
136
270
  });
137
271
  });
138
272
 
139
- annotationsList.addEventListener("click", function(e) {
273
+ stepsList.addEventListener("click", function(e) {
140
274
  var btn = e.target.closest("button[data-time]");
141
275
  if (btn) {
142
276
  video.currentTime = parseFloat(btn.getAttribute("data-time"));
@@ -144,7 +278,182 @@ export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
144
278
  }
145
279
  });
146
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
+
147
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
+ }
148
457
  })();
149
458
  </script>
150
459
  </body>
package/src/index.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  export { showCommentary, hideCommentary } from "./commentary.ts";
2
2
  export type { ShowCommentaryOptions } from "./commentary.ts";
3
3
 
4
- export type { DemoMetadata, ReviewMetadata } from "./review-types.ts";
5
- export { buildReviewPrompt, invokeClaude, parseReviewMetadata } from "./review.ts";
6
- export type { InvokeClaudeOptions, SpawnFn } from "./review.ts";
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";
7
10
 
8
11
  export { generateReviewHtml } from "./html-generator.ts";
9
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
+ });