build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const source = readFileSync(resolve('src/components/rtsp-plugin-video/lib/videoPlayer.js'), 'utf8');
|
|
let sdkWindow: any;
|
|
let control: any;
|
|
let sockets: MockSocket[];
|
|
let attemptedUrls: string[];
|
|
let throwOnConstruct: boolean;
|
|
|
|
class MockSocket {
|
|
readyState = 0;
|
|
onopen: (() => void) | null = null;
|
|
onclose: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
onmessage: ((event: { data: string }) => void) | null = null;
|
|
messages: any[] = [];
|
|
close = vi.fn(() => {
|
|
this.readyState = 3;
|
|
this.onclose?.();
|
|
});
|
|
send = vi.fn((value: string) => {
|
|
if (this.readyState !== 1) throw new Error('socket is not open');
|
|
this.messages.push(JSON.parse(value));
|
|
});
|
|
constructor(public url: string) {
|
|
attemptedUrls.push(url);
|
|
if (throwOnConstruct) throw new Error('local connection blocked');
|
|
sockets.push(this);
|
|
}
|
|
open() {
|
|
this.readyState = 1;
|
|
this.onopen?.();
|
|
}
|
|
fail() {
|
|
const close = this.onclose;
|
|
this.readyState = 3;
|
|
this.onerror?.();
|
|
close?.();
|
|
}
|
|
message(data: any) {
|
|
this.onmessage?.({ data: JSON.stringify(data) });
|
|
}
|
|
}
|
|
|
|
function makePlayer(id = 'one', options: Record<string, unknown> = {}, realCreate = false) {
|
|
const player = Object.create(sdkWindow.VideoPlayer.prototype);
|
|
player.setting = {
|
|
videoId: id, hwnd: '', show: false, visible: true, channelList: [],
|
|
division: 1, windowType: 0, outContent: {}, language: 'zh', request: {},
|
|
stopRefresh: false, usePluginLogin: false, isResetConnect: true,
|
|
createError: vi.fn(), createSuccess: vi.fn(), connectionStateChange: vi.fn(),
|
|
...options,
|
|
};
|
|
player.loginServer = vi.fn();
|
|
if (!realCreate) player.create = vi.fn();
|
|
document.body.insertAdjacentHTML('beforeend', `<div id="${id}"></div>`);
|
|
player.initPlayer();
|
|
return player;
|
|
}
|
|
|
|
async function failAttempts(count: number) {
|
|
for (let index = 0; index < count; index++) {
|
|
sockets.at(-1)!.fail();
|
|
await vi.advanceTimersByTimeAsync((index + 1) % 5 === 0 ? 1000 : 100);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
sockets = [];
|
|
attemptedUrls = [];
|
|
throwOnConstruct = false;
|
|
const navigator = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36' };
|
|
sdkWindow = {
|
|
addEventListener: vi.fn(), removeEventListener: vi.fn(),
|
|
cancelAnimationFrame: vi.fn(), requestAnimationFrame: vi.fn(() => 42),
|
|
getComputedStyle: window.getComputedStyle.bind(window),
|
|
innerWidth: 1280, innerHeight: 720, outerWidth: 1280, devicePixelRatio: 1,
|
|
screen: { availLeft: 0 }, screenX: 0, location: { hostname: 'localhost' },
|
|
document, navigator,
|
|
};
|
|
sdkWindow.parent = sdkWindow;
|
|
sdkWindow.top = sdkWindow;
|
|
runInNewContext(source, {
|
|
window: sdkWindow, document, navigator, localStorage: {},
|
|
console: { log: vi.fn() }, WebSocket: MockSocket,
|
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
});
|
|
control = sdkWindow.dhPlayerControl;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
describe('本机插件连接探测', () => {
|
|
it('并发组件共用一个连接,连接成功后分别初始化一次', () => {
|
|
const first = makePlayer();
|
|
const second = makePlayer('two');
|
|
first.initPlayer();
|
|
expect(sockets).toHaveLength(1);
|
|
sockets[0].open();
|
|
first.initPlayer();
|
|
expect(first.create).toHaveBeenCalledTimes(1);
|
|
expect(second.create).toHaveBeenCalledTimes(1);
|
|
expect(first.setting.createError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('服务稍晚启动时继续下一轮探测,不提前宣告未安装', async () => {
|
|
const player = makePlayer();
|
|
await failAttempts(5);
|
|
expect(attemptedUrls.map(url => new URL(url).port)).toEqual(['8000', '8001', '8002', '8003', '8004', '8000']);
|
|
expect(player.setting.createError).not.toHaveBeenCalled();
|
|
sockets.at(-1)!.open();
|
|
expect(player.create).toHaveBeenCalledTimes(1);
|
|
expect(control.windowState).toBe('wsSuccess');
|
|
});
|
|
|
|
it('三轮失败只报告一次终态,下次打开能重新扫描', async () => {
|
|
const first = makePlayer();
|
|
await failAttempts(15);
|
|
expect(sockets).toHaveLength(15);
|
|
expect(first.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(first.setting.createError).toHaveBeenCalledWith(expect.objectContaining({
|
|
code: 1001, message: '未能连接本机视频服务,请启动或重新启动视频插件后重试',
|
|
}));
|
|
expect(control.wsConnect).toBe(false);
|
|
expect(control.videoWS).toBeNull();
|
|
const retry = makePlayer('retry');
|
|
expect(sockets).toHaveLength(16);
|
|
sockets.at(-1)!.open();
|
|
expect(retry.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('一直没有open或close事件的端口也会超时,整个探测次数有上限', async () => {
|
|
const player = makePlayer();
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(sockets).toHaveLength(15);
|
|
expect(sockets.every(socket => socket.close.mock.calls.length === 1)).toBe(true);
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('构造WebSocket抛异常也能完整结束探测', async () => {
|
|
throwOnConstruct = true;
|
|
const player = makePlayer();
|
|
await vi.advanceTimersByTimeAsync(10000);
|
|
expect(attemptedUrls).toHaveLength(15);
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(control.wsConnect).toBe(false);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('error紧接close只推进一次,不启动并行扫描', async () => {
|
|
makePlayer();
|
|
sockets[0].fail();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(sockets).toHaveLength(2);
|
|
expect(sockets[1].url).toBe('ws://127.0.0.1:8001');
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(sockets).toHaveLength(2);
|
|
});
|
|
|
|
it('最后一个实例在探测中销毁会终止后台探测', async () => {
|
|
const player = makePlayer();
|
|
player.dispose();
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(sockets).toHaveLength(1);
|
|
expect(sockets[0].close).toHaveBeenCalledTimes(1);
|
|
expect(control.wsConnect).toBe(false);
|
|
expect(player.setting.createError).not.toHaveBeenCalled();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('销毁其中一个实例不打断其它实例的探测或已连接的共享socket', async () => {
|
|
const first = makePlayer();
|
|
const other = makePlayer('other');
|
|
first.dispose();
|
|
sockets[0].fail();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
sockets[1].open();
|
|
expect(other.create).toHaveBeenCalledTimes(1);
|
|
other.dispose();
|
|
expect(sockets[1].close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('主动关闭取消延迟任务,旧close事件不会重开连接', async () => {
|
|
const player = makePlayer();
|
|
const oldClose = sockets[0].onclose;
|
|
sockets[0].fail();
|
|
player.onbeforeunload();
|
|
oldClose?.();
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(sockets).toHaveLength(1);
|
|
expect(control.manualCloseWS).toBe(true);
|
|
expect(control.wsConnect).toBe(false);
|
|
expect(player.setting.createError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('连接中断及旧回调隔离', () => {
|
|
it('先重试上次成功端口,再探测其它端口,只通知一次reconnecting', async () => {
|
|
const player = makePlayer();
|
|
await failAttempts(2);
|
|
sockets[2].open();
|
|
sockets[2].fail();
|
|
expect(sockets[3].url).toBe('ws://127.0.0.1:8002');
|
|
expect(player.setting.createError).not.toHaveBeenCalled();
|
|
expect(player.setting.connectionStateChange.mock.calls.map(([state]: any[]) => state.state)).toEqual(['connecting', 'reconnecting']);
|
|
sockets[3].fail();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(sockets[4].url).toBe('ws://127.0.0.1:8000');
|
|
sockets[4].open();
|
|
expect(player.create).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('重连三轮也失败时报告1003,不误报插件未安装', async () => {
|
|
const player = makePlayer('native-ready', {}, true);
|
|
sockets[0].open();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const create = sockets[0].messages.find(message => message.method === 'window.create');
|
|
sockets[0].message({ id: create.id, code: 0, data: { hwnd: 101 } });
|
|
expect(player.setting.createSuccess).toHaveBeenCalledTimes(1);
|
|
sockets[0].fail();
|
|
await failAttempts(15);
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1003 }));
|
|
expect(sockets).toHaveLength(16);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('只成功握手却始终无法准备好窗口时,不会无限重置探测预算', () => {
|
|
const player = makePlayer();
|
|
for (let attempt = 0; attempt < 15; attempt++) {
|
|
sockets.at(-1)!.open();
|
|
sockets.at(-1)!.fail();
|
|
}
|
|
expect(sockets).toHaveLength(15);
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1003 }));
|
|
expect(control.wsConnect).toBe(false);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('旧socket的open/message/close回调不会污染已经恢复的连接', async () => {
|
|
const player = makePlayer();
|
|
const old = sockets[0];
|
|
const callbacks = { open: old.onopen!, close: old.onclose!, message: old.onmessage! };
|
|
old.open();
|
|
old.fail();
|
|
const current = sockets[1];
|
|
current.open();
|
|
callbacks.open();
|
|
callbacks.message({ data: JSON.stringify({ method: 'window.loginServer.notify', info: { result: 1 } }) });
|
|
callbacks.close();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(control.videoWS).toBe(current);
|
|
expect(control.windowState).toBe('wsSuccess');
|
|
expect(control.loginFlag).toBe('LOGIN_PENDING');
|
|
expect(sockets).toHaveLength(2);
|
|
expect(player.create).toHaveBeenCalledTimes(2);
|
|
expect(player.setting.createError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('心跳只发送给当前打开的连接,断开后停止旧心跳', async () => {
|
|
makePlayer();
|
|
const old = sockets[0];
|
|
old.open();
|
|
await vi.advanceTimersByTimeAsync(10000);
|
|
expect(old.messages.filter(message => message.method === 'common.heartbeat')).toHaveLength(1);
|
|
old.fail();
|
|
const current = sockets[1];
|
|
current.open();
|
|
await vi.advanceTimersByTimeAsync(10000);
|
|
expect(old.messages.filter(message => message.method === 'common.heartbeat')).toHaveLength(1);
|
|
expect(current.messages.filter(message => message.method === 'common.heartbeat')).toHaveLength(1);
|
|
});
|
|
|
|
it('关闭自动重连的实例不会参与其他实例的恢复', () => {
|
|
const stopped = makePlayer('stopped', { isResetConnect: false });
|
|
const recovering = makePlayer('recovering');
|
|
sockets[0].open();
|
|
sockets[0].fail();
|
|
expect(stopped.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1003 }));
|
|
sockets[1].open();
|
|
expect(stopped.create).toHaveBeenCalledTimes(1);
|
|
expect(recovering.create).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('所有实例关闭自动重连时,连接断开不再后台扫描', async () => {
|
|
const player = makePlayer('stopped', { isResetConnect: false });
|
|
sockets[0].open();
|
|
sockets[0].fail();
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(sockets).toHaveLength(1);
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('断线清理旧句柄和等待任务,新连接无需等待旧destroy回包', async () => {
|
|
const player = makePlayer('real', {}, true);
|
|
const old = sockets[0];
|
|
old.open();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const firstCreate = old.messages.find(message => message.method === 'window.create');
|
|
const lateCallback = control.callBackList[firstCreate.id];
|
|
player.setting.hwnd = 101;
|
|
player.setting.channelList = [{ channelId: 'old', snum: 0 }];
|
|
control.hwndList[101] = 'real';
|
|
player.setting.socketTimer = setTimeout(() => { throw new Error('old initialization timer'); }, 10);
|
|
player.setting.refreshTimer = 99;
|
|
old.fail();
|
|
expect(player.setting.hwnd).toBe('');
|
|
expect(control.hwndList).toEqual({});
|
|
expect(control.callBackList).toEqual({});
|
|
expect(sdkWindow.cancelAnimationFrame).toHaveBeenCalledWith(99);
|
|
const current = sockets[1];
|
|
current.open();
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
expect(current.messages.some(message => message.method === 'window.create')).toBe(true);
|
|
expect(current.messages.some(message => message.method === 'window.destroy')).toBe(false);
|
|
const count = current.messages.length;
|
|
lateCallback.onSuccess({ data: { hwnd: 606 } });
|
|
expect(current.messages).toHaveLength(count);
|
|
expect(player.setting.hwnd).toBe('');
|
|
});
|
|
|
|
it('window.create失败仅报告该实例,不销毁其他窗口或关闭共享连接', async () => {
|
|
const failed = makePlayer('failed', {}, true);
|
|
const other = makePlayer('other');
|
|
const socket = sockets[0];
|
|
socket.open();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const request = socket.messages.find(message => message.method === 'window.create');
|
|
socket.message({ id: request.id, code: 1 });
|
|
expect(failed.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1006 }));
|
|
expect(other.setting.createError).not.toHaveBeenCalled();
|
|
expect(socket.close).not.toHaveBeenCalled();
|
|
expect(socket.messages.some(message => message.method === 'window.destroyall')).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(sockets).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('共享连接的插件登录等待', () => {
|
|
it('登录中加入的实例也能继续创建,重复初始化不叠加等待', async () => {
|
|
const first = makePlayer('first', { usePluginLogin: true });
|
|
sockets[0].open();
|
|
const second = makePlayer('second', { usePluginLogin: true });
|
|
const waitTimer = second.loginWaitTimer;
|
|
second.initPlayer();
|
|
expect(second.loginWaitTimer).toBe(waitTimer);
|
|
expect(first.loginServer).toHaveBeenCalledTimes(1);
|
|
expect(second.loginServer).not.toHaveBeenCalled();
|
|
sockets[0].message({ method: 'window.loginServer.notify', info: { result: 0 } });
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
expect(first.create).toHaveBeenCalledTimes(1);
|
|
expect(second.create).toHaveBeenCalledTimes(1);
|
|
expect(first.loginWaitTimer).toBeNull();
|
|
expect(second.loginWaitTimer).toBeNull();
|
|
});
|
|
|
|
it('登录失败按1004报告,不创建窗口,也不误报未安装', async () => {
|
|
const player = makePlayer('login', { usePluginLogin: true });
|
|
sockets[0].open();
|
|
sockets[0].message({ method: 'window.loginServer.notify', info: { result: 1 } });
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
expect(player.create).not.toHaveBeenCalled();
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1004 }));
|
|
});
|
|
|
|
it('登录未返回也会超时结束,可由新实例重试登录', async () => {
|
|
const player = makePlayer('login', { usePluginLogin: true });
|
|
sockets[0].open();
|
|
await vi.advanceTimersByTimeAsync(29999);
|
|
expect(player.setting.createError).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(player.setting.createError).toHaveBeenCalledTimes(1);
|
|
expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1004, i18nKey: 'dhplayer.login.timeout' }));
|
|
expect(player.loginWaitTimer).toBeNull();
|
|
expect(player.loginTimeoutTimer).toBeNull();
|
|
const retry = makePlayer('retry', { usePluginLogin: true });
|
|
expect(retry.loginServer).toHaveBeenCalledTimes(1);
|
|
sockets[0].message({ method: 'window.loginServer.notify', info: { result: 0 } });
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
expect(retry.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('重连清理旧登录等待,旧连接的登录响应不能提前完成新登录', async () => {
|
|
const player = makePlayer('login', { usePluginLogin: true });
|
|
const old = sockets[0];
|
|
old.open();
|
|
const oldMessage = old.onmessage!;
|
|
old.fail();
|
|
sockets[1].open();
|
|
oldMessage({ data: JSON.stringify({ method: 'window.loginServer.notify', info: { result: 0 } }) });
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
expect(player.create).not.toHaveBeenCalled();
|
|
sockets[1].message({ method: 'window.loginServer.notify', info: { result: 0 } });
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
expect(player.create).toHaveBeenCalledTimes(1);
|
|
expect(player.loginServer).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|