Skip to content

Commit b3960a5

Browse files
committed
feat: export error contract helpers
1 parent d2b02f1 commit b3960a5

6 files changed

Lines changed: 234 additions & 196 deletions

File tree

src/__tests__/contracts-schema-public.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import { AppError } from '../index.ts';
36
import {
7+
defaultHintForCode,
48
daemonCommandRequestSchema,
59
daemonRuntimeSchema,
610
centerOfRect,
711
jsonRpcRequestSchema,
812
leaseAllocateSchema,
913
leaseHeartbeatSchema,
1014
leaseReleaseSchema,
15+
normalizeError,
16+
type AppErrorCode,
1117
type Rect,
1218
type SnapshotNode,
1319
} from '../contracts.ts';
1420

21+
const invalidArgsCode = 'INVALID_ARGS' satisfies AppErrorCode;
1522
const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect;
1623
const node = {
1724
index: 0,
@@ -21,6 +28,15 @@ const node = {
2128
rect,
2229
} satisfies SnapshotNode;
2330

31+
test('public contracts entrypoint does not import Node-only error module', () => {
32+
const contractsSource = fs.readFileSync(
33+
path.join(import.meta.dirname, '..', 'contracts.ts'),
34+
'utf8',
35+
);
36+
37+
assert.doesNotMatch(contractsSource, /['"]\.\/utils\/errors\.ts['"]/);
38+
});
39+
2440
test('public contract schemas validate daemon requests and lease payloads', () => {
2541
const runtime = daemonRuntimeSchema.parse({
2642
platform: 'ios',
@@ -70,6 +86,17 @@ test('public contract schemas validate daemon requests and lease payloads', () =
7086
assert.equal(node.ref, 'e1');
7187
});
7288

89+
test('public contract exports normalize and hint app errors', () => {
90+
const normalized = normalizeError(new AppError(invalidArgsCode, 'Invalid command'));
91+
92+
assert.equal(normalized.code, invalidArgsCode);
93+
assert.equal(normalized.hint, defaultHintForCode(invalidArgsCode));
94+
assert.equal(
95+
defaultHintForCode('UNKNOWN'),
96+
'Retry with --debug and inspect diagnostics log for details.',
97+
);
98+
});
99+
73100
test('public contract schemas reject invalid payloads', () => {
74101
assert.throws(
75102
() =>

src/contracts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
export type { AppErrorCode } from './utils/error-contract.ts';
2+
export { defaultHintForCode, normalizeError } from './utils/error-contract.ts';
3+
14
export type SessionRuntimeHints = {
25
platform?: 'ios' | 'android';
36
metroHost?: string;

src/utils/diagnostics.ts

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
33
import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6+
import { redactDiagnosticData } from './redaction.ts';
67

78
type DiagnosticLevel = 'info' | 'warn' | 'error' | 'debug';
89

@@ -33,11 +34,6 @@ type DiagnosticsScope = DiagnosticsScopeOptions & {
3334

3435
const diagnosticsStorage = new AsyncLocalStorage<DiagnosticsScope>();
3536

36-
const SENSITIVE_KEY_RE =
37-
/(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i;
38-
const SENSITIVE_VALUE_RE =
39-
/(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i;
40-
4137
export function createRequestId(): string {
4238
return crypto.randomBytes(8).toString('hex');
4339
}
@@ -161,58 +157,6 @@ export function flushDiagnosticsToSessionFile(options: { force?: boolean } = {})
161157
}
162158
}
163159

164-
export function redactDiagnosticData<T>(input: T): T {
165-
return redactValue(input, new WeakSet<object>()) as T;
166-
}
167-
168-
function redactValue(value: unknown, seen: WeakSet<object>, keyHint?: string): unknown {
169-
if (value === null || value === undefined) return value;
170-
if (typeof value === 'string') return redactString(value, keyHint);
171-
if (typeof value !== 'object') return value;
172-
173-
if (seen.has(value as object)) return '[Circular]';
174-
seen.add(value as object);
175-
176-
if (Array.isArray(value)) {
177-
return value.map((entry) => redactValue(entry, seen));
178-
}
179-
180-
const output: Record<string, unknown> = {};
181-
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
182-
if (SENSITIVE_KEY_RE.test(key)) {
183-
output[key] = '[REDACTED]';
184-
continue;
185-
}
186-
output[key] = redactValue(entry, seen, key);
187-
}
188-
return output;
189-
}
190-
191-
function redactString(value: string, keyHint?: string): string {
192-
const trimmed = value.trim();
193-
if (!trimmed) return value;
194-
if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]';
195-
if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]';
196-
const maskedUrl = redactUrl(trimmed);
197-
if (maskedUrl) return maskedUrl;
198-
if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...<truncated>`;
199-
return trimmed;
200-
}
201-
202-
function redactUrl(value: string): string | null {
203-
try {
204-
const parsed = new URL(value);
205-
if (parsed.search) parsed.search = '?REDACTED';
206-
if (parsed.username || parsed.password) {
207-
parsed.username = 'REDACTED';
208-
parsed.password = 'REDACTED';
209-
}
210-
return parsed.toString();
211-
} catch {
212-
return null;
213-
}
214-
}
215-
216160
function sanitizePathPart(value: string): string {
217161
return value.replace(/[^a-zA-Z0-9._-]/g, '_');
218162
}

src/utils/error-contract.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { redactDiagnosticData } from './redaction.ts';
2+
3+
export type AppErrorCode =
4+
| 'INVALID_ARGS'
5+
| 'DEVICE_NOT_FOUND'
6+
| 'TOOL_MISSING'
7+
| 'APP_NOT_INSTALLED'
8+
| 'UNSUPPORTED_PLATFORM'
9+
| 'UNSUPPORTED_OPERATION'
10+
| 'COMMAND_FAILED'
11+
| 'SESSION_NOT_FOUND'
12+
| 'UNAUTHORIZED'
13+
| 'UNKNOWN';
14+
15+
type AppErrorDetails = Record<string, unknown> & {
16+
hint?: string;
17+
diagnosticId?: string;
18+
logPath?: string;
19+
};
20+
21+
export type NormalizedError = {
22+
code: string;
23+
message: string;
24+
hint?: string;
25+
diagnosticId?: string;
26+
logPath?: string;
27+
details?: Record<string, unknown>;
28+
};
29+
30+
export class AppError extends Error {
31+
code: AppErrorCode;
32+
details?: AppErrorDetails;
33+
cause?: unknown;
34+
35+
constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
36+
super(message);
37+
this.code = code;
38+
this.details = details;
39+
this.cause = cause;
40+
}
41+
}
42+
43+
export function asAppError(err: unknown): AppError {
44+
if (err instanceof AppError) return err;
45+
if (err instanceof Error) {
46+
return new AppError('UNKNOWN', err.message, undefined, err);
47+
}
48+
return new AppError('UNKNOWN', 'Unknown error', { err });
49+
}
50+
51+
export function normalizeError(
52+
err: unknown,
53+
context: { diagnosticId?: string; logPath?: string } = {},
54+
): NormalizedError {
55+
const appErr = asAppError(err);
56+
const details = appErr.details ? redactDiagnosticData(appErr.details) : undefined;
57+
const detailHint = details && typeof details.hint === 'string' ? details.hint : undefined;
58+
const diagnosticId =
59+
(details && typeof details.diagnosticId === 'string' ? details.diagnosticId : undefined) ??
60+
context.diagnosticId;
61+
const logPath =
62+
(details && typeof details.logPath === 'string' ? details.logPath : undefined) ??
63+
context.logPath;
64+
const hint = detailHint ?? defaultHintForCode(appErr.code);
65+
const cleanDetails = stripDiagnosticMeta(details);
66+
const message = maybeEnrichCommandFailedMessage(appErr.code, appErr.message, details);
67+
68+
return {
69+
code: appErr.code,
70+
message,
71+
hint,
72+
diagnosticId,
73+
logPath,
74+
details: cleanDetails,
75+
};
76+
}
77+
78+
function maybeEnrichCommandFailedMessage(
79+
code: string,
80+
message: string,
81+
details: Record<string, unknown> | undefined,
82+
): string {
83+
if (code !== 'COMMAND_FAILED') return message;
84+
if (details?.processExitError !== true) return message;
85+
const stderr = typeof details?.stderr === 'string' ? details.stderr : '';
86+
const excerpt = firstStderrLine(stderr);
87+
if (!excerpt) return message;
88+
return excerpt;
89+
}
90+
91+
function firstStderrLine(stderr: string): string | null {
92+
const skipPatterns = [
93+
/^an error was encountered processing the command/i,
94+
/^underlying error\b/i,
95+
/^simulator device failed to complete the requested operation/i,
96+
];
97+
98+
for (const rawLine of stderr.split('\n')) {
99+
const line = rawLine.trim();
100+
if (!line) continue;
101+
if (skipPatterns.some((pattern) => pattern.test(line))) continue;
102+
return line.length > 200 ? `${line.slice(0, 200)}...` : line;
103+
}
104+
return null;
105+
}
106+
107+
function stripDiagnosticMeta(
108+
details: Record<string, unknown> | undefined,
109+
): Record<string, unknown> | undefined {
110+
if (!details) return undefined;
111+
const output = { ...details };
112+
delete output.hint;
113+
delete output.diagnosticId;
114+
delete output.logPath;
115+
return Object.keys(output).length > 0 ? output : undefined;
116+
}
117+
118+
export function defaultHintForCode(code: string): string | undefined {
119+
switch (code) {
120+
case 'INVALID_ARGS':
121+
return 'Check command arguments and run --help for usage examples.';
122+
case 'SESSION_NOT_FOUND':
123+
return 'Run open first or pass an explicit device selector.';
124+
case 'TOOL_MISSING':
125+
return 'Install required platform tooling and ensure it is available in PATH.';
126+
case 'DEVICE_NOT_FOUND':
127+
return 'Verify the target device is booted/connected and selectors match.';
128+
case 'APP_NOT_INSTALLED':
129+
return 'Run apps to discover the exact installed package or bundle id, or install the app before open.';
130+
case 'UNSUPPORTED_OPERATION':
131+
return 'This command is not available for the selected platform/device.';
132+
case 'COMMAND_FAILED':
133+
return 'Retry with --debug and inspect diagnostics log for details.';
134+
case 'UNAUTHORIZED':
135+
return 'Refresh daemon metadata and retry the command.';
136+
default:
137+
return 'Retry with --debug and inspect diagnostics log for details.';
138+
}
139+
}

0 commit comments

Comments
 (0)