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.
Files changed (62) hide show
  1. package/README-KR.md +98 -33
  2. package/README.md +101 -35
  3. package/bundled/baepsae-native +0 -0
  4. package/dist/backend.d.ts +26 -0
  5. package/dist/backend.d.ts.map +1 -0
  6. package/dist/backend.js +79 -0
  7. package/dist/backend.js.map +1 -0
  8. package/dist/index.js +3 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/tool-manifest.d.ts +12 -0
  11. package/dist/tool-manifest.d.ts.map +1 -0
  12. package/dist/tool-manifest.js +79 -0
  13. package/dist/tool-manifest.js.map +1 -0
  14. package/dist/tools/info.d.ts.map +1 -1
  15. package/dist/tools/info.js +104 -5
  16. package/dist/tools/info.js.map +1 -1
  17. package/dist/tools/input.js +7 -6
  18. package/dist/tools/input.js.map +1 -1
  19. package/dist/tools/media.d.ts.map +1 -1
  20. package/dist/tools/media.js +137 -11
  21. package/dist/tools/media.js.map +1 -1
  22. package/dist/tools/simulator.js +7 -7
  23. package/dist/tools/simulator.js.map +1 -1
  24. package/dist/tools/system.d.ts.map +1 -1
  25. package/dist/tools/system.js +2 -2
  26. package/dist/tools/system.js.map +1 -1
  27. package/dist/tools/ui.d.ts.map +1 -1
  28. package/dist/tools/ui.js +126 -8
  29. package/dist/tools/ui.js.map +1 -1
  30. package/dist/tools/workflow.d.ts +3 -0
  31. package/dist/tools/workflow.d.ts.map +1 -0
  32. package/dist/tools/workflow.js +434 -0
  33. package/dist/tools/workflow.js.map +1 -0
  34. package/dist/types.d.ts +15 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/utils.d.ts +19 -3
  37. package/dist/utils.d.ts.map +1 -1
  38. package/dist/utils.js +110 -5
  39. package/dist/utils.js.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/native/Sources/Commands/InputCommands.swift +53 -33
  43. package/native/Sources/Commands/SystemCommands.swift +86 -0
  44. package/native/Sources/Commands/UICommands.swift +254 -35
  45. package/native/Sources/Commands/WindowCommands.swift +11 -4
  46. package/native/Sources/IndigoHID/IndigoHIDClient.swift +222 -0
  47. package/native/Sources/IndigoHID/IndigoHIDCoordinates.swift +74 -0
  48. package/native/Sources/IndigoHID/IndigoHIDEvents.swift +63 -0
  49. package/native/Sources/IndigoHID/IndigoHIDLoader.swift +102 -0
  50. package/native/Sources/IndigoHID/IndigoHIDTypes.swift +41 -0
  51. package/native/Sources/Types.swift +26 -0
  52. package/native/Sources/Utils.swift +653 -13
  53. package/native/Sources/Version.swift +1 -1
  54. package/native/Sources/main.swift +55 -8
  55. package/native/Tests/BaepsaeNativeTests/BinaryInvocationTests.swift +54 -6
  56. package/package.json +12 -3
  57. package/scripts/dump-tabbar-actions.mjs +312 -0
  58. package/scripts/generate-tool-manifest.mjs +75 -0
  59. package/scripts/research-coordinate-calibration.mjs +276 -0
  60. package/scripts/research-input-channels.mjs +327 -0
  61. package/scripts/research-tap-tab-grid.mjs +271 -0
  62. package/scripts/verify-media-capture.mjs +99 -0
@@ -1 +1 @@
1
- let BAEPSAE_VERSION = "5.1.0"
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 message: String
146
- if let nativeError = error as? NativeError {
147
- message = nativeError.description
148
- } else {
149
- message = error.localizedDescription
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": "5.1.0",
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));