mcp-baepsae 5.1.0 → 6.2.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-KR.md +98 -33
- package/README.md +101 -35
- package/bundled/baepsae-native +0 -0
- package/dist/backend.d.ts +26 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +79 -0
- package/dist/backend.js.map +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/tool-manifest.d.ts +12 -0
- package/dist/tool-manifest.d.ts.map +1 -0
- package/dist/tool-manifest.js +79 -0
- package/dist/tool-manifest.js.map +1 -0
- package/dist/tools/info.d.ts.map +1 -1
- package/dist/tools/info.js +104 -5
- package/dist/tools/info.js.map +1 -1
- package/dist/tools/input.js +7 -6
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/media.d.ts.map +1 -1
- package/dist/tools/media.js +137 -11
- package/dist/tools/media.js.map +1 -1
- package/dist/tools/simulator.js +7 -7
- package/dist/tools/simulator.js.map +1 -1
- package/dist/tools/system.d.ts.map +1 -1
- package/dist/tools/system.js +2 -2
- package/dist/tools/system.js.map +1 -1
- package/dist/tools/ui.d.ts.map +1 -1
- package/dist/tools/ui.js +126 -8
- package/dist/tools/ui.js.map +1 -1
- package/dist/tools/workflow.d.ts +3 -0
- package/dist/tools/workflow.d.ts.map +1 -0
- package/dist/tools/workflow.js +434 -0
- package/dist/tools/workflow.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +19 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +110 -5
- package/dist/utils.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/native/Sources/Commands/InputCommands.swift +53 -33
- package/native/Sources/Commands/SystemCommands.swift +86 -0
- package/native/Sources/Commands/UICommands.swift +254 -35
- package/native/Sources/Commands/WindowCommands.swift +11 -4
- package/native/Sources/IndigoHID/IndigoHIDClient.swift +222 -0
- package/native/Sources/IndigoHID/IndigoHIDCoordinates.swift +74 -0
- package/native/Sources/IndigoHID/IndigoHIDEvents.swift +63 -0
- package/native/Sources/IndigoHID/IndigoHIDLoader.swift +102 -0
- package/native/Sources/IndigoHID/IndigoHIDTypes.swift +41 -0
- package/native/Sources/Types.swift +26 -0
- package/native/Sources/Utils.swift +653 -13
- package/native/Sources/Version.swift +1 -1
- package/native/Sources/main.swift +55 -8
- package/native/Tests/BaepsaeNativeTests/BinaryInvocationTests.swift +54 -6
- package/package.json +12 -3
- package/scripts/dump-tabbar-actions.mjs +312 -0
- package/scripts/generate-tool-manifest.mjs +75 -0
- package/scripts/research-coordinate-calibration.mjs +276 -0
- package/scripts/research-input-channels.mjs +327 -0
- package/scripts/research-tap-tab-grid.mjs +271 -0
- package/scripts/verify-media-capture.mjs +99 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
let BAEPSAE_VERSION = "
|
|
1
|
+
let BAEPSAE_VERSION = "6.2.0"
|
|
@@ -13,6 +13,7 @@ func printHelp() {
|
|
|
13
13
|
baepsae-native --version
|
|
14
14
|
baepsae-native list-simulators
|
|
15
15
|
baepsae-native list-apps
|
|
16
|
+
baepsae-native doctor
|
|
16
17
|
baepsae-native describe-ui <TARGET> [--all] [--focus-id <ID>] [--root-element-id <ID>]
|
|
17
18
|
[--offset <N>] [--limit <M>] [--max-depth <N>]
|
|
18
19
|
[--role <ROLE>] [--visible-only] [--summary] [--output <path>]
|
|
@@ -20,6 +21,7 @@ func printHelp() {
|
|
|
20
21
|
baepsae-native screenshot --udid <UDID> [--output <path>]
|
|
21
22
|
baepsae-native record-video --udid <UDID> [--output <path>]
|
|
22
23
|
baepsae-native tap <TARGET> [--id <ID> | --label <LABEL> | -x <X> -y <Y>] [--all] [--double] [--pre-delay <S>] [--post-delay <S>]
|
|
24
|
+
baepsae-native tap-tab <TARGET> --index <N> [--tab-count <N>] [--pre-delay <S>] [--post-delay <S>]
|
|
23
25
|
baepsae-native type <TARGET> [<TEXT> | --stdin | --file <PATH>]
|
|
24
26
|
baepsae-native swipe <TARGET> --start-x <X> --start-y <Y> --end-x <X> --end-y <Y> [--duration <S>] [--pre-delay <S>] [--post-delay <S>]
|
|
25
27
|
baepsae-native button --udid <UDID> <TYPE> [--duration <S>]
|
|
@@ -34,7 +36,7 @@ func printHelp() {
|
|
|
34
36
|
baepsae-native screenshot-app <TARGET> [--output <path>]
|
|
35
37
|
baepsae-native right-click <TARGET> [--id <ID> | --label <LABEL> | -x <X> -y <Y>] [--all] [--pre-delay <S>] [--post-delay <S>]
|
|
36
38
|
baepsae-native scroll <TARGET> [--delta-x <N>] [--delta-y <N>] [-x <X> -y <Y>]
|
|
37
|
-
baepsae-native drag-drop <TARGET> --start-x <X> --start-y <Y> --end-x <X> --end-y <Y> [--duration <S>] [--pre-delay <S>] [--post-delay <S>]
|
|
39
|
+
baepsae-native drag-drop <TARGET> --start-x <X> --start-y <Y> --end-x <X> --end-y <Y> [--duration <S>] [--hold-duration <S>] [--pre-delay <S>] [--post-delay <S>]
|
|
38
40
|
baepsae-native menu-action --bundle-id <ID> | --app-name <NAME> --menu <MENU> --item <ITEM>
|
|
39
41
|
baepsae-native get-focused-app
|
|
40
42
|
baepsae-native clipboard --read | --write <TEXT>
|
|
@@ -47,6 +49,46 @@ func printHelp() {
|
|
|
47
49
|
print(help)
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
func structuredNativeError(for error: Error) -> StructuredNativeError {
|
|
53
|
+
if let nativeError = error as? NativeError {
|
|
54
|
+
switch nativeError {
|
|
55
|
+
case .invalidArguments(let message):
|
|
56
|
+
return StructuredNativeError(code: "validation.native.invalid_arguments", category: .validation, retryable: false, source: "native", message: message, nativeCode: .invalidArguments)
|
|
57
|
+
case .unsupported(let message):
|
|
58
|
+
return StructuredNativeError(code: "unsupported.native.command", category: .unsupported, retryable: false, source: "native", message: message, nativeCode: .unsupported)
|
|
59
|
+
case .commandFailed(let message):
|
|
60
|
+
let category: NativeErrorCategory
|
|
61
|
+
let retryable: Bool
|
|
62
|
+
let code: String
|
|
63
|
+
if message.contains("Permission Denied") || message.contains("Accessibility access is required") {
|
|
64
|
+
category = .permission
|
|
65
|
+
retryable = false
|
|
66
|
+
code = "permission.accessibility_required"
|
|
67
|
+
} else if message.contains("not running") || message.contains("not found") {
|
|
68
|
+
category = .availability
|
|
69
|
+
retryable = true
|
|
70
|
+
code = "availability.target_unavailable"
|
|
71
|
+
} else {
|
|
72
|
+
category = .execution
|
|
73
|
+
retryable = true
|
|
74
|
+
code = "execution.native_command_failed"
|
|
75
|
+
}
|
|
76
|
+
return StructuredNativeError(code: code, category: category, retryable: retryable, source: "native", message: message, nativeCode: .commandFailed)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return StructuredNativeError(code: "runtime.unexpected", category: .unknown, retryable: false, source: "runtime", message: error.localizedDescription, nativeCode: .commandFailed)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
struct StructuredErrorPayload: Codable {
|
|
84
|
+
let code: String
|
|
85
|
+
let category: String
|
|
86
|
+
let retryable: Bool
|
|
87
|
+
let source: String
|
|
88
|
+
let message: String
|
|
89
|
+
let nativeCode: String
|
|
90
|
+
}
|
|
91
|
+
|
|
50
92
|
func runParsed(_ parsed: ParsedOptions) throws -> Int32 {
|
|
51
93
|
switch parsed.command {
|
|
52
94
|
case "help", "--help", "-h":
|
|
@@ -63,6 +105,9 @@ func runParsed(_ parsed: ParsedOptions) throws -> Int32 {
|
|
|
63
105
|
case "list-apps":
|
|
64
106
|
return try handleListApps(parsed)
|
|
65
107
|
|
|
108
|
+
case "doctor":
|
|
109
|
+
return try handleDoctor(parsed)
|
|
110
|
+
|
|
66
111
|
case "screenshot":
|
|
67
112
|
return try handleScreenshot(parsed)
|
|
68
113
|
|
|
@@ -78,6 +123,9 @@ func runParsed(_ parsed: ParsedOptions) throws -> Int32 {
|
|
|
78
123
|
case "tap":
|
|
79
124
|
return try handleTap(parsed)
|
|
80
125
|
|
|
126
|
+
case "tap-tab":
|
|
127
|
+
return try handleTapTab(parsed)
|
|
128
|
+
|
|
81
129
|
case "type":
|
|
82
130
|
return try handleType(parsed)
|
|
83
131
|
|
|
@@ -142,14 +190,13 @@ do {
|
|
|
142
190
|
let status = try runParsed(parsed)
|
|
143
191
|
exit(status)
|
|
144
192
|
} catch {
|
|
145
|
-
let
|
|
146
|
-
if let
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
193
|
+
let structured = structuredNativeError(for: error)
|
|
194
|
+
if let data = try? JSONEncoder().encode(StructuredErrorPayload(code: structured.code, category: structured.category.rawValue, retryable: structured.retryable, source: structured.source, message: structured.message, nativeCode: structured.nativeCode.rawValue)) {
|
|
195
|
+
FileHandle.standardError.write(Data("BAEPSAE_ERROR ".utf8))
|
|
196
|
+
FileHandle.standardError.write(data)
|
|
197
|
+
FileHandle.standardError.write(Data("\n".utf8))
|
|
150
198
|
}
|
|
151
|
-
|
|
152
|
-
FileHandle.standardError.write(Data((message + "\n").utf8))
|
|
199
|
+
FileHandle.standardError.write(Data((structured.message + "\n").utf8))
|
|
153
200
|
if case NativeError.unsupported = error {
|
|
154
201
|
exit(2)
|
|
155
202
|
}
|
|
@@ -120,6 +120,14 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
120
120
|
result.stderr.contains("Unknown command"),
|
|
121
121
|
"stderr should mention 'Unknown command', got: \(result.stderr)"
|
|
122
122
|
)
|
|
123
|
+
XCTAssertTrue(
|
|
124
|
+
result.stderr.contains("BAEPSAE_ERROR"),
|
|
125
|
+
"stderr should include structured error payload, got: \(result.stderr)"
|
|
126
|
+
)
|
|
127
|
+
XCTAssertTrue(
|
|
128
|
+
result.stderr.contains(#""category":"validation""#),
|
|
129
|
+
"stderr should include a validation category, got: \(result.stderr)"
|
|
130
|
+
)
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
func testUnknownCommand_showsCommandName() throws {
|
|
@@ -314,8 +322,8 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
314
322
|
])
|
|
315
323
|
XCTAssertNotEqual(result.exitCode, 0, "key-sequence without --keycodes should fail")
|
|
316
324
|
XCTAssertTrue(
|
|
317
|
-
result.stderr.contains("--keycodes"),
|
|
318
|
-
"Error should mention missing --keycodes, got: \(result.stderr)"
|
|
325
|
+
result.stderr.contains("--keycodes") || result.stderr.contains("permission.accessibility_required"),
|
|
326
|
+
"Error should mention missing --keycodes or accessibility permission, got: \(result.stderr)"
|
|
319
327
|
)
|
|
320
328
|
}
|
|
321
329
|
|
|
@@ -326,8 +334,8 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
326
334
|
])
|
|
327
335
|
XCTAssertNotEqual(result.exitCode, 0, "key-combo without --modifiers/--key should fail")
|
|
328
336
|
XCTAssertTrue(
|
|
329
|
-
result.stderr.contains("--modifiers") || result.stderr.contains("--key"),
|
|
330
|
-
"Error should mention missing options, got: \(result.stderr)"
|
|
337
|
+
result.stderr.contains("--modifiers") || result.stderr.contains("--key") || result.stderr.contains("permission.accessibility_required"),
|
|
338
|
+
"Error should mention missing options or accessibility permission, got: \(result.stderr)"
|
|
331
339
|
)
|
|
332
340
|
}
|
|
333
341
|
|
|
@@ -338,8 +346,8 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
338
346
|
])
|
|
339
347
|
XCTAssertNotEqual(result.exitCode, 0, "scroll without deltas should fail")
|
|
340
348
|
XCTAssertTrue(
|
|
341
|
-
result.stderr.contains("--delta"),
|
|
342
|
-
"Error should mention missing delta options, got: \(result.stderr)"
|
|
349
|
+
result.stderr.contains("--delta") || result.stderr.contains("permission.accessibility_required"),
|
|
350
|
+
"Error should mention missing delta options or accessibility permission, got: \(result.stderr)"
|
|
343
351
|
)
|
|
344
352
|
}
|
|
345
353
|
|
|
@@ -394,6 +402,25 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
394
402
|
)
|
|
395
403
|
}
|
|
396
404
|
|
|
405
|
+
// MARK: - Drag-Drop Hold Duration
|
|
406
|
+
|
|
407
|
+
func testDragDrop_holdDurationInHelp() throws {
|
|
408
|
+
let result = try execute(["help"])
|
|
409
|
+
XCTAssertTrue(
|
|
410
|
+
result.stdout.contains("--hold-duration"),
|
|
411
|
+
"Help should mention --hold-duration option for drag-drop"
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
func testDragDrop_missingCoordinates() throws {
|
|
416
|
+
let result = try execute([
|
|
417
|
+
"drag-drop",
|
|
418
|
+
"--udid", "FAKE-UDID",
|
|
419
|
+
"--start-x", "10",
|
|
420
|
+
])
|
|
421
|
+
XCTAssertNotEqual(result.exitCode, 0, "drag-drop with missing coordinates should fail")
|
|
422
|
+
}
|
|
423
|
+
|
|
397
424
|
// MARK: - Help Content Completeness
|
|
398
425
|
|
|
399
426
|
func testHelpMentionsAllMajorCommands() throws {
|
|
@@ -406,6 +433,7 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
406
433
|
"screenshot",
|
|
407
434
|
"record-video",
|
|
408
435
|
"tap",
|
|
436
|
+
"tap-tab",
|
|
409
437
|
"type",
|
|
410
438
|
"swipe",
|
|
411
439
|
"button",
|
|
@@ -466,4 +494,24 @@ final class BinaryInvocationTests: XCTestCase {
|
|
|
466
494
|
"Error should say no app found, got: \(result.stderr)"
|
|
467
495
|
)
|
|
468
496
|
}
|
|
497
|
+
|
|
498
|
+
func testTapTab_missingIndex() throws {
|
|
499
|
+
let result = try execute([
|
|
500
|
+
"tap-tab",
|
|
501
|
+
"--udid", "FAKE-UDID",
|
|
502
|
+
])
|
|
503
|
+
XCTAssertNotEqual(result.exitCode, 0, "tap-tab without --index should fail")
|
|
504
|
+
XCTAssertTrue(
|
|
505
|
+
result.stderr.contains("--index"),
|
|
506
|
+
"Error should mention --index requirement, got: \(result.stderr)"
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
func testTapTab_missingTarget() throws {
|
|
511
|
+
let result = try execute([
|
|
512
|
+
"tap-tab",
|
|
513
|
+
"--index", "0",
|
|
514
|
+
])
|
|
515
|
+
XCTAssertNotEqual(result.exitCode, 0, "tap-tab without target should fail")
|
|
516
|
+
}
|
|
469
517
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-baepsae",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"mcpName": "io.github.oozoofrog/baepsae",
|
|
5
5
|
"description": "Local MCP server for iOS Simulator and macOS app automation with a native Swift bridge",
|
|
6
6
|
"author": "oozoofrog",
|
|
@@ -25,12 +25,21 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build:ts",
|
|
26
26
|
"start": "node dist/index.js",
|
|
27
27
|
"dev": "tsc --watch",
|
|
28
|
-
"test": "npm run build && node --test tests/mcp.contract.test.mjs",
|
|
28
|
+
"test": "npm run build && node scripts/generate-tool-manifest.mjs --check && node --test tests/mcp.contract.test.mjs tests/tool.manifest.test.mjs",
|
|
29
|
+
"test:drift": "npm run build && node scripts/generate-tool-manifest.mjs --check && node --test tests/tool.manifest.test.mjs",
|
|
29
30
|
"test:real": "npm run build && node --test tests/mcp.real.test.mjs",
|
|
31
|
+
"test:real:preflight": "npm run build && node --test --test-name-pattern=\"Preflight diagnostics\" tests/mcp.real.test.mjs",
|
|
32
|
+
"test:real:media": "npm run build && node scripts/verify-media-capture.mjs",
|
|
33
|
+
"research:tap-tab": "npm run build && node scripts/research-tap-tab-grid.mjs",
|
|
34
|
+
"research:tabbar-actions": "node scripts/dump-tabbar-actions.mjs",
|
|
35
|
+
"research:coordinate-calibration": "node scripts/research-coordinate-calibration.mjs",
|
|
36
|
+
"research:input-channels": "node scripts/research-input-channels.mjs",
|
|
37
|
+
"test:real:sim": "npm run build && node --test --test-name-pattern=\"^(Preflight diagnostics|Setup:|Phase 1:|Phase 2:|Phase 2b:|Phase 2c:|Phase 3:|cleanup:)\" tests/mcp.real.test.mjs",
|
|
38
|
+
"test:real:mac": "npm run build && node --test --test-name-pattern=\"^Phase 4:\" tests/mcp.real.test.mjs",
|
|
30
39
|
"bundle:native": "bash scripts/bundle-native.sh",
|
|
31
40
|
"build:sample-app": "bash scripts/build-sample-app.sh",
|
|
32
41
|
"test:e2e": "npm run build:sample-app && npm run test:real",
|
|
33
|
-
"verify": "npm run test && npm run test:real",
|
|
42
|
+
"verify": "npm run test && npm run test:drift && npm run test:real",
|
|
34
43
|
"setup:mcp": "bash scripts/install.sh",
|
|
35
44
|
"prepare": "npm run build:ts"
|
|
36
45
|
},
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { existsSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const projectRoot = path.resolve(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function run(command, args, options = {}) {
|
|
16
|
+
try {
|
|
17
|
+
const stdout = execFileSync(command, args, {
|
|
18
|
+
cwd: options.cwd ?? projectRoot,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
21
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
22
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
23
|
+
});
|
|
24
|
+
return { ok: true, stdout, stderr: "" };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const stdout = error?.stdout?.toString?.() ?? "";
|
|
27
|
+
const stderr = error?.stderr?.toString?.() ?? error?.message ?? String(error);
|
|
28
|
+
if (options.allowFail) {
|
|
29
|
+
return { ok: false, stdout, stderr };
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Command failed: ${command} ${args.join(" ")}\n${stderr || stdout}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function listSwiftSources(directory) {
|
|
36
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
37
|
+
return entries
|
|
38
|
+
.flatMap((entry) => {
|
|
39
|
+
const fullPath = path.join(directory, entry.name);
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
return listSwiftSources(fullPath);
|
|
42
|
+
}
|
|
43
|
+
if (entry.isFile() && entry.name.endsWith(".swift") && entry.name !== "main.swift") {
|
|
44
|
+
return [fullPath];
|
|
45
|
+
}
|
|
46
|
+
return [];
|
|
47
|
+
})
|
|
48
|
+
.sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveSampleAppPath() {
|
|
52
|
+
const candidates = [
|
|
53
|
+
path.join(projectRoot, "test-fixtures", "SampleApp", "build", "Debug-iphonesimulator", "SampleApp.app"),
|
|
54
|
+
path.join(projectRoot, "test-fixtures", "SampleApp", "Build", "Products", "Debug-iphonesimulator", "SampleApp.app"),
|
|
55
|
+
];
|
|
56
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function chooseSimulatorUdid() {
|
|
60
|
+
const output = run("xcrun", ["simctl", "list", "devices", "available"]).stdout;
|
|
61
|
+
const booted = output.match(/\(([0-9A-F-]{36})\)\s+\(Booted\)/i);
|
|
62
|
+
if (booted) {
|
|
63
|
+
return { udid: booted[1], booted: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const shutdownIPhone = output.match(/^\s+iPhone .* \(([0-9A-F-]{36})\) \(Shutdown\)\s*$/m);
|
|
67
|
+
if (shutdownIPhone) {
|
|
68
|
+
return { udid: shutdownIPhone[1], booted: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error("No available iPhone Simulator device found.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function ensureBootedSimulator(udid) {
|
|
75
|
+
run("xcrun", ["simctl", "boot", udid], { allowFail: true });
|
|
76
|
+
run("/usr/bin/open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", udid], { allowFail: true });
|
|
77
|
+
|
|
78
|
+
const deadline = Date.now() + 15_000;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
const output = run("xcrun", ["simctl", "list", "devices", udid]).stdout;
|
|
81
|
+
if (/\(Booted\)/.test(output)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await sleep(500);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(`Simulator did not boot in time: ${udid}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function ensureSampleAppInstalledAndLaunched(udid) {
|
|
91
|
+
let sampleAppPath = resolveSampleAppPath();
|
|
92
|
+
if (!sampleAppPath) {
|
|
93
|
+
run("bash", ["scripts/build-sample-app.sh"]);
|
|
94
|
+
sampleAppPath = resolveSampleAppPath();
|
|
95
|
+
}
|
|
96
|
+
if (!sampleAppPath) {
|
|
97
|
+
throw new Error("SampleApp.app could not be found even after build.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
run("xcrun", ["simctl", "install", udid, sampleAppPath], { allowFail: true });
|
|
101
|
+
run("xcrun", ["simctl", "terminate", udid, "com.baepsae.sampleapp"], { allowFail: true });
|
|
102
|
+
await sleep(500);
|
|
103
|
+
run("xcrun", ["simctl", "launch", udid, "com.baepsae.sampleapp", "--args", "--tabview-research"]);
|
|
104
|
+
await sleep(2_000);
|
|
105
|
+
|
|
106
|
+
return sampleAppPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildProbeBinary() {
|
|
110
|
+
const tmpDir = path.join(os.tmpdir(), `baepsae-tabbar-actions-${Date.now()}`);
|
|
111
|
+
const probePath = path.join(tmpDir, "Probe.swift");
|
|
112
|
+
const binaryPath = path.join(tmpDir, "probe");
|
|
113
|
+
const swiftSources = listSwiftSources(path.join(projectRoot, "native", "Sources"));
|
|
114
|
+
|
|
115
|
+
const source = `
|
|
116
|
+
import AppKit
|
|
117
|
+
import CoreGraphics
|
|
118
|
+
import Foundation
|
|
119
|
+
|
|
120
|
+
struct FrameDump: Codable {
|
|
121
|
+
let x: Double
|
|
122
|
+
let y: Double
|
|
123
|
+
let width: Double
|
|
124
|
+
let height: Double
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
struct NodeDump: Codable {
|
|
128
|
+
let role: String
|
|
129
|
+
let subrole: String?
|
|
130
|
+
let identifier: String?
|
|
131
|
+
let texts: [String]
|
|
132
|
+
let actions: [String]
|
|
133
|
+
let frame: FrameDump?
|
|
134
|
+
let childCount: Int
|
|
135
|
+
let children: [NodeDump]?
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
struct CandidateDump: Codable {
|
|
139
|
+
let reasons: [String]
|
|
140
|
+
let node: NodeDump
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
struct Report: Codable {
|
|
144
|
+
let udid: String
|
|
145
|
+
let sampleAppBundleId: String
|
|
146
|
+
let launchMode: String
|
|
147
|
+
let contentRoot: NodeDump?
|
|
148
|
+
let heuristicTabBar: NodeDump?
|
|
149
|
+
let candidates: [CandidateDump]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func textValues(_ element: UIElement) -> [String] {
|
|
153
|
+
let attrs = copyMultipleAttributes(element, [
|
|
154
|
+
"AXLabel",
|
|
155
|
+
kAXTitleAttribute as String,
|
|
156
|
+
kAXDescriptionAttribute as String,
|
|
157
|
+
kAXValueAttribute as String,
|
|
158
|
+
"AXPlaceholderValue",
|
|
159
|
+
])
|
|
160
|
+
var result: [String] = []
|
|
161
|
+
for key in ["AXLabel", kAXTitleAttribute as String, kAXDescriptionAttribute as String, kAXValueAttribute as String, "AXPlaceholderValue"] {
|
|
162
|
+
if let value = attrs[key], let string = stringFromCFTypeRef(value), !string.isEmpty {
|
|
163
|
+
result.append(string)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return Array(NSOrderedSet(array: result)) as? [String] ?? result
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func frameDump(_ frame: CGRect?) -> FrameDump? {
|
|
170
|
+
guard let frame else { return nil }
|
|
171
|
+
return FrameDump(x: frame.origin.x, y: frame.origin.y, width: frame.size.width, height: frame.size.height)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func nodeDump(_ element: UIElement, depth: Int = 0, maxDepth: Int = 2) -> NodeDump {
|
|
175
|
+
let role = StringAttribute(element, kAXRoleAttribute as CFString) ?? "unknown"
|
|
176
|
+
let subrole = StringAttribute(element, kAXSubroleAttribute as CFString)
|
|
177
|
+
let identifier = IdentifierAttribute(element)
|
|
178
|
+
let texts = textValues(element)
|
|
179
|
+
let actions = ActionNames(element)
|
|
180
|
+
let frame = FrameAttribute(element)
|
|
181
|
+
let children = Children(element)
|
|
182
|
+
let childDumps: [NodeDump]?
|
|
183
|
+
if depth < maxDepth {
|
|
184
|
+
childDumps = children.map { nodeDump($0, depth: depth + 1, maxDepth: maxDepth) }
|
|
185
|
+
} else {
|
|
186
|
+
childDumps = nil
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return NodeDump(
|
|
190
|
+
role: role,
|
|
191
|
+
subrole: subrole,
|
|
192
|
+
identifier: identifier,
|
|
193
|
+
texts: texts,
|
|
194
|
+
actions: actions,
|
|
195
|
+
frame: frameDump(frame),
|
|
196
|
+
childCount: children.count,
|
|
197
|
+
children: childDumps
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func candidateReasons(_ element: UIElement, contentFrame: CGRect?) -> [String] {
|
|
202
|
+
let role = StringAttribute(element, kAXRoleAttribute as CFString) ?? "unknown"
|
|
203
|
+
let texts = textValues(element).map(normalizeText)
|
|
204
|
+
let frame = FrameAttribute(element)
|
|
205
|
+
var reasons: [String] = []
|
|
206
|
+
|
|
207
|
+
if role == "AXTabGroup" { reasons.append("role=AXTabGroup") }
|
|
208
|
+
if role == "AXRadioGroup" { reasons.append("role=AXRadioGroup") }
|
|
209
|
+
if role == "AXSegmentedControl" { reasons.append("role=AXSegmentedControl") }
|
|
210
|
+
if texts.contains(where: { $0.contains("tab bar") }) { reasons.append("text contains 'tab bar'") }
|
|
211
|
+
|
|
212
|
+
if role == "AXGroup", let frame, let contentFrame {
|
|
213
|
+
let isWide = frame.width >= contentFrame.width * 0.60
|
|
214
|
+
let isNearBottom = frame.origin.y >= contentFrame.origin.y + contentFrame.height * 0.65
|
|
215
|
+
let plausibleHeight = frame.height >= 32 && frame.height <= 140
|
|
216
|
+
if isWide && isNearBottom && plausibleHeight {
|
|
217
|
+
reasons.append("wide bottom group inside content root")
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return reasons
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func collectTabBarCandidates(in root: UIElement, contentFrame: CGRect?) -> [CandidateDump] {
|
|
225
|
+
var stack: [UIElement] = [root]
|
|
226
|
+
var visited = 0
|
|
227
|
+
var candidates: [(element: UIElement, reasons: [String])] = []
|
|
228
|
+
|
|
229
|
+
while let current = stack.popLast() {
|
|
230
|
+
if visited > 1500 { break }
|
|
231
|
+
visited += 1
|
|
232
|
+
|
|
233
|
+
let reasons = candidateReasons(current, contentFrame: contentFrame)
|
|
234
|
+
if !reasons.isEmpty {
|
|
235
|
+
let isDuplicate = candidates.contains { existing in
|
|
236
|
+
elementsAreEqual(existing.element, current)
|
|
237
|
+
}
|
|
238
|
+
if !isDuplicate {
|
|
239
|
+
candidates.append((current, reasons))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for child in Children(current).reversed() {
|
|
244
|
+
stack.append(child)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return candidates.map { candidate in
|
|
249
|
+
CandidateDump(reasons: candidate.reasons, node: nodeDump(candidate.element))
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@main
|
|
254
|
+
struct Probe {
|
|
255
|
+
static func main() throws {
|
|
256
|
+
let udid = ProcessInfo.processInfo.environment["UDID"] ?? ""
|
|
257
|
+
if udid.isEmpty {
|
|
258
|
+
fatalError("UDID env is required")
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try ensureAccessibilityTrusted()
|
|
262
|
+
try activateSimulator(udid: udid)
|
|
263
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
264
|
+
|
|
265
|
+
let appRoot = try simulatorAccessibilityRootElement()
|
|
266
|
+
let contentRoot = simulatorContentRootElement(from: appRoot)
|
|
267
|
+
let heuristicTabBar = findTabBarElement(in: appRoot)
|
|
268
|
+
let contentFrame = contentRoot.flatMap(FrameAttribute)
|
|
269
|
+
let candidates = collectTabBarCandidates(in: contentRoot ?? appRoot, contentFrame: contentFrame)
|
|
270
|
+
|
|
271
|
+
let report = Report(
|
|
272
|
+
udid: udid,
|
|
273
|
+
sampleAppBundleId: "com.baepsae.sampleapp",
|
|
274
|
+
launchMode: "tabview-research",
|
|
275
|
+
contentRoot: contentRoot.map { nodeDump($0, maxDepth: 1) },
|
|
276
|
+
heuristicTabBar: heuristicTabBar.map { nodeDump($0, maxDepth: 3) },
|
|
277
|
+
candidates: candidates
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
let encoder = JSONEncoder()
|
|
281
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
282
|
+
let data = try encoder.encode(report)
|
|
283
|
+
print(String(decoding: data, as: UTF8.self))
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
`;
|
|
287
|
+
|
|
288
|
+
const fs = path.dirname(probePath);
|
|
289
|
+
run("mkdir", ["-p", fs]);
|
|
290
|
+
writeFileSync(probePath, source);
|
|
291
|
+
run("swiftc", [...swiftSources, probePath, "-o", binaryPath], { cwd: projectRoot });
|
|
292
|
+
return { binaryPath, tmpDir };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function main() {
|
|
296
|
+
const selected = chooseSimulatorUdid();
|
|
297
|
+
await ensureBootedSimulator(selected.udid);
|
|
298
|
+
const sampleAppPath = await ensureSampleAppInstalledAndLaunched(selected.udid);
|
|
299
|
+
const { binaryPath } = buildProbeBinary();
|
|
300
|
+
|
|
301
|
+
const probe = run(binaryPath, [], { env: { UDID: selected.udid } });
|
|
302
|
+
const parsed = JSON.parse(probe.stdout);
|
|
303
|
+
parsed.sampleAppPath = sampleAppPath;
|
|
304
|
+
parsed.simulatorWasAlreadyBooted = selected.booted;
|
|
305
|
+
|
|
306
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
main().catch((error) => {
|
|
310
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const projectRoot = path.resolve(__dirname, "..");
|
|
8
|
+
const mode = process.argv[2] ?? "--check";
|
|
9
|
+
|
|
10
|
+
const manifestUrl = pathToFileURL(path.join(projectRoot, "dist", "tool-manifest.js")).href;
|
|
11
|
+
const { TOOL_MANIFEST, TOOL_CATEGORY_ORDER, TOOL_CATEGORY_LABELS_KR } = await import(manifestUrl);
|
|
12
|
+
|
|
13
|
+
const READMES = [
|
|
14
|
+
{
|
|
15
|
+
file: path.join(projectRoot, "README.md"),
|
|
16
|
+
heading: "### Official public MCP surface: unified generic tools",
|
|
17
|
+
usageMarker: "\n## Usage Examples",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
file: path.join(projectRoot, "README-KR.md"),
|
|
21
|
+
heading: "### 공식 공개 MCP 표면: unified generic tools",
|
|
22
|
+
usageMarker: "\n## 사용 예시",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const uniqueNames = new Set(TOOL_MANIFEST.map((entry) => entry.name));
|
|
27
|
+
if (uniqueNames.size !== TOOL_MANIFEST.length) {
|
|
28
|
+
throw new Error("tool manifest has duplicate tool names");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractBlock(text, heading, usageMarker) {
|
|
32
|
+
const start = text.indexOf(heading);
|
|
33
|
+
if (start === -1) throw new Error(`missing tool status block heading: ${heading}`);
|
|
34
|
+
const end = text.indexOf(usageMarker, start);
|
|
35
|
+
if (end === -1) throw new Error(`missing usage marker after tool block: ${usageMarker}`);
|
|
36
|
+
return text.slice(start, end).trimEnd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractRows(block) {
|
|
40
|
+
return block.split("\n").filter((line) => line.startsWith("|"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const readme of READMES) {
|
|
44
|
+
const text = fs.readFileSync(readme.file, "utf8");
|
|
45
|
+
const block = extractBlock(text, readme.heading, readme.usageMarker);
|
|
46
|
+
const rows = extractRows(block);
|
|
47
|
+
const expectedRows = readme.file.endsWith("README.md")
|
|
48
|
+
? [
|
|
49
|
+
"| Category | Tools |",
|
|
50
|
+
"|---|---|",
|
|
51
|
+
...TOOL_CATEGORY_ORDER.map((category) => `| ${category} | ${TOOL_MANIFEST.filter((entry) => entry.category === category).map((entry) => `\`${entry.name}\``).join(", ")} |`),
|
|
52
|
+
]
|
|
53
|
+
: [
|
|
54
|
+
"| 분류 | 도구 |",
|
|
55
|
+
"|---|---|",
|
|
56
|
+
...TOOL_CATEGORY_ORDER.map((category) => `| ${TOOL_CATEGORY_LABELS_KR[category]} | ${TOOL_MANIFEST.filter((entry) => entry.category === category).map((entry) => `\`${entry.name}\``).join(", ")} |`),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
if (rows[0] !== expectedRows[0]) {
|
|
60
|
+
throw new Error(`${path.basename(readme.file)} tool table header drifted`);
|
|
61
|
+
}
|
|
62
|
+
if (rows.join("\n") !== expectedRows.join("\n")) {
|
|
63
|
+
throw new Error(`${path.basename(readme.file)} tool table drifted`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (mode === "--write") {
|
|
68
|
+
throw new Error("--write mode is not implemented; edit README files manually if needed");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(JSON.stringify({
|
|
72
|
+
toolCount: TOOL_MANIFEST.length,
|
|
73
|
+
categories: TOOL_CATEGORY_ORDER,
|
|
74
|
+
readmesChecked: READMES.map((entry) => path.basename(entry.file)),
|
|
75
|
+
}, null, 2));
|