From 660e592fccdd0762e758e7ec771ee3590e54a4de Mon Sep 17 00:00:00 2001 From: Qiaochu Hu <110hqc@gmail.com> Date: Fri, 15 May 2026 19:12:44 +0800 Subject: [PATCH] fix: add error handling to parseServiceCliOutput (#1737) Co-authored-by: Test User Co-authored-by: Claude Opus 4.7 Co-authored-by: Steven Enamakel --- app/src/utils/tauriCommands/common.test.ts | 63 +++++++++++++++++++++- app/src/utils/tauriCommands/common.ts | 28 +++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/app/src/utils/tauriCommands/common.test.ts b/app/src/utils/tauriCommands/common.test.ts index f339a2d0b..5c20b0d3a 100644 --- a/app/src/utils/tauriCommands/common.test.ts +++ b/app/src/utils/tauriCommands/common.test.ts @@ -15,7 +15,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { isTauri } from './common'; +import { isTauri, parseServiceCliOutput } from './common'; const coreIsTauriMock = vi.fn(); @@ -90,3 +90,64 @@ describe('isTauri (tauriCommands/common)', () => { expect(isTauri()).toBe(false); }); }); + +// `parseServiceCliOutput` runs against raw CLI stdout from the `openhuman` +// sidecar. The core process can crash mid-write, return a partial response, or +// drift from the expected JSON schema across versions — any of which produces +// malformed input. The guard must reject those cases with a descriptive error +// rather than handing typed garbage back to callers. +describe('parseServiceCliOutput (tauriCommands/common)', () => { + it('returns the parsed response when shape matches CommandResponse', () => { + const raw = JSON.stringify({ result: { value: 42 }, logs: ['ok'] }); + + const parsed = parseServiceCliOutput<{ value: number }>(raw); + + expect(parsed.result).toEqual({ value: 42 }); + expect(parsed.logs).toEqual(['ok']); + }); + + it('accepts null result and empty logs (valid CommandResponse shape)', () => { + const raw = JSON.stringify({ result: null, logs: [] }); + + const parsed = parseServiceCliOutput(raw); + + expect(parsed.result).toBeNull(); + expect(parsed.logs).toEqual([]); + }); + + it('throws a descriptive error when the input is not valid JSON', () => { + expect(() => parseServiceCliOutput('not-json')).toThrow(/Failed to parse service CLI output/); + }); + + it('throws when the parsed value is null', () => { + expect(() => parseServiceCliOutput('null')).toThrow(/CommandResponse shape/); + }); + + it('throws when the parsed value is an array (not an object)', () => { + expect(() => parseServiceCliOutput('[]')).toThrow(/CommandResponse shape/); + }); + + it('throws when required `logs` field is missing', () => { + expect(() => parseServiceCliOutput(JSON.stringify({ result: 1 }))).toThrow( + /CommandResponse shape/ + ); + }); + + it('throws when required `result` field is missing', () => { + expect(() => parseServiceCliOutput(JSON.stringify({ logs: [] }))).toThrow( + /CommandResponse shape/ + ); + }); + + it('throws when `logs` is not an array', () => { + expect(() => parseServiceCliOutput(JSON.stringify({ result: 1, logs: 'oops' }))).toThrow( + /CommandResponse shape/ + ); + }); + + it('throws when `logs` contains non-string entries', () => { + expect(() => parseServiceCliOutput(JSON.stringify({ result: 1, logs: [1, 2] }))).toThrow( + /CommandResponse shape/ + ); + }); +}); diff --git a/app/src/utils/tauriCommands/common.ts b/app/src/utils/tauriCommands/common.ts index 6115af3d4..a1edbc1e4 100644 --- a/app/src/utils/tauriCommands/common.ts +++ b/app/src/utils/tauriCommands/common.ts @@ -64,7 +64,33 @@ export function tauriErrorMessage(err: unknown): string { return 'Unknown Tauri invoke error'; } +function isCommandResponse(value: unknown): value is CommandResponse { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const candidate = value as { result?: unknown; logs?: unknown }; + if (!('result' in candidate) || !('logs' in candidate)) { + return false; + } + if (!Array.isArray(candidate.logs)) { + return false; + } + return candidate.logs.every(entry => typeof entry === 'string'); +} + export function parseServiceCliOutput(raw: string): CommandResponse { - const parsed = JSON.parse(raw) as CommandResponse; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error( + `Failed to parse service CLI output as JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } + if (!isCommandResponse(parsed)) { + throw new Error( + 'Failed to parse service CLI output as JSON: parsed value does not match CommandResponse shape' + ); + } return parsed; }