crosspad-mcp-server 4.0.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.
Files changed (80) hide show
  1. package/README.md +187 -0
  2. package/dist/config.d.ts +10 -0
  3. package/dist/config.js +33 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +360 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/tools/architecture.d.ts +16 -0
  9. package/dist/tools/architecture.js +198 -0
  10. package/dist/tools/architecture.js.map +1 -0
  11. package/dist/tools/build-check.d.ts +23 -0
  12. package/dist/tools/build-check.js +162 -0
  13. package/dist/tools/build-check.js.map +1 -0
  14. package/dist/tools/build.d.ts +14 -0
  15. package/dist/tools/build.js +101 -0
  16. package/dist/tools/build.js.map +1 -0
  17. package/dist/tools/diff-core.d.ts +24 -0
  18. package/dist/tools/diff-core.js +88 -0
  19. package/dist/tools/diff-core.js.map +1 -0
  20. package/dist/tools/idf-build.d.ts +10 -0
  21. package/dist/tools/idf-build.js +155 -0
  22. package/dist/tools/idf-build.js.map +1 -0
  23. package/dist/tools/input.d.ts +36 -0
  24. package/dist/tools/input.js +61 -0
  25. package/dist/tools/input.js.map +1 -0
  26. package/dist/tools/log.d.ts +16 -0
  27. package/dist/tools/log.js +49 -0
  28. package/dist/tools/log.js.map +1 -0
  29. package/dist/tools/repos.d.ts +12 -0
  30. package/dist/tools/repos.js +63 -0
  31. package/dist/tools/repos.js.map +1 -0
  32. package/dist/tools/scaffold.d.ts +15 -0
  33. package/dist/tools/scaffold.js +192 -0
  34. package/dist/tools/scaffold.js.map +1 -0
  35. package/dist/tools/screenshot.d.ts +24 -0
  36. package/dist/tools/screenshot.js +80 -0
  37. package/dist/tools/screenshot.js.map +1 -0
  38. package/dist/tools/settings.d.ts +25 -0
  39. package/dist/tools/settings.js +48 -0
  40. package/dist/tools/settings.js.map +1 -0
  41. package/dist/tools/stats.d.ts +18 -0
  42. package/dist/tools/stats.js +31 -0
  43. package/dist/tools/stats.js.map +1 -0
  44. package/dist/tools/symbols.d.ts +20 -0
  45. package/dist/tools/symbols.js +157 -0
  46. package/dist/tools/symbols.js.map +1 -0
  47. package/dist/tools/test.d.ts +24 -0
  48. package/dist/tools/test.js +227 -0
  49. package/dist/tools/test.js.map +1 -0
  50. package/dist/utils/exec.d.ts +58 -0
  51. package/dist/utils/exec.js +292 -0
  52. package/dist/utils/exec.js.map +1 -0
  53. package/dist/utils/git.d.ts +10 -0
  54. package/dist/utils/git.js +29 -0
  55. package/dist/utils/git.js.map +1 -0
  56. package/dist/utils/remote-client.d.ts +17 -0
  57. package/dist/utils/remote-client.js +94 -0
  58. package/dist/utils/remote-client.js.map +1 -0
  59. package/package.json +21 -0
  60. package/server.json +23 -0
  61. package/src/config.ts +45 -0
  62. package/src/index.ts +484 -0
  63. package/src/tools/architecture.ts +260 -0
  64. package/src/tools/build-check.ts +178 -0
  65. package/src/tools/build.ts +130 -0
  66. package/src/tools/diff-core.ts +130 -0
  67. package/src/tools/idf-build.ts +182 -0
  68. package/src/tools/input.ts +80 -0
  69. package/src/tools/log.ts +75 -0
  70. package/src/tools/repos.ts +75 -0
  71. package/src/tools/scaffold.ts +229 -0
  72. package/src/tools/screenshot.ts +100 -0
  73. package/src/tools/settings.ts +68 -0
  74. package/src/tools/stats.ts +38 -0
  75. package/src/tools/symbols.ts +185 -0
  76. package/src/tools/test.ts +264 -0
  77. package/src/utils/exec.ts +376 -0
  78. package/src/utils/git.ts +45 -0
  79. package/src/utils/remote-client.ts +107 -0
  80. package/tsconfig.json +16 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * MCP tool: capture a screenshot from the running CrossPad simulator.
