* Copyright (C) 2026 Huawei Device Co., Ltd.
* 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 { LongTraceTransfer } from '../../../src/trace/longtrace/LongTraceTransfer';
import {
buildLongTraceProgressMessage,
LONG_TRACE_CHANNEL_DOWNLOAD,
LONG_TRACE_CHANNEL_UPLOAD,
LONG_TRACE_FALLBACK_PATH,
LONG_TRACE_MESSAGE_TYPE_HELLO,
LONG_TRACE_MESSAGE_TYPE_PATCH_PAYLOAD_END,
LONG_TRACE_MESSAGE_TYPE_RESULT,
LONG_TRACE_MESSAGE_TYPE_TRACE_END,
LONG_TRACE_MESSAGE_TYPE_TRACE_META,
LONG_TRACE_REASON_SESSION_BOUND,
LONG_TRACE_STATUS_NO_PATCH,
LONG_TRACE_STATUS_PATCH_READY,
} from '../../../src/trace/longtrace/LongTraceConstants';
const MOCK_SESSION_ID = 'mock-session';
const MOCK_TRACE_FILE_NAME = 'hiprofiler_data_20260101_010101_1.htrace';
const MOCK_TRACE_URL = `ws://mock${LONG_TRACE_FALLBACK_PATH}`;
const MOCK_TRACE_FILE_CONTENT = 'abc';
const MOCK_TRACE_PATCH_CONTENT = 'trace';
const MOCK_PATCH_PART_ONE = 'patch-';
const MOCK_PATCH_PART_TWO = 'payload';
const MOCK_PATCH_READY_PAGE = 1;
const MOCK_NO_PATCH_PAGE = 1;
type MockListener = (event?: { data?: unknown }) => void;
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly url: string;
readonly sentData: Array<string | ArrayBuffer> = [];
readonly listeners: Map<string, Set<MockListener>> = new Map();
readyState = MockWebSocket.CONNECTING;
binaryType = '';
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
addEventListener(type: string, listener: MockListener): void {
const group = this.listeners.get(type) ?? new Set<MockListener>();
group.add(listener);
this.listeners.set(type, group);
}
removeEventListener(type: string, listener: MockListener): void {
this.listeners.get(type)?.delete(listener);
}
send(data: string | ArrayBuffer): void {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error('socket not open');
}
this.sentData.push(data);
}
close(): void {
if (this.readyState === MockWebSocket.CLOSED) {
return;
}
this.readyState = MockWebSocket.CLOSED;
this.emit('close');
}
open(): void {
this.readyState = MockWebSocket.OPEN;
this.emit('open');
}
emit(type: string, event?: { data?: unknown }): void {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.forEach((listener) => listener(event));
}
}
function flushAsyncTasks(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0));
}
function createMockFile(name: string, content: string): File {
const encoder = new TextEncoder();
const bytes = encoder.encode(content);
return {
name,
size: bytes.byteLength,
slice(start?: number, end?: number): Blob {
const from = start ?? 0;
const to = end ?? bytes.byteLength;
return {
arrayBuffer(): Promise<ArrayBuffer> {
return Promise.resolve(bytes.slice(from, to).buffer);
},
} as Blob;
},
} as File;
}
function createArrayBuffer(content: string): ArrayBuffer {
const bytes = new TextEncoder().encode(content);
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function createSessionBoundMessage(): string {
return JSON.stringify({
type: LONG_TRACE_MESSAGE_TYPE_RESULT,
sessionId: MOCK_SESSION_ID,
n: 0,
fileName: '',
status: LONG_TRACE_STATUS_NO_PATCH,
hasPatchPayload: false,
reason: LONG_TRACE_REASON_SESSION_BOUND,
});
}
function createNoPatchResultMessage(n: number, fileName: string): string {
return JSON.stringify({
type: LONG_TRACE_MESSAGE_TYPE_RESULT,
sessionId: MOCK_SESSION_ID,
n,
fileName,
status: LONG_TRACE_STATUS_NO_PATCH,
hasPatchPayload: false,
});
}
function createPatchReadyMessage(n: number, fileName: string): string {
return JSON.stringify({
type: LONG_TRACE_MESSAGE_TYPE_RESULT,
sessionId: MOCK_SESSION_ID,
n,
fileName,
status: LONG_TRACE_STATUS_PATCH_READY,
hasPatchPayload: true,
});
}
function createPatchEndMessage(n: number, fileName: string): string {
return JSON.stringify({
type: LONG_TRACE_MESSAGE_TYPE_PATCH_PAYLOAD_END,
sessionId: MOCK_SESSION_ID,
n,
fileName,
});
}
function getSentTextMessages(ws: MockWebSocket): Array<Record<string, unknown>> {
return ws.sentData
.filter((data): data is string => typeof data === 'string')
.map((data) => JSON.parse(data) as Record<string, unknown>);
}
describe('LongTraceTransfer', () => {
const originalWebSocket = globalThis.WebSocket;
beforeEach(() => {
MockWebSocket.instances = [];
Object.defineProperty(globalThis, 'WebSocket', {
configurable: true,
writable: true,
value: MockWebSocket,
});
});
afterEach(() => {
Object.defineProperty(globalThis, 'WebSocket', {
configurable: true,
writable: true,
value: originalWebSocket,
});
});
* 测试条件:在会话绑定完成前下载通道关闭时,预处理 Promise 应直接 reject。
* 测试目的:验证 LongTraceTransfer 实例在绑定失败时能够及时通知业务层。
*/
it('在会话绑定完成前下载通道关闭时应直接失败', async () => {
const transfer = new LongTraceTransfer(MOCK_TRACE_URL);
const files = [createMockFile(MOCK_TRACE_FILE_NAME, MOCK_TRACE_FILE_CONTENT)];
const preprocessPromise = transfer.preprocessInBackground(files);
await flushAsyncTasks();
expect(MockWebSocket.instances.length).toBe(2);
const [uploadWs, downloadWs] = MockWebSocket.instances;
uploadWs.open();
downloadWs.open();
await flushAsyncTasks();
downloadWs.close();
await expect(preprocessPromise).rejects.toThrow('The extended service channel has been disconnected');
});
* 测试条件:在会话绑定完成后,服务端下发补丁数据。
* 测试目的:验证 LongTraceTransfer 实例能够正确合并补丁数据并回调结果。
*/
it('单文件无补丁场景下应完成握手、上传并回调结果与进度', async () => {
const transfer = new LongTraceTransfer(MOCK_TRACE_URL);
const onProgress = jest.fn();
const onResult = jest.fn();
const fileName = MOCK_TRACE_FILE_NAME;
const preprocessPromise = transfer.preprocessInBackground([createMockFile(fileName, MOCK_TRACE_FILE_CONTENT)], {
onProgress,
onResult,
});
await flushAsyncTasks();
expect(MockWebSocket.instances.length).toBe(2);
const [uploadWs, downloadWs] = MockWebSocket.instances;
uploadWs.open();
downloadWs.open();
await flushAsyncTasks();
downloadWs.emit('message', {
data: createSessionBoundMessage(),
});
await flushAsyncTasks();
downloadWs.emit('message', {
data: createNoPatchResultMessage(1, fileName),
});
await expect(preprocessPromise).resolves.toBeUndefined();
const uploadTextMessages = getSentTextMessages(uploadWs);
expect(uploadTextMessages[0]).toMatchObject({ type: LONG_TRACE_MESSAGE_TYPE_HELLO, channel: LONG_TRACE_CHANNEL_UPLOAD });
expect(uploadTextMessages[1]).toMatchObject({
type: LONG_TRACE_MESSAGE_TYPE_TRACE_META,
n: MOCK_NO_PATCH_PAGE,
fileName,
isLast: true,
});
expect(uploadWs.sentData.some((data) => typeof data !== 'string')).toBeTruthy();
expect(uploadTextMessages[2]).toMatchObject({ type: LONG_TRACE_MESSAGE_TYPE_TRACE_END, n: MOCK_NO_PATCH_PAGE, fileName });
expect(getSentTextMessages(downloadWs)[0]).toMatchObject({
type: LONG_TRACE_MESSAGE_TYPE_HELLO,
channel: LONG_TRACE_CHANNEL_DOWNLOAD,
});
expect(onResult).toHaveBeenCalledWith({
n: MOCK_NO_PATCH_PAGE,
fileName,
status: LONG_TRACE_STATUS_NO_PATCH,
reason: undefined,
});
expect(onProgress).toHaveBeenCalledWith({
done: MOCK_NO_PATCH_PAGE,
total: MOCK_NO_PATCH_PAGE,
message: buildLongTraceProgressMessage(MOCK_NO_PATCH_PAGE, MOCK_NO_PATCH_PAGE),
});
});
* 测试条件:在会话绑定完成后,服务端下发补丁数据。
* 测试目的:验证 LongTraceTransfer 实例能够正确合并补丁数据并回调结果。
*/
it('补丁场景下应合并二进制分片并回调 PATCH_READY 结果', async () => {
const transfer = new LongTraceTransfer(MOCK_TRACE_URL);
const onProgress = jest.fn();
const onResult = jest.fn();
const fileName = MOCK_TRACE_FILE_NAME;
const preprocessPromise = transfer.preprocessInBackground([createMockFile(fileName, MOCK_TRACE_PATCH_CONTENT)], {
onProgress,
onResult,
});
await flushAsyncTasks();
const [uploadWs, downloadWs] = MockWebSocket.instances;
uploadWs.open();
downloadWs.open();
await flushAsyncTasks();
downloadWs.emit('message', { data: createSessionBoundMessage() });
await flushAsyncTasks();
downloadWs.emit('message', { data: createPatchReadyMessage(MOCK_PATCH_READY_PAGE, fileName) });
await flushAsyncTasks();
downloadWs.emit('message', { data: createArrayBuffer(MOCK_PATCH_PART_ONE) });
downloadWs.emit('message', { data: createArrayBuffer(MOCK_PATCH_PART_TWO) });
downloadWs.emit('message', { data: createPatchEndMessage(MOCK_PATCH_READY_PAGE, fileName) });
await expect(preprocessPromise).resolves.toBeUndefined();
expect(onResult).toHaveBeenCalledTimes(1);
const result = onResult.mock.calls[0][0] as {
n: number;
fileName: string;
status: string;
patchPayload: Uint8Array;
};
expect(result.n).toBe(MOCK_PATCH_READY_PAGE);
expect(result.fileName).toBe(fileName);
expect(result.status).toBe(LONG_TRACE_STATUS_PATCH_READY);
expect(new TextDecoder().decode(result.patchPayload)).toBe(`${MOCK_PATCH_PART_ONE}${MOCK_PATCH_PART_TWO}`);
expect(onProgress).toHaveBeenCalledWith({
done: MOCK_PATCH_READY_PAGE,
total: MOCK_PATCH_READY_PAGE,
message: buildLongTraceProgressMessage(MOCK_PATCH_READY_PAGE, MOCK_PATCH_READY_PAGE),
});
});
});