fix(ui): localize native Android action displays

This commit is contained in:
chenmingxuan committed 2026-11-02 22:33:40 +08:00
1 parent 4b12ae7728
commit 6a69a595ef
5 files changed
+192 -3

No files matched your search

@@ -621,3 +621,40 @@ describe('extractActionExtraParams', () => {
expect(sequence.map(p => p.key)).toContain('Interval Ms');
});
});
describe('native action Chinese presentation', () => {
it('renders a pinch trace in Chinese without modifying the recorded gesture', () => {
const action = {
name: 'pinch', args: {
center: [360, 1040], start_span: 100, end_span: 400,
duration_ms: 1000, coordinate_space: 'pixel', target_description: 'timeline'
}
};
const original = JSON.stringify(action);
expect(getActionTitle(action)).toBe('双指缩放/旋转');
expect(extractActionExtraParams(action)).toContain({ key: '坐标空间', value: '像素坐标' });
expect(extractActionExtraParams(action)).toContain({ key: '起始指间距', value: '100' });
expect(JSON.stringify(action)).toBe(original);
});
it('translates sequence members while preserving literal target text and IDs', () => {
const params = extractActionExtraParams({ action: 'action_sequence', steps: [
{ action: 'control_action', args: {
selector: { text: 'visible', resource_id: 'demo:id/set_text' },
action: 'replace_selection', text: 'set_text'
} },
{ action: 'wait_until', state: 'stable', stable_ms: 500 },
{ action: 'click', args: { target: [100, 200] } },
{ action: 'wait', time_in_ms: 100 }
] });
const steps = JSON.parse(params.find(param => param.key === '动作步骤')!.value);
expect(steps[0]['操作']).toBe('操作原生控件');
expect(steps[0]['参数']['操作']).toBe('替换选区文本');
expect(steps[0]['参数']['控件选择器']['文本']).toBe('visible');
expect(steps[0]['参数']['控件选择器']['资源 ID']).toBe('demo:id/set_text');
expect(steps[0]['参数']['文本']).toBe('set_text');
expect(steps[1]['等待条件']).toBe('持续稳定');
expect(steps[2]['操作']).toBe('点击元素');
expect(steps[3]['操作']).toBe('等待');
});
});
@@ -18,6 +18,7 @@ import { ActionParam, StepReplayFrame } from '../core/models/stream.model';
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates, unwrapTraceAction } from './image-overlay.util';
import { cleanErrorMessage, joinTargetDescriptions } from './tool-formatter.util';
import { getPluginToolView } from './plugin-tool-view.util';
import { formatNativeActionParam, getNativeActionArgs, getNativeActionTitle } from './native-action-labels.util';
/**
* Safely parse JSON string if it looks like an object/array, otherwise return original value
@@ -170,6 +171,8 @@ export function getActionTitle(action: any): string {
const plugin = getPluginToolView(act);
if (plugin) return plugin.title;
const name = (act.name || act.action || '').toLowerCase();
const nativeTitle = getNativeActionTitle(name, act.args || act);
if (nativeTitle) return nativeTitle;
switch (name) {
case 'tap':
case 'click':
@@ -539,6 +542,8 @@ export function extractActionExtraParams(action: any, cache?: WeakMap<any, Actio
}
const mergedObj = { ...act, ...parsedArgs };
const native = getNativeActionTitle(act.name || act.action || '', parsedArgs) !== null;
if (native) Object.assign(mergedObj, getNativeActionArgs(parsedArgs));
// Explicitly check duration if available and not already formatted
const dur = mergedObj.duration || mergedObj.duration_ms;
@@ -548,8 +553,12 @@ export function extractActionExtraParams(action: any, cache?: WeakMap<any, Actio
for (const [k, v] of Object.entries(mergedObj)) {
const lowerK = k.toLowerCase();
if (standardKeys.has(lowerK)) continue;
if (standardKeys.has(lowerK) && !(native && lowerK === 'target')) continue;
if (v === null || v === undefined || v === '') continue;
if (native) {
if (lowerK !== 'duration_ms') result.push(formatNativeActionParam(k, v));
continue;
}
let valStr = String(v);
if (valStr.includes('object at 0x') || valStr.startsWith('<artemis.') || valStr.includes('<controller')) continue;
@@ -0,0 +1,102 @@
import { ActionParam } from '../core/models/stream.model';
// Display-only translations. Protocol names and original trace payloads stay intact.
const ACTION_LABELS = new Map(Object.entries({
pinch: '双指缩放/旋转',
touch_gesture: '连续多指手势',
read_control: '读取原生控件',
control_action: '操作原生控件',
wait_until: '等待控件条件',
scroll_to: '滚动查找控件',
action_sequence: '动作序列'
}));
const CONTROL_LABELS = new Map(Object.entries({
click: '原生点击', long_click: '原生长按', focus: '聚焦控件',
show_on_screen: '显示控件', set_progress: '设置控件值',
scroll_forward: '向前滚动', scroll_backward: '向后滚动',
scroll_up: '向上滚动', scroll_down: '向下滚动',
scroll_left: '向左滚动', scroll_right: '向右滚动',
set_selection: '设置文本选区', replace_selection: '替换选区文本',
set_text: '设置文本', ime_enter: '提交输入法操作'
}));
const STEP_LABELS = new Map(Object.entries({
click: '点击元素', long_press: '长按元素', drag: '拖动屏幕', wait: '等待'
}));
const PARAM_LABELS = new Map(Object.entries({
center: '中心点', start_span: '起始指间距', end_span: '结束指间距',
angle_degrees: '起始角度', rotation_degrees: '旋转角度',
duration_ms: '持续时间(毫秒)', coordinate_space: '坐标空间',
frames: '触摸轨迹', at_ms: '时间点(毫秒)', points: '触点',
selector: '控件选择器', container: '滚动容器', target: '目标控件',
resource_id: '资源 ID', text: '文本', content_desc: '控件描述',
class_name: '控件类名', package_name: '应用包名', bounds: '控件边界',
expected_token: '预期控件标识', value: '目标值',
selection_start: '选区起点', selection_end: '选区终点',
state: '等待条件', expected_text: '预期文本', expected_value: '预期值',
tolerance: '允许误差', timeout_ms: '超时(毫秒)',
poll_interval_ms: '检查间隔(毫秒)', stable_ms: '稳定时长(毫秒)',
direction: '滚动方向', max_scrolls: '最多滚动次数', settle_ms: '停稳时长(毫秒)',
require_visible: '可见条件', unless_visible: '跳过条件',
steps: '动作步骤', action: '操作', args: '参数',
time_in_ms: '等待时长(毫秒)', target_description: '目标说明',
execution: '执行状态', verification: '验证状态'
}));
const ENUM_LABELS: Record<string, Map<string, string>> = {
coordinate_space: new Map(Object.entries({ normalized: '归一化坐标', pixel: '像素坐标' })),
state: new Map(Object.entries({
visible: '可见', absent: '消失', enabled: '可用', disabled: '禁用',
checked: '已勾选', unchecked: '未勾选', selected: '已选中',
unselected: '未选中', text: '文本匹配', value: '数值匹配', stable: '持续稳定'
})),
direction: new Map(Object.entries({
forward: '向前', backward: '向后', up: '向上', down: '向下', left: '向左', right: '向右'
})),
execution: new Map(Object.entries({
not_started: '未开始', observed: '已观测', completed: '已完成',
partial: '部分完成', cancelled: '已取消', unknown: '结果未知'
})),
verification: new Map(Object.entries({ pending_observation: '等待观测' }))
};
export function getNativeActionTitle(name: string, args: any = {}): string | null {
const clean = String(name).toLowerCase().replace(/^_?(?:self\.)?exec_/, '')
.replace(/^action_sequence\./, '');
if (clean === 'control_action') {
const request = getNativeActionArgs(args);
return CONTROL_LABELS.get(request.action) ?? ACTION_LABELS.get(clean)!;
}
return ACTION_LABELS.get(clean) ?? null;
}
export function getNativeActionArgs(args: any): any {
return args?.action === 'control_action' && args.args ? args.args : (args ?? {});
}
function localizedValue(value: any, key: string, controlAction = false): any {
if (Array.isArray(value)) return value.map(item => localizedValue(item, key, controlAction));
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([field, item]) => [
PARAM_LABELS.get(field) ?? field,
localizedValue(item, field, field === 'action' ? controlAction : field === 'args' && value.action === 'control_action')
]));
}
if (typeof value === 'string') {
if (key === 'action') {
return ACTION_LABELS.get(value) ?? (controlAction ? CONTROL_LABELS : STEP_LABELS).get(value) ?? value;
}
if (Object.hasOwn(ENUM_LABELS, key)) return ENUM_LABELS[key].get(value) ?? value;
}
return value;
}
export function formatNativeActionParam(key: string, value: any): ActionParam {
const localized = localizedValue(value, key);
return {
key: PARAM_LABELS.get(key) ?? key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
value: typeof localized === 'object' ? JSON.stringify(localized) : String(localized)
};
}
@@ -6,6 +6,8 @@ import {
getCompressionPhaseLabel,
isCompressionWaiting,
getToolDisplayLabel,
getToolTitle,
getToolGenericDetails,
getToolIcon,
getToolTargetText,
getUniqueGenericTools,
@@ -373,3 +375,27 @@ describe('extractToolExtraParams', () => {
expect(keys).toContain('Interval Ms');
});
});
describe('native tool Chinese presentation', () => {
it('shares Chinese titles across live tool lines and expanded cards', () => {
for (const name of ['pinch', 'touch_gesture', 'read_control', 'wait_until', 'scroll_to', 'action_sequence']) {
const tool = { name: `self.exec_${name}`, payload: { args: {} } };
expect(getToolTitle(tool)).toMatch(/[\u4e00-\u9fff]/);
expect(getToolDisplayLabel(tool)).toBe(getToolTitle(tool));
}
});
it('understands nested native sequence calls and parsed payload arguments', () => {
const tool = { name: 'action_sequence.control_action', payload: { args: {
action: 'control_action', args: { action: 'ime_enter', selector: { content_desc: 'Send' } }
} } };
expect(getToolTitle(tool)).toBe('提交输入法操作');
expect(getToolDisplayLabel(tool)).toBe('提交输入法操作');
expect(extractToolExtraParams(tool)).toContain({ key: '控件选择器', value: '{"控件描述":"Send"}' });
const wait = { name: 'wait_until', payload: JSON.stringify({ args: {
state: 'text', expected_text: 'visible', timeout_ms: 3000
} }) };
expect(extractToolExtraParams(wait)).toContain({ key: '等待条件', value: '文本匹配' });
expect(getToolGenericDetails(wait)).toContain('预期文本:visible');
});
});
@@ -16,6 +16,7 @@
import { ActionParam } from '../core/models/stream.model';
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util';
import { formatNativeActionParam, getNativeActionArgs, getNativeActionTitle } from './native-action-labels.util';
// Tool objects are replaced (not mutated) when a trace is updated, so a
// WeakMap keyed on the tool is a safe memo for the parsed-args lookup that
@@ -553,6 +554,8 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
const cleanName = tool.name.replace(/^(_)?exec_/, '');
const nameLower = cleanName.toLowerCase();
const args = getToolArgs(tool);
const nativeTitle = getNativeActionTitle(tool.name, args);
if (nativeTitle) return nativeTitle;
switch (nameLower) {
case 'manage_app':
@@ -899,6 +902,8 @@ export function getToolIcon(tool: any): string {
*/
export function getToolTitle(tool: any): string {
if (!tool || !tool.name) return "工具调用";
const nativeTitle = getNativeActionTitle(tool.name, getToolArgs(tool));
if (nativeTitle) return nativeTitle;
const cleanName = tool.name.replace(/^(_)?exec_/, '');
const name = cleanName.toLowerCase();
switch (name) {
@@ -1191,6 +1196,9 @@ export function getAdbTerminalId(tool: any): string {
*/
export function getToolGenericDetails(tool: any): string {
if (!tool) return '';
if (getNativeActionTitle(tool.name || '', getToolArgs(tool))) {
return extractToolExtraParams(tool).map(param => `${param.key}:${param.value}`).join(',');
}
if (isAdbCommandTool(tool)) {
return '';
}
@@ -1235,7 +1243,9 @@ export function getToolErrorMessage(tool: any): string {
*/
export function extractToolExtraParams(toolData: any, cache?: WeakMap<any, ActionParam[]>): ActionParam[] {
if (!toolData) return [];
const payload = toolData.payload || toolData.args || toolData;
const nativeArgs = getNativeActionArgs(getToolArgs(toolData));
const native = getNativeActionTitle(toolData.name || '', nativeArgs) !== null;
const payload = native ? nativeArgs : (toolData.payload || toolData.args || toolData);
if (!payload || typeof payload !== 'object') return [];
if (cache && cache.has(payload)) {
return cache.get(payload)!;
@@ -1272,11 +1282,16 @@ export function extractToolExtraParams(toolData: any, cache?: WeakMap<any, Actio
}
const mergedObj = { ...toolData, ...(typeof payload === 'object' ? payload : {}), ...parsedArgs };
if (native) Object.assign(mergedObj, nativeArgs);
for (const [k, v] of Object.entries(mergedObj)) {
const lowerK = k.toLowerCase();
if (standardKeys.has(lowerK)) continue;
if (standardKeys.has(lowerK) && !(native && lowerK === 'target')) continue;
if (v === null || v === undefined || v === '') continue;
if (native) {
result.push(formatNativeActionParam(k, v));
continue;
}
let valStr = String(v);
if (valStr.includes('object at 0x') || valStr.startsWith('<artemis.') || valStr.includes('<controller') || valStr.includes('<android_world')) continue;