@nhonh/qabot 0.1.0
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/bin/qabot.js +24 -0
- package/package.json +56 -0
- package/src/ai/ai-engine.js +114 -0
- package/src/ai/prompt-builder.js +86 -0
- package/src/ai/usecase-parser.js +124 -0
- package/src/analyzers/env-detector.js +110 -0
- package/src/analyzers/feature-detector.js +298 -0
- package/src/analyzers/project-analyzer.js +157 -0
- package/src/analyzers/test-detector.js +158 -0
- package/src/cli/commands/generate.js +125 -0
- package/src/cli/commands/init.js +137 -0
- package/src/cli/commands/list.js +130 -0
- package/src/cli/commands/report.js +46 -0
- package/src/cli/commands/run.js +132 -0
- package/src/core/config.js +71 -0
- package/src/core/constants.js +70 -0
- package/src/core/logger.js +104 -0
- package/src/executor/process-manager.js +82 -0
- package/src/executor/result-collector.js +81 -0
- package/src/executor/test-executor.js +124 -0
- package/src/index.js +12 -0
- package/src/reporter/html-builder.js +149 -0
- package/src/reporter/report-generator.js +84 -0
- package/src/reporter/report-server.js +18 -0
- package/src/runners/base-runner.js +19 -0
- package/src/runners/dotnet-runner.js +54 -0
- package/src/runners/jest-runner.js +118 -0
- package/src/runners/playwright-runner.js +106 -0
- package/src/runners/pytest-runner.js +84 -0
- package/src/runners/runner-registry.js +32 -0
- package/src/runners/vitest-runner.js +74 -0
- package/src/utils/file-utils.js +69 -0
- package/templates/configs/default.config.js +79 -0
package/bin/qabot.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { VERSION, TOOL_NAME } from "../src/core/constants.js";
|
|
5
|
+
import { registerInitCommand } from "../src/cli/commands/init.js";
|
|
6
|
+
import { registerRunCommand } from "../src/cli/commands/run.js";
|
|
7
|
+
import { registerListCommand } from "../src/cli/commands/list.js";
|
|
8
|
+
import { registerGenerateCommand } from "../src/cli/commands/generate.js";
|
|
9
|
+
import { registerReportCommand } from "../src/cli/commands/report.js";
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name(TOOL_NAME)
|
|
15
|
+
.description("AI-powered universal QA automation tool")
|
|
16
|
+
.version(VERSION);
|
|
17
|
+
|
|
18
|
+
registerInitCommand(program);
|
|
19
|
+
registerRunCommand(program);
|
|
20
|
+
registerListCommand(program);
|
|
21
|
+
registerGenerateCommand(program);
|
|
22
|
+
registerReportCommand(program);
|
|
23
|
+
|
|
24
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nhonh/qabot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered universal QA automation tool. Import any project, AI analyzes and runs tests across all layers.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"qabot": "bin/qabot.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/qabot.js",
|
|
12
|
+
"dev": "node --watch bin/qabot.js",
|
|
13
|
+
"test": "node --test tests/core/*.test.js tests/executor/*.test.js tests/runners/*.test.js tests/analyzers/*.test.js tests/ai/*.test.js tests/reporter/*.test.js",
|
|
14
|
+
"lint": "eslint src/",
|
|
15
|
+
"prepublishOnly": "npm test"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"qa",
|
|
19
|
+
"testing",
|
|
20
|
+
"automation",
|
|
21
|
+
"ai",
|
|
22
|
+
"playwright",
|
|
23
|
+
"jest",
|
|
24
|
+
"vitest",
|
|
25
|
+
"e2e",
|
|
26
|
+
"unit-test",
|
|
27
|
+
"test-runner",
|
|
28
|
+
"test-report",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"author": "GearGames",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"chalk": "^5.3.0",
|
|
38
|
+
"commander": "^12.1.0",
|
|
39
|
+
"cosmiconfig": "^9.0.0",
|
|
40
|
+
"enquirer": "^2.4.1",
|
|
41
|
+
"glob": "^11.0.0",
|
|
42
|
+
"handlebars": "^4.7.8",
|
|
43
|
+
"nanoid": "^5.0.9",
|
|
44
|
+
"ora": "^8.1.1",
|
|
45
|
+
"strip-ansi": "^7.1.0",
|
|
46
|
+
"tree-kill": "^1.2.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"eslint": "^9.0.0"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"bin/",
|
|
53
|
+
"src/",
|
|
54
|
+
"templates/"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildAnalysisPrompt,
|
|
3
|
+
buildGenerationPrompt,
|
|
4
|
+
buildRecommendationPrompt,
|
|
5
|
+
} from "./prompt-builder.js";
|
|
6
|
+
|
|
7
|
+
export class AIEngine {
|
|
8
|
+
constructor(config = {}) {
|
|
9
|
+
this.provider = config.provider || "none";
|
|
10
|
+
this.model = config.model || "gpt-4o";
|
|
11
|
+
this.apiKey = process.env[config.apiKeyEnv || "OPENAI_API_KEY"] || "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
isAvailable() {
|
|
15
|
+
return this.provider !== "none" && !!this.apiKey;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async analyzeCode(code, context) {
|
|
19
|
+
const prompt = buildAnalysisPrompt(code, context);
|
|
20
|
+
const response = await this.complete(prompt);
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(response);
|
|
23
|
+
} catch {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
name: "Parse error",
|
|
27
|
+
description: response,
|
|
28
|
+
type: "unit",
|
|
29
|
+
priority: "P1",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async generateTestCode(testCases, context) {
|
|
36
|
+
const prompt = buildGenerationPrompt(testCases, context);
|
|
37
|
+
return this.complete(prompt);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async generateRecommendations(results) {
|
|
41
|
+
const prompt = buildRecommendationPrompt(results);
|
|
42
|
+
return this.complete(prompt);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async complete(prompt) {
|
|
46
|
+
switch (this.provider) {
|
|
47
|
+
case "openai":
|
|
48
|
+
return this.callOpenAI(prompt);
|
|
49
|
+
case "anthropic":
|
|
50
|
+
return this.callAnthropic(prompt);
|
|
51
|
+
case "ollama":
|
|
52
|
+
return this.callOllama(prompt);
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(
|
|
55
|
+
`AI provider "${this.provider}" not configured. Set provider in qabot.config.js`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async callOpenAI(prompt) {
|
|
61
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
model: this.model,
|
|
69
|
+
messages: [{ role: "user", content: prompt }],
|
|
70
|
+
temperature: 0.3,
|
|
71
|
+
max_tokens: 4096,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
return data.choices?.[0]?.message?.content || "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async callAnthropic(prompt) {
|
|
81
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"x-api-key": this.apiKey,
|
|
86
|
+
"anthropic-version": "2023-06-01",
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
model: this.model || "claude-sonnet-4-20250514",
|
|
90
|
+
max_tokens: 4096,
|
|
91
|
+
messages: [{ role: "user", content: prompt }],
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok)
|
|
95
|
+
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
|
|
96
|
+
const data = await res.json();
|
|
97
|
+
return data.content?.[0]?.text || "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async callOllama(prompt) {
|
|
101
|
+
const res = await fetch("http://localhost:11434/api/generate", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
model: this.model || "llama3",
|
|
106
|
+
prompt,
|
|
107
|
+
stream: false,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) throw new Error(`Ollama API error: ${res.status}`);
|
|
111
|
+
const data = await res.json();
|
|
112
|
+
return data.response || "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export function buildAnalysisPrompt(code, context) {
|
|
2
|
+
const useCaseSection = context.useCases?.length
|
|
3
|
+
? `\n## Use Cases from QA Team\n${context.useCases.map((uc) => `- Scenario: ${uc.scenario}\n Steps: ${uc.steps.join(" -> ")}`).join("\n")}\n`
|
|
4
|
+
: "";
|
|
5
|
+
|
|
6
|
+
return `You are a senior QA engineer analyzing source code to identify test cases.
|
|
7
|
+
|
|
8
|
+
## Project Context
|
|
9
|
+
- Framework: ${context.framework || "unknown"}
|
|
10
|
+
- Test Runner: ${context.runner || "jest"}
|
|
11
|
+
- Existing Test Count: ${context.existingTestCount || 0}
|
|
12
|
+
${useCaseSection}
|
|
13
|
+
## Source Code
|
|
14
|
+
\`\`\`
|
|
15
|
+
${truncate(code, 8000)}
|
|
16
|
+
\`\`\`
|
|
17
|
+
|
|
18
|
+
## Task
|
|
19
|
+
Analyze the code and return a JSON array of test cases. Each test case:
|
|
20
|
+
{
|
|
21
|
+
"name": "descriptive test name using should/when pattern",
|
|
22
|
+
"type": "unit | integration | e2e",
|
|
23
|
+
"priority": "P0 | P1 | P2",
|
|
24
|
+
"description": "what this test verifies and why",
|
|
25
|
+
"assertions": ["list of specific things to assert"]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Focus on:
|
|
29
|
+
1. Happy path scenarios (P0)
|
|
30
|
+
2. Error handling and edge cases (P1)
|
|
31
|
+
3. User interaction flows (P1)
|
|
32
|
+
4. Boundary conditions (P2)
|
|
33
|
+
|
|
34
|
+
Return ONLY valid JSON array, no markdown fences, no explanation.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildGenerationPrompt(testCases, context) {
|
|
38
|
+
return `You are a senior test engineer writing test code.
|
|
39
|
+
|
|
40
|
+
## Context
|
|
41
|
+
- Framework: ${context.framework || "react"}
|
|
42
|
+
- Test Runner: ${context.runner || "jest"}
|
|
43
|
+
- Language: JavaScript/JSX
|
|
44
|
+
- Libraries: @testing-library/react, @testing-library/user-event, redux-mock-store
|
|
45
|
+
|
|
46
|
+
## Test Cases to Implement
|
|
47
|
+
${JSON.stringify(testCases, null, 2)}
|
|
48
|
+
|
|
49
|
+
## Rules
|
|
50
|
+
1. Use @testing-library/react for rendering
|
|
51
|
+
2. Use @testing-library/user-event for interactions
|
|
52
|
+
3. Use screen queries (getByRole, getByText, getByTestId)
|
|
53
|
+
4. Use waitFor for async assertions
|
|
54
|
+
5. Each test must be independent
|
|
55
|
+
6. Use descriptive test names
|
|
56
|
+
7. Handle async operations properly
|
|
57
|
+
|
|
58
|
+
Write complete, runnable test code. Return ONLY the code, no explanation.`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildRecommendationPrompt(results) {
|
|
62
|
+
const summary = results.summary || {};
|
|
63
|
+
const failedTests = (results.results || [])
|
|
64
|
+
.flatMap((r) => r.tests || [])
|
|
65
|
+
.filter((t) => t.status === "failed")
|
|
66
|
+
.slice(0, 5);
|
|
67
|
+
|
|
68
|
+
return `You are a QA lead reviewing test results.
|
|
69
|
+
|
|
70
|
+
## Summary
|
|
71
|
+
- Total: ${summary.totalTests}, Passed: ${summary.totalPassed}, Failed: ${summary.totalFailed}
|
|
72
|
+
- Pass Rate: ${summary.overallPassRate}%
|
|
73
|
+
|
|
74
|
+
## Failed Tests
|
|
75
|
+
${failedTests.map((t) => `- ${t.name}: ${t.error?.message || "unknown error"}`).join("\n")}
|
|
76
|
+
|
|
77
|
+
Provide 3-5 actionable recommendations to improve test quality. Return as JSON array:
|
|
78
|
+
[{"priority": "high|medium|low", "recommendation": "...", "rationale": "..."}]
|
|
79
|
+
|
|
80
|
+
Return ONLY valid JSON array.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function truncate(str, maxLen) {
|
|
84
|
+
if (str.length <= maxLen) return str;
|
|
85
|
+
return str.slice(0, maxLen) + "\n... (truncated)";
|
|
86
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class UseCaseParser {
|
|
5
|
+
async parse(filePath) {
|
|
6
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
7
|
+
const content = await readFile(filePath, "utf-8");
|
|
8
|
+
|
|
9
|
+
switch (ext) {
|
|
10
|
+
case ".md":
|
|
11
|
+
return this.parseMarkdown(content);
|
|
12
|
+
case ".feature":
|
|
13
|
+
return this.parseGherkin(content);
|
|
14
|
+
default:
|
|
15
|
+
return this.parsePlainText(content);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
parseMarkdown(content) {
|
|
20
|
+
const scenarios = [];
|
|
21
|
+
const sections = content.split(/^##\s+/m).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
for (const section of sections) {
|
|
24
|
+
const lines = section.split("\n");
|
|
25
|
+
const title = lines[0]?.trim();
|
|
26
|
+
if (!title) continue;
|
|
27
|
+
|
|
28
|
+
const titleIsStep =
|
|
29
|
+
/^\s*\d+\.\s+/.test(title) || /^\s*[-*]\s+/.test(title);
|
|
30
|
+
if (titleIsStep) continue;
|
|
31
|
+
|
|
32
|
+
const steps = lines
|
|
33
|
+
.slice(1)
|
|
34
|
+
.filter((l) => /^\s*\d+\.\s+/.test(l) || /^\s*[-*]\s+/.test(l))
|
|
35
|
+
.map((l) =>
|
|
36
|
+
l
|
|
37
|
+
.replace(/^\s*\d+\.\s+/, "")
|
|
38
|
+
.replace(/^\s*[-*]\s+/, "")
|
|
39
|
+
.trim(),
|
|
40
|
+
)
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
|
|
43
|
+
if (steps.length > 0) {
|
|
44
|
+
scenarios.push({ scenario: title, steps, source: "markdown" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (scenarios.length === 0) {
|
|
49
|
+
const allSteps = content
|
|
50
|
+
.split("\n")
|
|
51
|
+
.filter((l) => /^\s*\d+\.\s+/.test(l) || /^\s*[-*]\s+/.test(l))
|
|
52
|
+
.map((l) =>
|
|
53
|
+
l
|
|
54
|
+
.replace(/^\s*\d+\.\s+/, "")
|
|
55
|
+
.replace(/^\s*[-*]\s+/, "")
|
|
56
|
+
.trim(),
|
|
57
|
+
)
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
|
|
60
|
+
if (allSteps.length > 0) {
|
|
61
|
+
scenarios.push({
|
|
62
|
+
scenario: "Default Flow",
|
|
63
|
+
steps: allSteps,
|
|
64
|
+
source: "markdown",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return scenarios;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
parseGherkin(content) {
|
|
73
|
+
const scenarios = [];
|
|
74
|
+
const scenarioBlocks = content
|
|
75
|
+
.split(/^(?:Scenario|Scenario Outline):\s*/m)
|
|
76
|
+
.slice(1);
|
|
77
|
+
|
|
78
|
+
for (const block of scenarioBlocks) {
|
|
79
|
+
const lines = block.split("\n");
|
|
80
|
+
const title = lines[0]?.trim();
|
|
81
|
+
const steps = lines
|
|
82
|
+
.slice(1)
|
|
83
|
+
.filter((l) => /^\s*(Given|When|Then|And|But)\s+/i.test(l))
|
|
84
|
+
.map((l) => l.trim())
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
if (title && steps.length > 0) {
|
|
88
|
+
scenarios.push({ scenario: title, steps, source: "gherkin" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return scenarios;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
parsePlainText(content) {
|
|
96
|
+
const scenarios = [];
|
|
97
|
+
const blocks = content.split(/\n\s*\n/).filter(Boolean);
|
|
98
|
+
|
|
99
|
+
for (const block of blocks) {
|
|
100
|
+
const lines = block
|
|
101
|
+
.split("\n")
|
|
102
|
+
.map((l) => l.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
if (lines.length < 2) continue;
|
|
105
|
+
|
|
106
|
+
const hasNumbers = lines.some((l) => /^\d+[.)]\s+/.test(l));
|
|
107
|
+
if (hasNumbers) {
|
|
108
|
+
const steps = lines
|
|
109
|
+
.filter((l) => /^\d+[.)]\s+/.test(l))
|
|
110
|
+
.map((l) => l.replace(/^\d+[.)]\s+/, "").trim());
|
|
111
|
+
const title = lines.find((l) => !/^\d+[.)]\s+/.test(l)) || "Flow";
|
|
112
|
+
scenarios.push({ scenario: title, steps, source: "text" });
|
|
113
|
+
} else if (lines.length >= 2) {
|
|
114
|
+
scenarios.push({
|
|
115
|
+
scenario: lines[0],
|
|
116
|
+
steps: lines.slice(1),
|
|
117
|
+
source: "text",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return scenarios;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { safeReadFile, fileExists } from "../utils/file-utils.js";
|
|
4
|
+
|
|
5
|
+
export async function detectEnvironments(projectDir) {
|
|
6
|
+
const result = { environments: {}, envFiles: [], variables: {} };
|
|
7
|
+
|
|
8
|
+
result.envFiles = await findEnvFiles(projectDir);
|
|
9
|
+
|
|
10
|
+
for (const envFile of result.envFiles) {
|
|
11
|
+
const parsed = await parseEnvFile(path.join(projectDir, envFile));
|
|
12
|
+
const envName = envNameFromFile(envFile);
|
|
13
|
+
|
|
14
|
+
const urlVars = Object.entries(parsed).filter(
|
|
15
|
+
([k, v]) =>
|
|
16
|
+
(k.includes("URL") ||
|
|
17
|
+
k.includes("HOST") ||
|
|
18
|
+
k.includes("BASE") ||
|
|
19
|
+
k.includes("API")) &&
|
|
20
|
+
(v.startsWith("http://") || v.startsWith("https://")),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (urlVars.length > 0) {
|
|
24
|
+
const [, url] = urlVars[0];
|
|
25
|
+
result.environments[envName] = { url, source: envFile };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Object.assign(result.variables, parsed);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (Object.keys(result.environments).length === 0) {
|
|
32
|
+
const webpackEnvs = await detectWebpackEnvironments(projectDir);
|
|
33
|
+
Object.assign(result.environments, webpackEnvs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function findEnvFiles(dir) {
|
|
40
|
+
try {
|
|
41
|
+
const entries = await readdir(dir);
|
|
42
|
+
return entries
|
|
43
|
+
.filter((e) => e.startsWith(".env") && !e.includes("example"))
|
|
44
|
+
.sort();
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function parseEnvFile(filePath) {
|
|
51
|
+
const content = await safeReadFile(filePath);
|
|
52
|
+
if (!content) return {};
|
|
53
|
+
|
|
54
|
+
const vars = {};
|
|
55
|
+
for (const line of content.split("\n")) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
58
|
+
const eqIdx = trimmed.indexOf("=");
|
|
59
|
+
if (eqIdx === -1) continue;
|
|
60
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
61
|
+
const value = trimmed
|
|
62
|
+
.slice(eqIdx + 1)
|
|
63
|
+
.trim()
|
|
64
|
+
.replace(/^["']|["']$/g, "");
|
|
65
|
+
vars[key] = value;
|
|
66
|
+
}
|
|
67
|
+
return vars;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function envNameFromFile(filename) {
|
|
71
|
+
if (filename === ".env") return "default";
|
|
72
|
+
const suffix = filename.replace(/^\.env\.?/, "");
|
|
73
|
+
return suffix || "default";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function detectWebpackEnvironments(dir) {
|
|
77
|
+
const envs = {};
|
|
78
|
+
try {
|
|
79
|
+
const entries = await readdir(dir);
|
|
80
|
+
const webpackConfigs = entries.filter(
|
|
81
|
+
(e) =>
|
|
82
|
+
e.startsWith("webpack.") &&
|
|
83
|
+
e.endsWith(".js") &&
|
|
84
|
+
e !== "webpack.base.babel.js",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
for (const config of webpackConfigs) {
|
|
88
|
+
const envName = config
|
|
89
|
+
.replace("webpack.", "")
|
|
90
|
+
.replace(".babel.js", "")
|
|
91
|
+
.replace(".js", "");
|
|
92
|
+
if (envName === "base" || envName === "common") continue;
|
|
93
|
+
|
|
94
|
+
const content = await safeReadFile(path.join(dir, config));
|
|
95
|
+
if (!content) continue;
|
|
96
|
+
|
|
97
|
+
const urlMatch = content.match(
|
|
98
|
+
/(?:BASE_URL|API_URL|SITE_URL|PUBLIC_URL)\s*[:=]\s*["'`](https?:\/\/[^"'`]+)["'`]/,
|
|
99
|
+
);
|
|
100
|
+
if (urlMatch) {
|
|
101
|
+
envs[envName] = { url: urlMatch[1], source: config };
|
|
102
|
+
} else {
|
|
103
|
+
envs[envName] = { url: null, source: config };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
return envs;
|
|
110
|
+
}
|