@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.
@@ -0,0 +1,125 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { logger } from "../../core/logger.js";
4
+ import { loadConfig } from "../../core/config.js";
5
+ import { analyzeProject } from "../../analyzers/project-analyzer.js";
6
+ import { AIEngine } from "../../ai/ai-engine.js";
7
+ import { UseCaseParser } from "../../ai/usecase-parser.js";
8
+ import { findFiles, safeReadFile } from "../../utils/file-utils.js";
9
+
10
+ export function registerGenerateCommand(program) {
11
+ program
12
+ .command("generate [feature]")
13
+ .description("Generate test cases and code using AI")
14
+ .option("-l, --layer <layer>", "Target layer (unit, integration, e2e)")
15
+ .option("--use-cases <dir>", "Directory containing use case documents")
16
+ .option("--dry-run", "Show plan without writing files")
17
+ .option("--model <model>", "AI model to use")
18
+ .option("-d, --dir <path>", "Project directory", process.cwd())
19
+ .action(runGenerate);
20
+ }
21
+
22
+ async function runGenerate(feature, options) {
23
+ const projectDir = options.dir;
24
+ const { config, isEmpty } = await loadConfig(projectDir);
25
+
26
+ if (isEmpty) {
27
+ logger.warn("No qabot.config.js found. Run `qabot init` first.");
28
+ return;
29
+ }
30
+
31
+ const aiConfig = { ...config.ai };
32
+ if (options.model) aiConfig.model = options.model;
33
+
34
+ const ai = new AIEngine(aiConfig);
35
+ if (!ai.isAvailable()) {
36
+ logger.error("AI is not configured. Set your API key:");
37
+ logger.dim(` export ${aiConfig.apiKeyEnv || "OPENAI_API_KEY"}=your-key`);
38
+ logger.dim(" Or update qabot.config.js ai.provider setting.");
39
+ return;
40
+ }
41
+
42
+ const profile = await analyzeProject(projectDir);
43
+ logger.header("QABot \u2014 AI Test Generation");
44
+
45
+ const spinner = ora("Analyzing feature source code...").start();
46
+
47
+ const featureConfig = config.features?.[feature];
48
+ if (!featureConfig) {
49
+ spinner.fail(
50
+ `Feature "${feature}" not found in config. Run \`qabot list features\` to see available.`,
51
+ );
52
+ return;
53
+ }
54
+
55
+ const sourceFiles = await findFiles(
56
+ projectDir,
57
+ `${featureConfig.src}/**/*.{js,jsx,ts,tsx}`,
58
+ );
59
+ const sourceCode = (
60
+ await Promise.all(sourceFiles.slice(0, 10).map((f) => safeReadFile(f)))
61
+ )
62
+ .filter(Boolean)
63
+ .join("\n\n---\n\n");
64
+
65
+ let useCases = [];
66
+ const useCaseDir = options.useCases || config.useCases?.dir;
67
+ if (useCaseDir) {
68
+ const parser = new UseCaseParser();
69
+ const ucFiles = await findFiles(
70
+ projectDir,
71
+ `${useCaseDir}/**/*.{md,feature,txt}`,
72
+ );
73
+ for (const f of ucFiles) {
74
+ const parsed = await parser.parse(f);
75
+ useCases.push(...parsed);
76
+ }
77
+ }
78
+
79
+ spinner.text = "AI is analyzing code and generating test plan...";
80
+
81
+ try {
82
+ const testPlan = await ai.analyzeCode(sourceCode, {
83
+ framework: profile.techStack.framework,
84
+ runner: config.layers?.[options.layer || "unit"]?.runner || "jest",
85
+ existingTestCount: 0,
86
+ useCases,
87
+ });
88
+
89
+ spinner.succeed("Test plan generated");
90
+ logger.blank();
91
+
92
+ if (Array.isArray(testPlan)) {
93
+ logger.info(
94
+ `Generated ${chalk.bold(testPlan.length)} test cases for "${feature}":`,
95
+ );
96
+ logger.blank();
97
+ for (const tc of testPlan) {
98
+ const icon =
99
+ tc.priority === "P0"
100
+ ? chalk.red("\u25cf")
101
+ : tc.priority === "P1"
102
+ ? chalk.yellow("\u25cf")
103
+ : chalk.blue("\u25cf");
104
+ console.log(` ${icon} ${chalk.bold(tc.name)}`);
105
+ logger.dim(` ${tc.description}`);
106
+ logger.dim(` Type: ${tc.type} | Priority: ${tc.priority}`);
107
+ }
108
+ }
109
+
110
+ if (options.dryRun) {
111
+ logger.blank();
112
+ logger.info("Dry run complete. No files written.");
113
+ return;
114
+ }
115
+
116
+ logger.blank();
117
+ logger.info("Test code generation coming in next version.");
118
+ logger.dim(
119
+ "For now, use the test plan above as a guide to write tests manually.",
120
+ );
121
+ } catch (err) {
122
+ spinner.fail("AI analysis failed");
123
+ logger.error(err.message);
124
+ }
125
+ }
@@ -0,0 +1,137 @@
1
+ import ora from "ora";
2
+ import Enquirer from "enquirer";
3
+ import { logger } from "../../core/logger.js";
4
+ import { writeConfig } from "../../core/config.js";
5
+ import { analyzeProject } from "../../analyzers/project-analyzer.js";
6
+ import { detectTests } from "../../analyzers/test-detector.js";
7
+ import { detectFeatures } from "../../analyzers/feature-detector.js";
8
+ import { detectEnvironments } from "../../analyzers/env-detector.js";
9
+ import { DEFAULT_CONFIG } from "../../core/constants.js";
10
+
11
+ export function registerInitCommand(program) {
12
+ program
13
+ .command("init")
14
+ .description("Initialize QABot for current project")
15
+ .option("-y, --yes", "Accept all defaults without prompting")
16
+ .option("-d, --dir <path>", "Project directory", process.cwd())
17
+ .action(runInit);
18
+ }
19
+
20
+ async function runInit(options) {
21
+ const projectDir = options.dir;
22
+ const spinner = ora("Analyzing project...").start();
23
+
24
+ try {
25
+ const profile = await analyzeProject(projectDir);
26
+ const testInfo = await detectTests(projectDir, profile);
27
+ const features = await detectFeatures(projectDir, profile);
28
+ const envInfo = await detectEnvironments(projectDir);
29
+ spinner.succeed("Project analysis complete");
30
+
31
+ logger.blank();
32
+ logger.box("QABot - Project Analysis", [
33
+ `Project: ${profile.name}`,
34
+ `Type: ${profile.type}`,
35
+ `Language: ${profile.techStack.language}`,
36
+ `Framework: ${profile.techStack.framework || "none"}`,
37
+ `Bundler: ${profile.techStack.bundler || "none"}`,
38
+ `State: ${profile.techStack.stateManagement || "none"}`,
39
+ `Auth: ${profile.techStack.authProvider || "none"}`,
40
+ `Pkg Mgr: ${profile.packageManager}`,
41
+ ``,
42
+ `Test Frameworks: ${testInfo.frameworks.map((f) => f.name).join(", ") || "none"}`,
43
+ `Unit Tests: ${testInfo.testFiles.unit.count} files`,
44
+ `E2E Tests: ${testInfo.testFiles.e2e.count} files`,
45
+ `Pages: ${features.pages.length}`,
46
+ `Features: ${features.features.length}`,
47
+ `Environments: ${Object.keys(envInfo.environments).length}`,
48
+ ]);
49
+
50
+ const configData = buildConfigFromAnalysis(
51
+ profile,
52
+ testInfo,
53
+ features,
54
+ envInfo,
55
+ );
56
+
57
+ if (!options.yes) {
58
+ const enquirer = new Enquirer();
59
+ const { proceed } = await enquirer.prompt({
60
+ type: "confirm",
61
+ name: "proceed",
62
+ message: "Generate qabot.config.js with these settings?",
63
+ initial: true,
64
+ });
65
+ if (!proceed) {
66
+ logger.warn("Cancelled. Run `qabot init` again to retry.");
67
+ return;
68
+ }
69
+ }
70
+
71
+ const configPath = await writeConfig(projectDir, configData);
72
+ logger.blank();
73
+ logger.success(`Config written to ${configPath}`);
74
+ logger.blank();
75
+ logger.info("Next steps:");
76
+ logger.dim(" qabot list - View detected features and test info");
77
+ logger.dim(" qabot run - Run all tests");
78
+ logger.dim(" qabot run auth - Run tests for a specific feature");
79
+ logger.dim(" qabot generate auth - AI-generate tests for a feature");
80
+ logger.blank();
81
+ } catch (err) {
82
+ spinner.fail("Analysis failed");
83
+ logger.error(err.message);
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ function buildConfigFromAnalysis(profile, testInfo, features, envInfo) {
89
+ const config = {
90
+ project: { name: profile.name, type: profile.type },
91
+ environments: {},
92
+ layers: {},
93
+ features: {},
94
+ ...DEFAULT_CONFIG,
95
+ };
96
+
97
+ for (const [name, env] of Object.entries(envInfo.environments)) {
98
+ config.environments[name] = { url: env.url || "" };
99
+ }
100
+
101
+ for (const fw of testInfo.frameworks) {
102
+ if (fw.name === "playwright" || fw.name === "cypress") {
103
+ config.layers.e2e = {
104
+ runner: fw.name,
105
+ command: `npx ${fw.name} test {pattern}`,
106
+ testDir: "e2e/tests",
107
+ };
108
+ } else {
109
+ config.layers.unit = {
110
+ runner: fw.name,
111
+ command: testInfo.scripts.test || `npx ${fw.name} {pattern}`,
112
+ testMatch: testInfo.testFiles.unit.pattern || "**/*.test.*",
113
+ };
114
+ }
115
+ }
116
+
117
+ for (const f of features.features) {
118
+ config.features[f.name] = { src: f.path, priority: "P1" };
119
+ }
120
+ for (const p of features.pages) {
121
+ const name = p.name
122
+ .replace(/^Page/, "")
123
+ .replace(/([A-Z])/g, "-$1")
124
+ .toLowerCase()
125
+ .replace(/^-/, "");
126
+ config.features[name] = { src: p.path, priority: "P1" };
127
+ }
128
+
129
+ if (profile.techStack.authProvider) {
130
+ config.auth = {
131
+ provider: profile.techStack.authProvider,
132
+ credentials: { email: "E2E_TEST_EMAIL", password: "E2E_TEST_PASSWORD" },
133
+ };
134
+ }
135
+
136
+ return config;
137
+ }
@@ -0,0 +1,130 @@
1
+ import { logger } from "../../core/logger.js";
2
+ import { loadConfig } from "../../core/config.js";
3
+ import { analyzeProject } from "../../analyzers/project-analyzer.js";
4
+ import { detectTests } from "../../analyzers/test-detector.js";
5
+ import { detectFeatures } from "../../analyzers/feature-detector.js";
6
+ import { detectEnvironments } from "../../analyzers/env-detector.js";
7
+
8
+ export function registerListCommand(program) {
9
+ program
10
+ .command("list [type]")
11
+ .description(
12
+ "List detected project info (features, envs, layers, tests, all)",
13
+ )
14
+ .option("-d, --dir <path>", "Project directory", process.cwd())
15
+ .action(runList);
16
+ }
17
+
18
+ async function runList(type = "all", options) {
19
+ const projectDir = options.dir;
20
+ const { config, isEmpty } = await loadConfig(projectDir);
21
+ const profile = await analyzeProject(projectDir);
22
+ const testInfo = await detectTests(projectDir, profile);
23
+ const features = await detectFeatures(projectDir, profile);
24
+ const envInfo = await detectEnvironments(projectDir);
25
+
26
+ const sections = {
27
+ features: () => listFeatures(config, features),
28
+ envs: () => listEnvironments(config, envInfo),
29
+ layers: () => listLayers(config, testInfo),
30
+ tests: () => listTests(testInfo),
31
+ all: () => {
32
+ listFeatures(config, features);
33
+ listEnvironments(config, envInfo);
34
+ listLayers(config, testInfo);
35
+ listTests(testInfo);
36
+ },
37
+ };
38
+
39
+ if (!sections[type]) {
40
+ logger.error(
41
+ `Unknown list type: ${type}. Use: features, envs, layers, tests, all`,
42
+ );
43
+ return;
44
+ }
45
+
46
+ logger.header(`QABot - ${type === "all" ? "Project Overview" : type}`);
47
+ sections[type]();
48
+ logger.blank();
49
+ }
50
+
51
+ function listFeatures(config, features) {
52
+ logger.blank();
53
+ logger.info("Features & Pages");
54
+ const rows = [];
55
+ if (config.features) {
56
+ for (const [name, f] of Object.entries(config.features)) {
57
+ rows.push([name, f.src || "-", f.priority || "P1"]);
58
+ }
59
+ } else {
60
+ for (const f of features.features) rows.push([f.name, f.path, "P1"]);
61
+ for (const p of features.pages) rows.push([p.name, p.path, "P1"]);
62
+ }
63
+ if (rows.length === 0) {
64
+ logger.dim("No features detected");
65
+ return;
66
+ }
67
+ logger.table(["Name", "Source Path", "Priority"], rows);
68
+ }
69
+
70
+ function listEnvironments(config, envInfo) {
71
+ logger.blank();
72
+ logger.info("Environments");
73
+ const envs = config.environments || envInfo.environments;
74
+ const rows = Object.entries(envs).map(([name, e]) => [
75
+ name,
76
+ e.url || "-",
77
+ e.vpn ? "Yes" : "No",
78
+ ]);
79
+ if (rows.length === 0) {
80
+ logger.dim("No environments detected");
81
+ return;
82
+ }
83
+ logger.table(["Name", "URL", "VPN"], rows);
84
+ }
85
+
86
+ function listLayers(config, testInfo) {
87
+ logger.blank();
88
+ logger.info("Test Layers");
89
+ const layers = config.layers || {};
90
+ const rows = Object.entries(layers).map(([name, l]) => [
91
+ name,
92
+ l.runner || "-",
93
+ l.command || "-",
94
+ ]);
95
+ if (rows.length === 0) {
96
+ const fwRows = testInfo.frameworks.map((f) => [
97
+ f.name,
98
+ f.version,
99
+ f.configFile || "-",
100
+ ]);
101
+ if (fwRows.length > 0)
102
+ logger.table(["Framework", "Version", "Config"], fwRows);
103
+ else logger.dim("No test layers configured");
104
+ return;
105
+ }
106
+ logger.table(["Layer", "Runner", "Command"], rows);
107
+ }
108
+
109
+ function listTests(testInfo) {
110
+ logger.blank();
111
+ logger.info("Existing Tests");
112
+ const rows = [
113
+ [
114
+ "Unit",
115
+ String(testInfo.testFiles.unit.count),
116
+ testInfo.testFiles.unit.pattern || "-",
117
+ ],
118
+ [
119
+ "Integration",
120
+ String(testInfo.testFiles.integration.count),
121
+ testInfo.testFiles.integration.pattern || "-",
122
+ ],
123
+ [
124
+ "E2E",
125
+ String(testInfo.testFiles.e2e.count),
126
+ testInfo.testFiles.e2e.pattern || "-",
127
+ ],
128
+ ];
129
+ logger.table(["Layer", "Files", "Pattern"], rows);
130
+ }
@@ -0,0 +1,46 @@
1
+ import { logger } from "../../core/logger.js";
2
+ import { loadConfig } from "../../core/config.js";
3
+ import { ReportGenerator } from "../../reporter/report-generator.js";
4
+
5
+ export function registerReportCommand(program) {
6
+ program
7
+ .command("report")
8
+ .description("Open the latest test report or list all reports")
9
+ .option("--list", "List all available reports")
10
+ .option("--open <path>", "Open a specific report")
11
+ .option("-d, --dir <path>", "Project directory", process.cwd())
12
+ .action(runReport);
13
+ }
14
+
15
+ async function runReport(options) {
16
+ const { config } = await loadConfig(options.dir);
17
+ const reporter = new ReportGenerator(config);
18
+
19
+ if (options.list) {
20
+ const reports = await reporter.listReports();
21
+ if (reports.length === 0) {
22
+ logger.info("No reports found. Run `qabot run` first.");
23
+ return;
24
+ }
25
+ logger.header("QABot - Reports");
26
+ logger.table(
27
+ ["Date", "Feature", "Pass Rate", "Path"],
28
+ reports.map((r) => [r.date, r.feature, `${r.passRate}%`, r.path]),
29
+ );
30
+ logger.blank();
31
+ return;
32
+ }
33
+
34
+ if (options.open) {
35
+ const { openInBrowser } = await import("../../reporter/report-server.js");
36
+ await openInBrowser(options.open);
37
+ return;
38
+ }
39
+
40
+ try {
41
+ await reporter.openLatest();
42
+ } catch (err) {
43
+ logger.error(`Could not open report: ${err.message}`);
44
+ logger.dim("Run `qabot run` first to generate a report.");
45
+ }
46
+ }
@@ -0,0 +1,132 @@
1
+ import chalk from "chalk";
2
+ import { logger, formatMs } from "../../core/logger.js";
3
+ import { loadConfig } from "../../core/config.js";
4
+ import { analyzeProject } from "../../analyzers/project-analyzer.js";
5
+ import { TestExecutor } from "../../executor/test-executor.js";
6
+ import { ReportGenerator } from "../../reporter/report-generator.js";
7
+
8
+ export function registerRunCommand(program) {
9
+ program
10
+ .command("run [feature]")
11
+ .description("Run tests for a feature or all features")
12
+ .option(
13
+ "-l, --layer <layers...>",
14
+ "Test layers to run (unit, integration, e2e)",
15
+ )
16
+ .option(
17
+ "-e, --env <environment>",
18
+ "Target environment for E2E tests",
19
+ "local",
20
+ )
21
+ .option("--coverage", "Generate coverage report")
22
+ .option("--verbose", "Show detailed output")
23
+ .option("--timeout <ms>", "Timeout per test suite in ms", "120000")
24
+ .option("--no-report", "Skip report generation")
25
+ .option("-d, --dir <path>", "Project directory", process.cwd())
26
+ .action(runTests);
27
+ }
28
+
29
+ async function runTests(feature, options) {
30
+ const projectDir = options.dir;
31
+ const { config, isEmpty } = await loadConfig(projectDir);
32
+
33
+ if (isEmpty) {
34
+ logger.warn("No qabot.config.js found. Run `qabot init` first.");
35
+ logger.dim("Or run tests with: qabot init -y && qabot run");
36
+ return;
37
+ }
38
+
39
+ const profile = await analyzeProject(projectDir);
40
+
41
+ const executor = new TestExecutor(config, profile);
42
+ executor.onProgress(createProgressHandler(options.verbose));
43
+
44
+ logger.header("QABot \u2014 Running Tests");
45
+ logger.blank();
46
+ logger.info(`Feature: ${chalk.bold(feature || "all")}`);
47
+ logger.info(`Environment: ${chalk.bold(options.env)}`);
48
+ logger.info(
49
+ `Layers: ${chalk.bold((options.layer || Object.keys(config.layers || {})).join(", "))}`,
50
+ );
51
+ logger.blank();
52
+ console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
53
+ logger.blank();
54
+
55
+ const startTime = Date.now();
56
+
57
+ const collector = await executor.execute({
58
+ feature: feature || "all",
59
+ layers: options.layer,
60
+ env: options.env,
61
+ coverage: options.coverage,
62
+ verbose: options.verbose,
63
+ timeout: parseInt(options.timeout),
64
+ });
65
+
66
+ const totalDuration = Date.now() - startTime;
67
+ const summary = collector.getSummary();
68
+
69
+ logger.blank();
70
+ console.log(chalk.dim(` ${"\u2500".repeat(50)}`));
71
+ logger.blank();
72
+
73
+ const passColor = summary.totalFailed === 0 ? chalk.green : chalk.red;
74
+ logger.info(
75
+ `Summary: ${chalk.green(`${summary.totalPassed} passed`)}, ${summary.totalFailed > 0 ? chalk.red(`${summary.totalFailed} failed`) : `${summary.totalFailed} failed`}, ${chalk.yellow(`${summary.totalSkipped} skipped`)} ${chalk.dim(`(${formatMs(totalDuration)})`)}`,
76
+ );
77
+
78
+ if (options.report !== false) {
79
+ try {
80
+ const reporter = new ReportGenerator(config);
81
+ const reportPaths = await reporter.generate(collector.toJSON(), {
82
+ feature: feature || "all",
83
+ environment: options.env,
84
+ projectName: profile.name,
85
+ timestamp: new Date().toISOString(),
86
+ duration: totalDuration,
87
+ });
88
+ logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
89
+ } catch (err) {
90
+ logger.warn(`Report generation failed: ${err.message}`);
91
+ }
92
+ }
93
+
94
+ logger.blank();
95
+ if (summary.totalFailed > 0) process.exit(1);
96
+ }
97
+
98
+ function createProgressHandler(verbose) {
99
+ return (event) => {
100
+ switch (event.type) {
101
+ case "layer-start":
102
+ logger.blank();
103
+ logger.info(`${chalk.bold(event.runner)} (${event.layer})`);
104
+ break;
105
+ case "layer-skip":
106
+ logger.dim(` Skipping ${event.layer}: ${event.reason}`);
107
+ break;
108
+ case "test-pass":
109
+ logger.testResult(event.test.name, "passed", event.test.duration);
110
+ break;
111
+ case "test-fail":
112
+ logger.testResult(event.test.name, "failed", event.test.duration);
113
+ if (event.test.error?.message) {
114
+ logger.dim(
115
+ ` ${chalk.red(event.test.error.message.split("\n")[0])}`,
116
+ );
117
+ }
118
+ break;
119
+ case "test-skip":
120
+ if (verbose) logger.testResult(event.test.name, "skipped", 0);
121
+ break;
122
+ case "layer-end":
123
+ logger.dim(
124
+ ` ${event.summary.passed} passed, ${event.summary.failed} failed (${formatMs(event.duration)})`,
125
+ );
126
+ break;
127
+ case "stdout":
128
+ if (verbose) console.log(chalk.dim(` ${event.line}`));
129
+ break;
130
+ }
131
+ };
132
+ }
@@ -0,0 +1,71 @@
1
+ import { cosmiconfig } from "cosmiconfig";
2
+ import path from "node:path";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { DEFAULT_CONFIG, TOOL_NAME } from "./constants.js";
5
+
6
+ const explorer = cosmiconfig(TOOL_NAME, {
7
+ searchPlaces: [
8
+ "qabot.config.js",
9
+ "qabot.config.mjs",
10
+ "qabot.config.cjs",
11
+ ".qabotrc.json",
12
+ ".qabotrc.js",
13
+ "package.json",
14
+ ],
15
+ });
16
+
17
+ export async function loadConfig(projectDir) {
18
+ try {
19
+ const result = await explorer.search(projectDir || process.cwd());
20
+ if (!result || result.isEmpty) {
21
+ return { config: { ...DEFAULT_CONFIG }, configPath: null, isEmpty: true };
22
+ }
23
+ const merged = deepMerge(DEFAULT_CONFIG, result.config);
24
+ return { config: merged, configPath: result.filepath, isEmpty: false };
25
+ } catch (err) {
26
+ return {
27
+ config: { ...DEFAULT_CONFIG },
28
+ configPath: null,
29
+ isEmpty: true,
30
+ error: err.message,
31
+ };
32
+ }
33
+ }
34
+
35
+ export async function writeConfig(projectDir, configData) {
36
+ const configPath = path.join(projectDir, "qabot.config.js");
37
+ const lines = [
38
+ "export default " + JSON.stringify(configData, null, 2) + ";\n",
39
+ ];
40
+ await writeFile(configPath, lines.join(""), "utf-8");
41
+ return configPath;
42
+ }
43
+
44
+ export function validateConfig(config) {
45
+ const errors = [];
46
+ if (!config.project?.name) errors.push("project.name is required");
47
+ if (!config.project?.type) errors.push("project.type is required");
48
+ if (config.layers) {
49
+ for (const [layer, lc] of Object.entries(config.layers)) {
50
+ if (!lc.runner) errors.push(`layers.${layer}.runner is required`);
51
+ if (!lc.command) errors.push(`layers.${layer}.command is required`);
52
+ }
53
+ }
54
+ return { valid: errors.length === 0, errors };
55
+ }
56
+
57
+ function deepMerge(target, source) {
58
+ const result = { ...target };
59
+ for (const key of Object.keys(source)) {
60
+ if (
61
+ source[key] &&
62
+ typeof source[key] === "object" &&
63
+ !Array.isArray(source[key])
64
+ ) {
65
+ result[key] = deepMerge(result[key] || {}, source[key]);
66
+ } else {
67
+ result[key] = source[key];
68
+ }
69
+ }
70
+ return result;
71
+ }