@demon-utils/playwright 0.1.6 → 0.1.7

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,221 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, dirname, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import {
6
+ buildReviewPrompt,
7
+ invokeClaude,
8
+ parseLlmResponse,
9
+ } from "./review.ts";
10
+ import { getRepoContext } from "./git-context.ts";
11
+ import type { ReviewMetadata, DemoType } from "./review-types.ts";
12
+
13
+ export interface ReviewAppData {
14
+ metadata: ReviewMetadata;
15
+ title: string;
16
+ videos: Record<string, string>;
17
+ logs?: Record<string, string>;
18
+ feedbackEndpoint?: string;
19
+ }
20
+
21
+ export interface DemoFile {
22
+ path: string;
23
+ filename: string;
24
+ relativePath: string;
25
+ type: DemoType;
26
+ }
27
+
28
+ export interface GenerateReviewOptions {
29
+ directory: string;
30
+ agent?: string;
31
+ feedbackEndpoint?: string;
32
+ title?: string;
33
+ diffBase?: string; // Base commit/branch for diff (auto-detected if not provided)
34
+ }
35
+
36
+ export interface GenerateReviewResult {
37
+ htmlPath: string;
38
+ metadataPath: string;
39
+ metadata: ReviewMetadata;
40
+ }
41
+
42
+ export function getReviewTemplate(): string {
43
+ const currentFile = fileURLToPath(import.meta.url);
44
+ const distDir = dirname(currentFile);
45
+ const templatePath = join(distDir, "review-template.html");
46
+
47
+ if (!existsSync(templatePath)) {
48
+ throw new Error(
49
+ `Review template not found at ${templatePath}. ` +
50
+ `Make sure to build the review-app package first.`
51
+ );
52
+ }
53
+
54
+ return readFileSync(templatePath, "utf-8");
55
+ }
56
+
57
+ function escapeHtml(s: string): string {
58
+ return s
59
+ .replace(/&/g, "&amp;")
60
+ .replace(/</g, "&lt;")
61
+ .replace(/>/g, "&gt;")
62
+ .replace(/"/g, "&quot;")
63
+ .replace(/'/g, "&#39;");
64
+ }
65
+
66
+ export function generateReviewHtml(appData: ReviewAppData): string {
67
+ const template = getReviewTemplate();
68
+ const jsonData = JSON.stringify(appData);
69
+
70
+ return template
71
+ .replace("<title>Demo Review</title>", `<title>${escapeHtml(appData.title)}</title>`)
72
+ .replace('"{{__INJECT_REVIEW_DATA__}}"', jsonData);
73
+ }
74
+
75
+ /**
76
+ * Discover demo files (.webm and .jsonl) in the given directory.
77
+ * Searches top-level first, then one level deep (Playwright creates per-test subdirectories).
78
+ */
79
+ export function discoverDemoFiles(directory: string): DemoFile[] {
80
+ const files: DemoFile[] = [];
81
+
82
+ const processFile = (filePath: string, filename: string) => {
83
+ const relativePath = relative(directory, filePath);
84
+ if (filename.endsWith(".webm")) {
85
+ files.push({ path: filePath, filename, relativePath, type: "web-ux" });
86
+ } else if (filename.endsWith(".jsonl")) {
87
+ files.push({ path: filePath, filename, relativePath, type: "log-based" });
88
+ }
89
+ };
90
+
91
+ // Search top-level
92
+ for (const f of readdirSync(directory)) {
93
+ processFile(join(directory, f), f);
94
+ }
95
+
96
+ // If no files found at top level, search one level deep
97
+ if (files.length === 0) {
98
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
99
+ if (!entry.isDirectory()) continue;
100
+ const subdir = join(directory, entry.name);
101
+ for (const f of readdirSync(subdir)) {
102
+ processFile(join(subdir, f), f);
103
+ }
104
+ }
105
+ }
106
+
107
+ return files.sort((a, b) => a.filename.localeCompare(b.filename));
108
+ }
109
+
110
+ /**
111
+ * Generate a review HTML page from demo files in the given directory.
112
+ * This is the main entry point for programmatic use.
113
+ */
114
+ export async function generateReview(options: GenerateReviewOptions): Promise<GenerateReviewResult> {
115
+ const { directory, agent, feedbackEndpoint, title = "Demo Review", diffBase } = options;
116
+
117
+ const demoFiles = discoverDemoFiles(directory);
118
+ const webUxDemos = demoFiles.filter((d) => d.type === "web-ux");
119
+ const logBasedDemos = demoFiles.filter((d) => d.type === "log-based");
120
+
121
+ if (demoFiles.length === 0) {
122
+ throw new Error(`No .webm or .jsonl files found in "${directory}" or its subdirectories.`);
123
+ }
124
+
125
+ // Build maps from filename to relativePath for lookup
126
+ const filenameToRelativePath = new Map(demoFiles.map((d) => [d.filename, d.relativePath]));
127
+
128
+ // Collect demo-steps.json from the directory of each .webm file
129
+ // Key by filename for prompt builder, and by relativePath for metadata
130
+ const stepsMapByFilename: Record<string, Array<{ text: string; timestampSeconds: number }>> = {};
131
+ const stepsMapByRelativePath: Record<string, Array<{ text: string; timestampSeconds: number }>> = {};
132
+ for (const demo of webUxDemos) {
133
+ const stepsPath = join(dirname(demo.path), "demo-steps.json");
134
+ if (!existsSync(stepsPath)) continue;
135
+ try {
136
+ const raw = readFileSync(stepsPath, "utf-8");
137
+ const parsed = JSON.parse(raw);
138
+ if (Array.isArray(parsed)) {
139
+ stepsMapByFilename[demo.filename] = parsed;
140
+ stepsMapByRelativePath[demo.relativePath] = parsed;
141
+ }
142
+ } catch {
143
+ // skip malformed steps files
144
+ }
145
+ }
146
+
147
+ // Collect JSONL content for log-based demos, keyed by relativePath
148
+ const logsMap: Record<string, string> = {};
149
+ for (const demo of logBasedDemos) {
150
+ logsMap[demo.relativePath] = readFileSync(demo.path, "utf-8");
151
+ }
152
+
153
+ // For web-ux demos, require steps
154
+ const hasWebUxDemos = webUxDemos.length > 0;
155
+ const hasLogDemos = logBasedDemos.length > 0;
156
+
157
+ if (hasWebUxDemos && Object.keys(stepsMapByFilename).length === 0) {
158
+ throw new Error(
159
+ "No demo-steps.json found alongside any .webm files. " +
160
+ "Use DemoRecorder in your demo tests to generate step data."
161
+ );
162
+ }
163
+
164
+ if (!hasWebUxDemos && !hasLogDemos) {
165
+ throw new Error("No demo files found.");
166
+ }
167
+
168
+ // Gather repo context (git diff + guidelines)
169
+ let gitDiff: string | undefined;
170
+ let guidelines: string[] | undefined;
171
+ try {
172
+ const repoContext = await getRepoContext(directory, { diffBase });
173
+ gitDiff = repoContext.gitDiff;
174
+ guidelines = repoContext.guidelines;
175
+ } catch {
176
+ // Silently continue without repo context
177
+ }
178
+
179
+ const allFilenames = demoFiles.map((d) => d.filename);
180
+ const prompt = buildReviewPrompt({ filenames: allFilenames, stepsMap: stepsMapByFilename, gitDiff, guidelines });
181
+
182
+ const rawOutput = await invokeClaude(prompt, { agent });
183
+ const llmResponse = parseLlmResponse(rawOutput);
184
+
185
+ // Build a map of filename to type for easy lookup
186
+ const typeMap = new Map(demoFiles.map((d) => [d.filename, d.type]));
187
+
188
+ // Construct final metadata by merging LLM summaries with steps and type
189
+ // Convert filenames from LLM response to relative paths for proper video loading
190
+ const metadata: ReviewMetadata = {
191
+ demos: llmResponse.demos.map((demo) => {
192
+ const relativePath = filenameToRelativePath.get(demo.file) ?? demo.file;
193
+ return {
194
+ file: relativePath,
195
+ type: typeMap.get(demo.file) ?? "web-ux",
196
+ summary: demo.summary,
197
+ steps: stepsMapByRelativePath[relativePath] ?? [],
198
+ };
199
+ }),
200
+ review: llmResponse.review,
201
+ };
202
+
203
+ const metadataPath = join(directory, "review-metadata.json");
204
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + "\n");
205
+
206
+ // Build app data and generate HTML
207
+ // Videos are referenced by relative path in demo.file, no base64 encoding needed
208
+ const appData: ReviewAppData = {
209
+ metadata,
210
+ title,
211
+ videos: {},
212
+ logs: Object.keys(logsMap).length > 0 ? logsMap : undefined,
213
+ feedbackEndpoint,
214
+ };
215
+
216
+ const html = generateReviewHtml(appData);
217
+ const htmlPath = join(directory, "review.html");
218
+ writeFileSync(htmlPath, html);
219
+
220
+ return { htmlPath, metadataPath, metadata };
221
+ }
@@ -1,5 +1,8 @@
1
+ export type DemoType = "web-ux" | "log-based";
2
+
1
3
  export interface DemoMetadata {
2
4
  file: string;
5
+ type: DemoType;
3
6
  summary: string;
4
7
  steps: Array<{ timestampSeconds: number; text: string }>;
5
8
  }
package/src/review.ts CHANGED
@@ -234,18 +234,24 @@ export function parseLlmResponse(raw: string): LlmReviewResponse {
234
234
  return parsed as LlmReviewResponse;
235
235
  }
236
236
 
237
+ import { spawn } from "node:child_process";
238
+ import { Readable } from "node:stream";
239
+
237
240
  function defaultSpawn(
238
241
  cmd: string[],
239
242
  ): { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> } {
240
243
  const [command, ...args] = cmd;
241
- const proc = Bun.spawn([command!, ...args], {
242
- stdout: "pipe",
243
- stderr: "pipe",
244
+ const proc = spawn(command!, args, {
245
+ stdio: ["ignore", "pipe", "pipe"],
244
246
  });
245
- return {
246
- exitCode: proc.exited,
247
- stdout: proc.stdout as unknown as ReadableStream<Uint8Array>,
248
- };
247
+
248
+ const exitCode = new Promise<number>((resolve) => {
249
+ proc.on("close", (code) => resolve(code ?? 1));
250
+ });
251
+
252
+ const stdout = Readable.toWeb(proc.stdout!) as unknown as ReadableStream<Uint8Array>;
253
+
254
+ return { exitCode, stdout };
249
255
  }
250
256
 
251
257
  function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {