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.
- package/README.md +187 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +33 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/architecture.d.ts +16 -0
- package/dist/tools/architecture.js +198 -0
- package/dist/tools/architecture.js.map +1 -0
- package/dist/tools/build-check.d.ts +23 -0
- package/dist/tools/build-check.js +162 -0
- package/dist/tools/build-check.js.map +1 -0
- package/dist/tools/build.d.ts +14 -0
- package/dist/tools/build.js +101 -0
- package/dist/tools/build.js.map +1 -0
- package/dist/tools/diff-core.d.ts +24 -0
- package/dist/tools/diff-core.js +88 -0
- package/dist/tools/diff-core.js.map +1 -0
- package/dist/tools/idf-build.d.ts +10 -0
- package/dist/tools/idf-build.js +155 -0
- package/dist/tools/idf-build.js.map +1 -0
- package/dist/tools/input.d.ts +36 -0
- package/dist/tools/input.js +61 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/log.d.ts +16 -0
- package/dist/tools/log.js +49 -0
- package/dist/tools/log.js.map +1 -0
- package/dist/tools/repos.d.ts +12 -0
- package/dist/tools/repos.js +63 -0
- package/dist/tools/repos.js.map +1 -0
- package/dist/tools/scaffold.d.ts +15 -0
- package/dist/tools/scaffold.js +192 -0
- package/dist/tools/scaffold.js.map +1 -0
- package/dist/tools/screenshot.d.ts +24 -0
- package/dist/tools/screenshot.js +80 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/settings.d.ts +25 -0
- package/dist/tools/settings.js +48 -0
- package/dist/tools/settings.js.map +1 -0
- package/dist/tools/stats.d.ts +18 -0
- package/dist/tools/stats.js +31 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/tools/symbols.d.ts +20 -0
- package/dist/tools/symbols.js +157 -0
- package/dist/tools/symbols.js.map +1 -0
- package/dist/tools/test.d.ts +24 -0
- package/dist/tools/test.js +227 -0
- package/dist/tools/test.js.map +1 -0
- package/dist/utils/exec.d.ts +58 -0
- package/dist/utils/exec.js +292 -0
- package/dist/utils/exec.js.map +1 -0
- package/dist/utils/git.d.ts +10 -0
- package/dist/utils/git.js +29 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/remote-client.d.ts +17 -0
- package/dist/utils/remote-client.js +94 -0
- package/dist/utils/remote-client.js.map +1 -0
- package/package.json +21 -0
- package/server.json +23 -0
- package/src/config.ts +45 -0
- package/src/index.ts +484 -0
- package/src/tools/architecture.ts +260 -0
- package/src/tools/build-check.ts +178 -0
- package/src/tools/build.ts +130 -0
- package/src/tools/diff-core.ts +130 -0
- package/src/tools/idf-build.ts +182 -0
- package/src/tools/input.ts +80 -0
- package/src/tools/log.ts +75 -0
- package/src/tools/repos.ts +75 -0
- package/src/tools/scaffold.ts +229 -0
- package/src/tools/screenshot.ts +100 -0
- package/src/tools/settings.ts +68 -0
- package/src/tools/stats.ts +38 -0
- package/src/tools/symbols.ts +185 -0
- package/src/tools/test.ts +264 -0
- package/src/utils/exec.ts +376 -0
- package/src/utils/git.ts +45 -0
- package/src/utils/remote-client.ts +107 -0
- 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
|
+
}
|