@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,183 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import type { GitHubIssue } from "./github-issue.ts";
4
+ import type { DemoFile } from "./review-generator.ts";
5
+ import { buildPresenterPrompt, buildReviewerPrompt } from "./orchestrator.ts";
6
+
7
+ const mockIssue: GitHubIssue = {
8
+ number: 42,
9
+ title: "Add user authentication",
10
+ body: `## Description
11
+ Add basic user authentication to the app.
12
+
13
+ ## Acceptance Criteria
14
+ - [ ] Users can log in with email/password
15
+ - [ ] Users can log out
16
+ - [ ] Invalid credentials show error message`,
17
+ labels: ["feature", "auth"],
18
+ state: "open",
19
+ };
20
+
21
+ describe("buildPresenterPrompt", () => {
22
+ test("includes issue number and title", () => {
23
+ const prompt = buildPresenterPrompt({
24
+ issue: mockIssue,
25
+ gitDiff: "diff content",
26
+ guidelines: [],
27
+ reviewFolder: "/path/to/review",
28
+ assetsFolder: "/path/to/assets",
29
+ testsFolder: "/path/to/tests",
30
+ });
31
+
32
+ expect(prompt).toContain("GitHub Issue #42: Add user authentication");
33
+ expect(prompt).toContain("Add basic user authentication");
34
+ expect(prompt).toContain("Users can log in with email/password");
35
+ });
36
+
37
+ test("includes git diff", () => {
38
+ const prompt = buildPresenterPrompt({
39
+ issue: mockIssue,
40
+ gitDiff: "+function login() { return true; }",
41
+ guidelines: [],
42
+ reviewFolder: "/path/to/review",
43
+ assetsFolder: "/path/to/assets",
44
+ testsFolder: "/path/to/tests",
45
+ });
46
+
47
+ expect(prompt).toContain("## Git Diff");
48
+ expect(prompt).toContain("+function login() { return true; }");
49
+ });
50
+
51
+ test("includes guidelines when provided", () => {
52
+ const prompt = buildPresenterPrompt({
53
+ issue: mockIssue,
54
+ gitDiff: "diff",
55
+ guidelines: ["# CLAUDE.md\nUse TypeScript", "# SKILL.md\nFollow patterns"],
56
+ reviewFolder: "/path/to/review",
57
+ assetsFolder: "/path/to/assets",
58
+ testsFolder: "/path/to/tests",
59
+ });
60
+
61
+ expect(prompt).toContain("## Coding Guidelines");
62
+ expect(prompt).toContain("Use TypeScript");
63
+ expect(prompt).toContain("Follow patterns");
64
+ });
65
+
66
+ test("includes folder configuration", () => {
67
+ const prompt = buildPresenterPrompt({
68
+ issue: mockIssue,
69
+ gitDiff: "diff",
70
+ guidelines: [],
71
+ reviewFolder: "/project/.demoon/reviews/feature-auth",
72
+ assetsFolder: "/project/.demoon/reviews/feature-auth/assets",
73
+ testsFolder: "/project/.demoon/reviews/feature-auth/tests",
74
+ });
75
+
76
+ expect(prompt).toContain("**Review Folder:** /project/.demoon/reviews/feature-auth");
77
+ expect(prompt).toContain("**Assets Directory:** /project/.demoon/reviews/feature-auth/assets");
78
+ expect(prompt).toContain("**Tests Directory:** /project/.demoon/reviews/feature-auth/tests");
79
+ });
80
+
81
+ test("truncates diff if too long", () => {
82
+ const longDiff = "x".repeat(60_000);
83
+ const prompt = buildPresenterPrompt({
84
+ issue: mockIssue,
85
+ gitDiff: longDiff,
86
+ guidelines: [],
87
+ reviewFolder: "/path",
88
+ assetsFolder: "/path/assets",
89
+ testsFolder: "/path/tests",
90
+ });
91
+
92
+ expect(prompt).toContain("... (diff truncated at 50k characters)");
93
+ expect(prompt.length).toBeLessThan(70_000);
94
+ });
95
+ });
96
+
97
+ describe("buildReviewerPrompt", () => {
98
+ const mockDemoFiles: DemoFile[] = [
99
+ { path: "/path/to/login.webm", filename: "login.webm", relativePath: "login.webm", type: "web-ux" },
100
+ { path: "/path/to/api.jsonl", filename: "api.jsonl", relativePath: "api.jsonl", type: "log-based" },
101
+ ];
102
+
103
+ test("includes issue information", () => {
104
+ const prompt = buildReviewerPrompt({
105
+ issue: mockIssue,
106
+ gitDiff: "diff",
107
+ guidelines: [],
108
+ demoFiles: mockDemoFiles,
109
+ stepsMap: {},
110
+ logsMap: {},
111
+ });
112
+
113
+ expect(prompt).toContain("GitHub Issue #42: Add user authentication");
114
+ expect(prompt).toContain("Users can log in with email/password");
115
+ });
116
+
117
+ test("includes demo recordings section", () => {
118
+ const prompt = buildReviewerPrompt({
119
+ issue: mockIssue,
120
+ gitDiff: "diff",
121
+ guidelines: [],
122
+ demoFiles: mockDemoFiles,
123
+ stepsMap: {
124
+ "login.webm": [
125
+ { text: "Navigate to login page", timestampSeconds: 0 },
126
+ { text: "Enter credentials", timestampSeconds: 5 },
127
+ ],
128
+ },
129
+ logsMap: {
130
+ "api.jsonl": '{"level":"info","message":"Login successful"}\n{"level":"info","message":"Session created"}',
131
+ },
132
+ });
133
+
134
+ expect(prompt).toContain("## Demo Recordings");
135
+ expect(prompt).toContain("Video: login.webm");
136
+ expect(prompt).toContain("[0s] Navigate to login page");
137
+ expect(prompt).toContain("[5s] Enter credentials");
138
+ expect(prompt).toContain("Log: api.jsonl");
139
+ expect(prompt).toContain("Login successful");
140
+ });
141
+
142
+ test("shows (no steps recorded) for demos without steps", () => {
143
+ const prompt = buildReviewerPrompt({
144
+ issue: mockIssue,
145
+ gitDiff: "diff",
146
+ guidelines: [],
147
+ demoFiles: [{ path: "/path/to/demo.webm", filename: "demo.webm", relativePath: "demo.webm", type: "web-ux" }],
148
+ stepsMap: {},
149
+ logsMap: {},
150
+ });
151
+
152
+ expect(prompt).toContain("(no steps recorded)");
153
+ });
154
+
155
+ test("includes validation rules for acceptance criteria", () => {
156
+ const prompt = buildReviewerPrompt({
157
+ issue: mockIssue,
158
+ gitDiff: "diff",
159
+ guidelines: [],
160
+ demoFiles: mockDemoFiles,
161
+ stepsMap: {},
162
+ logsMap: {},
163
+ });
164
+
165
+ expect(prompt).toContain("Verify that demo steps demonstrate ALL acceptance criteria from the issue");
166
+ expect(prompt).toContain("Use \"request_changes\" if acceptance criteria from the issue are not demonstrated");
167
+ });
168
+
169
+ test("requests JSON output format", () => {
170
+ const prompt = buildReviewerPrompt({
171
+ issue: mockIssue,
172
+ gitDiff: "diff",
173
+ guidelines: [],
174
+ demoFiles: mockDemoFiles,
175
+ stepsMap: {},
176
+ logsMap: {},
177
+ });
178
+
179
+ expect(prompt).toContain('"verdict": "approve" | "request_changes"');
180
+ expect(prompt).toContain('"severity": "major" | "minor" | "nit"');
181
+ expect(prompt).toContain("Return ONLY the JSON object");
182
+ });
183
+ });
@@ -0,0 +1,341 @@
1
+ import { mkdirSync, existsSync, writeFileSync, readFileSync } from "node:fs";
2
+ import { join, dirname as pathDirname } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ import type { SpawnFn } from "./review.ts";
6
+ import { invokeClaude, parseLlmResponse } from "./review.ts";
7
+ import { getRepoContext, type ExecFn } from "./git-context.ts";
8
+ import { fetchGitHubIssue, type GitHubIssue, type FetchGitHubIssueOptions } from "./github-issue.ts";
9
+ import {
10
+ discoverDemoFiles,
11
+ generateReviewHtml,
12
+ type ReviewAppData,
13
+ type DemoFile,
14
+ } from "./review-generator.ts";
15
+ import type { ReviewMetadata, DemoType } from "./review-types.ts";
16
+
17
+ export interface OrchestratorOptions {
18
+ issueId: string | number;
19
+ issue?: GitHubIssue; // Pre-loaded issue (skips GitHub API fetch)
20
+ diffBase?: string;
21
+ agent?: string;
22
+ feedbackEndpoint?: string; // URL for feedback submission (enables MCP integration)
23
+ spawn?: SpawnFn;
24
+ exec?: ExecFn;
25
+ cwd?: string;
26
+ github?: FetchGitHubIssueOptions;
27
+ }
28
+
29
+ export interface OrchestratorResult {
30
+ reviewFolder: string;
31
+ htmlPath: string;
32
+ metadataPath: string;
33
+ metadata: ReviewMetadata;
34
+ issue: GitHubIssue;
35
+ }
36
+
37
+ const GIT_DIFF_MAX_CHARS = 50_000;
38
+
39
+ const defaultExec: ExecFn = async (cmd: string[], cwd: string) => {
40
+ const [command, ...args] = cmd;
41
+ const proc = spawnSync(command!, args, { cwd, encoding: "utf-8" });
42
+ if (proc.status !== 0) {
43
+ const stderr = (proc.stderr ?? "").trim();
44
+ throw new Error(`Command failed (exit ${proc.status}): ${cmd.join(" ")}${stderr ? `: ${stderr}` : ""}`);
45
+ }
46
+ return proc.stdout ?? "";
47
+ };
48
+
49
+ async function getGitRoot(exec: ExecFn, cwd: string): Promise<string> {
50
+ return (await exec(["git", "rev-parse", "--show-toplevel"], cwd)).trim();
51
+ }
52
+
53
+ async function getCurrentBranch(exec: ExecFn, cwd: string): Promise<string> {
54
+ const branch = (await exec(["git", "branch", "--show-current"], cwd)).trim();
55
+ return branch.replace(/\//g, "-") || "unknown";
56
+ }
57
+
58
+ export function buildPresenterPrompt(options: {
59
+ issue: GitHubIssue;
60
+ gitDiff: string;
61
+ guidelines: string[];
62
+ reviewFolder: string;
63
+ assetsFolder: string;
64
+ testsFolder: string;
65
+ }): string {
66
+ const { issue, gitDiff, guidelines, reviewFolder, assetsFolder, testsFolder } = options;
67
+
68
+ let diff = gitDiff;
69
+ if (diff.length > GIT_DIFF_MAX_CHARS) {
70
+ diff = diff.slice(0, GIT_DIFF_MAX_CHARS) + "\n\n... (diff truncated at 50k characters)";
71
+ }
72
+
73
+ const sections: string[] = [];
74
+
75
+ sections.push(`## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}`);
76
+
77
+ if (guidelines.length > 0) {
78
+ sections.push(`## Coding Guidelines\n\n${guidelines.join("\n\n")}`);
79
+ }
80
+
81
+ sections.push(`## Git Diff\n\n\`\`\`diff\n${diff}\n\`\`\``);
82
+
83
+ return `You are a demo presenter. You must create demo recordings that showcase the feature described in the GitHub issue.
84
+
85
+ ${sections.join("\n\n")}
86
+
87
+ ## Configuration
88
+
89
+ - **Review Folder:** ${reviewFolder}
90
+ - **Assets Directory:** ${assetsFolder}
91
+ - **Tests Directory:** ${testsFolder}
92
+
93
+ ## Task
94
+
95
+ Based on the GitHub issue and git diff above, create demo recordings that demonstrate each acceptance criterion is met.
96
+
97
+ For web-ux demos:
98
+ - Create Playwright test files in the Tests Directory
99
+ - Use DemoRecorder to capture steps
100
+ - Save recordings to the Assets Directory
101
+
102
+ For log-based demos:
103
+ - Create .jsonl files directly in the Assets Directory
104
+ - Use demon__highlight annotations for key lines
105
+
106
+ After creating demos, report:
107
+ 1. List of demo test files created (paths to .demo.ts files)
108
+ 2. List of artifact files generated (.webm for web-ux, .jsonl for log-based)
109
+ 3. Any errors encountered`;
110
+ }
111
+
112
+ export function buildReviewerPrompt(options: {
113
+ issue: GitHubIssue;
114
+ gitDiff: string;
115
+ guidelines: string[];
116
+ demoFiles: DemoFile[];
117
+ stepsMap: Record<string, Array<{ text: string; timestampSeconds: number }>>;
118
+ logsMap: Record<string, string>;
119
+ }): string {
120
+ const { issue, gitDiff, guidelines, demoFiles, stepsMap, logsMap } = options;
121
+
122
+ let diff = gitDiff;
123
+ if (diff.length > GIT_DIFF_MAX_CHARS) {
124
+ diff = diff.slice(0, GIT_DIFF_MAX_CHARS) + "\n\n... (diff truncated at 50k characters)";
125
+ }
126
+
127
+ const sections: string[] = [];
128
+
129
+ sections.push(`## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}`);
130
+
131
+ if (guidelines.length > 0) {
132
+ sections.push(`## Coding Guidelines\n\n${guidelines.join("\n\n")}`);
133
+ }
134
+
135
+ sections.push(`## Git Diff\n\n\`\`\`diff\n${diff}\n\`\`\``);
136
+
137
+ const demoEntries = demoFiles.map((f) => {
138
+ if (f.type === "web-ux") {
139
+ const steps = stepsMap[f.filename] ?? stepsMap[f.relativePath] ?? [];
140
+ const stepLines = steps
141
+ .map((s) => `- [${s.timestampSeconds}s] ${s.text}`)
142
+ .join("\n");
143
+ return `Video: ${f.relativePath}\nRecorded steps:\n${stepLines || "(no steps recorded)"}`;
144
+ } else {
145
+ const logContent = logsMap[f.relativePath] ?? "";
146
+ const preview = logContent.split("\n").slice(0, 20).join("\n");
147
+ return `Log: ${f.relativePath}\nContent preview:\n${preview}`;
148
+ }
149
+ });
150
+
151
+ sections.push(`## Demo Recordings\n\n${demoEntries.join("\n\n")}`);
152
+
153
+ return `You are a code reviewer. You are given a GitHub issue, git diff, coding guidelines, and demo recordings that show the feature in action.
154
+
155
+ ${sections.join("\n\n")}
156
+
157
+ ## Task
158
+
159
+ Review the code changes and demo recordings against the GitHub issue's acceptance criteria. Generate a JSON object matching this exact schema:
160
+
161
+ {
162
+ "demos": [
163
+ {
164
+ "file": "<filename>",
165
+ "summary": "<a meaningful sentence describing what this demo showcases based on the steps>"
166
+ }
167
+ ],
168
+ "review": {
169
+ "summary": "<2-3 sentence overview of the changes>",
170
+ "highlights": ["<positive aspect 1>", "<positive aspect 2>"],
171
+ "verdict": "approve" | "request_changes",
172
+ "verdictReason": "<one sentence justifying the verdict>",
173
+ "issues": [
174
+ {
175
+ "severity": "major" | "minor" | "nit",
176
+ "description": "<what the issue is and how to fix it>"
177
+ }
178
+ ]
179
+ }
180
+ }
181
+
182
+ Rules:
183
+ - Return ONLY the JSON object, no markdown fences or extra text.
184
+ - Include one entry in "demos" for each demo file, in the same order.
185
+ - "file" must exactly match the provided relative path.
186
+ - "verdict" must be exactly "approve" or "request_changes".
187
+ - Use "request_changes" if acceptance criteria from the issue are not demonstrated.
188
+ - "severity" must be exactly "major", "minor", or "nit".
189
+ - "major": bugs, security issues, broken functionality, missing acceptance criteria.
190
+ - "minor": code quality, readability, missing edge cases.
191
+ - "nit": style, naming, trivial improvements.
192
+ - "highlights" must have at least one entry.
193
+ - "issues" can be an empty array if there are no issues.
194
+ - Verify that demo steps demonstrate ALL acceptance criteria from the issue.`;
195
+ }
196
+
197
+ function collectDemoData(
198
+ demoFiles: DemoFile[],
199
+ ): {
200
+ stepsMapByFilename: Record<string, Array<{ text: string; timestampSeconds: number }>>;
201
+ stepsMapByRelativePath: Record<string, Array<{ text: string; timestampSeconds: number }>>;
202
+ logsMap: Record<string, string>;
203
+ } {
204
+ const stepsMapByFilename: Record<string, Array<{ text: string; timestampSeconds: number }>> = {};
205
+ const stepsMapByRelativePath: Record<string, Array<{ text: string; timestampSeconds: number }>> = {};
206
+ const logsMap: Record<string, string> = {};
207
+
208
+ for (const demo of demoFiles) {
209
+ if (demo.type === "web-ux") {
210
+ const stepsPath = join(pathDirname(demo.path), "demo-steps.json");
211
+ if (existsSync(stepsPath)) {
212
+ try {
213
+ const raw = readFileSync(stepsPath, "utf-8");
214
+ const parsed = JSON.parse(raw);
215
+ if (Array.isArray(parsed)) {
216
+ stepsMapByFilename[demo.filename] = parsed;
217
+ stepsMapByRelativePath[demo.relativePath] = parsed;
218
+ }
219
+ } catch {
220
+ // skip malformed
221
+ }
222
+ }
223
+ } else {
224
+ logsMap[demo.relativePath] = readFileSync(demo.path, "utf-8");
225
+ }
226
+ }
227
+
228
+ return { stepsMapByFilename, stepsMapByRelativePath, logsMap };
229
+ }
230
+
231
+ export async function runReviewOrchestration(
232
+ options: OrchestratorOptions,
233
+ ): Promise<OrchestratorResult> {
234
+ const exec = options.exec ?? defaultExec;
235
+ const cwd = options.cwd ?? process.cwd();
236
+
237
+ // 1. Fetch GitHub issue (or use pre-loaded issue)
238
+ const issue = options.issue ?? await fetchGitHubIssue(options.issueId, options.github);
239
+
240
+ // 2. Get repo context (git diff + guidelines)
241
+ const gitRoot = await getGitRoot(exec, cwd);
242
+ const branchName = await getCurrentBranch(exec, cwd);
243
+
244
+ const repoContext = await getRepoContext(gitRoot, {
245
+ exec,
246
+ diffBase: options.diffBase,
247
+ });
248
+
249
+ // 3. Create review folder structure
250
+ const reviewFolder = join(gitRoot, ".demoon", "reviews", branchName);
251
+ const assetsFolder = join(reviewFolder, "assets");
252
+ const testsFolder = join(reviewFolder, "tests");
253
+
254
+ if (!existsSync(reviewFolder)) {
255
+ mkdirSync(reviewFolder, { recursive: true });
256
+ }
257
+ if (!existsSync(assetsFolder)) {
258
+ mkdirSync(assetsFolder, { recursive: true });
259
+ }
260
+ if (!existsSync(testsFolder)) {
261
+ mkdirSync(testsFolder, { recursive: true });
262
+ }
263
+
264
+ // 4. Run Presenter phase
265
+ const presenterPrompt = buildPresenterPrompt({
266
+ issue,
267
+ gitDiff: repoContext.gitDiff,
268
+ guidelines: repoContext.guidelines,
269
+ reviewFolder,
270
+ assetsFolder,
271
+ testsFolder,
272
+ });
273
+
274
+ await invokeClaude(presenterPrompt, { agent: options.agent, spawn: options.spawn });
275
+
276
+ // 5. Discover generated demos
277
+ const demoFiles = discoverDemoFiles(assetsFolder);
278
+
279
+ if (demoFiles.length === 0) {
280
+ throw new Error(`No demo files (.webm or .jsonl) found in ${assetsFolder} after Presenter phase`);
281
+ }
282
+
283
+ // 6. Collect demo data (steps, logs)
284
+ const { stepsMapByFilename, stepsMapByRelativePath, logsMap } = collectDemoData(
285
+ demoFiles,
286
+ );
287
+
288
+ // 7. Run Reviewer phase
289
+ const reviewerPrompt = buildReviewerPrompt({
290
+ issue,
291
+ gitDiff: repoContext.gitDiff,
292
+ guidelines: repoContext.guidelines,
293
+ demoFiles,
294
+ stepsMap: stepsMapByFilename,
295
+ logsMap,
296
+ });
297
+
298
+ const rawOutput = await invokeClaude(reviewerPrompt, { agent: options.agent, spawn: options.spawn });
299
+ const llmResponse = parseLlmResponse(rawOutput);
300
+
301
+ // 8. Build metadata
302
+ const filenameToRelativePath = new Map(demoFiles.map((d) => [d.filename, d.relativePath]));
303
+ const typeMap = new Map<string, DemoType>(demoFiles.map((d) => [d.filename, d.type]));
304
+
305
+ const metadata: ReviewMetadata = {
306
+ demos: llmResponse.demos.map((demo) => {
307
+ const relativePath = filenameToRelativePath.get(demo.file) ?? demo.file;
308
+ return {
309
+ file: relativePath,
310
+ type: typeMap.get(demo.file) ?? "web-ux",
311
+ summary: demo.summary,
312
+ steps: stepsMapByRelativePath[relativePath] ?? [],
313
+ };
314
+ }),
315
+ review: llmResponse.review,
316
+ };
317
+
318
+ // 9. Write metadata and HTML
319
+ const metadataPath = join(assetsFolder, "review-metadata.json");
320
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + "\n");
321
+
322
+ const appData: ReviewAppData = {
323
+ metadata,
324
+ title: `Review: Issue #${issue.number} - ${issue.title}`,
325
+ videos: {},
326
+ logs: Object.keys(logsMap).length > 0 ? logsMap : undefined,
327
+ feedbackEndpoint: options.feedbackEndpoint,
328
+ };
329
+
330
+ const html = generateReviewHtml(appData);
331
+ const htmlPath = join(assetsFolder, "review.html");
332
+ writeFileSync(htmlPath, html);
333
+
334
+ return {
335
+ reviewFolder,
336
+ htmlPath,
337
+ metadataPath,
338
+ metadata,
339
+ issue,
340
+ };
341
+ }