@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.
- package/dist/bin/demon-demo-init.js +56 -0
- package/dist/bin/demon-demo-init.js.map +10 -0
- package/dist/bin/demon-demo-review.js +187 -523
- package/dist/bin/demon-demo-review.js.map +7 -7
- package/dist/bin/demoon.js +1445 -0
- package/dist/bin/demoon.js.map +22 -0
- package/dist/bin/review-template.html +62 -0
- package/dist/github-issue.js +749 -0
- package/dist/github-issue.js.map +16 -0
- package/dist/index.js +1320 -867
- package/dist/index.js.map +16 -8
- package/dist/orchestrator.js +1421 -0
- package/dist/orchestrator.js.map +20 -0
- package/dist/review-generator.js +424 -0
- package/dist/review-generator.js.map +12 -0
- package/dist/review-template.html +62 -0
- package/package.json +11 -2
- package/src/bin/demon-demo-init.ts +59 -0
- package/src/bin/demon-demo-review.ts +19 -97
- package/src/bin/demoon.ts +140 -0
- package/src/feedback-server.ts +138 -0
- package/src/git-context.test.ts +68 -2
- package/src/git-context.ts +48 -9
- package/src/github-issue.test.ts +188 -0
- package/src/github-issue.ts +139 -0
- package/src/html-generator.e2e.test.ts +361 -80
- package/src/index.ts +9 -3
- package/src/orchestrator.test.ts +183 -0
- package/src/orchestrator.ts +341 -0
- package/src/review-generator.ts +221 -0
- package/src/review-types.ts +3 -0
- package/src/review.ts +13 -7
- package/src/html-generator.test.ts +0 -561
- package/src/html-generator.ts +0 -461
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@demon-utils/playwright",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "./build.sh",
|
|
7
|
+
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
|
8
|
+
},
|
|
5
9
|
"module": "dist/index.js",
|
|
6
10
|
"types": "src/index.ts",
|
|
7
11
|
"exports": {
|
|
@@ -15,9 +19,14 @@
|
|
|
15
19
|
"src"
|
|
16
20
|
],
|
|
17
21
|
"bin": {
|
|
18
|
-
"demon-demo-review": "dist/bin/demon-demo-review.js"
|
|
22
|
+
"demon-demo-review": "dist/bin/demon-demo-review.js",
|
|
23
|
+
"demon-demo-init": "dist/bin/demon-demo-init.js",
|
|
24
|
+
"demoon": "dist/bin/demoon.js"
|
|
19
25
|
},
|
|
20
26
|
"type": "module",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@octokit/graphql": "^8.2.1"
|
|
29
|
+
},
|
|
21
30
|
"peerDependencies": {
|
|
22
31
|
"@playwright/test": ">=1.40.0"
|
|
23
32
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve, join, dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEMO_CONFIG_FILENAME = "playwright.demo.config.ts";
|
|
6
|
+
|
|
7
|
+
const EXAMPLE_DEMO_TEMPLATE = `import { test } from "@playwright/test";
|
|
8
|
+
import { DemoRecorder } from "@demon-utils/playwright";
|
|
9
|
+
// You can also import hideCommentary to manually hide tooltips:
|
|
10
|
+
// import { hideCommentary } from "@demon-utils/playwright";
|
|
11
|
+
|
|
12
|
+
test("example demo", async ({ page }, testInfo) => {
|
|
13
|
+
const demo = new DemoRecorder({ testStep: test.step });
|
|
14
|
+
|
|
15
|
+
// Navigate to the starting page
|
|
16
|
+
await demo.step(page, "Navigate to the application", { selector: "body" });
|
|
17
|
+
await page.goto("/");
|
|
18
|
+
await page.waitForTimeout(1000);
|
|
19
|
+
|
|
20
|
+
// Demonstrate an interaction
|
|
21
|
+
await demo.step(page, "Click a button or link", { selector: "body" });
|
|
22
|
+
// await page.click("#my-button");
|
|
23
|
+
await page.waitForTimeout(1000);
|
|
24
|
+
|
|
25
|
+
// Save the demo steps metadata
|
|
26
|
+
await demo.save(testInfo.outputDir);
|
|
27
|
+
});
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function findConfigDir(startDir: string): string | null {
|
|
31
|
+
let current = resolve(startDir);
|
|
32
|
+
const root = resolve("/");
|
|
33
|
+
|
|
34
|
+
while (current !== root) {
|
|
35
|
+
const configPath = join(current, DEMO_CONFIG_FILENAME);
|
|
36
|
+
if (existsSync(configPath)) {
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
const parent = dirname(current);
|
|
40
|
+
if (parent === current) break;
|
|
41
|
+
current = parent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const startPath = process.argv[2] ?? process.cwd();
|
|
48
|
+
const resolved = resolve(startPath);
|
|
49
|
+
|
|
50
|
+
const configDir = findConfigDir(resolved);
|
|
51
|
+
|
|
52
|
+
if (!configDir) {
|
|
53
|
+
console.error(`Error: Could not find ${DEMO_CONFIG_FILENAME} in any parent directory of "${resolved}".`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const examplePath = join(configDir, "example.demo.ts");
|
|
58
|
+
writeFileSync(examplePath, EXAMPLE_DEMO_TEMPLATE);
|
|
59
|
+
console.log(examplePath);
|
|
@@ -1,31 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { existsSync, statSync
|
|
3
|
-
import { resolve
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
buildReviewPrompt,
|
|
7
|
-
invokeClaude,
|
|
8
|
-
parseLlmResponse,
|
|
9
|
-
} from "../review.ts";
|
|
10
|
-
import { generateReviewHtml } from "../html-generator.ts";
|
|
11
|
-
import { getRepoContext } from "../git-context.ts";
|
|
12
|
-
import type { ReviewMetadata } from "../review-types.ts";
|
|
5
|
+
import { generateReview, discoverDemoFiles } from "../review-generator.ts";
|
|
13
6
|
|
|
14
7
|
let dir: string | undefined;
|
|
15
8
|
let agent: string | undefined;
|
|
9
|
+
let diffBase: string | undefined;
|
|
16
10
|
|
|
17
11
|
const args = process.argv.slice(2);
|
|
18
12
|
for (let i = 0; i < args.length; i++) {
|
|
19
13
|
if (args[i] === "--agent") {
|
|
20
14
|
agent = args[++i];
|
|
15
|
+
} else if (args[i] === "--base") {
|
|
16
|
+
diffBase = args[++i];
|
|
21
17
|
} else if (!dir) {
|
|
22
18
|
dir = args[i];
|
|
23
19
|
}
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
if (!dir) {
|
|
27
|
-
console.error("Usage: demon-demo-review [--agent <path>] <directory>");
|
|
28
|
-
console.error(" Discovers .webm
|
|
23
|
+
console.error("Usage: demon-demo-review [--agent <path>] [--base <ref>] <directory>");
|
|
24
|
+
console.error(" Discovers .webm and .jsonl demo files in the given directory.");
|
|
25
|
+
console.error(" --base <ref> Base commit/branch for diff (auto-detects main/master if on feature branch)");
|
|
29
26
|
process.exit(1);
|
|
30
27
|
}
|
|
31
28
|
|
|
@@ -36,102 +33,27 @@ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
|
36
33
|
process.exit(1);
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
// Discover
|
|
40
|
-
|
|
41
|
-
let webmFiles = readdirSync(resolved)
|
|
42
|
-
.filter((f) => f.endsWith(".webm"))
|
|
43
|
-
.map((f) => join(resolved, f));
|
|
36
|
+
// Discover and print demo files
|
|
37
|
+
const demoFiles = discoverDemoFiles(resolved);
|
|
44
38
|
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
if (!entry.isDirectory()) continue;
|
|
48
|
-
const subdir = join(resolved, entry.name);
|
|
49
|
-
for (const f of readdirSync(subdir)) {
|
|
50
|
-
if (f.endsWith(".webm")) {
|
|
51
|
-
webmFiles.push(join(subdir, f));
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
webmFiles.sort();
|
|
58
|
-
|
|
59
|
-
if (webmFiles.length === 0) {
|
|
60
|
-
console.error(`Error: No .webm files found in "${resolved}" or its subdirectories.`);
|
|
39
|
+
if (demoFiles.length === 0) {
|
|
40
|
+
console.error(`Error: No .webm or .jsonl files found in "${resolved}" or its subdirectories.`);
|
|
61
41
|
process.exit(1);
|
|
62
42
|
}
|
|
63
43
|
|
|
64
|
-
for (const file of
|
|
65
|
-
console.log(file);
|
|
44
|
+
for (const file of demoFiles) {
|
|
45
|
+
console.log(file.path);
|
|
66
46
|
}
|
|
67
47
|
|
|
68
|
-
// Collect demo-steps.json from the directory of each .webm file
|
|
69
|
-
const stepsMap: Record<string, Array<{ text: string; timestampSeconds: number }>> = {};
|
|
70
|
-
for (const webmFile of webmFiles) {
|
|
71
|
-
const stepsPath = join(dirname(webmFile), "demo-steps.json");
|
|
72
|
-
if (!existsSync(stepsPath)) continue;
|
|
73
|
-
try {
|
|
74
|
-
const raw = readFileSync(stepsPath, "utf-8");
|
|
75
|
-
const parsed = JSON.parse(raw);
|
|
76
|
-
if (Array.isArray(parsed)) {
|
|
77
|
-
stepsMap[basename(webmFile)] = parsed;
|
|
78
|
-
}
|
|
79
|
-
} catch {
|
|
80
|
-
// skip malformed steps files
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (Object.keys(stepsMap).length === 0) {
|
|
85
|
-
console.error("Error: No demo-steps.json found alongside any .webm files.");
|
|
86
|
-
console.error("Use DemoRecorder in your demo tests to generate step data.");
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Gather repo context (git diff + guidelines)
|
|
91
|
-
let gitDiff: string | undefined;
|
|
92
|
-
let guidelines: string[] | undefined;
|
|
93
48
|
try {
|
|
94
|
-
const repoContext = await getRepoContext(resolved);
|
|
95
|
-
gitDiff = repoContext.gitDiff;
|
|
96
|
-
guidelines = repoContext.guidelines;
|
|
97
|
-
} catch (err) {
|
|
98
|
-
console.warn(
|
|
99
|
-
"Warning: Could not gather repo context:",
|
|
100
|
-
err instanceof Error ? err.message : err,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const basenames = webmFiles.map((f) => basename(f));
|
|
106
|
-
|
|
107
|
-
const prompt = buildReviewPrompt({ filenames: basenames, stepsMap, gitDiff, guidelines });
|
|
108
|
-
|
|
109
49
|
console.log("Invoking claude to generate review metadata...");
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
const llmResponse = parseLlmResponse(rawOutput);
|
|
113
|
-
|
|
114
|
-
// Construct final metadata by merging LLM summaries with steps
|
|
115
|
-
const metadata: ReviewMetadata = {
|
|
116
|
-
demos: llmResponse.demos.map((demo) => ({
|
|
117
|
-
file: demo.file,
|
|
118
|
-
summary: demo.summary,
|
|
119
|
-
steps: stepsMap[demo.file] ?? [],
|
|
120
|
-
})),
|
|
121
|
-
review: llmResponse.review,
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const outputPath = join(resolved, "review-metadata.json");
|
|
125
|
-
writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
126
|
-
console.log(`Review metadata written to ${outputPath}`);
|
|
50
|
+
const result = await generateReview({ directory: resolved, agent, diffBase });
|
|
127
51
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
writeFileSync(htmlPath, html);
|
|
131
|
-
console.log(resolve(htmlPath));
|
|
52
|
+
console.log(`Review metadata written to ${result.metadataPath}`);
|
|
53
|
+
console.log(resolve(result.htmlPath));
|
|
132
54
|
} catch (err) {
|
|
133
55
|
console.error(
|
|
134
|
-
"Error generating review
|
|
56
|
+
"Error generating review:",
|
|
135
57
|
err instanceof Error ? err.message : err,
|
|
136
58
|
);
|
|
137
59
|
process.exit(1);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
import { runReviewOrchestration } from "../orchestrator.ts";
|
|
6
|
+
import { startFeedbackServer, type FeedbackPayload } from "../feedback-server.ts";
|
|
7
|
+
import type { GitHubIssue } from "../github-issue.ts";
|
|
8
|
+
|
|
9
|
+
function printUsage(): void {
|
|
10
|
+
console.error("Usage: demoon <command> [options]");
|
|
11
|
+
console.error("");
|
|
12
|
+
console.error("Commands:");
|
|
13
|
+
console.error(" review Generate a review from a GitHub issue");
|
|
14
|
+
console.error("");
|
|
15
|
+
console.error("Review options:");
|
|
16
|
+
console.error(" --github-issue-id <id> GitHub issue number (required unless --issue-file is provided)");
|
|
17
|
+
console.error(" --issue-file <path> Path to JSON file with issue data (for testing, skips GitHub API)");
|
|
18
|
+
console.error(" --base <ref> Base commit/branch for diff (auto-detects main/master if on feature branch)");
|
|
19
|
+
console.error(" --agent <path> Path to Claude agent binary");
|
|
20
|
+
console.error(" --port <number> Port for feedback server (default: random available port)");
|
|
21
|
+
console.error("");
|
|
22
|
+
console.error("Environment variables:");
|
|
23
|
+
console.error(" GITHUB_TOKEN or GH_TOKEN GitHub personal access token (required for API access)");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main(): Promise<void> {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
|
|
29
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
30
|
+
printUsage();
|
|
31
|
+
process.exit(args.length === 0 ? 1 : 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const command = args[0];
|
|
35
|
+
|
|
36
|
+
if (command !== "review") {
|
|
37
|
+
console.error(`Unknown command: ${command}`);
|
|
38
|
+
console.error("");
|
|
39
|
+
printUsage();
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse review command options
|
|
44
|
+
let issueId: string | undefined;
|
|
45
|
+
let issueFile: string | undefined;
|
|
46
|
+
let diffBase: string | undefined;
|
|
47
|
+
let agent: string | undefined;
|
|
48
|
+
let port = 0;
|
|
49
|
+
|
|
50
|
+
for (let i = 1; i < args.length; i++) {
|
|
51
|
+
const arg = args[i];
|
|
52
|
+
if (arg === "--github-issue-id" || arg === "--issue") {
|
|
53
|
+
issueId = args[++i];
|
|
54
|
+
} else if (arg === "--issue-file") {
|
|
55
|
+
issueFile = args[++i];
|
|
56
|
+
} else if (arg === "--base") {
|
|
57
|
+
diffBase = args[++i];
|
|
58
|
+
} else if (arg === "--agent") {
|
|
59
|
+
agent = args[++i];
|
|
60
|
+
} else if (arg === "--port") {
|
|
61
|
+
port = parseInt(args[++i] ?? "0", 10);
|
|
62
|
+
} else if (!arg?.startsWith("-")) {
|
|
63
|
+
// Allow positional issue ID for convenience
|
|
64
|
+
if (!issueId) {
|
|
65
|
+
issueId = arg;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Load issue from file if provided
|
|
71
|
+
let issue: GitHubIssue | undefined;
|
|
72
|
+
if (issueFile) {
|
|
73
|
+
const content = readFileSync(issueFile, "utf-8");
|
|
74
|
+
issue = JSON.parse(content) as GitHubIssue;
|
|
75
|
+
issueId = String(issue.number);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!issueId && !issue) {
|
|
79
|
+
console.error("Error: --github-issue-id or --issue-file is required");
|
|
80
|
+
console.error("");
|
|
81
|
+
printUsage();
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Start feedback server
|
|
86
|
+
const feedbackServer = startFeedbackServer(port);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (issue) {
|
|
90
|
+
console.log(`Using issue from file: #${issue.number} - ${issue.title}`);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(`Fetching GitHub issue #${issueId}...`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result = await runReviewOrchestration({
|
|
96
|
+
issueId: issueId!,
|
|
97
|
+
issue,
|
|
98
|
+
diffBase,
|
|
99
|
+
agent,
|
|
100
|
+
feedbackEndpoint: feedbackServer.feedbackEndpoint,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log(`Review generated for issue #${result.issue.number}: ${result.issue.title}`);
|
|
105
|
+
console.log("");
|
|
106
|
+
console.log(`Verdict: ${result.metadata.review?.verdict ?? "unknown"}`);
|
|
107
|
+
if (result.metadata.review?.verdictReason) {
|
|
108
|
+
console.log(`Reason: ${result.metadata.review.verdictReason}`);
|
|
109
|
+
}
|
|
110
|
+
console.log("");
|
|
111
|
+
console.log(`Review folder: ${result.reviewFolder}`);
|
|
112
|
+
console.log(`Review HTML: ${resolve(result.htmlPath)}`);
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(`Feedback server running at: http://localhost:${feedbackServer.port}`);
|
|
115
|
+
console.log(`Open the review HTML and approve/request changes to complete.`);
|
|
116
|
+
console.log("");
|
|
117
|
+
|
|
118
|
+
// Wait for user feedback
|
|
119
|
+
const feedback: FeedbackPayload = await feedbackServer.waitForFeedback();
|
|
120
|
+
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log("=".repeat(60));
|
|
123
|
+
console.log(`User verdict: ${feedback.verdict}`);
|
|
124
|
+
if (feedback.feedback) {
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log("Feedback:");
|
|
127
|
+
console.log(feedback.feedback);
|
|
128
|
+
}
|
|
129
|
+
console.log("=".repeat(60));
|
|
130
|
+
|
|
131
|
+
// Exit with appropriate code
|
|
132
|
+
process.exit(feedback.verdict === "approve" ? 0 : 1);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
135
|
+
feedbackServer.stop();
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type ReviewVerdict = "approve" | "request_changes";
|
|
4
|
+
|
|
5
|
+
export interface FeedbackPayload {
|
|
6
|
+
verdict: ReviewVerdict;
|
|
7
|
+
feedback?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface PendingReview {
|
|
11
|
+
resolve: (payload: FeedbackPayload) => void;
|
|
12
|
+
reject: (error: Error) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pendingReviews = new Map<string, PendingReview>();
|
|
16
|
+
|
|
17
|
+
const CORS_HEADERS = {
|
|
18
|
+
"Access-Control-Allow-Origin": "*",
|
|
19
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
20
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function isValidPayload(body: unknown): body is FeedbackPayload {
|
|
24
|
+
if (typeof body !== "object" || body === null) return false;
|
|
25
|
+
const obj = body as Record<string, unknown>;
|
|
26
|
+
if (obj["verdict"] !== "approve" && obj["verdict"] !== "request_changes") return false;
|
|
27
|
+
if (obj["feedback"] !== undefined && typeof obj["feedback"] !== "string") return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FeedbackServerResult {
|
|
32
|
+
server: ReturnType<typeof Bun.serve>;
|
|
33
|
+
port: number;
|
|
34
|
+
reviewId: string;
|
|
35
|
+
feedbackEndpoint: string;
|
|
36
|
+
waitForFeedback: () => Promise<FeedbackPayload>;
|
|
37
|
+
stop: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function startFeedbackServer(preferredPort = 0): FeedbackServerResult {
|
|
41
|
+
const reviewId = randomUUID();
|
|
42
|
+
|
|
43
|
+
const server = Bun.serve({
|
|
44
|
+
port: preferredPort,
|
|
45
|
+
async fetch(req) {
|
|
46
|
+
const url = new URL(req.url);
|
|
47
|
+
|
|
48
|
+
// Handle CORS preflight
|
|
49
|
+
if (req.method === "OPTIONS") {
|
|
50
|
+
return new Response(null, {
|
|
51
|
+
status: 204,
|
|
52
|
+
headers: CORS_HEADERS,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle feedback endpoint
|
|
57
|
+
if (url.pathname === "/feedback" && req.method === "POST") {
|
|
58
|
+
const requestReviewId = url.searchParams.get("reviewId");
|
|
59
|
+
const headers = {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
...CORS_HEADERS,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!requestReviewId) {
|
|
65
|
+
return new Response(
|
|
66
|
+
JSON.stringify({ error: "Missing reviewId query parameter" }),
|
|
67
|
+
{ status: 400, headers }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pending = pendingReviews.get(requestReviewId);
|
|
72
|
+
if (!pending) {
|
|
73
|
+
return new Response(
|
|
74
|
+
JSON.stringify({ error: "Review not found or already completed" }),
|
|
75
|
+
{ status: 404, headers }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let body: unknown;
|
|
80
|
+
try {
|
|
81
|
+
body = await req.json();
|
|
82
|
+
} catch {
|
|
83
|
+
return new Response(
|
|
84
|
+
JSON.stringify({ error: "Invalid JSON" }),
|
|
85
|
+
{ status: 400, headers }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!isValidPayload(body)) {
|
|
90
|
+
return new Response(
|
|
91
|
+
JSON.stringify({ error: "Invalid payload" }),
|
|
92
|
+
{ status: 400, headers }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pending.resolve(body);
|
|
97
|
+
pendingReviews.delete(requestReviewId);
|
|
98
|
+
|
|
99
|
+
return new Response(
|
|
100
|
+
JSON.stringify({ success: true, verdict: body.verdict }),
|
|
101
|
+
{ status: 200, headers }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Health check
|
|
106
|
+
if (url.pathname === "/health") {
|
|
107
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new Response("Not Found", { status: 404 });
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const port = server.port ?? 3000;
|
|
117
|
+
const feedbackEndpoint = `http://localhost:${port}/feedback?reviewId=${reviewId}`;
|
|
118
|
+
|
|
119
|
+
const feedbackPromise = new Promise<FeedbackPayload>((resolve, reject) => {
|
|
120
|
+
pendingReviews.set(reviewId, { resolve, reject });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
server,
|
|
125
|
+
port,
|
|
126
|
+
reviewId,
|
|
127
|
+
feedbackEndpoint,
|
|
128
|
+
waitForFeedback: () => feedbackPromise,
|
|
129
|
+
stop: () => {
|
|
130
|
+
const pending = pendingReviews.get(reviewId);
|
|
131
|
+
if (pending) {
|
|
132
|
+
pending.reject(new Error("Server stopped"));
|
|
133
|
+
pendingReviews.delete(reviewId);
|
|
134
|
+
}
|
|
135
|
+
server.stop();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/git-context.test.ts
CHANGED
|
@@ -23,9 +23,10 @@ function mockReadFile(files: Record<string, string>): ReadFileFn {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
describe("getRepoContext", () => {
|
|
26
|
-
test("returns diff from working tree when dirty", async () => {
|
|
26
|
+
test("returns diff from working tree when dirty and on main", async () => {
|
|
27
27
|
const exec = mockExec({
|
|
28
28
|
"git rev-parse --show-toplevel": "/repo\n",
|
|
29
|
+
"git rev-parse --abbrev-ref HEAD": "main\n",
|
|
29
30
|
"git diff HEAD": "diff --git a/file.ts\n+added line\n",
|
|
30
31
|
"git ls-files": "src/index.ts\n",
|
|
31
32
|
});
|
|
@@ -35,9 +36,10 @@ describe("getRepoContext", () => {
|
|
|
35
36
|
expect(ctx.gitDiff).toBe("diff --git a/file.ts\n+added line");
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
test("falls back to HEAD~1..HEAD when working tree is clean", async () => {
|
|
39
|
+
test("falls back to HEAD~1..HEAD when working tree is clean and on main", async () => {
|
|
39
40
|
const exec = mockExec({
|
|
40
41
|
"git rev-parse --show-toplevel": "/repo\n",
|
|
42
|
+
"git rev-parse --abbrev-ref HEAD": "main\n",
|
|
41
43
|
"git diff HEAD": "",
|
|
42
44
|
"git diff HEAD~1..HEAD": "diff --git a/committed.ts\n+committed line\n",
|
|
43
45
|
"git ls-files": "",
|
|
@@ -48,9 +50,72 @@ describe("getRepoContext", () => {
|
|
|
48
50
|
expect(ctx.gitDiff).toBe("diff --git a/committed.ts\n+committed line");
|
|
49
51
|
});
|
|
50
52
|
|
|
53
|
+
test("auto-detects main as base when on feature branch", async () => {
|
|
54
|
+
const exec = mockExec({
|
|
55
|
+
"git rev-parse --show-toplevel": "/repo\n",
|
|
56
|
+
"git rev-parse --abbrev-ref HEAD": "feature-branch\n",
|
|
57
|
+
"git rev-parse --verify main": "abc123\n",
|
|
58
|
+
"git diff main...HEAD": "diff --git a/feature.ts\n+feature line\n",
|
|
59
|
+
"git ls-files": "",
|
|
60
|
+
});
|
|
61
|
+
const readFile = mockReadFile({});
|
|
62
|
+
|
|
63
|
+
const ctx = await getRepoContext("/repo/demos", { exec, readFile });
|
|
64
|
+
expect(ctx.gitDiff).toBe("diff --git a/feature.ts\n+feature line");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("auto-detects master as base when main does not exist", async () => {
|
|
68
|
+
const exec: ExecFn = async (cmd: string[], _cwd: string) => {
|
|
69
|
+
const key = cmd.join(" ");
|
|
70
|
+
const responses: Record<string, string> = {
|
|
71
|
+
"git rev-parse --show-toplevel": "/repo\n",
|
|
72
|
+
"git rev-parse --abbrev-ref HEAD": "feature-branch\n",
|
|
73
|
+
"git rev-parse --verify master": "abc123\n",
|
|
74
|
+
"git diff master...HEAD": "diff --git a/feature.ts\n+feature line\n",
|
|
75
|
+
"git ls-files": "",
|
|
76
|
+
};
|
|
77
|
+
if (key === "git rev-parse --verify main") {
|
|
78
|
+
throw new Error("fatal: Needed a single revision");
|
|
79
|
+
}
|
|
80
|
+
if (key in responses) {
|
|
81
|
+
return responses[key]!;
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Unexpected command: ${key}`);
|
|
84
|
+
};
|
|
85
|
+
const readFile = mockReadFile({});
|
|
86
|
+
|
|
87
|
+
const ctx = await getRepoContext("/repo/demos", { exec, readFile });
|
|
88
|
+
expect(ctx.gitDiff).toBe("diff --git a/feature.ts\n+feature line");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uses explicit diffBase when provided", async () => {
|
|
92
|
+
const exec = mockExec({
|
|
93
|
+
"git rev-parse --show-toplevel": "/repo\n",
|
|
94
|
+
"git diff develop...HEAD": "diff --git a/feature.ts\n+feature line\n",
|
|
95
|
+
"git ls-files": "",
|
|
96
|
+
});
|
|
97
|
+
const readFile = mockReadFile({});
|
|
98
|
+
|
|
99
|
+
const ctx = await getRepoContext("/repo/demos", { exec, readFile, diffBase: "develop" });
|
|
100
|
+
expect(ctx.gitDiff).toBe("diff --git a/feature.ts\n+feature line");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("uses explicit diffBase with commit hash", async () => {
|
|
104
|
+
const exec = mockExec({
|
|
105
|
+
"git rev-parse --show-toplevel": "/repo\n",
|
|
106
|
+
"git diff abc123...HEAD": "diff --git a/commit.ts\n+commit changes\n",
|
|
107
|
+
"git ls-files": "",
|
|
108
|
+
});
|
|
109
|
+
const readFile = mockReadFile({});
|
|
110
|
+
|
|
111
|
+
const ctx = await getRepoContext("/repo/demos", { exec, readFile, diffBase: "abc123" });
|
|
112
|
+
expect(ctx.gitDiff).toBe("diff --git a/commit.ts\n+commit changes");
|
|
113
|
+
});
|
|
114
|
+
|
|
51
115
|
test("discovers CLAUDE.md and SKILL.md files", async () => {
|
|
52
116
|
const exec = mockExec({
|
|
53
117
|
"git rev-parse --show-toplevel": "/repo\n",
|
|
118
|
+
"git rev-parse --abbrev-ref HEAD": "main\n",
|
|
54
119
|
"git diff HEAD": "some diff\n",
|
|
55
120
|
"git ls-files": "CLAUDE.md\nplugins/demo/SKILL.md\nsrc/index.ts\n",
|
|
56
121
|
});
|
|
@@ -68,6 +133,7 @@ describe("getRepoContext", () => {
|
|
|
68
133
|
test("returns empty guidelines when no CLAUDE.md or SKILL.md exist", async () => {
|
|
69
134
|
const exec = mockExec({
|
|
70
135
|
"git rev-parse --show-toplevel": "/repo\n",
|
|
136
|
+
"git rev-parse --abbrev-ref HEAD": "main\n",
|
|
71
137
|
"git diff HEAD": "some diff\n",
|
|
72
138
|
"git ls-files": "src/index.ts\npackage.json\n",
|
|
73
139
|
});
|