@miot-rn/common-component 1.0.3 → 1.0.4
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.
- package/dist/service/specs/index.js +63 -32
- package/dist/service/specs/spec.js +6 -0
- package/dist/service/specs/std-spec.js +10 -1
- package/dist/service/specs/subscribe-logging.test.d.ts +17 -0
- package/dist/service/specs/subscribe-logging.test.js +217 -0
- package/dist/service/track/TrackFlatList.d.ts +3 -0
- package/dist/service/track/TrackFlatList.js +19 -0
- package/dist/service/track/TrackScrollContext.d.ts +10 -0
- package/dist/service/track/TrackScrollContext.js +2 -0
- package/dist/service/track/TrackScrollView.d.ts +3 -0
- package/dist/service/track/TrackScrollView.js +20 -0
- package/dist/service/track/TrackService.d.ts +10 -0
- package/dist/service/track/TrackService.js +104 -0
- package/dist/service/track/index.d.ts +12 -0
- package/dist/service/track/index.js +9 -0
- package/dist/service/track/intersect.d.ts +8 -0
- package/dist/service/track/intersect.js +15 -0
- package/dist/service/track/intersect.test.d.ts +1 -0
- package/dist/service/track/intersect.test.js +189 -0
- package/dist/service/track/types.d.ts +16 -0
- package/dist/service/track/types.js +1 -0
- package/dist/service/track/useCardTrack.d.ts +14 -0
- package/dist/service/track/useCardTrack.js +61 -0
- package/dist/service/track/useExposeOnVisible.d.ts +11 -0
- package/dist/service/track/useExposeOnVisible.js +74 -0
- package/dist/service/track/usePageTrack.d.ts +7 -0
- package/dist/service/track/usePageTrack.js +101 -0
- package/dist/service/track/useTrack.d.ts +7 -0
- package/dist/service/track/useTrack.js +23 -0
- package/dist/service/track/useTrackScrollProvider.d.ts +24 -0
- package/dist/service/track/useTrackScrollProvider.js +81 -0
- package/dist/specs/instance-parser.js +13 -11
- package/dist/store/useGlobalSpecManager.d.ts +3 -0
- package/dist/store/useGlobalSpecManager.js +395 -54
- package/dist/store/useGlobalSpecManager.test.d.ts +1 -0
- package/dist/store/useGlobalSpecManager.test.js +247 -0
- package/dist/utils/subscribe-log.d.ts +1 -0
- package/dist/utils/subscribe-log.js +37 -0
- package/package.json +2 -2
|
@@ -19,10 +19,11 @@ import { Device } from 'miot';
|
|
|
19
19
|
import Subject from "./subject";
|
|
20
20
|
import { getEventGenerator } from "./utils";
|
|
21
21
|
import { encodeProp } from "../../utils/spec";
|
|
22
|
-
import { OK, PLUGIN_NULL_ERROR } from "./error-code";
|
|
22
|
+
import { OK, PLUGIN_NULL_ERROR, PROP_NOT_FOUND } from "./error-code";
|
|
23
23
|
import * as spec from "./spec";
|
|
24
24
|
import specCache, { CACHE_SOURCE } from "./specCache";
|
|
25
25
|
import { copy } from "../../utils/objects";
|
|
26
|
+
import { reportSubLog } from "../../utils/subscribe-log";
|
|
26
27
|
// 事件主题 management
|
|
27
28
|
const specSubject = new Subject(getEventGenerator('spec_subject', encodeProp));
|
|
28
29
|
export { CACHE_SOURCE };
|
|
@@ -38,6 +39,29 @@ export function isCacheEnabled() {
|
|
|
38
39
|
|
|
39
40
|
// Type definitions
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* 对齐 native 层返回的结果数组。
|
|
44
|
+
*
|
|
45
|
+
* native 层在遇到未识别的 siid/piid 时可能整体丢弃返回项,导致返回数组比入参数组短,
|
|
46
|
+
* 或对应位置为 undefined。这里按 props 长度做对齐,未命中位置补 PROP_NOT_FOUND 占位,
|
|
47
|
+
* 避免上层 `results.forEach(r => r.code)` 抛 TypeError。
|
|
48
|
+
*/
|
|
49
|
+
function alignResultsToProps(props, rawResults) {
|
|
50
|
+
const arr = Array.isArray(rawResults) ? rawResults : [];
|
|
51
|
+
return props.map((p, i) => {
|
|
52
|
+
const r = arr[i];
|
|
53
|
+
if (r && typeof r === 'object') {
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
siid: p.siid,
|
|
58
|
+
piid: p.piid,
|
|
59
|
+
code: PROP_NOT_FOUND,
|
|
60
|
+
message: 'native returned no result'
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
/**
|
|
42
66
|
* 读取属性值
|
|
43
67
|
*
|
|
@@ -108,43 +132,31 @@ export function getProps(props, options = {}) {
|
|
|
108
132
|
|
|
109
133
|
// 从源获取数据并更新缓存
|
|
110
134
|
const fetchFromSourceAndUpdateCache = cachedData => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
return results.map(r => ({
|
|
123
|
-
...r,
|
|
124
|
-
specDataSource: r.specDataSource || CACHE_SOURCE.GET
|
|
125
|
-
}));
|
|
135
|
+
const runFetch = () => spec.getProps(props).then(rawResults => {
|
|
136
|
+
// 与入参对齐,缺失位置补 PROP_NOT_FOUND 占位,避免 undefined 传导到上层
|
|
137
|
+
const results = alignResultsToProps(props, rawResults);
|
|
138
|
+
return ensureCache().then(() => {
|
|
139
|
+
results.forEach(result => {
|
|
140
|
+
if (result && result.code === OK && result.value !== undefined) {
|
|
141
|
+
const key = specCache.getCacheKey(result);
|
|
142
|
+
specCache.put(key, result.value, result.updateTime || Math.floor(Date.now() / 1000), CACHE_SOURCE.GET);
|
|
143
|
+
}
|
|
126
144
|
});
|
|
145
|
+
return results.map(r => ({
|
|
146
|
+
...r,
|
|
147
|
+
specDataSource: r.specDataSource || CACHE_SOURCE.GET
|
|
148
|
+
}));
|
|
127
149
|
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 如果强制刷新或缓存未命中,从源获取
|
|
153
|
+
if (forceRefresh || !cachedData || !useCache) {
|
|
154
|
+
return runFetch();
|
|
128
155
|
}
|
|
129
156
|
|
|
130
157
|
// 如果缓存中有数据,且fromSourceIfNeed为true,仍然从源获取并更新
|
|
131
158
|
if (fromSourceIfNeed && cachedData) {
|
|
132
|
-
return
|
|
133
|
-
// 更新缓存
|
|
134
|
-
return ensureCache().then(() => {
|
|
135
|
-
results.forEach(result => {
|
|
136
|
-
if (result.code === OK && result.value !== undefined) {
|
|
137
|
-
const key = specCache.getCacheKey(result);
|
|
138
|
-
specCache.put(key, result.value, result.updateTime || Math.floor(Date.now() / 1000), CACHE_SOURCE.GET);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
// 返回设备最新数据,缓存仅作为提前通知使用
|
|
142
|
-
return results.map(r => ({
|
|
143
|
-
...r,
|
|
144
|
-
specDataSource: r.specDataSource || CACHE_SOURCE.GET
|
|
145
|
-
}));
|
|
146
|
-
});
|
|
147
|
-
});
|
|
159
|
+
return runFetch();
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
// 直接返回缓存数据
|
|
@@ -338,6 +350,10 @@ export function subscribe(props, listener, options = {}) {
|
|
|
338
350
|
// 受全局开关控制
|
|
339
351
|
updateCache = cacheEnabled // 受全局开关控制
|
|
340
352
|
} = options;
|
|
353
|
+
const subT0 = Date.now();
|
|
354
|
+
const subStage = (name, extra = '') => {
|
|
355
|
+
reportSubLog(`[SPEC-SUB][specs.subscribe][${name}][${new Date().toISOString()}] propCount=${props.length}, cost=${Date.now() - subT0}ms, publishImmediately=${publishImmediately}${extra}`);
|
|
356
|
+
};
|
|
341
357
|
|
|
342
358
|
// 确保缓存已初始化
|
|
343
359
|
const ensureCache = () => {
|
|
@@ -347,9 +363,12 @@ export function subscribe(props, listener, options = {}) {
|
|
|
347
363
|
// 如果需要立即发布当前值,从缓存读取并通知
|
|
348
364
|
const publishFromCache = () => {
|
|
349
365
|
if (!publishImmediately) {
|
|
366
|
+
subStage('cache-skip');
|
|
350
367
|
return Promise.resolve();
|
|
351
368
|
}
|
|
369
|
+
subStage('cache-init-enter');
|
|
352
370
|
return ensureCache().then(() => {
|
|
371
|
+
subStage('cache-init-done');
|
|
353
372
|
// 从缓存读取并通知
|
|
354
373
|
props.forEach(prop => {
|
|
355
374
|
const key = specCache.getCacheKey(prop);
|
|
@@ -391,21 +410,33 @@ export function subscribe(props, listener, options = {}) {
|
|
|
391
410
|
|
|
392
411
|
// 执行订阅
|
|
393
412
|
const executeSubscribe = () => {
|
|
413
|
+
subStage('exec-subscribe-enter');
|
|
394
414
|
return spec.subscribe(props, wrappedListener);
|
|
395
415
|
};
|
|
396
416
|
|
|
417
|
+
// 看门狗:整条链路若长时间不 settle(例如 specCache.init / Host.storage.get 一直 pending),
|
|
418
|
+
// 成功和失败两个分支都不会执行,日志表现为彻底静默。这里用超时把"卡住"这种状态显式打出来。
|
|
419
|
+
const stuckTimer = setTimeout(() => {
|
|
420
|
+
subStage('STUCK', ', 订阅流程超过 8s 未完成,疑似 specCache.init/Host.storage.get 未 settle');
|
|
421
|
+
}, 8000);
|
|
422
|
+
|
|
397
423
|
// 执行流程
|
|
398
424
|
return publishFromCache().then(() => executeSubscribe()).then(result => {
|
|
425
|
+
clearTimeout(stuckTimer);
|
|
426
|
+
subStage('success', `, subscribedPropCount=${result?.props?.length}`);
|
|
399
427
|
// 返回订阅对象,包含取消订阅方法
|
|
400
428
|
return {
|
|
401
429
|
props: result.props,
|
|
402
430
|
unsubscribe: () => {
|
|
431
|
+
reportSubLog(`[SPEC-SUB][specs.unsubscribe][${new Date().toISOString()}] 取消订阅, propCount=${props.length}`);
|
|
403
432
|
if (result && result.unsubscribe) {
|
|
404
433
|
result.unsubscribe();
|
|
405
434
|
}
|
|
406
435
|
}
|
|
407
436
|
};
|
|
408
437
|
}).catch(e => {
|
|
438
|
+
clearTimeout(stuckTimer);
|
|
439
|
+
subStage('fail', `, code=${e?.code}, message=${e?.message}, raw=${JSON.stringify(e)}`);
|
|
409
440
|
console.log('subscribe error:', props, e);
|
|
410
441
|
return Promise.reject(e);
|
|
411
442
|
});
|
|
@@ -18,6 +18,7 @@ import { reduceResults } from "./utils";
|
|
|
18
18
|
import { isSuccess } from "./error-code";
|
|
19
19
|
// 引入std-spec
|
|
20
20
|
import * as stdSpec from "./std-spec";
|
|
21
|
+
import { reportSubLog } from "../../utils/subscribe-log";
|
|
21
22
|
export const ServiceSourceType = {
|
|
22
23
|
WIFI: 1,
|
|
23
24
|
BLE: 2,
|
|
@@ -91,9 +92,14 @@ export function subscribe(props, listener) {
|
|
|
91
92
|
const specSubscribeProps = current(props);
|
|
92
93
|
const ps = specs.map((spec, index) => spec.subscribe(specSubscribeProps[index], listener));
|
|
93
94
|
return Promise.all(ps).then(arr => {
|
|
95
|
+
// arr[0].props 为 undefined 说明底层订阅走了失败分支(被 mapError 转成了 resolve)
|
|
96
|
+
if (!arr[0] || !arr[0].props) {
|
|
97
|
+
reportSubLog(`[SPEC-SUB][spec.subscribe][fail][${new Date().toISOString()}] 底层返回结构异常, propCount=${props.length}, raw=${JSON.stringify(arr[0])}`);
|
|
98
|
+
}
|
|
94
99
|
const result = {
|
|
95
100
|
props: arr[0].props,
|
|
96
101
|
unsubscribe: () => {
|
|
102
|
+
reportSubLog(`[SPEC-SUB][spec.unsubscribe][${new Date().toISOString()}] 取消订阅, subCount=${arr.length}, propCount=${props.length}`);
|
|
97
103
|
subscriptions.delete(result);
|
|
98
104
|
arr.forEach(sub => {
|
|
99
105
|
if (sub && sub.unsubscribe) {
|
|
@@ -3,6 +3,7 @@ import Subject from "./subject";
|
|
|
3
3
|
import { genError, getEventGenerator, mapError } from "./utils";
|
|
4
4
|
import { isTypeOf } from "../../utils/types";
|
|
5
5
|
import { encodeProp, decodeProp, specsToSubscribeKeys, specAccess } from "../../utils/spec";
|
|
6
|
+
import { reportSubLog } from "../../utils/subscribe-log";
|
|
6
7
|
import { OK } from "./error-code";
|
|
7
8
|
const specSubject = new Subject(getEventGenerator('std_spec_subject', encodeProp));
|
|
8
9
|
let receivedMessagesSubscription = null;
|
|
@@ -209,6 +210,8 @@ export function subscribe(props, listener) {
|
|
|
209
210
|
}
|
|
210
211
|
props = fillInfo(props);
|
|
211
212
|
const keys = specsToSubscribeKeys(props);
|
|
213
|
+
const t0 = Date.now();
|
|
214
|
+
reportSubLog(`[SPEC-SUB][std-spec.subscribe][enter][${new Date().toISOString()}] 即将调用原生 listenMessages, keyCount=${keys.length}, keys=${JSON.stringify(keys)}`);
|
|
212
215
|
// return Device.getDeviceWifi().subscribeMessages(...keys).then(() => {
|
|
213
216
|
return Device.getDeviceWifi().listenMessages(...keys).then(() => {
|
|
214
217
|
const subscription = specSubject.subscribe(props, listener);
|
|
@@ -218,11 +221,17 @@ export function subscribe(props, listener) {
|
|
|
218
221
|
code: OK
|
|
219
222
|
};
|
|
220
223
|
});
|
|
224
|
+
reportSubLog(`[SPEC-SUB][std-spec.subscribe][success][${new Date().toISOString()}] 原生 listenMessages 成功, cost=${Date.now() - t0}ms, keyCount=${keys.length}`);
|
|
221
225
|
return {
|
|
222
226
|
props: subscribed,
|
|
223
227
|
unsubscribe: () => {
|
|
228
|
+
reportSubLog(`[SPEC-SUB][std-spec.unsubscribe][${new Date().toISOString()}] 取消本地监听, keyCount=${keys.length}, keys=${JSON.stringify(keys)}`);
|
|
224
229
|
subscription && subscription.unsubscribe && subscription.unsubscribe();
|
|
225
230
|
}
|
|
226
231
|
};
|
|
227
|
-
}).catch(
|
|
232
|
+
}).catch(e => {
|
|
233
|
+
// 注意:这里沿用 mapError 的既有行为(返回值而非 rethrow,Promise 仍会 resolve),仅补充失败日志
|
|
234
|
+
reportSubLog(`[SPEC-SUB][std-spec.subscribe][fail][${new Date().toISOString()}] 原生 listenMessages 失败, cost=${Date.now() - t0}ms, keyCount=${keys.length}, code=${e?.code}, message=${e?.message}, raw=${JSON.stringify(e)}`);
|
|
235
|
+
return mapError(props)(e);
|
|
236
|
+
});
|
|
228
237
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare let mockListenMessagesImpl: (...keys: string[]) => Promise<any>;
|
|
2
|
+
declare let mockStorageGetImpl: (key: string) => Promise<any>;
|
|
3
|
+
declare const mockListenMessagesCalls: string[][];
|
|
4
|
+
declare const mockReportLogCalls: Array<{
|
|
5
|
+
model: string;
|
|
6
|
+
line: string;
|
|
7
|
+
}>;
|
|
8
|
+
declare const SPECS: {
|
|
9
|
+
siid: number;
|
|
10
|
+
piid: number;
|
|
11
|
+
access: string[];
|
|
12
|
+
}[];
|
|
13
|
+
declare let logs: string[];
|
|
14
|
+
declare const collect: (...args: unknown[]) => void;
|
|
15
|
+
declare const specSubLogs: (stage: string) => string[];
|
|
16
|
+
declare const isDoubleWritten: (line: string) => boolean;
|
|
17
|
+
declare const expectAllSpecSubReported: () => void;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 复现 spec 订阅的两类静默失败,并验证 [SPEC-SUB] 日志能把它们打出来
|
|
5
|
+
*
|
|
6
|
+
* 1. listenMessages 失败:被 std-spec 的 mapError 转成 resolve,上层看到的是"假成功"
|
|
7
|
+
* 2. Host.storage.get 不 settle:publishFromCache 永不完成,listenMessages 根本不会被调用
|
|
8
|
+
* (对应线上 6 次进插件里 2 次原生零 manageSub 的现象)
|
|
9
|
+
*
|
|
10
|
+
* 注:变量必须带 mock 前缀,否则 jest.mock 的 factory 不允许引用。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/* 每个用例都要 resetModules 后拿到全新的模块实例(模块级 cacheEnabled / receivedMessagesSubscription
|
|
14
|
+
都是单例状态),因此必须用惰性 require 而非顶层 import。 */
|
|
15
|
+
/* eslint-disable global-require, @typescript-eslint/no-var-requires */
|
|
16
|
+
|
|
17
|
+
let mockListenMessagesImpl = () => Promise.resolve();
|
|
18
|
+
let mockStorageGetImpl = () => Promise.resolve({});
|
|
19
|
+
const mockListenMessagesCalls = [];
|
|
20
|
+
/* 反馈日志上报的入参记录:console 只有连着调试工具才看得见,
|
|
21
|
+
订阅链路必须同时落到 reportLog 才能被测试同学上传回来。 */
|
|
22
|
+
const mockReportLogCalls = [];
|
|
23
|
+
jest.mock('miot', () => ({
|
|
24
|
+
Device: {
|
|
25
|
+
deviceID: 'test-did',
|
|
26
|
+
model: 'micar.car.kunlun',
|
|
27
|
+
getDeviceWifi: () => ({
|
|
28
|
+
listenMessages: (...keys) => {
|
|
29
|
+
mockListenMessagesCalls.push(keys);
|
|
30
|
+
return mockListenMessagesImpl(...keys);
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
},
|
|
34
|
+
DeviceEvent: {
|
|
35
|
+
deviceReceivedMessages: {
|
|
36
|
+
addListener: () => ({
|
|
37
|
+
remove: () => {}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
// specCache 在模块加载期就读 Service.account.ID 拼 STORAGE_KEY,必须提供
|
|
42
|
+
Service: {
|
|
43
|
+
account: {
|
|
44
|
+
ID: 'test-account'
|
|
45
|
+
},
|
|
46
|
+
smarthome: {
|
|
47
|
+
reportLog: (model, line) => {
|
|
48
|
+
mockReportLogCalls.push({
|
|
49
|
+
model,
|
|
50
|
+
line
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
Host: {
|
|
56
|
+
storage: {
|
|
57
|
+
get: key => mockStorageGetImpl(key),
|
|
58
|
+
set: () => Promise.resolve()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}), {
|
|
62
|
+
virtual: true
|
|
63
|
+
});
|
|
64
|
+
const SPECS = [{
|
|
65
|
+
siid: 6,
|
|
66
|
+
piid: 1,
|
|
67
|
+
access: ['read', 'notify']
|
|
68
|
+
}, {
|
|
69
|
+
siid: 6,
|
|
70
|
+
piid: 8,
|
|
71
|
+
access: ['read', 'notify']
|
|
72
|
+
}];
|
|
73
|
+
let logs = [];
|
|
74
|
+
const collect = (...args) => {
|
|
75
|
+
logs.push(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '));
|
|
76
|
+
};
|
|
77
|
+
const specSubLogs = stage => logs.filter(l => l.includes('[SPEC-SUB]') && l.includes(stage));
|
|
78
|
+
|
|
79
|
+
/* reportSubLog 自身的降级提示是 console-only 的,不参与双写校验 */
|
|
80
|
+
const isDoubleWritten = line => line.includes('[SPEC-SUB]') && !line.includes('[reportSubLog]');
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 断言订阅链路日志确实双写到了反馈日志。
|
|
84
|
+
*
|
|
85
|
+
* console 只有连着调试工具才看得见,测试同学复现后捞不到;只有 reportLog 才会落到
|
|
86
|
+
* 可上传的反馈日志里。这里不逐个枚举阶段名,而是校验"凡是打到 console 的
|
|
87
|
+
* [SPEC-SUB] 都必须同时出现在 reportLog 中"——将来新增日志点若漏接 reportSubLog,
|
|
88
|
+
* 会直接被这条断言拦住。
|
|
89
|
+
*/
|
|
90
|
+
const expectAllSpecSubReported = () => {
|
|
91
|
+
const consoleLines = logs.filter(isDoubleWritten);
|
|
92
|
+
const reportedLines = mockReportLogCalls.map(c => c.line);
|
|
93
|
+
expect(consoleLines.length).toBeGreaterThan(0);
|
|
94
|
+
consoleLines.forEach(line => expect(reportedLines).toContain(line));
|
|
95
|
+
// 反馈日志里的 model 必须是设备 model,否则日志归不到设备上
|
|
96
|
+
mockReportLogCalls.forEach(c => expect(c.model).toBe('micar.car.kunlun'));
|
|
97
|
+
};
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
jest.resetModules();
|
|
100
|
+
logs = [];
|
|
101
|
+
mockListenMessagesCalls.length = 0;
|
|
102
|
+
mockReportLogCalls.length = 0;
|
|
103
|
+
mockListenMessagesImpl = () => Promise.resolve();
|
|
104
|
+
mockStorageGetImpl = () => Promise.resolve({});
|
|
105
|
+
jest.spyOn(console, 'log').mockImplementation(collect);
|
|
106
|
+
jest.spyOn(console, 'error').mockImplementation(collect);
|
|
107
|
+
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
108
|
+
});
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
jest.restoreAllMocks();
|
|
111
|
+
jest.useRealTimers();
|
|
112
|
+
});
|
|
113
|
+
describe('订阅成功路径', () => {
|
|
114
|
+
it('打出 cache-init / listenMessages / success 全链路日志,取消订阅也有日志', async () => {
|
|
115
|
+
const {
|
|
116
|
+
subscribe
|
|
117
|
+
} = require("./index");
|
|
118
|
+
const result = await subscribe(SPECS, () => {}, {
|
|
119
|
+
publishImmediately: true
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 原生确实被调到,且两个 notify 属性都进了订阅 key
|
|
123
|
+
expect(mockListenMessagesCalls).toHaveLength(1);
|
|
124
|
+
expect(mockListenMessagesCalls[0]).toHaveLength(2);
|
|
125
|
+
expect(mockListenMessagesCalls[0].every(k => k.startsWith('prop.'))).toBe(true);
|
|
126
|
+
expect(specSubLogs('[cache-init-enter]')).toHaveLength(1);
|
|
127
|
+
expect(specSubLogs('[cache-init-done]')).toHaveLength(1);
|
|
128
|
+
expect(specSubLogs('[exec-subscribe-enter]')).toHaveLength(1);
|
|
129
|
+
expect(specSubLogs('[std-spec.subscribe][enter]')).toHaveLength(1);
|
|
130
|
+
expect(specSubLogs('[std-spec.subscribe][success]')).toHaveLength(1);
|
|
131
|
+
expect(specSubLogs('[specs.subscribe][success]')).toHaveLength(1);
|
|
132
|
+
// 成功路径不应出现任何 fail
|
|
133
|
+
expect(specSubLogs('[fail]')).toHaveLength(0);
|
|
134
|
+
|
|
135
|
+
// 订阅链路的日志必须同时进反馈日志,否则测试同学复现后捞不到
|
|
136
|
+
expectAllSpecSubReported();
|
|
137
|
+
|
|
138
|
+
// 取消订阅:三层各一条
|
|
139
|
+
logs = [];
|
|
140
|
+
result.unsubscribe();
|
|
141
|
+
expect(specSubLogs('[specs.unsubscribe]')).toHaveLength(1);
|
|
142
|
+
expect(specSubLogs('[spec.unsubscribe]')).toHaveLength(1);
|
|
143
|
+
expect(specSubLogs('[std-spec.unsubscribe]')).toHaveLength(1);
|
|
144
|
+
|
|
145
|
+
// 取消订阅这三条同样要能被上传回来
|
|
146
|
+
expectAllSpecSubReported();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('失败形态 1:listenMessages 失败被 mapError 吞掉', () => {
|
|
150
|
+
it('Promise 仍 resolve 成"假成功",但 fail 日志已能定位', async () => {
|
|
151
|
+
mockListenMessagesImpl = () => Promise.reject(Object.assign(new Error('listen timeout'), {
|
|
152
|
+
code: -70012
|
|
153
|
+
}));
|
|
154
|
+
const {
|
|
155
|
+
subscribe
|
|
156
|
+
} = require("./index");
|
|
157
|
+
const result = await subscribe(SPECS, () => {}, {
|
|
158
|
+
publishImmediately: true
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 复现核心:整条链路没有 reject,调用方拿到的是"成功"
|
|
162
|
+
expect(result).toBeDefined();
|
|
163
|
+
expect(result.props).toBeUndefined(); // 结构已损坏——真正的证据
|
|
164
|
+
|
|
165
|
+
// 新日志把失败点钉在 std-spec,并在 spec 层标出结构异常
|
|
166
|
+
const stdFail = specSubLogs('[std-spec.subscribe][fail]');
|
|
167
|
+
expect(stdFail).toHaveLength(1);
|
|
168
|
+
expect(stdFail[0]).toContain('code=-70012');
|
|
169
|
+
expect(stdFail[0]).toContain('listen timeout');
|
|
170
|
+
expect(specSubLogs('[spec.subscribe][fail]')).toHaveLength(1);
|
|
171
|
+
|
|
172
|
+
// index 层仍误报 success(既有行为,日志里 subscribedPropCount=undefined 可辨识)
|
|
173
|
+
const idxSuccess = specSubLogs('[specs.subscribe][success]');
|
|
174
|
+
expect(idxSuccess).toHaveLength(1);
|
|
175
|
+
expect(idxSuccess[0]).toContain('subscribedPropCount=undefined');
|
|
176
|
+
|
|
177
|
+
// 失败分支尤其要能上传回来——这是定位「偶现收不到推送」的唯一现场
|
|
178
|
+
expectAllSpecSubReported();
|
|
179
|
+
expect(mockReportLogCalls.map(c => c.line).join('\n')).toContain('[std-spec.subscribe][fail]');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('失败形态 2:Host.storage.get 不 settle', () => {
|
|
183
|
+
it('listenMessages 永不调用,STUCK 看门狗把静默卡死打出来', async () => {
|
|
184
|
+
jest.useFakeTimers();
|
|
185
|
+
mockStorageGetImpl = () => new Promise(() => {}); // 永不 settle
|
|
186
|
+
|
|
187
|
+
const {
|
|
188
|
+
subscribe
|
|
189
|
+
} = require("./index");
|
|
190
|
+
let settled = false;
|
|
191
|
+
subscribe(SPECS, () => {}, {
|
|
192
|
+
publishImmediately: true
|
|
193
|
+
}).then(() => {
|
|
194
|
+
settled = true;
|
|
195
|
+
}, () => {
|
|
196
|
+
settled = true;
|
|
197
|
+
});
|
|
198
|
+
await Promise.resolve();
|
|
199
|
+
jest.advanceTimersByTime(8000);
|
|
200
|
+
|
|
201
|
+
// 复现线上现象:原生零调用、Promise 不 settle、成功和失败分支都不执行
|
|
202
|
+
expect(mockListenMessagesCalls).toHaveLength(0);
|
|
203
|
+
expect(settled).toBe(false);
|
|
204
|
+
expect(specSubLogs('[specs.subscribe][success]')).toHaveLength(0);
|
|
205
|
+
expect(specSubLogs('[specs.subscribe][fail]')).toHaveLength(0);
|
|
206
|
+
|
|
207
|
+
// 唯一能说明问题的就是看门狗
|
|
208
|
+
const stuck = specSubLogs('[STUCK]');
|
|
209
|
+
expect(stuck).toHaveLength(1);
|
|
210
|
+
expect(stuck[0]).toContain('specCache.init/Host.storage.get');
|
|
211
|
+
|
|
212
|
+
// 卡在 cache-init,没有进到 exec-subscribe
|
|
213
|
+
expect(specSubLogs('[cache-init-enter]')).toHaveLength(1);
|
|
214
|
+
expect(specSubLogs('[cache-init-done]')).toHaveLength(0);
|
|
215
|
+
expect(specSubLogs('[exec-subscribe-enter]')).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { FlatList } from 'react-native';
|
|
4
|
+
import { useTrackScrollProvider } from "./useTrackScrollProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RN FlatList 的 ergonomic wrapper:内部建立 TrackScrollContext。
|
|
8
|
+
*
|
|
9
|
+
* 后续可改用官方 onViewableItemsChanged + viewabilityConfig,绕过 measureInWindow,
|
|
10
|
+
* 性能更好;本期先走统一路径降低复杂度。
|
|
11
|
+
*/
|
|
12
|
+
export function TrackFlatList(props) {
|
|
13
|
+
const tracker = useTrackScrollProvider({
|
|
14
|
+
onScroll: props.onScroll,
|
|
15
|
+
onLayout: props.onLayout,
|
|
16
|
+
onMomentumScrollEnd: props.onMomentumScrollEnd
|
|
17
|
+
});
|
|
18
|
+
return /*#__PURE__*/React.createElement(tracker.Provider, null, /*#__PURE__*/React.createElement(FlatList, _extends({}, props, tracker.scrollProps)));
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
export type ScrollListener = () => void;
|
|
3
|
+
export interface MeasurableNode {
|
|
4
|
+
measureInWindow: (cb: (x: number, y: number, width: number, height: number) => void) => void;
|
|
5
|
+
}
|
|
6
|
+
export interface TrackScrollContextValue {
|
|
7
|
+
containerRef: RefObject<MeasurableNode | null>;
|
|
8
|
+
subscribe: (cb: ScrollListener) => () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare const TrackScrollContext: import("react").Context<TrackScrollContextValue | null>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { ScrollView } from 'react-native';
|
|
4
|
+
import { useTrackScrollProvider } from "./useTrackScrollProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 直接消费 RN ScrollView 的 ergonomic wrapper:内部建立 TrackScrollContext,
|
|
8
|
+
* 业务用法与原生 <ScrollView> 完全一致。
|
|
9
|
+
*
|
|
10
|
+
* 包装层 / SDK 容器(SubpageLayout / PageLayout)不要用本组件——
|
|
11
|
+
* 改用 useTrackScrollProvider 取 scrollProps,把 ref/onScroll 透传到容器暴露的接缝。
|
|
12
|
+
*/
|
|
13
|
+
export function TrackScrollView(props) {
|
|
14
|
+
const tracker = useTrackScrollProvider({
|
|
15
|
+
onScroll: props.onScroll,
|
|
16
|
+
onLayout: props.onLayout,
|
|
17
|
+
onMomentumScrollEnd: props.onMomentumScrollEnd
|
|
18
|
+
});
|
|
19
|
+
return /*#__PURE__*/React.createElement(tracker.Provider, null, /*#__PURE__*/React.createElement(ScrollView, _extends({}, props, tracker.scrollProps)));
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TrackEventType, TrackParams, TrackServiceConfig } from './types';
|
|
2
|
+
export declare function initTrackService(config: TrackServiceConfig): void;
|
|
3
|
+
export declare function getCommonParams(): Record<string, unknown>;
|
|
4
|
+
export declare function setPageRef(ref: string, subRef?: string): void;
|
|
5
|
+
export declare function reportTrack(eventTypes: TrackEventType[], params?: TrackParams): void;
|
|
6
|
+
export declare function getTrackLog(): {
|
|
7
|
+
time: number;
|
|
8
|
+
event: string;
|
|
9
|
+
params: Record<string, unknown>;
|
|
10
|
+
}[];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Service } from 'miot';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
let _config = null;
|
|
4
|
+
let _currentRef = '';
|
|
5
|
+
let _currentSubRef = '';
|
|
6
|
+
const _debugLog = [];
|
|
7
|
+
|
|
8
|
+
/** 初始化,插件入口调用一次 */
|
|
9
|
+
export function initTrackService(config) {
|
|
10
|
+
_config = config;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 读取已注册的埋点公参。
|
|
15
|
+
*
|
|
16
|
+
* 给自带上报通道的第三方组件用(如 miot/ui/smartScene 的 trackParams):那些组件不经过本文件的
|
|
17
|
+
* reportTrack,公参必须由调用方拼进它们的载荷,从这里取可保证与 reportTrack 同一来源、不会漂移。
|
|
18
|
+
* 未 init 时返回空对象,调用方无需判空。
|
|
19
|
+
*/
|
|
20
|
+
export function getCommonParams() {
|
|
21
|
+
return _config ? _config.getCommonParams() : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 设置当前页面 ref(同步到 Native),并驱动 Android 的页面 view。
|
|
26
|
+
*
|
|
27
|
+
* 双端差异(与已上线的 stdcamera / 门铃 / miot.plugin.spec 完全一致):
|
|
28
|
+
* - Android:页面 view 由 Native「内部 view 机制」自主产出、SDK 计算 duration,只依赖插件
|
|
29
|
+
* 通过 updatePluginPageRef 设置的 ref/subRef,且**必须携带插件公参**
|
|
30
|
+
* (package_name/package_id/plugin_id/plugin_version/did/device_model...),否则 Native
|
|
31
|
+
* 不知道当前是哪个插件页、无法产出该页 view。只要每次进入/切换页面都用带公参的
|
|
32
|
+
* updatePluginPageRef 设置新 ref,Native 就会在下一次 ref 变化(或插件退出)时结算并上报
|
|
33
|
+
* 上一页的 view(含 from_ref 来源链)。
|
|
34
|
+
* - iOS:不依赖公参,view 由 reportTrack 显式经 reportEventRefChannel('view') 上报。
|
|
35
|
+
*
|
|
36
|
+
* 同值去重:Native 端每次调用都会把 old current_ref 当作下一条事件的 from_ref,同 (ref,subRef)
|
|
37
|
+
* 重复调用会让 from_ref 串档为自己。这里在 JS 侧短路。
|
|
38
|
+
*/
|
|
39
|
+
export function setPageRef(ref, subRef) {
|
|
40
|
+
const sub = subRef || ref;
|
|
41
|
+
if (ref === _currentRef && sub === _currentSubRef) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
_currentRef = ref;
|
|
45
|
+
_currentSubRef = sub;
|
|
46
|
+
// Native updatePluginPageRef 的 JSDoc 要求 key 为 ref / subRef(驼峰);同时带 sub_ref(下划线)
|
|
47
|
+
// 兼容事件 schema。
|
|
48
|
+
// Android 的页面 view 由 Native 依据这里的 refParams 自主产出(见下文平台差异说明),
|
|
49
|
+
// 必须把 item_type 一并塞进来,否则 Native 生成的 view 事件缺 item_type(MIIO-130722)。
|
|
50
|
+
const refParams = {
|
|
51
|
+
ref,
|
|
52
|
+
subRef: sub,
|
|
53
|
+
sub_ref: sub,
|
|
54
|
+
item_type: 'ref'
|
|
55
|
+
};
|
|
56
|
+
// Android 的 view 机制强依赖此处携带的插件公参(见上方说明);iOS 无需。
|
|
57
|
+
if (Platform.OS === 'android' && _config) {
|
|
58
|
+
Object.assign(refParams, _config.getCommonParams());
|
|
59
|
+
}
|
|
60
|
+
Service.smarthome.updatePluginPageRef?.(refParams);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 核心上报方法
|
|
65
|
+
* 底层 SDK reportEventRefChannel 接受单个事件类型,
|
|
66
|
+
* 遍历 eventTypes 逐个上报,确保每条事件独立发送。
|
|
67
|
+
*/
|
|
68
|
+
export function reportTrack(eventTypes, params = {}) {
|
|
69
|
+
if (!_config) return;
|
|
70
|
+
const finalParams = {
|
|
71
|
+
ref: _currentRef,
|
|
72
|
+
sub_ref: _currentSubRef,
|
|
73
|
+
..._config.getCommonParams(),
|
|
74
|
+
...params
|
|
75
|
+
};
|
|
76
|
+
for (const eventType of eventTypes) {
|
|
77
|
+
if (_config.debug) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log('[Track]', eventType, finalParams);
|
|
80
|
+
_debugLog.push({
|
|
81
|
+
time: Date.now(),
|
|
82
|
+
event: eventType,
|
|
83
|
+
params: {
|
|
84
|
+
...finalParams
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
if (_debugLog.length > 200) _debugLog.shift();
|
|
88
|
+
}
|
|
89
|
+
// 页面 view 的双端差异(与 stdcamera / 门铃 / miot.plugin.spec 一致):
|
|
90
|
+
// - Android:view 由 Native 内部机制依据 updatePluginPageRef(带公参) 自主产出、SDK 计算
|
|
91
|
+
// duration,**不能**经 reportEventRefChannel(平台侧不消费、且会与内部机制冲突)。此处跳过——
|
|
92
|
+
// Android view 已由 setPageRef 在每次页面切换时驱动。
|
|
93
|
+
// - iOS:view 必须显式经 reportEventRefChannel('view') 上报,SDK 使用 JS 计算的 duration。
|
|
94
|
+
if (eventType === 'view' && Platform.OS === 'android') {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
Service.smarthome.reportEventRefChannel?.(eventType, finalParams);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 调试用:获取最近上报记录 */
|
|
102
|
+
export function getTrackLog() {
|
|
103
|
+
return _debugLog;
|
|
104
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { initTrackService, setPageRef, reportTrack, getTrackLog, getCommonParams } from './TrackService';
|
|
2
|
+
export { usePageTrack } from './usePageTrack';
|
|
3
|
+
export { useCardTrack } from './useCardTrack';
|
|
4
|
+
export { useTrack } from './useTrack';
|
|
5
|
+
export { useExposeOnVisible } from './useExposeOnVisible';
|
|
6
|
+
export { TrackScrollView } from './TrackScrollView';
|
|
7
|
+
export { TrackFlatList } from './TrackFlatList';
|
|
8
|
+
export { useTrackScrollProvider } from './useTrackScrollProvider';
|
|
9
|
+
export { TrackScrollContext } from './TrackScrollContext';
|
|
10
|
+
export type { TrackScrollContextValue, ScrollListener, MeasurableNode } from './TrackScrollContext';
|
|
11
|
+
export type { TrackEventType, TrackItemType, TrackParams, TrackServiceConfig } from './types';
|
|
12
|
+
export type { Frame } from './intersect';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { initTrackService, setPageRef, reportTrack, getTrackLog, getCommonParams } from "./TrackService";
|
|
2
|
+
export { usePageTrack } from "./usePageTrack";
|
|
3
|
+
export { useCardTrack } from "./useCardTrack";
|
|
4
|
+
export { useTrack } from "./useTrack";
|
|
5
|
+
export { useExposeOnVisible } from "./useExposeOnVisible";
|
|
6
|
+
export { TrackScrollView } from "./TrackScrollView";
|
|
7
|
+
export { TrackFlatList } from "./TrackFlatList";
|
|
8
|
+
export { useTrackScrollProvider } from "./useTrackScrollProvider";
|
|
9
|
+
export { TrackScrollContext } from "./TrackScrollContext";
|