import { Transport, Collector, ReportItemType, ReportItemWithContext } from '@tencent/merlin-core'; import { BehaviorReporter, BehaviorReportItemData, BehaviorType, ElementExposeReportItem } from '..'; import { UA_REGEX, UUID_REGEX, UUID_REGEX_STRING } from '../../../../tests/shared'; class TestCollector extends Collector { public reporter?: BehaviorReporter; } class TestTransport extends Transport { /** 待消费上报项(测试用) */ recordsToConsume: ReportItemWithContext[] = []; /** 取得当前待消费上报项(测试用) */ consume() { const records = [...this.recordsToConsume]; this.recordsToConsume = []; return records; } send(records: ReportItemWithContext[]) { this.recordsToConsume.push(...records); } } describe('BehaviorReporter', () => { beforeEach(() => { localStorage.clear(); history.pushState({}, '', '/'); jest.resetAllMocks(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); afterAll(() => { localStorage.clear(); jest.resetAllMocks(); }); it('construct with correct default options value', () => { const reporter = new BehaviorReporter() as any; expect(reporter.pageStackStorageLimit).toBe(20); }); it('can setOptions with custom options correctly', () => { const reporter = new BehaviorReporter() as any; reporter.setOptions({ pageStackStorageLimit: 5, }); expect(reporter.pageStackStorageLimit).toBe(5); const reporter2 = new BehaviorReporter() as any; reporter2.setOptions({ collectors: [new TestCollector()], transports: [new TestTransport()], pageStackStorageLimit: 99, debug: true, errorHandler: () => {}, }); expect(reporter2.userInfo).toEqual({}); expect(reporter2.pageStackStorageLimit).toBe(99); expect((reporter2 as any).debug).toBe(true); expect((reporter2 as any).errorHandler).toEqual(expect.any(Function)); }); it('get serializeLinkedData correctly', () => { const reporter = new BehaviorReporter() as any; reporter.init({}); // expect(reporter.getLinkedData()).toStrictEqual({ // contextId: '1', // refAccessId: undefined, // fromElementId: undefined, // }); expect(reporter.serializeLinkedData).toMatch(new RegExp(`^context_id=${UUID_REGEX_STRING}$`)); reporter.scopeInfo.entranceId = 'any'; reporter.pageManager.currentPage = { name: 'Nezuko', step: 1, accessId: 'a1', }; expect(reporter.serializeLinkedData).toMatch( new RegExp(`^context_id=${UUID_REGEX_STRING}&entrance_id=any&from_access_id=a1`), ); history.pushState({}, 'El Psy Congroo', 'http://localhost/home?a=1&context_id=el-psy-congroo&entrance_id=ei12312'); // reporter.init({ // user: { // uin: 9999, // }, // }); reporter.pushPage({ name: 'Makise Kurisu', }); expect(reporter.serializeLinkedData).toMatch( new RegExp(`^context_id=el-psy-congroo&entrance_id=ei12312&from_access_id=${UUID_REGEX_STRING}`), ); }); test('errorHandler works correctly', () => { const reporter = new BehaviorReporter() as any; reporter.setOptions({ errorHandler: (err) => { throw err; }, }); expect(() => reporter.pageManager.updatePageInfo({})).toThrowError('currentPage not found'); }); it('getContext correctly', () => { const reporter = new BehaviorReporter() as any; reporter.setOptions({ errorHandler: (err) => { throw err; }, }); expect(() => reporter.getContext()).toThrowError('currentPage not found'); reporter.pageManager.push({ name: 'Nezuko', extInfo: { is: 'cute', }, }); expect(reporter.getContext()).toStrictEqual({ scope: { accessId: expect.stringMatching(UUID_REGEX), extInfo: { is: 'cute', }, pageName: 'Nezuko', refAccessId: undefined, fromElementId: undefined, refPageName: undefined, step: 1, // entranceId: undefined, // entranceInfo: { // subEntranceid: undefined, // }, // screenHeight: 0, // screenWidth: 0, // clientHeight: 768, // clientWidth: 1024, contextId: expect.stringMatching(UUID_REGEX), // devicePixelRatio: 1, }, base: { user: {}, release: undefined, }, env: {}, }); }); it("can't report if not correctly initialized", async () => { const reporter = new BehaviorReporter() as any; reporter.setOptions({ errorHandler: (err) => { throw err; }, }); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); reporter.report({ type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }); expect(consoleWarnSpy).toHaveBeenCalled(); reporter.init({ user: { uin: 9999, }, }); expect(() => reporter.report({ type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }), ).toThrowError('currentPage not found'); reporter.pageManager.push({ name: 'Nezuko', }); expect(() => reporter.report({ type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }), ).not.toThrowError('currentPage not found'); }); test('report and flush correctly', () => { /** 流程设计 * 1. 初始化:5s 一次,上限 20 * 2. 过 5s 不操作,没有数据,不该上报出去 * 3. 加入 2 条数据后等 5s,应该触发上报 * 4. 直接塞入 50 条数据,应该立刻触发两次上报 * 5. 过 5s,应该会把剩余 10 条都发出 */ const transport1 = new TestTransport({ flushInterval: 5000, bufferSize: 20, }); const transport2 = new TestTransport({ flushInterval: 5000, bufferSize: 20, }); const reporter = new BehaviorReporter() as any; const transport1SendSpy = jest.spyOn(transport1, 'send'); const transport2SendSpy = jest.spyOn(transport2, 'send'); const flushSpy = jest.spyOn(reporter, 'flush'); jest.spyOn(Date, 'now').mockImplementation(() => 1638374400000); reporter.init({ user: { uin: 9999, }, transports: [transport1, transport2], }); reporter.pageManager.push({ name: 'Nezuko', }); // 初始化后等五秒不进行 report,不应该触发 transport 的 send jest.advanceTimersByTime(5100); expect(flushSpy).toHaveBeenCalledTimes(0); expect(transport1SendSpy).toHaveBeenCalledTimes(0); expect(transport2SendSpy).toHaveBeenCalledTimes(0); const record: ElementExposeReportItem = { type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }; // report 两项,不会立刻触发上报 reporter.report(record); reporter.report(record); expect(flushSpy).toHaveBeenCalledTimes(2); expect(transport1SendSpy).toHaveBeenCalledTimes(0); expect(transport2SendSpy).toHaveBeenCalledTimes(0); expect((reporter as any).buffer.length).toBe(0); expect((transport1 as any).buffer.length).toBe(2); expect((transport2 as any).buffer.length).toBe(2); // 五秒后这两项应该报上去了 jest.advanceTimersByTime(5100); expect(flushSpy).toHaveBeenCalledTimes(2); expect(transport1SendSpy).toHaveBeenCalledTimes(1); expect(transport2SendSpy).toHaveBeenCalledTimes(1); expect((reporter as any).buffer.length).toBe(0); expect(transport1.consume().length).toBe(2); expect(transport2.consume().length).toBe(2); // 连续 report 五十项,其间应该会触发 2 次超限上报,每次报 20 条,留下 10 条数据 for (let i = 0; i < 50; i++) { reporter.report(record); } expect(flushSpy).toHaveBeenCalledTimes(52); expect(transport1SendSpy).toHaveBeenCalledTimes(3); expect(transport2SendSpy).toHaveBeenCalledTimes(3); expect((reporter as any).buffer.length).toBe(0); expect((transport1 as any).buffer.length).toBe(10); expect((transport2 as any).buffer.length).toBe(10); expect(transport1.consume().length).toBe(40); expect(transport2.consume().length).toBe(40); // 5 秒后,这 10 条数据也应该上报了 jest.advanceTimersByTime(5000); expect(flushSpy).toHaveBeenCalledTimes(52); expect(transport1SendSpy).toHaveBeenCalledTimes(4); expect(transport2SendSpy).toHaveBeenCalledTimes(4); expect((reporter as any).buffer.length).toBe(0); expect(transport1.consume().length).toBe(10); const transport2FinalConsume = transport2.consume(); expect(transport2FinalConsume.length).toBe(10); expect(transport2FinalConsume?.[0]).toStrictEqual({ ctime: 1638374400000, type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, context: { scope: { pageName: 'Nezuko', accessId: expect.stringMatching(UUID_REGEX), step: 1, contextId: expect.stringMatching(UUID_REGEX), // entranceId: undefined, // entranceInfo: { // subEntranceid: undefined, // }, clientHeight: 768, clientWidth: 1024, screenHeight: 0, screenWidth: 0, devicePixelRatio: 1, extInfo: undefined, fromElementId: undefined, href: 'http://localhost/', refAccessId: undefined, refPageName: undefined, }, base: { user: { aid: expect.stringMatching(UUID_REGEX), uin: 9999, }, release: undefined, }, env: { userAgent: expect.stringMatching(UA_REGEX), }, }, }); }); it('enableBuffering correctly', () => { const reporter = new BehaviorReporter() as any; expect(reporter.buffering).toBe(false); reporter.enableBuffering(); expect(reporter.buffering).toBe(true); }); it("won't trigger sendToTransports if is buffering", () => { const reporter = new BehaviorReporter() as any; const sendToTransportsSpy = jest.spyOn(reporter, 'sendToTransports'); reporter.init({ user: { uin: 9999, }, }); reporter.pageManager.push({ name: 'Nezuko', }); reporter.enableBuffering(); const record: ElementExposeReportItem = { type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }; reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); expect(sendToTransportsSpy).toHaveBeenCalledTimes(0); }); test('settleBuffering change buffering correctly', () => { const reporter = new BehaviorReporter(); reporter.enableBuffering(); expect((reporter as any).buffering).toBe(true); reporter.settleBuffering(); expect((reporter as any).buffering).toBe(false); }); test('settleBuffering will send all buffered records to transports', async () => { const transport = new TestTransport({ bufferSize: 20, flushInterval: 0, }); const reporter = new BehaviorReporter() as any; const flushSpy = jest.spyOn(reporter, 'flush'); const sendToTransportsSpy = jest.spyOn(reporter, 'sendToTransports'); reporter.init({ transports: [transport], user: { uin: 9999, }, }); reporter.pageManager.push({ name: 'Nezuko', }); reporter.enableBuffering(); const record: ElementExposeReportItem = { type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }; reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); expect(flushSpy).toHaveBeenCalledTimes(5); // 在 report 中被触发,但不会实际 sendToTransports expect(sendToTransportsSpy).toHaveBeenCalledTimes(0); const promise = reporter.settleBuffering(); jest.runAllTimers(); await promise; expect(flushSpy).toHaveBeenCalledTimes(6); expect(sendToTransportsSpy).toHaveBeenCalledTimes(1); expect((transport as any).buffer.length).toBe(5); }); test('buffering mechanism works well', () => { /** 流程设计 * 1. 初始化:5s 一次,bufferSize 5 * 2. 推入 5 条,应该立刻触发上报 * 3. enableBuffering * 4. 推入 6 条,等 5s,应该一直不上报 * 5. settleBuffering,应该立刻触发上报 * 6. 推入 1 条,等 5s,应该正常触发上报 */ const transport = new TestTransport({ bufferSize: 5, flushInterval: 5000, }); const reporter = new BehaviorReporter() as any; const transportSendSpy = jest.spyOn(transport, 'send'); jest.spyOn(Date, 'now').mockImplementation(() => 1638374400000); reporter.init({ user: { uin: 9999, }, transports: [transport], }); reporter.pageManager.push({ name: 'Nezuko', }); const record: ElementExposeReportItem = { type: ReportItemType.BEHAVIOR, data: { behaviorType: BehaviorType.ELEMENT_EXPOSE, key: 'Anya', exposeId: 'Forger', }, }; // 推入 5 条,应该立刻触发上报 reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); expect(transportSendSpy).toHaveBeenCalledTimes(1); expect(transport.consume().length).toBe(5); reporter.enableBuffering(); // 推入 6 条,等 5s,应该一直不上报 reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); reporter.report(record); jest.advanceTimersByTime(5100); expect(transportSendSpy).toHaveBeenCalledTimes(1); expect(transport.consume().length).toBe(0); // settleBuffering,应该立刻触发上报 reporter.settleBuffering(); expect(transportSendSpy).toHaveBeenCalledTimes(2); expect(transport.consume().length).toBe(6); // 推入 1 条,等 5s,应该正常触发上报 reporter.report(record); jest.advanceTimersByTime(5100); expect(transportSendSpy).toHaveBeenCalledTimes(3); expect(transport.consume().length).toBe(1); }); });