rrvideo: improve the video quality and add a progress bar for the CLI tool (#1197)
* refactor rrvideo: use playwright rather than puppeteer * add a progress bar for the tool * add tests for cli.ts * fix build error * add change log * update readme file * Apply a scaling method to improve the resolution of the output video
This commit is contained in:
@@ -4,11 +4,12 @@
|
||||
|
||||
rrvideo is a tool for transforming the session recorded by [rrweb](https://github.com/rrweb-io/rrweb) into a video.
|
||||
|
||||

|
||||
|
||||
## Install rrvideo
|
||||
|
||||
1. Install [ffmpeg](https://ffmpeg.org/download.html)。
|
||||
2. Install [Node.JS](https://nodejs.org/en/download/)。
|
||||
3. Run `npm i -g rrvideo` to install the rrvideo CLI。
|
||||
1. Install [Node.JS](https://nodejs.org/en/download/)。
|
||||
2. Run `npm i -g rrvideo` to install the rrvideo CLI.
|
||||
|
||||
## Use rrvideo
|
||||
|
||||
@@ -18,7 +19,7 @@ rrvideo is a tool for transforming the session recorded by [rrweb](https://githu
|
||||
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE
|
||||
```
|
||||
|
||||
Running this command will output a `rrvideo-output.mp4` file in the current working directory.
|
||||
Running this command will output a `rrvideo-output.webm` file in the current working directory.
|
||||
|
||||
### Config the output path
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
rrvideo 是用于将 [rrweb](https://github.com/rrweb-io/rrweb) 录制的数据转为视频格式的工具。
|
||||
|
||||

|
||||
|
||||
## 安装 rrvideo
|
||||
|
||||
1. 安装 [ffmpeg](https://ffmpeg.org/download.html)。
|
||||
2. 安装 [Node.JS](https://nodejs.org/en/download/)。
|
||||
3. 执行 `npm i -g rrvideo` 以安装 rrvideo CLI。
|
||||
1. 安装 [Node.JS](https://nodejs.org/en/download/)。
|
||||
2. 执行 `npm i -g rrvideo` 以安装 rrvideo CLI。
|
||||
|
||||
## 使用 rrvideo
|
||||
|
||||
@@ -16,7 +17,7 @@ rrvideo 是用于将 [rrweb](https://github.com/rrweb-io/rrweb) 录制的数据
|
||||
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE
|
||||
```
|
||||
|
||||
运行以上命令会在执行文件夹中生成一个 `rrvideo-output.mp4` 文件。
|
||||
运行以上命令会在执行文件夹中生成一个 `rrvideo-output.webm` 文件。
|
||||
|
||||
### 指定输出路径
|
||||
|
||||
|
||||
BIN
packages/rrvideo/demo/demo.gif
Normal file
BIN
packages/rrvideo/demo/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 616 KiB |
6
packages/rrvideo/jest.config.js
Normal file
6
packages/rrvideo/jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// eslint-disable-next-line tsdoc/syntax
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
@@ -11,18 +11,28 @@
|
||||
],
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"install": "playwright install",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"check-types": "tsc -noEmit",
|
||||
"prepublish": "yarn build"
|
||||
},
|
||||
"author": "yanzhen@smartx.com",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "11.0.1",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/minimist": "^1.2.1",
|
||||
"@types/node": "^18.15.11",
|
||||
"jest": "^27.5.1",
|
||||
"ts-jest": "^27.1.3",
|
||||
"@rrweb/types": "^2.0.0-alpha.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-tech-world/cli-progress-bar": "^2.0.2",
|
||||
"fs-extra": "^11.1.1",
|
||||
"minimist": "^1.2.5",
|
||||
"puppeteer": "^19.7.2",
|
||||
"playwright": "^1.32.1",
|
||||
"rrweb-player": "^2.0.0-alpha.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import minimist from 'minimist';
|
||||
import { ProgressBar } from '@open-tech-world/cli-progress-bar';
|
||||
import type { RRwebPlayerOptions } from 'rrweb-player';
|
||||
import { transformToVideo } from './index';
|
||||
|
||||
@@ -24,10 +25,18 @@ if (argv.config) {
|
||||
>;
|
||||
}
|
||||
|
||||
const pBar = new ProgressBar({ prefix: 'Transforming' });
|
||||
const onProgressUpdate = (percent: number) => {
|
||||
if (percent < 1) pBar.run({ value: percent * 100, total: 100 });
|
||||
else
|
||||
pBar.run({ value: 100, total: 100, prefix: 'Transformation Completed!' });
|
||||
};
|
||||
|
||||
transformToVideo({
|
||||
input: argv.input as string,
|
||||
output: argv.output as string,
|
||||
rrwebPlayer: config,
|
||||
onProgressUpdate,
|
||||
})
|
||||
.then((file) => {
|
||||
console.log(`Successfully transformed into "${file}".`);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import puppeteer from 'puppeteer';
|
||||
import type { Page, Browser } from 'puppeteer';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import { chromium } from 'playwright';
|
||||
import { EventType, eventWithTime } from '@rrweb/types';
|
||||
import type { RRwebPlayerOptions } from 'rrweb-player';
|
||||
|
||||
const rrwebScriptPath = path.resolve(
|
||||
@@ -13,38 +11,38 @@ const rrwebScriptPath = path.resolve(
|
||||
const rrwebStylePath = path.resolve(rrwebScriptPath, '../style.css');
|
||||
const rrwebRaw = fs.readFileSync(rrwebScriptPath, 'utf-8');
|
||||
const rrwebStyle = fs.readFileSync(rrwebStylePath, 'utf-8');
|
||||
// The max valid scale value for the scaling method which can improve the video quality.
|
||||
const MaxScaleValue = 2.5;
|
||||
|
||||
type RRvideoConfig = {
|
||||
input: string;
|
||||
output?: string;
|
||||
headless?: boolean;
|
||||
fps?: number;
|
||||
cb?: (file: string, error: null | Error) => void;
|
||||
// start playback delay time
|
||||
startDelayTime?: number;
|
||||
// A number between 0 and 1. The higher the value, the better the quality of the video.
|
||||
resolutionRatio?: number;
|
||||
// A callback function that will be called when the progress of the replay is updated.
|
||||
onProgressUpdate?: (percent: number) => void;
|
||||
rrwebPlayer?: Omit<RRwebPlayerOptions['props'], 'events'>;
|
||||
};
|
||||
|
||||
const defaultConfig: Required<RRvideoConfig> = {
|
||||
input: '',
|
||||
output: 'rrvideo-output.mp4',
|
||||
output: 'rrvideo-output.webm',
|
||||
headless: true,
|
||||
fps: 15,
|
||||
cb: () => {
|
||||
// A good trade-off value between quality and file size.
|
||||
resolutionRatio: 0.8,
|
||||
onProgressUpdate: () => {
|
||||
//
|
||||
},
|
||||
startDelayTime: 1000,
|
||||
rrwebPlayer: {},
|
||||
};
|
||||
|
||||
function getHtml(
|
||||
events: Array<eventWithTime>,
|
||||
config?: Omit<RRwebPlayerOptions['props'], 'events'>,
|
||||
): string {
|
||||
function getHtml(events: Array<eventWithTime>, config?: RRvideoConfig): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>${rrwebStyle}</style>
|
||||
<style>html, body {padding: 0; border: none; margin: 0;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
@@ -55,163 +53,122 @@ function getHtml(
|
||||
'<\\/script>',
|
||||
)};
|
||||
/*-->*/
|
||||
const userConfig = ${JSON.stringify(config || {})};
|
||||
const userConfig = ${JSON.stringify(config?.rrwebPlayer || {})};
|
||||
window.replayer = new rrwebPlayer({
|
||||
target: document.body,
|
||||
width: userConfig.width,
|
||||
height: userConfig.height,
|
||||
props: {
|
||||
...userConfig,
|
||||
events,
|
||||
showController: false,
|
||||
autoPlay: false, // autoPlay off by default
|
||||
showController: false,
|
||||
},
|
||||
});
|
||||
window.replayer.addEventListener('finish', () => window.onReplayFinish());
|
||||
window.replayer.addEventListener('ui-update-progress', (payload)=> window.onReplayProgressUpdate
|
||||
(payload));
|
||||
window.replayer.addEventListener('resize',()=>document.querySelector('.replayer-wrapper').style.transform = 'scale(${
|
||||
(config?.resolutionRatio ?? 1) * MaxScaleValue
|
||||
}) translate(-50%, -50%)');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export class RRvideo {
|
||||
private browser!: Browser;
|
||||
private page!: Page;
|
||||
private state: 'idle' | 'recording' | 'closed' = 'idle';
|
||||
private config = {
|
||||
...defaultConfig,
|
||||
};
|
||||
|
||||
constructor(config: RRvideoConfig) {
|
||||
this.updateConfig(config);
|
||||
}
|
||||
|
||||
public async transform() {
|
||||
try {
|
||||
this.browser = await puppeteer.launch({
|
||||
headless: this.config.headless,
|
||||
});
|
||||
this.page = await this.browser.newPage();
|
||||
await this.page.goto('about:blank');
|
||||
|
||||
await this.page.exposeFunction('onReplayFinish', () => {
|
||||
void this.finishRecording();
|
||||
});
|
||||
|
||||
const eventsPath = path.isAbsolute(this.config.input)
|
||||
? this.config.input
|
||||
: path.resolve(process.cwd(), this.config.input);
|
||||
const events = JSON.parse(
|
||||
fs.readFileSync(eventsPath, 'utf-8'),
|
||||
) as eventWithTime[];
|
||||
|
||||
await this.page.setContent(getHtml(events, this.config.rrwebPlayer));
|
||||
|
||||
setTimeout(() => {
|
||||
void this.startRecording().then(() => {
|
||||
return this.page.evaluate('window.replayer.play();');
|
||||
});
|
||||
}, this.config.startDelayTime);
|
||||
} catch (error) {
|
||||
this.config.cb('', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public updateConfig(config: RRvideoConfig) {
|
||||
if (!config.input) throw new Error('input is required');
|
||||
config.output = config.output || defaultConfig.output;
|
||||
Object.assign(this.config, defaultConfig, config);
|
||||
}
|
||||
|
||||
private async startRecording() {
|
||||
this.state = 'recording';
|
||||
let wrapperSelector = '.replayer-wrapper';
|
||||
if (this.config.rrwebPlayer.width && this.config.rrwebPlayer.height) {
|
||||
wrapperSelector = '.rr-player';
|
||||
}
|
||||
const wrapperEl = await this.page.$(wrapperSelector);
|
||||
|
||||
if (!wrapperEl) {
|
||||
throw new Error('failed to get replayer element');
|
||||
}
|
||||
|
||||
// start ffmpeg
|
||||
const args = [
|
||||
// fps
|
||||
'-framerate',
|
||||
this.config.fps.toString(),
|
||||
// input
|
||||
'-f',
|
||||
'image2pipe',
|
||||
'-i',
|
||||
'-',
|
||||
// output
|
||||
'-y',
|
||||
this.config.output,
|
||||
];
|
||||
|
||||
const ffmpegProcess = spawn('ffmpeg', args);
|
||||
ffmpegProcess.stderr.setEncoding('utf-8');
|
||||
ffmpegProcess.stderr.on('data', console.log);
|
||||
|
||||
let processError: Error | null = null;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (this.state === 'recording' && !processError) {
|
||||
void wrapperEl
|
||||
.screenshot({
|
||||
encoding: 'binary',
|
||||
})
|
||||
.then((buffer) => ffmpegProcess.stdin.write(buffer))
|
||||
.catch();
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
if (this.state === 'closed' && !processError) {
|
||||
ffmpegProcess.stdin.end();
|
||||
}
|
||||
}
|
||||
}, 1000 / this.config.fps);
|
||||
|
||||
const outputPath = path.isAbsolute(this.config.output)
|
||||
? this.config.output
|
||||
: path.resolve(process.cwd(), this.config.output);
|
||||
ffmpegProcess.on('close', () => {
|
||||
if (processError) {
|
||||
return;
|
||||
}
|
||||
this.config.cb(outputPath, null);
|
||||
});
|
||||
ffmpegProcess.on('error', (error) => {
|
||||
if (processError) {
|
||||
return;
|
||||
}
|
||||
processError = error;
|
||||
this.config.cb(outputPath, error);
|
||||
});
|
||||
ffmpegProcess.stdin.on('error', (error) => {
|
||||
if (processError) {
|
||||
return;
|
||||
}
|
||||
processError = error;
|
||||
this.config.cb(outputPath, error);
|
||||
});
|
||||
}
|
||||
|
||||
private async finishRecording() {
|
||||
this.state = 'closed';
|
||||
await this.browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function transformToVideo(config: RRvideoConfig): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rrvideo = new RRvideo({
|
||||
...config,
|
||||
cb(file, error) {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
resolve(file);
|
||||
},
|
||||
});
|
||||
void rrvideo.transform();
|
||||
/**
|
||||
* Preprocess all events to get a maximum view port size.
|
||||
*/
|
||||
function getMaxViewport(events: eventWithTime[]) {
|
||||
let maxWidth = 0,
|
||||
maxHeight = 0;
|
||||
events.forEach((event) => {
|
||||
if (event.type !== EventType.Meta) return;
|
||||
if (event.data.width > maxWidth) maxWidth = event.data.width;
|
||||
if (event.data.height > maxHeight) maxHeight = event.data.height;
|
||||
});
|
||||
return {
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export async function transformToVideo(options: RRvideoConfig) {
|
||||
const defaultVideoDir = '__rrvideo__temp__';
|
||||
const config = { ...defaultConfig };
|
||||
if (!options.input) throw new Error('input is required');
|
||||
// If the output is not specified or undefined, use the default value.
|
||||
if (!options.output) delete options.output;
|
||||
Object.assign(config, options);
|
||||
if (config.resolutionRatio > 1) config.resolutionRatio = 1; // The max value is 1.
|
||||
|
||||
const eventsPath = path.isAbsolute(config.input)
|
||||
? config.input
|
||||
: path.resolve(process.cwd(), config.input);
|
||||
const outputPath = path.isAbsolute(config.output)
|
||||
? config.output
|
||||
: path.resolve(process.cwd(), config.output);
|
||||
const events = JSON.parse(
|
||||
fs.readFileSync(eventsPath, 'utf-8'),
|
||||
) as eventWithTime[];
|
||||
|
||||
// Make the browser viewport fit the player size.
|
||||
const maxViewport = getMaxViewport(events);
|
||||
// Use the scaling method to improve the video quality.
|
||||
const scaledViewport = {
|
||||
width: Math.round(
|
||||
maxViewport.width * (config.resolutionRatio ?? 1) * MaxScaleValue,
|
||||
),
|
||||
height: Math.round(
|
||||
maxViewport.height * (config.resolutionRatio ?? 1) * MaxScaleValue,
|
||||
),
|
||||
};
|
||||
Object.assign(config.rrwebPlayer, scaledViewport);
|
||||
const browser = await chromium.launch({
|
||||
headless: config.headless,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: scaledViewport,
|
||||
recordVideo: {
|
||||
dir: defaultVideoDir,
|
||||
size: scaledViewport,
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.exposeFunction(
|
||||
'onReplayProgressUpdate',
|
||||
(data: { payload: number }) => {
|
||||
config.onProgressUpdate(data.payload);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the replay to finish
|
||||
await new Promise<void>(
|
||||
(resolve) =>
|
||||
void page
|
||||
.exposeFunction('onReplayFinish', () => resolve())
|
||||
.then(() => page.setContent(getHtml(events, config))),
|
||||
);
|
||||
const videoPath = (await page.video()?.path()) || '';
|
||||
const cleanFiles = async (videoPath: string) => {
|
||||
await fs.remove(videoPath);
|
||||
if ((await fs.readdir(defaultVideoDir)).length === 0) {
|
||||
await fs.remove(defaultVideoDir);
|
||||
}
|
||||
};
|
||||
await context.close();
|
||||
await Promise.all([
|
||||
fs
|
||||
.move(videoPath, outputPath, { overwrite: true })
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
"Can't create video file. Please check the output path.",
|
||||
e,
|
||||
);
|
||||
})
|
||||
.finally(() => void cleanFiles(videoPath)),
|
||||
browser.close(),
|
||||
]);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
45
packages/rrvideo/test/cli.test.ts
Normal file
45
packages/rrvideo/test/cli.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import exampleEvents from './events/example';
|
||||
|
||||
describe('should be able to run cli', () => {
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(path.resolve(__dirname, './generated'));
|
||||
fs.writeJsonSync(
|
||||
path.resolve(__dirname, './generated/example.json'),
|
||||
exampleEvents,
|
||||
{
|
||||
spaces: 2,
|
||||
},
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.remove(path.resolve(__dirname, './generated'));
|
||||
});
|
||||
|
||||
it('should throw error without input path', () => {
|
||||
expect(() => {
|
||||
execSync('node ./build/cli.js', { stdio: 'pipe' });
|
||||
}).toThrowError(/.*please pass --input to your rrweb events file.*/);
|
||||
});
|
||||
|
||||
it('should generate a video without output path', () => {
|
||||
execSync('node ./build/cli.js --input ./test/generated/example.json', {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const outputFile = path.resolve(__dirname, '../rrvideo-output.webm');
|
||||
expect(fs.existsSync(outputFile)).toBe(true);
|
||||
fs.removeSync(outputFile);
|
||||
});
|
||||
|
||||
it('should generate a video with specific output path', () => {
|
||||
const outputFile = path.resolve(__dirname, './generated/output.webm');
|
||||
execSync(
|
||||
`node ./build/cli.js --input ./test/generated/example.json --output ${outputFile}`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(fs.existsSync(outputFile)).toBe(true);
|
||||
fs.removeSync(outputFile);
|
||||
});
|
||||
});
|
||||
147
packages/rrvideo/test/events/example.ts
Normal file
147
packages/rrvideo/test/events/example.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { EventType, IncrementalSource } from '@rrweb/types';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
|
||||
const now = Date.now();
|
||||
const events: eventWithTime[] = [
|
||||
{
|
||||
type: EventType.DomContentLoaded,
|
||||
data: {},
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
type: EventType.Load,
|
||||
data: {},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: 'http://localhost',
|
||||
width: 1000,
|
||||
height: 800,
|
||||
},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
// full snapshot:
|
||||
{
|
||||
data: {
|
||||
node: {
|
||||
id: 1,
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{ id: 2, name: 'html', type: 1, publicId: '', systemId: '' },
|
||||
{
|
||||
id: 3,
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: { lang: 'en' },
|
||||
childNodes: [
|
||||
{
|
||||
id: 4,
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
initialOffset: { top: 0, left: 0 },
|
||||
},
|
||||
type: EventType.FullSnapshot,
|
||||
timestamp: now + 100,
|
||||
},
|
||||
// mutation that adds select elements
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Mutation,
|
||||
texts: [],
|
||||
attributes: [],
|
||||
removes: [],
|
||||
adds: [
|
||||
{
|
||||
parentId: 5,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'select',
|
||||
childNodes: [],
|
||||
attributes: {},
|
||||
id: 26,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 26,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'option',
|
||||
attributes: { value: 'valueC' },
|
||||
childNodes: [],
|
||||
id: 27,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 27,
|
||||
nextId: null,
|
||||
node: { type: 3, textContent: 'C', id: 28 },
|
||||
},
|
||||
{
|
||||
parentId: 26,
|
||||
nextId: 27,
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'option',
|
||||
attributes: { value: 'valueB', selected: true },
|
||||
childNodes: [],
|
||||
id: 29,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 26,
|
||||
nextId: 29,
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'option',
|
||||
attributes: { value: 'valueA' },
|
||||
childNodes: [],
|
||||
id: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 30,
|
||||
nextId: null,
|
||||
node: { type: 3, textContent: 'A', id: 31 },
|
||||
},
|
||||
{
|
||||
parentId: 29,
|
||||
nextId: null,
|
||||
node: { type: 3, textContent: 'B', id: 32 },
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 200,
|
||||
},
|
||||
// input event
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Input,
|
||||
text: 'valueA',
|
||||
isChecked: false,
|
||||
id: 26,
|
||||
},
|
||||
timestamp: now + 300,
|
||||
},
|
||||
];
|
||||
|
||||
export default events;
|
||||
3
packages/rrvideo/test/tsconfig.json
Normal file
3
packages/rrvideo/test/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"exclude": ["build", "node_modules", "test"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../rrweb-player"
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@types/chai": "^4.1.4",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/jsdom": "^20.0.0",
|
||||
"@types/node": "^10.11.3",
|
||||
"@types/node": "^18.15.11",
|
||||
"@types/puppeteer": "^1.12.4",
|
||||
"cross-env": "^5.2.0",
|
||||
"jest": "^27.2.4",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"@types/inquirer": "^8.2.1",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/jest-image-snapshot": "^5.1.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/node": "^18.15.11",
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"@types/puppeteer": "^5.4.4",
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
|
||||
@@ -74,6 +74,7 @@ const events: eventWithTime[] = [
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'select',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 26,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user