feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
63
frontend/packages/arch/report-tti/README.md
Normal file
63
frontend/packages/arch/report-tti/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# @coze-arch/report-tti
|
||||
|
||||
A architecture package for the Coze Studio monorepo
|
||||
|
||||
## Overview
|
||||
|
||||
This package is part of the Coze Studio monorepo and provides architecture functionality. It serves as a core component in the Coze ecosystem.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Add this package to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@coze-arch/report-tti": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
rush update
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { /* exported functions/components */ } from '@coze-arch/report-tti';
|
||||
|
||||
// Example usage
|
||||
// TODO: Add specific usage examples
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Core functionality for Coze Studio
|
||||
- TypeScript support
|
||||
- Modern ES modules
|
||||
|
||||
## API Reference
|
||||
|
||||
Please refer to the TypeScript definitions for detailed API documentation.
|
||||
|
||||
## Development
|
||||
|
||||
This package is built with:
|
||||
|
||||
- TypeScript
|
||||
- React
|
||||
- Vitest for testing
|
||||
- ESLint for code quality
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is part of the Coze Studio monorepo. Please follow the monorepo contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
125
frontend/packages/arch/report-tti/__tests__/index.test.tsx
Normal file
125
frontend/packages/arch/report-tti/__tests__/index.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useReportTti } from '../src/index';
|
||||
|
||||
// 模拟 custom-perf-metric 模块
|
||||
vi.mock('../src/utils/custom-perf-metric', () => ({
|
||||
reportTti: vi.fn(),
|
||||
REPORT_TTI_DEFAULT_SCENE: 'init',
|
||||
}));
|
||||
|
||||
// 导入被模拟的函数,以便在测试中访问
|
||||
import { reportTti } from '../src/utils/custom-perf-metric';
|
||||
|
||||
describe('useReportTti', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call reportTti when isLive is true', () => {
|
||||
// Arrange
|
||||
const params = {
|
||||
isLive: true,
|
||||
extra: { key: 'value' },
|
||||
};
|
||||
|
||||
// Act
|
||||
renderHook(() => useReportTti(params));
|
||||
|
||||
// Assert
|
||||
expect(reportTti).toHaveBeenCalledTimes(1);
|
||||
expect(reportTti).toHaveBeenCalledWith(params.extra, 'init');
|
||||
});
|
||||
|
||||
it('should not call reportTti when isLive is false', () => {
|
||||
// Arrange
|
||||
const params = {
|
||||
isLive: false,
|
||||
extra: { key: 'value' },
|
||||
};
|
||||
|
||||
// Act
|
||||
renderHook(() => useReportTti(params));
|
||||
|
||||
// Assert
|
||||
expect(reportTti).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call reportTti with custom scene when provided', () => {
|
||||
// Arrange
|
||||
const params = {
|
||||
isLive: true,
|
||||
extra: { key: 'value' },
|
||||
scene: 'custom-scene',
|
||||
};
|
||||
|
||||
// Act
|
||||
renderHook(() => useReportTti(params));
|
||||
|
||||
// Assert
|
||||
expect(reportTti).toHaveBeenCalledTimes(1);
|
||||
expect(reportTti).toHaveBeenCalledWith(params.extra, params.scene);
|
||||
});
|
||||
|
||||
it('should not call reportTti again if dependencies do not change', () => {
|
||||
// Arrange
|
||||
const params = {
|
||||
isLive: true,
|
||||
extra: { key: 'value' },
|
||||
};
|
||||
|
||||
// Act
|
||||
const { rerender } = renderHook(() => useReportTti(params));
|
||||
rerender();
|
||||
|
||||
// Assert
|
||||
expect(reportTti).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call reportTti again if isLive changes from false to true', () => {
|
||||
// Arrange
|
||||
const initialParams = {
|
||||
isLive: false,
|
||||
extra: { key: 'value' },
|
||||
};
|
||||
|
||||
// Act
|
||||
const { rerender } = renderHook(props => useReportTti(props), {
|
||||
initialProps: initialParams,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(reportTti).not.toHaveBeenCalled();
|
||||
|
||||
// Act - change isLive to true
|
||||
rerender({
|
||||
isLive: true,
|
||||
extra: { key: 'value' },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(reportTti).toHaveBeenCalledTimes(1);
|
||||
expect(reportTti).toHaveBeenCalledWith(initialParams.extra, 'init');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { reporter } from '@coze-arch/logger';
|
||||
|
||||
const mockSlardarInstance = vi.fn();
|
||||
mockSlardarInstance.config = vi.fn();
|
||||
|
||||
// 模拟 logger 和 reporter
|
||||
vi.mock('@coze-arch/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
reporter: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
getSlardarInstance: vi.fn(() => mockSlardarInstance),
|
||||
}));
|
||||
|
||||
describe('custom-perf-metric', () => {
|
||||
// 保存原始的全局对象
|
||||
const originalPerformance = global.performance;
|
||||
const originalDocument = global.document;
|
||||
const originalPerformanceObserver = global.PerformanceObserver;
|
||||
|
||||
// 模拟函数
|
||||
const mockObserve = vi.fn();
|
||||
const mockDisconnect = vi.fn();
|
||||
const mockGetEntriesByName = vi.fn();
|
||||
const mockPerformanceNow = vi.fn().mockReturnValue(1000);
|
||||
|
||||
// 模拟路由变更条目
|
||||
const mockRouteChangeEntry = {
|
||||
startTime: 500,
|
||||
detail: {
|
||||
location: {
|
||||
pathname: '/test-path',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 确保数组有 at 方法
|
||||
if (!Array.prototype.at) {
|
||||
// 添加 at 方法的 polyfill
|
||||
Object.defineProperty(Array.prototype, 'at', {
|
||||
value(index) {
|
||||
// 将负索引转换为从数组末尾开始的索引
|
||||
return this[index < 0 ? this.length + index : index];
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// 模拟 performance 对象
|
||||
vi.stubGlobal('performance', {
|
||||
now: mockPerformanceNow,
|
||||
getEntriesByName: mockGetEntriesByName,
|
||||
});
|
||||
|
||||
// 默认模拟 getEntriesByName 返回值
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
return [mockRouteChangeEntry];
|
||||
}
|
||||
if (name === 'first-contentful-paint') {
|
||||
return [
|
||||
{
|
||||
startTime: 800,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 模拟 document 对象
|
||||
global.document = {
|
||||
visibilityState: 'visible',
|
||||
} as any;
|
||||
|
||||
// 模拟 PerformanceObserver
|
||||
global.PerformanceObserver = vi.fn().mockImplementation(callback => ({
|
||||
observe: mockObserve,
|
||||
disconnect: mockDisconnect,
|
||||
})) as any;
|
||||
|
||||
// 添加 supportedEntryTypes 属性
|
||||
Object.defineProperty(global.PerformanceObserver, 'supportedEntryTypes', {
|
||||
value: ['paint'],
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 恢复原始对象
|
||||
global.performance = originalPerformance;
|
||||
global.document = originalDocument;
|
||||
global.PerformanceObserver = originalPerformanceObserver;
|
||||
});
|
||||
|
||||
it('应该导出常量和函数', async () => {
|
||||
const { PerfMetricNames, REPORT_TTI_DEFAULT_SCENE, reportTti } =
|
||||
await vi.importActual<any>('../../src/utils/custom-perf-metric');
|
||||
|
||||
expect(PerfMetricNames).toBeDefined();
|
||||
expect(PerfMetricNames.TTI).toBe('coze_custom_tti');
|
||||
expect(PerfMetricNames.TTI_HOT).toBe('coze_custom_tti_hot');
|
||||
expect(REPORT_TTI_DEFAULT_SCENE).toBe('init');
|
||||
expect(reportTti).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('当页面可见且有 FCP 时应该调用 slardar 上报 TTI', async () => {
|
||||
const { reportTti, PerfMetricNames } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 执行
|
||||
reportTti({ key: 'value' });
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).toHaveBeenCalledWith('sendCustomPerfMetric', {
|
||||
value: 1000,
|
||||
name: PerfMetricNames.TTI,
|
||||
type: 'perf',
|
||||
extra: {
|
||||
key: 'value',
|
||||
fcpTime: '800',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('当页面处于隐藏状态时不应该上报 TTI', async () => {
|
||||
const { reportTti } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 document.visibilityState
|
||||
Object.defineProperty(global.document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti();
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当同一路由和场景已经上报过时不应该重复上报', async () => {
|
||||
const { reportTti } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 第一次调用
|
||||
reportTti({}, 'test-scene');
|
||||
|
||||
// 清除模拟
|
||||
vi.clearAllMocks();
|
||||
|
||||
// 第二次调用同一路由和场景
|
||||
reportTti({}, 'test-scene');
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当有多个路由变更时应该上报热启动 TTI', async () => {
|
||||
const { reportTti, PerfMetricNames } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 返回多个路由变更
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
const entries = [
|
||||
{
|
||||
startTime: 300,
|
||||
detail: {
|
||||
location: {
|
||||
pathname: '/first-path',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
startTime: 500,
|
||||
detail: {
|
||||
location: {
|
||||
pathname: '/test-path2',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 确保数组有 at 方法
|
||||
if (!entries.at) {
|
||||
entries.at = function (index) {
|
||||
return this[index < 0 ? this.length + index : index];
|
||||
};
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti({ key: 'value' });
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).toHaveBeenCalledWith('sendCustomPerfMetric', {
|
||||
value: 500, // 1000 - 500 = 500
|
||||
name: PerfMetricNames.TTI_HOT,
|
||||
type: 'perf',
|
||||
extra: {
|
||||
key: 'value',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('当 FCP 时间晚于当前时间时应该使用 FCP 时间作为 TTI', async () => {
|
||||
const { reportTti, PerfMetricNames } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 performance.now 返回值
|
||||
mockPerformanceNow.mockReturnValue(700);
|
||||
|
||||
// 执行
|
||||
reportTti();
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).toHaveBeenCalledWith('sendCustomPerfMetric', {
|
||||
value: 800, // 使用 FCP 时间
|
||||
name: PerfMetricNames.TTI,
|
||||
type: 'perf',
|
||||
extra: {
|
||||
fcpTime: '800',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('当页面可见且没有 FCP 时应该设置 PerformanceObserver', async () => {
|
||||
const { reportTti } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 不返回 FCP
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
return [mockRouteChangeEntry];
|
||||
}
|
||||
// 返回空数组表示没有 FCP
|
||||
return [];
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti();
|
||||
|
||||
// 验证
|
||||
expect(mockObserve).toHaveBeenCalledWith({ type: 'paint', buffered: true });
|
||||
expect(mockSlardarInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当 PerformanceObserver 触发回调时应该上报 TTI', async () => {
|
||||
const { reportTti, PerfMetricNames } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 不返回 FCP
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
return [mockRouteChangeEntry];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 准备模拟 PerformanceObserver 回调
|
||||
let observerCallback: Function | undefined;
|
||||
global.PerformanceObserver = vi.fn().mockImplementation(callback => {
|
||||
observerCallback = callback;
|
||||
return {
|
||||
observe: mockObserve,
|
||||
disconnect: mockDisconnect,
|
||||
};
|
||||
}) as any;
|
||||
|
||||
// 执行
|
||||
reportTti({ key: 'value' });
|
||||
|
||||
// 模拟 PerformanceObserver 回调
|
||||
const mockList = {
|
||||
getEntriesByName: vi.fn().mockReturnValue([{ startTime: 900 }]),
|
||||
};
|
||||
|
||||
// 确保 observerCallback 已被赋值
|
||||
expect(observerCallback).toBeDefined();
|
||||
|
||||
// 执行回调
|
||||
if (observerCallback) {
|
||||
observerCallback(mockList);
|
||||
}
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).toHaveBeenCalledWith('sendCustomPerfMetric', {
|
||||
value: 900,
|
||||
name: PerfMetricNames.TTI,
|
||||
type: 'perf',
|
||||
extra: {
|
||||
key: 'value',
|
||||
fcpTime: '900',
|
||||
},
|
||||
});
|
||||
expect(mockDisconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当 PerformanceObserver.observe 抛出错误时应该尝试替代方法', async () => {
|
||||
const { reportTti } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 不返回 FCP
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
return [mockRouteChangeEntry];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 模拟 observe 抛出错误
|
||||
mockObserve.mockImplementationOnce(() => {
|
||||
throw new Error('Failed to execute observe');
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti();
|
||||
|
||||
// 验证
|
||||
expect(mockObserve).toHaveBeenCalledTimes(2);
|
||||
expect(mockObserve).toHaveBeenNthCalledWith(1, {
|
||||
type: 'paint',
|
||||
buffered: true,
|
||||
});
|
||||
expect(mockObserve).toHaveBeenNthCalledWith(2, { entryTypes: ['paint'] });
|
||||
expect(reporter.info).toHaveBeenCalledWith({
|
||||
message: 'Failed to execute observe',
|
||||
namespace: 'performance',
|
||||
});
|
||||
});
|
||||
|
||||
it('当 PerformanceObserver 不支持 paint 类型时应该处理兼容性问题', async () => {
|
||||
const { reportTti } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 不返回 FCP
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
return [mockRouteChangeEntry];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 模拟 observe 抛出错误
|
||||
mockObserve.mockImplementationOnce(() => {
|
||||
throw new Error('Failed to execute observe');
|
||||
});
|
||||
|
||||
// 移除 supportedEntryTypes
|
||||
Object.defineProperty(global.PerformanceObserver, 'supportedEntryTypes', {
|
||||
value: [],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti();
|
||||
|
||||
// 验证
|
||||
expect(mockObserve).toHaveBeenCalledTimes(1);
|
||||
expect(reporter.info).toHaveBeenCalledWith({
|
||||
message: 'Failed to execute observe',
|
||||
namespace: 'performance',
|
||||
});
|
||||
});
|
||||
|
||||
it('当 PerformanceObserver 的第二种方法也失败时应该处理错误', async () => {
|
||||
const { reportTti } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 不返回 FCP
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
return [mockRouteChangeEntry];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 模拟 observe 抛出错误
|
||||
mockObserve
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('First error');
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Second error');
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti();
|
||||
|
||||
// 验证
|
||||
expect(mockObserve).toHaveBeenCalledTimes(2);
|
||||
expect(reporter.info).toHaveBeenCalledTimes(2);
|
||||
expect(reporter.info).toHaveBeenNthCalledWith(1, {
|
||||
message: 'Second error',
|
||||
namespace: 'performance',
|
||||
});
|
||||
expect(reporter.info).toHaveBeenNthCalledWith(2, {
|
||||
message: 'First error',
|
||||
namespace: 'performance',
|
||||
});
|
||||
});
|
||||
|
||||
it('当有多个路由变更但最后一个路由没有startTime时应该使用默认值0', async () => {
|
||||
const { reportTti, PerfMetricNames } = await vi.importActual<any>(
|
||||
'../../src/utils/custom-perf-metric',
|
||||
);
|
||||
|
||||
// 修改 getEntriesByName 返回多个路由变更,但最后一个没有startTime
|
||||
mockGetEntriesByName.mockImplementation(name => {
|
||||
if (name === 'route_change') {
|
||||
const entries = [
|
||||
{
|
||||
startTime: 300,
|
||||
detail: {
|
||||
location: {
|
||||
pathname: '/first-path',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// 没有startTime属性
|
||||
detail: {
|
||||
location: {
|
||||
pathname: '/test-path2',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 确保数组有 at 方法
|
||||
if (!entries.at) {
|
||||
entries.at = function (index) {
|
||||
return this[index < 0 ? this.length + index : index];
|
||||
};
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 执行
|
||||
reportTti({ key: 'value' });
|
||||
|
||||
// 验证
|
||||
expect(mockSlardarInstance).toHaveBeenCalledWith('sendCustomPerfMetric', {
|
||||
value: 700, // 1000 - 300 = 700 (使用了第一个路由的startTime)
|
||||
name: PerfMetricNames.TTI_HOT,
|
||||
type: 'perf',
|
||||
extra: {
|
||||
key: 'value',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
12
frontend/packages/arch/report-tti/config/rush-project.json
Normal file
12
frontend/packages/arch/report-tti/config/rush-project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
},
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["./coverage"]
|
||||
}
|
||||
]
|
||||
}
|
||||
7
frontend/packages/arch/report-tti/eslint.config.js
Normal file
7
frontend/packages/arch/report-tti/eslint.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'node',
|
||||
rules: {},
|
||||
});
|
||||
49
frontend/packages/arch/report-tti/package.json
Normal file
49
frontend/packages/arch/report-tti/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@coze-arch/report-tti",
|
||||
"version": "0.0.1",
|
||||
"author": "tanjizhen@bytedance.com",
|
||||
"maintainers": [
|
||||
"duwenhan@bytedance.com"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./custom-perf-metric": "./src/utils/custom-perf-metric"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"custom-perf-metric": [
|
||||
"./src/utils/custom-perf-metric"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"lint": "eslint ./ --fix",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:cov": "npm run test -- --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coze-arch/logger": "workspace:*",
|
||||
"react": "~18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coze-arch/bot-typings": "workspace:*",
|
||||
"@coze-arch/eslint-config": "workspace:*",
|
||||
"@coze-arch/ts-config": "workspace:*",
|
||||
"@coze-arch/vitest-config": "workspace:*",
|
||||
"@rsbuild/core": "1.1.13",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/node": "18.18.9",
|
||||
"@types/react": "18.2.37",
|
||||
"@vitest/coverage-v8": "~3.0.5",
|
||||
"react-dom": "~18.2.0",
|
||||
"react-is": ">= 16.8.0",
|
||||
"styled-components": ">= 2",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "~3.0.5",
|
||||
"webpack": "~5.91.0"
|
||||
}
|
||||
}
|
||||
|
||||
25
frontend/packages/arch/report-tti/src/global.d.ts
vendored
Normal file
25
frontend/packages/arch/report-tti/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types='@coze-arch/bot-typings' />
|
||||
|
||||
declare interface Window {
|
||||
// 运行 e2e 时会注入这个全局方法
|
||||
REPORT_TTI_FOR_E2E?: (
|
||||
timestamp: number,
|
||||
performanceEntry: PerformanceEntryList,
|
||||
) => void;
|
||||
}
|
||||
42
frontend/packages/arch/report-tti/src/index.ts
Normal file
42
frontend/packages/arch/report-tti/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
reportTti,
|
||||
REPORT_TTI_DEFAULT_SCENE,
|
||||
} from './utils/custom-perf-metric';
|
||||
|
||||
export interface ReportTtiParams {
|
||||
isLive: boolean;
|
||||
extra?: Record<string, string>;
|
||||
scene?: string; // 一个页面默认只上报一次tti,设置不同的scene可上报多次
|
||||
}
|
||||
|
||||
export const useReportTti = ({
|
||||
isLive,
|
||||
extra,
|
||||
scene = REPORT_TTI_DEFAULT_SCENE,
|
||||
}: ReportTtiParams) => {
|
||||
useEffect(() => {
|
||||
if (isLive) {
|
||||
// TODO useEffect 与真实 DOM 渲染之间会有 gap,需要考虑如何抹平差异
|
||||
// settimeout 在网页后台会挂起,导致 TTI 严重不准
|
||||
reportTti(extra, scene);
|
||||
}
|
||||
}, [isLive]);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger, reporter, getSlardarInstance } from '@coze-arch/logger';
|
||||
|
||||
enum CustomPerfMarkNames {
|
||||
RouteChange = 'route_change',
|
||||
}
|
||||
|
||||
export enum PerfMetricNames {
|
||||
TTI = 'coze_custom_tti',
|
||||
TTI_HOT = 'coze_custom_tti_hot',
|
||||
}
|
||||
|
||||
const fcpEntryName = 'first-contentful-paint';
|
||||
|
||||
const lastRouteNameRef: {
|
||||
name: string;
|
||||
reportScene: Array<string>;
|
||||
} = { name: '', reportScene: [] };
|
||||
|
||||
export const REPORT_TTI_DEFAULT_SCENE = 'init';
|
||||
|
||||
export const reportTti = (extra?: Record<string, string>, scene?: string) => {
|
||||
const sceneKey = scene ?? REPORT_TTI_DEFAULT_SCENE;
|
||||
const value = performance.now();
|
||||
const routeChangeEntries = performance.getEntriesByName(
|
||||
CustomPerfMarkNames.RouteChange,
|
||||
) as PerformanceMark[];
|
||||
const lastRoute = routeChangeEntries.at(-1);
|
||||
// 当前页面已经上报过
|
||||
if (
|
||||
lastRoute?.detail?.location?.pathname &&
|
||||
lastRoute.detail.location.pathname === lastRouteNameRef.name &&
|
||||
lastRouteNameRef.reportScene.includes(sceneKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// 页签处于后台,FCP / TTI 均不准确,放弃上报
|
||||
reporter.info({
|
||||
message: 'page_hidden_on_tti_report',
|
||||
namespace: 'performance',
|
||||
});
|
||||
return;
|
||||
}
|
||||
lastRouteNameRef.name = lastRoute?.detail?.location?.pathname;
|
||||
lastRouteNameRef.reportScene.push(sceneKey);
|
||||
|
||||
// 首个路由视为冷启动,否则视为热启动,因为预期 TTI 时间差异会比较大,这里上报到不同的埋点上
|
||||
if (routeChangeEntries.length > 1) {
|
||||
// startTime 是相对于 performance.timeOrigin 的一个偏移量
|
||||
executeSendTtiHot(value - (lastRoute?.startTime ?? 0), extra);
|
||||
return;
|
||||
}
|
||||
const fcp = performance.getEntriesByName(fcpEntryName)[0];
|
||||
if (fcp) {
|
||||
// 已发生 FCP,比较 TTI 与 FCP 时间,取耗时更长的一个
|
||||
executeSendTti(value > fcp.startTime ? value : fcp.startTime, {
|
||||
...extra,
|
||||
fcpTime: `${fcp.startTime}`,
|
||||
});
|
||||
} else if (window.PerformanceObserver) {
|
||||
// 还未发生 FCP 时,监听 FCP 作为 TTI 上报
|
||||
const observer = new PerformanceObserver(list => {
|
||||
const fcpEntry = list.getEntriesByName(fcpEntryName)[0];
|
||||
if (fcpEntry) {
|
||||
executeSendTti(fcpEntry.startTime, {
|
||||
...extra,
|
||||
fcpTime: `${fcpEntry.startTime}`,
|
||||
});
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
try {
|
||||
observer.observe({ type: 'paint', buffered: true });
|
||||
} catch (error) {
|
||||
// 处理兼容性问题 Failed to execute 'observe' on 'PerformanceObserver': required member entryTypes is undefined.
|
||||
if (PerformanceObserver.supportedEntryTypes?.includes('paint')) {
|
||||
try {
|
||||
observer.observe({ entryTypes: ['paint'] });
|
||||
} catch (innerError) {
|
||||
reporter.info({
|
||||
message: (innerError as Error).message,
|
||||
namespace: 'performance',
|
||||
});
|
||||
}
|
||||
}
|
||||
reporter.info({
|
||||
message: (error as Error).message,
|
||||
namespace: 'performance',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const executeSendTti = (value: number, extra?: Record<string, string>) => {
|
||||
getSlardarInstance()?.('sendCustomPerfMetric', {
|
||||
value,
|
||||
name: PerfMetricNames.TTI,
|
||||
/** 性能指标类型, perf => 传统性能, spa => SPA 性能, mf => 微前端性能 */
|
||||
type: 'perf',
|
||||
extra: {
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({
|
||||
message: 'coze_custom_tti',
|
||||
meta: { value, extra },
|
||||
});
|
||||
};
|
||||
|
||||
const executeSendTtiHot = (value: number, extra?: Record<string, string>) => {
|
||||
getSlardarInstance()?.('sendCustomPerfMetric', {
|
||||
value,
|
||||
name: PerfMetricNames.TTI_HOT,
|
||||
/** 性能指标类型, perf => 传统性能, spa => SPA 性能, mf => 微前端性能 */
|
||||
type: 'perf',
|
||||
extra: {
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({
|
||||
message: 'coze_custom_tti_hot',
|
||||
meta: { value, extra },
|
||||
});
|
||||
};
|
||||
28
frontend/packages/arch/report-tti/tsconfig.build.json
Normal file
28
frontend/packages/arch/report-tti/tsconfig.build.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../bot-typings/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/eslint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/ts-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/vitest-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../logger/tsconfig.build.json"
|
||||
}
|
||||
],
|
||||
"$schema": "https://json.schemastore.org/tsconfig"
|
||||
}
|
||||
15
frontend/packages/arch/report-tti/tsconfig.json
Normal file
15
frontend/packages/arch/report-tti/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.misc.json"
|
||||
}
|
||||
],
|
||||
"exclude": ["**/*"]
|
||||
}
|
||||
16
frontend/packages/arch/report-tti/tsconfig.misc.json
Normal file
16
frontend/packages/arch/report-tti/tsconfig.misc.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"include": ["__tests__", "vitest.config.ts"],
|
||||
"exclude": ["./dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"strictNullChecks": true,
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
25
frontend/packages/arch/report-tti/vitest.config.ts
Normal file
25
frontend/packages/arch/report-tti/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { defineConfig } from '@coze-arch/vitest-config';
|
||||
|
||||
export default defineConfig({
|
||||
dirname: __dirname,
|
||||
preset: 'web',
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user