refactor(simulator): extract errors and device catalog (#708 1/4)#738
Conversation
…1/4) Behavior-preserving extraction: - src/simulator/errors.ts: all simulator error classes (BootTimeoutError, ShutdownTimeoutError, DeviceNotFoundError, DeviceNotBootedError, ScreenshotTimeoutError, AppNotInstalledError, AppLaunchError) - src/simulator/device-catalog.ts: pure discovery functions (listDevices, listRuntimes, getDevice, resolveDevice) with SimctlExecutor DI - src/simulator/manager.ts: delegates to catalog module; re-exports error classes as compatibility shim for existing callers No behavior changes. instanceof semantics preserved via re-export. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
There was a problem hiding this comment.
Code Review
This pull request refactors the simulator management logic by extracting device cataloging and error definitions into dedicated modules, device-catalog.ts and errors.ts, and adding unit tests. The SimulatorManager now delegates device-related tasks to the new catalog and re-exports error classes for backward compatibility. Feedback focuses on improving the robustness of the simctl JSON parsing by handling optional keys and providing fallbacks for missing data. Additionally, a suggestion was made to harden the fuzzy matching logic to prevent incorrect matches when the input consists only of whitespace or separators.
| devices: Record<string, Array<{ | ||
| udid: string; | ||
| name: string; | ||
| state: string; | ||
| isAvailable: boolean; | ||
| }>>; | ||
| runtimes: Array<{ | ||
| identifier: string; | ||
| version: string; | ||
| isAvailable: boolean; | ||
| platform: string; | ||
| }>; |
There was a problem hiding this comment.
The SimctlListResult interface defines devices and runtimes as required fields. However, simctl list devices -j only returns the devices key, and simctl list runtimes -j only returns the runtimes key. Marking them as optional is more accurate and prevents potential runtime errors when these keys are missing from the JSON response.
| devices: Record<string, Array<{ | |
| udid: string; | |
| name: string; | |
| state: string; | |
| isAvailable: boolean; | |
| }>>; | |
| runtimes: Array<{ | |
| identifier: string; | |
| version: string; | |
| isAvailable: boolean; | |
| platform: string; | |
| }>; | |
| devices?: Record<string, Array<{ | |
| udid: string; | |
| name: string; | |
| state: string; | |
| isAvailable: boolean; | |
| }>>; | |
| runtimes?: Array<{ | |
| identifier: string; | |
| version: string; | |
| isAvailable: boolean; | |
| platform: string; | |
| }>; |
| const result = await simctl.execJson<SimctlListResult>(['list', 'devices']); | ||
| const devices: SimulatorDevice[] = []; | ||
|
|
||
| for (const [runtimeId, deviceList] of Object.entries(result.devices)) { |
There was a problem hiding this comment.
Since simctl list devices -j does not return a runtimes key, and the devices key itself might be missing in some error states or different simctl versions, it's safer to use a fallback when accessing result.devices to avoid a crash during iteration.
| for (const [runtimeId, deviceList] of Object.entries(result.devices)) { | |
| for (const [runtimeId, deviceList] of Object.entries(result.devices ?? {})) { |
|
|
||
| export async function listRuntimes(simctl: SimctlExecutor): Promise<SimulatorRuntime[]> { | ||
| const result = await simctl.execJson<SimctlListResult>(['list', 'runtimes']); | ||
| return result.runtimes.filter(r => r.isAvailable); |
There was a problem hiding this comment.
Similar to listDevices, result.runtimes will be undefined if the JSON response from simctl only contains other keys (like devices). Adding a fallback prevents a runtime error when calling .filter().
| return result.runtimes.filter(r => r.isAvailable); | |
| return (result.runtimes ?? []).filter(r => r.isAvailable); |
| const keywords = lower.split(/[\s-]+/); | ||
| const fuzzy = devices.find(d => { |
There was a problem hiding this comment.
The fuzzy matching logic can match all devices if the presetKey results in empty keywords (e.g., if it contains only whitespace or multiple separators). Since string.includes("") is always true, the current implementation would return the first available device for such inputs. Filtering out empty keywords and ensuring the keyword list is not empty improves the reliability of the resolver.
| const keywords = lower.split(/[\s-]+/); | |
| const fuzzy = devices.find(d => { | |
| const keywords = lower.split(/[\s-]+/).filter(Boolean); | |
| if (keywords.length === 0) throw new DeviceNotFoundError(presetKey, devices.map(d => d.name)); | |
| const fuzzy = devices.find(d => { |
Address gemini-code-assist review on PR #738: - Treat `SimctlListResult.devices` and `runtimes` as optional. `simctl list devices -j` and `simctl list runtimes -j` only return the relevant key, so marking both required misrepresents the wire format and made `Object.entries` / `.filter` brittle to a missing key. - Add `?? {}` / `?? []` fallbacks at the call sites for the same reason. - In `resolveDevice`, filter empty fuzzy keywords. With whitespace- or separator-only input the previous split produced an array of `""` tokens, and `every(kw => name.includes(""))` returned `true` for every device — silently resolving to the first available simulator. Skipping the fuzzy step when no real keywords remain makes the resolver fall through to the explicit `DeviceNotFoundError`. Tests: existing 20 device-catalog/error suites still pass.
Moves boot, shutdown, erase, delete, clone from SimulatorManager into src/simulator/lifecycle.ts. SimulatorManager delegates to the new module; all public method shapes are unchanged. Behavior-preserving: same timeouts, same retry policy, same shutdown escalation (poll → retry → nuclear erase). Timeout errors surface explicitly — ShutdownTimeoutError is thrown after erase, not swallowed. Erase/delete require explicit caller intent (no implicit cleanup). Adds tests/unit/simulator-lifecycle.test.ts: boot happy path, boot timeout, shutdown happy path, shutdown timeout surfaces, delete explicitness, clone UDID extraction. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…4/4) Extract screenshot, setAppearance/getAppearance/toggleAppearance, rotate, overrideStatusBar, and openUrl from SimulatorManager into src/simulator/ui-controller.ts with dependency-injected SimctlExecutor. SimulatorManager is now a thin facade: constructor wiring + one-liner delegations only — zero business logic remaining in manager.ts. Behavior strictly preserved: same simctl commands, same error semantics, same screenshot temp-file cleanup. Transient-screenshot retry (PR #658) lives in src/tools/app-screenshot-native.ts (MCP tool layer, unchanged). Tests: 97 existing tests pass; 30 new tests added in tests/unit/simulator-ui-controller.test.ts covering all extracted functions. manager.ts reduction across 4-PR series: PR1 (#738, errors+catalog): 560 → 452 lines (-108) PR2 (#740, lifecycle): 452 → 404 lines (-48) PR3 (#742, app-manager): 404 → 296 lines (-108) PR4 (this, ui-controller): 296 → 211 lines (-85) Total: 560 → 211 lines (-349 lines, -62%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…4/4) Extract screenshot, setAppearance/getAppearance/toggleAppearance, rotate, overrideStatusBar, and openUrl from SimulatorManager into src/simulator/ui-controller.ts with dependency-injected SimctlExecutor. SimulatorManager is now a thin facade: constructor wiring + one-liner delegations only — zero business logic remaining in manager.ts. Behavior strictly preserved: same simctl commands, same error semantics, same screenshot temp-file cleanup. Transient-screenshot retry (PR #658) lives in src/tools/app-screenshot-native.ts (MCP tool layer, unchanged). Tests: 97 existing tests pass; 30 new tests added in tests/unit/simulator-ui-controller.test.ts covering all extracted functions. manager.ts reduction across 4-PR series: PR1 (#738, errors+catalog): 560 → 452 lines (-108) PR2 (#740, lifecycle): 452 → 404 lines (-48) PR3 (#742, app-manager): 404 → 296 lines (-108) PR4 (this, ui-controller): 296 → 211 lines (-85) Total: 560 → 211 lines (-349 lines, -62%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Address codex review on PR #740 (de3d645): The previous iteration of this PR re-threw `ShutdownTimeoutError` after the nuclear `erase` step. That broke the pre-refactor `SimulatorManager.shutdown` contract: it used to return on a successful erase, so MCP callers like `device_shutdown` (`src/tools/device-shutdown.ts`) treated a stalled-but- cleaned-up teardown as success. Re-throwing turned that into a user-visible hard failure even though the simulator was already wiped. Match pre-refactor behavior exactly: - After `simctl erase` succeeds, return. - If the erase itself fails, propagate the underlying `SimctlError` so callers can still distinguish "cleaned up" from "couldn't even erase". Tests: - Replace the `ShutdownTimeoutError` assertions with the best-effort-cleanup contract: `shutdown(...)` resolves after the nuclear erase, and propagates `SimctlError` when erase itself fails. The "issues simctl erase" test is reworded to no longer imply a throw. - Drop the now-redundant `ShutdownTimeoutError` import from the lifecycle module (the error class itself stays exported from `errors.ts` for callers that want to surface a timeout in a higher-level wrapper).
Behavior-preserving extraction of installApp/launchApp/terminateApp/ uninstallApp/activateApp/listRunningApps/resetApp from SimulatorManager into src/simulator/app-manager.ts. Same simctl commands, same error semantics (AppNotInstalledError, AppLaunchError), same retry/timeout policy. SimulatorManager facade delegates to the new module — all public method signatures unchanged. Adds tests/unit/simulator-app-manager.test.ts with 28 cases covering install/launch/terminate happy paths, DeviceNotBootedError, AppNotInstalledError, AppLaunchError, and resetApp step tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…etection (#708 3/4) Replace greedy /\[.*\]$/ with non-greedy /\[[^\]]*\]$/ in listRunningApps so a label containing nested brackets is stripped correctly. Extract isAppNotInstalledError() helper that checks 'domain not found' and 'not installed' patterns; replace the four duplicated inline checks in launchApp, terminateApp, activateApp, and resetApp with a single call. Add unit tests covering both detection strings and the non-greedy regex. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…#708 3/4) Codex P2 follow-up: the previous non-greedy regex `/\[[^\]]*\]$/` only stripped the last bracketed suffix, so labels like `UIKitApplication:com.example.app[0x1][debug]` became `com.example.app[0x1]`, causing classifyMobileContext bundle-id comparisons to mismatch. Switch to `/(\[[^\]]*\])+$/` so one or more trailing bracket groups are removed without overshooting bracketless prefixes. Test added for the multi-suffix case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…4/4) Extract screenshot, setAppearance/getAppearance/toggleAppearance, rotate, overrideStatusBar, and openUrl from SimulatorManager into src/simulator/ui-controller.ts with dependency-injected SimctlExecutor. SimulatorManager is now a thin facade: constructor wiring + one-liner delegations only — zero business logic remaining in manager.ts. Behavior strictly preserved: same simctl commands, same error semantics, same screenshot temp-file cleanup. Transient-screenshot retry (PR #658) lives in src/tools/app-screenshot-native.ts (MCP tool layer, unchanged). Tests: 97 existing tests pass; 30 new tests added in tests/unit/simulator-ui-controller.test.ts covering all extracted functions. manager.ts reduction across 4-PR series: PR1 (#738, errors+catalog): 560 → 452 lines (-108) PR2 (#740, lifecycle): 452 → 404 lines (-48) PR3 (#742, app-manager): 404 → 296 lines (-108) PR4 (this, ui-controller): 296 → 211 lines (-85) Total: 560 → 211 lines (-349 lines, -62%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…simctl.exec (#708 4/4) - Hoist `promisify(execFile)` to module top-level as `execFileAsync` instead of recreating it on every rotate() call. - Replace direct `xcrun simctl` execFile invocation in the simctl path with `deps.simctl.exec(['io', deviceId, 'setorientation', orientation], { timeout: 10000 })` for consistency with the rest of the module (xcrun prefixing + structured errors). - AppleScript fallback path retains `execFileAsync` (module-level) for osascript; simctl-first / AppleScript-fallback ordering and error semantics are fully preserved. - Update rotation-fallback.test.ts mocks to provide `{ stdout, stderr }` result object required by promisify(execFile) so SimctlExecutor.exec() resolves correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Pushed fix commit addressing previous review feedback. @codex review @gemini-code-assist review |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Codex Review: Didn't find any major issues. 🎉 ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Resolve conflicts and integrate develop changes with the simulator refactor: - Preserve facade pattern in src/simulator/manager.ts; new state-cache and getDeviceState logic delegates to extracted modules. - Wire invalidateCache + getDeviceState through src/simulator/lifecycle.ts so polling loops observe transient ShuttingDown via SimulatorStateCache. - Extend logAuditEntry with optional status parameter to satisfy mcp-server call sites added on develop. - Take develop's mcp-server.ts signatures (returns MCP envelope shape). Conflicts resolved per plan in src/simulator/manager.ts.
Fixes 24 of the test failures introduced by the develop merge: - auth-tools-verification, issue-167-wire-verification: add httpHighRiskTools flag so tier-3 / high-risk auth tools register on the test HTTP transport. - mcp-server: add httpInsecure to the freshServer.start() call so the request reaches the JSON-RPC handler instead of returning 401. - http-high-risk-tools: spy fs.appendFileSync (matches the audit logger's current write path) instead of the deprecated fs.appendFile signature. - audit-logger: drop 'text' and 'value' from SENSITIVE_KEYS — the spec is to redact only credential-named keys; over-redacting blanks out legitimate type.text and select_option.value arguments. Same change applied in sibling PR #731. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 86a1920d58
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'session', | ||
| // Free-form user input that flows through MCP tool calls (e.g. | ||
| // `type.text`, `select_option.value`) can carry passwords or OTPs, so | ||
| // redact these keys defensively even though they are not credentials | ||
| // by name. | ||
| 'text', | ||
| 'value', | ||
| ]; |
There was a problem hiding this comment.
Restore redaction for free-form text/value audit fields
Keep text and value in SENSITIVE_KEYS; removing them causes raw user-entered form content to be written to args_summary in audit logs. MCP tool payloads commonly carry credentials/OTPs under keys like type.text and select_option.value, so this change reintroduces a concrete secret-leak path even when other credential-named fields are redacted.
Useful? React with 👍 / 👎.
Restore defensive redaction for free-form MCP text/value arguments and add regression coverage for the device-catalog hardening that earlier bot reviews requested. Constraint: PR #738 is a #708 simulator refactor but includes develop audit-hardening reconciliation. Rejected: leaving text/value visible in audit summaries | Codex flagged a concrete secret-leak path for type.text and select_option.value. Confidence: high Scope-risk: narrow Directive: Keep audit policy changes separate from simulator refactors unless they are conflict-resolution fixes with explicit regression tests. Tested: npx jest affected 12 suites --runInBand; npx tsc --noEmit; npm run lint -- --quiet; npm run build; git diff --check Not-tested: live macOS Simulator smoke.
|
I reviewed this PR for intent, scope, and merge readiness. Summary:
Validation evidence:
Residual risk:
Given the above, this PR has been improved to a merge-ready state and is safe to merge. |
Implements #708 steps 1+2 (errors + device catalog). Behavior-preserving. Follow-ups: lifecycle, app-manager, ui-controller+facade.
Does not Close — multi-PR refactor.
What changed
src/simulator/errors.ts(new, 70 lines): all 7 simulator error classes extracted verbatim —BootTimeoutError,ShutdownTimeoutError,DeviceNotFoundError,DeviceNotBootedError,ScreenshotTimeoutError,AppNotInstalledError,AppLaunchError. Same names, same constructors, sameinstanceofsemantics.src/simulator/device-catalog.ts(new, 87 lines): pure discovery/catalog functions (listDevices,listRuntimes,getDevice,resolveDevice) extracted fromSimulatorManager. Uses dependency injection forSimctlExecutor.src/simulator/manager.ts(reduced from 560 → 357 lines, −203): imports catalog functions and delegates; re-exports all error classes as a compatibility shim so existing callers are unaffected.Tests
tests/unit/simulator-errors.test.ts(new): 14 cases —instanceof,.name,.messagecontent, and public fields for every error class; also verifies manager re-exports are identical classes.tests/unit/device-catalog.test.ts(new): 9 cases —listDeviceshappy path, unavailable filtering, unknown runtime, multi-runtime;getDeviceby UDID + unknown UDID;resolveDeviceby UDID/preset/substring/fuzzy +DeviceNotFoundErrorshape.Validation
npx tsc --noEmit: cleannpx jest(new tests): 20/20 passednpx jest(existing simulator suites): 33/33 passednpm run build: successnpm run lint -- --quiet: cleangit diff --check: clean