3
+ * The simulator encodes PNG natively via stb_image_write.
4
+ *
5
+ * When saving to file, the simulator writes the PNG directly to disk
6
+ * (no base64 round-trip over TCP). Otherwise returns inline base64.
7
+ */
8
+
9
+ import { sendRemoteCommand, isSimulatorRunning } from "../utils/remote-client.js";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { CROSSPAD_PC_ROOT } from "../config.js";
13
+
14
+ export interface ScreenshotResult {
15
+ success: boolean;
16
+ width?: number;
17
+ height?: number;
18
+ format?: string;
19
+ file_path?: string;
20
+ data_base64?: string;
21
+ size?: number;
22
+ error?: string;
23
+ }
24
+
25
+ /**
26
+ * Take a screenshot of the simulator window.
27
+ * @param save_to_file If true, simulator writes PNG directly to disk (fast path).
28
+ * @param filename Custom filename (default: screenshot_<timestamp>.png)
29
+ * @param region "full" for entire window (490x680), "lcd" for LCD only (320x240)
30
+ */
31
+ export async function crosspadScreenshot(
32
+ saveToFile: boolean = true,
33
+ filename?: string,
34
+ region: "full" | "lcd" = "full"
35
+ ): Promise<ScreenshotResult> {
36
+ const running = await isSimulatorRunning();
37
+ if (!running) {
38
+ return {
39
+ success: false,
40
+ error: "Simulator is not running. Use crosspad_run to start it.",
41
+ };
42
+ }
43
+
44
+ try {
45
+ if (saveToFile) {
46
+ // Fast path: simulator writes PNG directly to disk
47
+ const fname = filename || `screenshot_${Date.now()}.png`;
48
+ const screenshotsDir = path.join(CROSSPAD_PC_ROOT, "screenshots");
49
+ if (!fs.existsSync(screenshotsDir)) {
50
+ fs.mkdirSync(screenshotsDir, { recursive: true });
51
+ }
52
+ const filePath = path.join(screenshotsDir, fname).replace(/\\/g, "/");
53
+
54
+ const cmd: Record<string, unknown> = { cmd: "screenshot", file: filePath };
55
+ if (region === "lcd") cmd.region = "lcd";
56
+ const resp = await sendRemoteCommand(cmd);
57
+
58
+ if (!resp.ok) {
59
+ return {
60
+ success: false,
61
+ error: (resp.error as string) || "Screenshot failed",
62
+ };
63
+ }
64
+
65
+ return {
66
+ success: true,
67
+ width: resp.width as number,
68
+ height: resp.height as number,
69
+ format: "png",
70
+ file_path: filePath,
71
+ size: resp.size as number,
72
+ };
73
+ }
74
+
75
+ // Inline path: returns base64-encoded PNG
76
+ const inlineCmd: Record<string, unknown> = { cmd: "screenshot" };
77
+ if (region === "lcd") inlineCmd.region = "lcd";
78
+ const resp = await sendRemoteCommand(inlineCmd);
79
+
80
+ if (!resp.ok) {
81
+ return {
82
+ success: false,
83
+ error: (resp.error as string) || "Screenshot failed",
84
+ };
85
+ }
86
+
87
+ return {
88
+ success: true,
89
+ width: resp.width as number,
90
+ height: resp.height as number,
91
+ format: "png",
92
+ data_base64: resp.data as string,
93
+ };
94
+ } catch (err: any) {
95
+ return {
96
+ success: false,
97
+ error: err.message,
98
+ };
99
+ }
100
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * MCP tool: read/write CrossPad settings via the running simulator.
3
+ */
4
+
5
+ import { sendRemoteCommand, isSimulatorRunning } from "../utils/remote-client.js";
6
+
7
+ export interface SettingsGetResult {
8
+ success: boolean;
9
+ settings?: Record<string, unknown>;
10
+ error?: string;
11
+ }
12
+
13
+ export interface SettingsSetResult {
14
+ success: boolean;
15
+ key?: string;
16
+ value?: number;
17
+ error?: string;
18
+ }
19
+
20
+ /**
21
+ * Read settings from the running simulator.
22
+ * @param category "all", "display", "keypad", "vibration", "wireless", "audio", "system"
23
+ */
24
+ export async function crosspadSettingsGet(
25
+ category: string = "all"
26
+ ): Promise<SettingsGetResult> {
27
+ const running = await isSimulatorRunning();
28
+ if (!running) {
29
+ return { success: false, error: "Simulator is not running. Use crosspad_run to start it." };
30
+ }
31
+
32
+ try {
33
+ const resp = await sendRemoteCommand({ cmd: "settings_get", category });
34
+ if (!resp.ok) {
35
+ return { success: false, error: (resp.error as string) || "settings_get failed" };
36
+ }
37
+ // Remove 'ok' field, pass the rest as settings
38
+ const { ok, ...settings } = resp;
39
+ return { success: true, settings };
40
+ } catch (err: any) {
41
+ return { success: false, error: err.message };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Write a single setting on the running simulator.
47
+ * @param key Dotted key name (e.g. "lcd_brightness", "keypad.eco_mode", "vibration.enable")
48
+ * @param value Numeric value (booleans: 0=false, 1=true)
49
+ */
50
+ export async function crosspadSettingsSet(
51
+ key: string,
52
+ value: number
53
+ ): Promise<SettingsSetResult> {
54
+ const running = await isSimulatorRunning();
55
+ if (!running) {
56
+ return { success: false, error: "Simulator is not running. Use crosspad_run to start it." };
57
+ }
58
+
59
+ try {
60
+ const resp = await sendRemoteCommand({ cmd: "settings_set", key, value });
61
+ if (!resp.ok) {
62
+ return { success: false, error: (resp.error as string) || "settings_set failed" };
63
+ }
64
+ return { success: true, key: resp.key as string, value: resp.value as number };
65
+ } catch (err: any) {
66
+ return { success: false, error: err.message };
67
+ }
68
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * MCP tool: read runtime statistics from the running CrossPad simulator.
3
+ */
4
+
5
+ import { sendRemoteCommand, isSimulatorRunning } from "../utils/remote-client.js";
6
+
7
+ export interface StatsResult {
8
+ success: boolean;
9
+ stats?: Record<string, unknown>;
10
+ error?: string;
11
+ }
12
+
13
+ /**
14
+ * Query runtime statistics from the simulator:
15
+ * - Platform capabilities (active flags)
16
+ * - Pad state (16 pads: pressed, playing, note, channel, RGB color)
17
+ * - Active pad logic handler + registered handlers
18
+ * - Registered apps
19
+ * - Heap stats (SRAM/PSRAM)
20
+ * - Settings summary (brightness, theme, kit, audio engine)
21
+ */
22
+ export async function crosspadStats(): Promise<StatsResult> {
23
+ const running = await isSimulatorRunning();
24
+ if (!running) {
25
+ return { success: false, error: "Simulator is not running. Use crosspad_run to start it." };
26
+ }
27
+
28
+ try {
29
+ const resp = await sendRemoteCommand({ cmd: "stats" });
30
+ if (!resp.ok) {
31
+ return { success: false, error: (resp.error as string) || "stats failed" };
32
+ }
33
+ const { ok, ...stats } = resp;
34
+ return { success: true, stats };
35
+ } catch (err: any) {
36
+ return { success: false, error: err.message };
37
+ }
38
+ }
@@ -0,0 +1,185 @@
1
+ import fs from "fs";
2
+ import { REPOS } from "../config.js";
3
+ import { runCommand } from "../utils/exec.js";
4
+
5
+ export interface SymbolResult {
6
+ symbol: string;
7
+ kind: "class" | "function" | "macro" | "enum" | "typedef";
8
+ file: string;
9
+ line: number;
10
+ context: string;
11
+ repo: string;
12
+ }
13
+
14
+ export interface SymbolSearchResult {
15
+ query: string;
16
+ kind_filter: string;
17
+ results: SymbolResult[];
18
+ total_matches: number;
19
+ truncated: boolean;
20
+ }
21
+
22
+ /**
23
+ * Build a regex pattern that matches definition lines containing the query.
24
+ * Each kind has a specific pattern that only matches declarations/definitions.
25
+ */
26
+ function buildPattern(query: string, kind: string): string {
27
+ const q = query; // Already escaped by caller
28
+ const patterns: string[] = [];
29
+
30
+ if (kind === "class" || kind === "all") {
31
+ // class/struct definition: class Foo { or class Foo : public Bar {
32
+ patterns.push(`(class|struct)\\s+\\w*${q}\\w*\\s*[:{]`);
33
+ patterns.push(`(class|struct)\\s+\\w*${q}\\w*\\s*$`); // multi-line def
34
+ }
35
+ if (kind === "macro" || kind === "all") {
36
+ patterns.push(`#define\\s+\\w*${q}\\w*`);
37
+ }
38
+ if (kind === "enum" || kind === "all") {
39
+ patterns.push(`enum\\s+(class\\s+)?\\w*${q}\\w*`);
40
+ }
41
+ if (kind === "typedef" || kind === "all") {
42
+ patterns.push(`using\\s+\\w*${q}\\w*\\s*=`);
43
+ patterns.push(`typedef\\s+.*\\b\\w*${q}\\w*\\s*;`);
44
+ }
45
+ if (kind === "function" || kind === "all") {
46
+ // Function definition: type name( or void name( — exclude calls by requiring return type or line start
47
+ patterns.push(`^\\w[\\w:\\s*&<>]+\\b\\w*${q}\\w*\\s*\\(`);
48
+ }
49
+
50
+ return patterns.join("|");
51
+ }
52
+
53
+ /**
54
+ * Search for symbol definitions (classes, functions, macros, enums) across CrossPad repos.
55
+ * Only matches definition lines, not usages.
56
+ */
57
+ export function crosspadSearchSymbols(
58
+ query: string,
59
+ kind: string = "all",
60
+ repos: string[] = ["all"],
61
+ maxResults: number = 50
62
+ ): SymbolSearchResult {
63
+ const results: SymbolResult[] = [];
64
+
65
+ const targetRepos = repos.includes("all")
66
+ ? Object.entries(REPOS)
67
+ : Object.entries(REPOS).filter(([name]) => repos.includes(name));
68
+
69
+ const pattern = buildPattern(escapeForRegex(query), kind);
70
+ if (!pattern) {
71
+ return { query, kind_filter: kind, results: [], total_matches: 0, truncated: false };
72
+ }
73
+
74
+ for (const [repoName, repoPath] of targetRepos) {
75
+ if (!fs.existsSync(repoPath)) continue;
76
+
77
+ const grepCmd = `git grep -n -E "${escapeForShell(pattern)}" -- "*.hpp" "*.h" "*.cpp" "*.c"`;
78
+ const result = runCommand(grepCmd, repoPath, 30_000);
79
+
80
+ if (!result.success && result.stdout.length === 0) continue;
81
+
82
+ for (const line of result.stdout.split("\n")) {
83
+ if (!line.trim()) continue;
84
+
85
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
86
+ if (!match) continue;
87
+
88
+ const [, file, lineStr, content] = match;
89
+ const lineNum = parseInt(lineStr, 10);
90
+ const trimmedContent = content.trim();
91
+
92
+ // Skip forward declarations (class Foo;)
93
+ if (/^\s*(class|struct)\s+\w+\s*;/.test(trimmedContent)) continue;
94
+ // Skip includes
95
+ if (/^\s*#include/.test(trimmedContent)) continue;
96
+ // Skip comments
97
+ if (/^\s*(\/\/|\/\*|\*)/.test(trimmedContent)) continue;
98
+
99
+ const detectedKind = classifyDefinition(trimmedContent);
100
+ if (!detectedKind) continue;
101
+ if (kind !== "all" && detectedKind !== kind) continue;
102
+
103
+ const symbolName = extractSymbolName(trimmedContent, detectedKind);
104
+ if (!symbolName) continue;
105
+
106
+ // Symbol name must contain query
107
+ if (!symbolName.toLowerCase().includes(query.toLowerCase())) continue;
108
+
109
+ // Deduplicate by symbol+file
110
+ const key = `${symbolName}:${file}`;
111
+ if (results.some((r) => `${r.symbol}:${r.file.split("/").pop()}` === key)) continue;
112
+
113
+ results.push({
114
+ symbol: symbolName,
115
+ kind: detectedKind,
116
+ file: `${repoPath}/${file}`.replace(/\\/g, "/"),
117
+ line: lineNum,
118
+ context: trimmedContent.slice(0, 150),
119
+ repo: repoName,
120
+ });
121
+
122
+ if (results.length >= maxResults) break;
123
+ }
124
+
125
+ if (results.length >= maxResults) break;
126
+ }
127
+
128
+ return {
129
+ query,
130
+ kind_filter: kind,
131
+ results: results.slice(0, maxResults),
132
+ total_matches: results.length,
133
+ truncated: results.length >= maxResults,
134
+ };
135
+ }
136
+
137
+ function escapeForShell(s: string): string {
138
+ // Only escape shell metacharacters, NOT backslashes (needed for regex \s \w etc.)
139
+ return s.replace(/["`$]/g, "\\$&");
140
+ }
141
+
142
+ function escapeForRegex(s: string): string {
143
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144
+ }
145
+
146
+ function classifyDefinition(line: string): SymbolResult["kind"] | null {
147
+ if (/^\s*#define\s+/.test(line)) return "macro";
148
+ if (/^\s*enum\s+/.test(line)) return "enum";
149
+ if (/^\s*(typedef|using)\s+/.test(line)) return "typedef";
150
+ if (/^\s*(class|struct)\s+\w+/.test(line)) return "class";
151
+ // Function: starts with type qualifier, has word( pattern
152
+ if (/^[\w:][\w:\s*&<>,]*\b\w+\s*\(/.test(line) &&
153
+ !/^\s*(if|while|for|switch|return|delete|new|throw|sizeof)\b/.test(line)) {
154
+ return "function";
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function extractSymbolName(line: string, kind: SymbolResult["kind"]): string | null {
160
+ switch (kind) {
161
+ case "class": {
162
+ const m = line.match(/(?:class|struct)\s+(\w+)/);
163
+ return m ? m[1] : null;
164
+ }
165
+ case "macro": {
166
+ const m = line.match(/#define\s+(\w+)/);
167
+ return m ? m[1] : null;
168
+ }
169
+ case "enum": {
170
+ const m = line.match(/enum\s+(?:class\s+)?(\w+)/);
171
+ return m ? m[1] : null;
172
+ }
173
+ case "typedef": {
174
+ const m = line.match(/using\s+(\w+)\s*=/) || line.match(/typedef\s+.*\b(\w+)\s*;/);
175
+ return m ? m[1] : null;
176
+ }
177
+ case "function": {
178
+ // Last word before opening paren: returnType funcName(
179
+ const m = line.match(/\b(\w+)\s*\(/);
180
+ return m ? m[1] : null;
181
+ }
182
+ default:
183
+ return null;
184
+ }
185
+ }
@@ -0,0 +1,264 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CROSSPAD_PC_ROOT, VCPKG_TOOLCHAIN } from "../config.js";
4
+ import { runBuild, runBuildStream, OnLine } from "../utils/exec.js";
5
+ import { IS_WINDOWS } from "../config.js";
6
+
7
+ export interface TestResult {
8
+ success: boolean;
9
+ tests_found: boolean;
10
+ build_output: string;
11
+ test_output: string;
12
+ passed: number;
13
+ failed: number;
14
+ errors: string[];
15
+ duration_seconds: number;
16
+ }
17
+
18
+ const TESTS_DIR = path.join(CROSSPAD_PC_ROOT, "tests");
19
+ const BIN_DIR = path.join(CROSSPAD_PC_ROOT, "bin");
20
+ const EXE_EXT = IS_WINDOWS ? ".exe" : "";
21
+ const TEST_EXE = path.join(BIN_DIR, `crosspad_tests${EXE_EXT}`);
22
+
23
+ /**
24
+ * Build and run the crosspad test suite (Catch2).
25
+ * If tests/ dir doesn't exist, offers to scaffold it.
26
+ */
27
+ export async function crosspadTest(
28
+ filter: string = "",
29
+ listOnly: boolean = false,
30
+ onLine?: OnLine
31
+ ): Promise<TestResult> {
32
+ const startTime = Date.now();
33
+
34
+ // Check if test infrastructure exists
35
+ if (!fs.existsSync(TESTS_DIR)) {
36
+ return {
37
+ success: false,
38
+ tests_found: false,
39
+ build_output: "",
40
+ test_output: `No tests/ directory found. Use crosspad_test_scaffold to create test infrastructure.`,
41
+ passed: 0,
42
+ failed: 0,
43
+ errors: ["tests/ directory not found"],
44
+ duration_seconds: 0,
45
+ };
46
+ }
47
+
48
+ // Ensure cmake is configured with BUILD_TESTING=ON
49
+ onLine?.("stdout", "[crosspad] Configuring cmake with BUILD_TESTING=ON...");
50
+
51
+ const generator = process.env.CMAKE_GENERATOR || (IS_WINDOWS ? "Ninja" : "");
52
+ const genFlag = generator ? ` -G ${generator}` : "";
53
+ const configCmd = `cmake -B build${genFlag} -DCMAKE_TOOLCHAIN_FILE=${VCPKG_TOOLCHAIN} -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON`;
54
+
55
+ let configResult;
56
+ if (onLine) {
57
+ configResult = await runBuildStream(configCmd, CROSSPAD_PC_ROOT, onLine, 120_000);
58
+ } else {
59
+ configResult = runBuild(configCmd, CROSSPAD_PC_ROOT, 120_000);
60
+ }
61
+
62
+ if (!configResult.success) {
63
+ return {
64
+ success: false,
65
+ tests_found: true,
66
+ build_output: (configResult.stdout + "\n" + configResult.stderr).slice(-3000),
67
+ test_output: "",
68
+ passed: 0,
69
+ failed: 0,
70
+ errors: parseErrors(configResult.stdout + "\n" + configResult.stderr),
71
+ duration_seconds: (Date.now() - startTime) / 1000,
72
+ };
73
+ }
74
+
75
+ // Build tests target
76
+ onLine?.("stdout", "[crosspad] Building test target...");
77
+
78
+ const buildCmd = "cmake --build build --target crosspad_tests";
79
+ let buildResult;
80
+ if (onLine) {
81
+ buildResult = await runBuildStream(buildCmd, CROSSPAD_PC_ROOT, onLine, 300_000);
82
+ } else {
83
+ buildResult = runBuild(buildCmd, CROSSPAD_PC_ROOT, 300_000);
84
+ }
85
+
86
+ if (!buildResult.success) {
87
+ return {
88
+ success: false,
89
+ tests_found: true,
90
+ build_output: (buildResult.stdout + "\n" + buildResult.stderr).slice(-3000),
91
+ test_output: "",
92
+ passed: 0,
93
+ failed: 0,
94
+ errors: parseErrors(buildResult.stdout + "\n" + buildResult.stderr),
95
+ duration_seconds: (Date.now() - startTime) / 1000,
96
+ };
97
+ }
98
+
99
+ if (!fs.existsSync(TEST_EXE)) {
100
+ return {
101
+ success: false,
102
+ tests_found: true,
103
+ build_output: buildResult.stdout.slice(-1000),
104
+ test_output: "Test executable not found after build",
105
+ passed: 0,
106
+ failed: 0,
107
+ errors: [`${TEST_EXE} not found`],
108
+ duration_seconds: (Date.now() - startTime) / 1000,
109
+ };
110
+ }
111
+
112
+ // Run tests
113
+ let testCmd = `"${TEST_EXE}"`;
114
+ if (listOnly) {
115
+ testCmd += " --list-tests";
116
+ } else {
117
+ testCmd += " --reporter compact";
118
+ if (filter) {
119
+ testCmd += ` "${filter}"`;
120
+ }
121
+ }
122
+
123
+ onLine?.("stdout", "[crosspad] Running tests...");
124
+
125
+ let testResult;
126
+ if (onLine) {
127
+ testResult = await runBuildStream(testCmd, CROSSPAD_PC_ROOT, onLine, 120_000);
128
+ } else {
129
+ testResult = runBuild(testCmd, CROSSPAD_PC_ROOT, 120_000);
130
+ }
131
+
132
+ const testOutput = testResult.stdout + "\n" + testResult.stderr;
133
+
134
+ // Parse Catch2 compact output
135
+ const { passed, failed } = parseCatch2Output(testOutput);
136
+
137
+ const result: TestResult = {
138
+ success: testResult.success,
139
+ tests_found: true,
140
+ build_output: buildResult.stdout.slice(-500),
141
+ test_output: testOutput.slice(-5000),
142
+ passed,
143
+ failed,
144
+ errors: testResult.success ? [] : parseErrors(testOutput),
145
+ duration_seconds: (Date.now() - startTime) / 1000,
146
+ };
147
+
148
+ onLine?.("stdout", `[crosspad] Tests ${result.success ? "PASSED" : "FAILED"}: ${passed} passed, ${failed} failed (${result.duration_seconds.toFixed(1)}s)`);
149
+
150
+ return result;
151
+ }
152
+
153
+ /**
154
+ * Scaffold the test infrastructure: CMakeLists.txt additions + sample test file.
155
+ * Returns file contents — does NOT write to disk.
156
+ */
157
+ export function crosspadTestScaffold(): { files: Record<string, string>; cmake_patch: string } {
158
+ const files: Record<string, string> = {};
159
+
160
+ // tests/CMakeLists.txt
161
+ files["tests/CMakeLists.txt"] = `# CrossPad test suite — Catch2 v3
162
+ Include(FetchContent)
163
+
164
+ FetchContent_Declare(
165
+ Catch2
166
+ GIT_REPOSITORY https://github.com/catchorg/Catch2.git
167
+ GIT_TAG v3.5.2
168
+ )
169
+ FetchContent_MakeAvailable(Catch2)
170
+
171
+ # Collect test sources
172
+ file(GLOB_RECURSE TEST_SOURCES "\${CMAKE_CURRENT_SOURCE_DIR}/*.cpp")
173
+
174
+ add_executable(crosspad_tests \${TEST_SOURCES})
175
+
176
+ target_link_libraries(crosspad_tests PRIVATE
177
+ Catch2::Catch2WithMain
178
+ )
179
+
180
+ # Include crosspad-core headers (for testing core logic)
181
+ target_include_directories(crosspad_tests PRIVATE
182
+ \${CMAKE_SOURCE_DIR}/crosspad-core/include
183
+ \${CMAKE_SOURCE_DIR}/crosspad-gui/include
184
+ \${CMAKE_SOURCE_DIR}/src
185
+ )
186
+
187
+ # Same defines as main target
188
+ target_compile_definitions(crosspad_tests PRIVATE
189
+ PLATFORM_PC=1
190
+ CP_LCD_HOR_RES=320
191
+ CP_LCD_VER_RES=240
192
+ )
193
+
194
+ # Add crosspad-core sources we want to test (non-platform-specific)
195
+ # Add individual source files as needed:
196
+ # target_sources(crosspad_tests PRIVATE
197
+ # \${CMAKE_SOURCE_DIR}/crosspad-core/src/SomeFile.cpp
198
+ # )
199
+
200
+ include(CTest)
201
+ include(Catch)
202
+ catch_discover_tests(crosspad_tests)
203
+ `;
204
+
205
+ // tests/test_pad_manager.cpp — sample test
206
+ files["tests/test_pad_manager.cpp"] = `#include <catch2/catch_test_macros.hpp>
207
+
208
+ // Example: test crosspad-core types without full platform init
209
+ // #include <crosspad/pad/PadManager.hpp>
210
+ // #include <crosspad/platform/PlatformCapabilities.hpp>
211
+
212
+ TEST_CASE("Sanity check", "[core]") {
213
+ REQUIRE(1 + 1 == 2);
214
+ }
215
+
216
+ // TEST_CASE("PlatformCapabilities bitflags", "[core][capabilities]") {
217
+ // using crosspad::Capability;
218
+ // using crosspad::setPlatformCapabilities;
219
+ // using crosspad::hasCapability;
220
+ // using crosspad::hasAnyCapability;
221
+ //
222
+ // setPlatformCapabilities(Capability::Midi | Capability::Pads);
223
+ //
224
+ // REQUIRE(hasCapability(Capability::Midi));
225
+ // REQUIRE(hasCapability(Capability::Pads));
226
+ // REQUIRE_FALSE(hasCapability(Capability::AudioOut));
227
+ // REQUIRE(hasAnyCapability(Capability::Midi | Capability::AudioOut));
228
+ // }
229
+ `;
230
+
231
+ // Patch for root CMakeLists.txt
232
+ const cmakePatch = `
233
+ # Add this near the end of your CMakeLists.txt, before any final install/packaging:
234
+ # --- Test suite ---
235
+ if(EXISTS "\${CMAKE_SOURCE_DIR}/tests/CMakeLists.txt")
236
+ add_subdirectory(tests)
237
+ endif()
238
+ `;
239
+
240
+ return { files, cmake_patch: cmakePatch };
241
+ }
242
+
243
+ function parseCatch2Output(output: string): { passed: number; failed: number } {
244
+ // Catch2 compact reporter: "Passed X test(s)" / "Failed X test(s)"
245
+ const passedMatch = output.match(/(\d+)\s+assertion[s]?\s+.*passed/i) ||
246
+ output.match(/All tests passed\s*\((\d+)/i);
247
+ const failedMatch = output.match(/(\d+)\s+assertion[s]?\s+.*failed/i) ||
248
+ output.match(/test cases?:\s*\d+\s*\|\s*(\d+)\s+failed/i);
249
+
250
+ return {
251
+ passed: passedMatch ? parseInt(passedMatch[1], 10) : 0,
252
+ failed: failedMatch ? parseInt(failedMatch[1], 10) : 0,
253
+ };
254
+ }
255
+
256
+ function parseErrors(output: string): string[] {
257
+ const errors: string[] = [];
258
+ for (const line of output.split("\n")) {
259
+ if (/\berror\b/i.test(line) && !line.includes("error(s)")) {
260
+ errors.push(line.trim());
261
+ }
262
+ }
263
+ return errors.slice(0, 20);
264
+ }