Add WebGL support (#756)

* Add very basic webgl support

* document the default

* only capture rr_dataURL in 2d canvas contexts

* rr_dataURL no longer part of webgl snapshot

* ignore __diff_output__ from jest-image-snapshot

* Rename generic "Monorepo" to "RRWeb Monorepo"

* Serialize WebGL variables

* Move rrweb test port number to unique port

rrweb-snapshot uses 3030, rrweb uses 3031

* Prepare for WebGL2

* Split up canvas replay and record webgl vars

* fix typo

* fix typo part 2

* fix typo

* Handle non-variables too

* provide correct context for warning

* (De)Serialize a lot of different objects

* monorepo root should be the first in the list

* Upgrade puppeteer to 11.x

* Correctly de-serialize webgl variables

* Encode arrayBuffers contents to base64

* rename contents to base64

* add webgl2 support and serialize HTMLImageElements

* Support serializing ImageData

* Correctly classify WebGL2 events

* Serialize format changed

* check if canvas has contents before we save the dataURL

* Remove blank dataURL

* reference original file not type defintion file

* update types

* rename code worspace

* update dependencies

* add spector to inspect webgl

* remove live server settings from code workspace

* Save canvas context in the node

Prevents from saving webgl canvases as 2d dataUrls

* remove extra braces

* add ICanvas type

* use ICanvas from rrweb-snapshot in rrweb instead of OgmentedCanvas

* add snapshots and webgl 2 tests

* Upgrade to puppeteer 12.0.1

* Revert back to puppeteer 9.1.1

* Keep index order consistent between replay and record

* keep correct index order in webgl2

* fixed forgotten import

* buffer up pending canvas mutations

* unify the way webgl and webgl2 get patched

* fix parsing error

* Add types for serialize-args

* Add debugging for webgl replay

* Move start-server to utils

* turn off debug mode by default

* Move pendingCanvasMutations to local object and fix if/else statement

* Always save pending mutations

* only use assert snapshot as it's clearer whats going on

* Ugly fix for now

* Making the tests more DRY

* flush at the end of each request animation frame

* Looks like the promise made this test more predictable

* add waitForRAF

* Make nested iframe recording robust no matter the test speed

* mute noisy error in test

* force a requestAnimationFrame

* Bundle events within one frame together as much as possible

WebGL events need to be bundled together as much as possible so they don't accidentally get split over multiple animation frames.

 `newFrame: true` is used to indicate the start of an new animation frame in the recording, and that the event shouldn't be bundled with the previous events.

* Rename RafStamps

* Override event.delay

* cleanup

* Add tests for addDelay

* Add webgl e2e test

* Remove settimeout

* DRY-up test

* Preload images in webgl

* Add e2e test for webgl image preloading

* don't turn on devtools by default!

* Remove spector

* close server after use

* Add imageMap parameter

* Make e2e image test more robust

* document debug mode

* cleanup

* WebGL recording in iframes & Safari 14 support

* fix tests

* don't save null objects as WebGLVar

* group (de)serialized webgl variables by context

* Fix test

* fix tests

* bundle webgl mutations on request animation frame

Instead of fixing it on the replay side we buffer up webgl canvas mutations and wait for a new RAF to flush them. This allows us to remove `newFrame` from the events and simplify things a little

* Add canvas element to mutation observer file

* Add Canvas (Mutation) Manager

Allows you to do `record.freezePage()` and canvas events will get paused.

Based on https://github.com/rrweb-io/rrweb/pull/756#issuecomment-1007566907

* cleanup

* Make sure the correct </body> gets replaced

* Perf: Speed up check to see if canvas is blank

* Access unpatched getImageData

* Use is2DCanvasBlank only for 2d context
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent b3fb1f13ba
commit 7cd03662a4
63 changed files with 5695 additions and 328 deletions

View File

@@ -98,9 +98,21 @@ exports[`record integration tests can freeze mutations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -109,15 +121,15 @@ exports[`record integration tests can freeze mutations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -141,7 +153,7 @@ exports[`record integration tests can freeze mutations 1`] = `
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"id\\": 20,
\\"attributes\\": {
\\"foo\\": \\"bar\\"
}
@@ -165,7 +177,7 @@ exports[`record integration tests can freeze mutations 1`] = `
\\"foo\\": \\"bar\\"
},
\\"childNodes\\": [],
\\"id\\": 18
\\"id\\": 20
}
}
]
@@ -272,9 +284,21 @@ exports[`record integration tests can mask character data mutations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -283,15 +307,15 @@ exports[`record integration tests can mask character data mutations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -338,16 +362,16 @@ exports[`record integration tests can mask character data mutations 1`] = `
\\"class\\": \\"rr-mask\\"
},
\\"childNodes\\": [],
\\"id\\": 18
\\"id\\": 20
}
},
{
\\"parentId\\": 18,
\\"parentId\\": 20,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"*** **** ****\\",
\\"id\\": 19
\\"id\\": 21
}
},
{
@@ -356,7 +380,7 @@ exports[`record integration tests can mask character data mutations 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"*******\\",
\\"id\\": 20
\\"id\\": 22
}
}
]
@@ -463,9 +487,21 @@ exports[`record integration tests can record attribute mutation 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -474,15 +510,15 @@ exports[`record integration tests can record attribute mutation 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -622,9 +658,21 @@ exports[`record integration tests can record character data muatations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -633,15 +681,15 @@ exports[`record integration tests can record character data muatations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -681,7 +729,7 @@ exports[`record integration tests can record character data muatations 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"mutated\\",
\\"id\\": 18
\\"id\\": 20
}
}
]
@@ -788,9 +836,21 @@ exports[`record integration tests can record childList mutations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -799,15 +859,15 @@ exports[`record integration tests can record childList mutations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -845,7 +905,7 @@ exports[`record integration tests can record childList mutations 1`] = `
\\"tagName\\": \\"span\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 18
\\"id\\": 20
}
}
]
@@ -7315,33 +7375,28 @@ exports[`record integration tests should record canvas mutations 1`] = `
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"property\\": \\"moveTo\\",
\\"args\\": [
0,
0
\\"type\\": 0,
\\"commands\\": [
{
\\"property\\": \\"moveTo\\",
\\"args\\": [
0,
0
]
},
{
\\"property\\": \\"lineTo\\",
\\"args\\": [
200,
100
]
},
{
\\"property\\": \\"stroke\\",
\\"args\\": []
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"property\\": \\"lineTo\\",
\\"args\\": [
200,
100
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"property\\": \\"stroke\\",
\\"args\\": []
}
}
]"
`;
@@ -9522,6 +9577,221 @@ exports[`record integration tests should record shadow DOM 1`] = `
]"
`;
exports[`record integration tests should record webgl canvas mutations 1`] = `
"[
{
\\"type\\": 0,
\\"data\\": {}
},
{
\\"type\\": 1,
\\"data\\": {}
},
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"canvas\\",
\\"id\\": 11
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 12
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"myCanvas\\",
\\"width\\": \\"200\\",
\\"height\\": \\"100\\",
\\"style\\": \\"border: 1px solid #000000\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 17
}
],
\\"id\\": 16
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 18
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 20
}
],
\\"id\\": 19
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 21
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 23
}
],
\\"id\\": 22
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 24
}
],
\\"id\\": 14
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"clearColor\\",
\\"args\\": [
1,
0,
0,
1
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record integration tests will serialize node before record 1`] = `
"[
{
@@ -9620,9 +9890,21 @@ exports[`record integration tests will serialize node before record 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -9631,15 +9913,15 @@ exports[`record integration tests will serialize node before record 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -9667,28 +9949,6 @@ exports[`record integration tests will serialize node before record 1`] = `
{
\\"parentId\\": 10,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 18
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 18,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 19
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 19,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
@@ -9696,6 +9956,28 @@ exports[`record integration tests will serialize node before record 1`] = `
\\"childNodes\\": [],
\\"id\\": 20
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 20,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 21
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 21,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 22
}
}
]
}

View File

@@ -0,0 +1,175 @@
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import {
startServer,
launchPuppeteer,
getServerURL,
replaceLast,
waitForRAF,
} from '../utils';
import {
recordOptions,
eventWithTime,
EventType,
IncrementalSource,
} from '../../src/types';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });
interface ISuite {
code: string;
browser: puppeteer.Browser;
server: http.Server;
page: puppeteer.Page;
events: eventWithTime[];
serverURL: string;
}
describe('e2e webgl', () => {
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
const getHtml = (
fileName: string,
options: recordOptions<eventWithTime> = {},
): string => {
const filePath = path.resolve(__dirname, `../html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return replaceLast(
html,
'</body>',
`
<script>
${code}
window.snapshots = [];
rrweb.record({
emit: event => {
window.snapshots.push(event);
},
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas},
plugins: ${options.plugins}
});
</script>
</body>
`,
);
};
const fakeGoto = async (page: puppeteer.Page, url: string) => {
const intercept = async (request: puppeteer.HTTPRequest) => {
await request.respond({
status: 200,
contentType: 'text/html',
body: ' ', // non-empty string or page will load indefinitely
});
};
await page.setRequestInterception(true);
page.on('request', intercept);
await page.goto(url);
page.off('request', intercept);
await page.setRequestInterception(false);
};
const hideMouseAnimation = async (page: puppeteer.Page) => {
await page.addStyleTag({
content: '.replayer-mouse-tail{display: none !important;}',
});
};
it('will record and replay a webgl square', async () => {
page = await browser.newPage();
await fakeGoto(page, `${serverURL}/html/canvas-webgl-square.html`);
await page.setContent(
getHtml.call(this, 'canvas-webgl-square.html', { recordCanvas: true }),
);
await waitForRAF(page);
const snapshots: eventWithTime[] = await page.evaluate('window.snapshots');
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code);
await hideMouseAnimation(page);
await page.evaluate(`let events = ${JSON.stringify(snapshots)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
replayer.play(500);
`);
await page.waitForTimeout(50);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
expect(frameImage).toMatchImageSnapshot();
});
it('will record and replay a webgl image', async () => {
page = await browser.newPage();
await fakeGoto(page, `${serverURL}/html/canvas-webgl-image.html`);
await page.setContent(
getHtml.call(this, 'canvas-webgl-image.html', { recordCanvas: true }),
);
await page.waitForTimeout(100);
const snapshots: eventWithTime[] = await page.evaluate('window.snapshots');
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code);
await hideMouseAnimation(page);
await page.evaluate(`let events = ${JSON.stringify(snapshots)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
`);
// wait for iframe to get added and `preloadAllImages` to ge called
await page.waitForSelector('iframe');
await page.evaluate(`replayer.play(500);`);
await page.waitForTimeout(50);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
expect(frameImage).toMatchImageSnapshot();
});
});

View File

@@ -0,0 +1,118 @@
export default [
{
type: 4,
data: {
href: '',
width: 1600,
height: 900,
},
timestamp: 1636379531385,
},
{
type: 2,
data: {
node: {
type: 0,
childNodes: [
{ type: 1, name: 'html', publicId: '', systemId: '', id: 2 },
{
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 5 },
{
type: 2,
tagName: 'meta',
attributes: { charset: 'UTF-8' },
childNodes: [],
id: 6,
},
{ type: 3, textContent: '\n ', id: 7 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: 'canvas', id: 11 }],
id: 10,
},
{ type: 3, textContent: '\n ', id: 12 },
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 13 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 15 },
{
type: 2,
tagName: 'canvas',
attributes: {
id: 'myCanvas',
width: '200',
height: '100',
style: 'border: 1px solid #000000',
},
childNodes: [{ type: 3, textContent: '\n ', id: 17 }],
id: 16,
},
{ type: 3, textContent: '\n ', id: 18 },
{
type: 2,
tagName: 'script',
attributes: {},
childNodes: [
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 20 },
],
id: 19,
},
{ type: 3, textContent: '\n \n\n', id: 21 },
],
id: 14,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: 1636379531389,
},
{
type: 3,
data: {
source: 9,
id: 16,
type: 1,
property: 'clearColor',
args: [1, 0, 0, 1],
},
timestamp: 1636379532355,
},
{
type: 3,
data: { source: 9, id: 16, type: 1, property: 'clear', args: [16384] },
timestamp: 1636379532356,
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<!-- FROM https://stackoverflow.com/a/12268575/543604 -->
<script src="./assets/webgl-utils.js"></script>
<canvas id="c"></canvas>
<!-- vertex shader -->
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrix;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(u_matrix * vec3(a_position, 1), 1);
v_texCoord = a_position;
}
</script>
<!-- fragment shader -->
<script id="2d-fragment-shader" type="x-shader/x-fragment">
precision mediump float;
// our texture
uniform sampler2D u_image;
// the texCoords passed in from the vertex shader.
varying vec2 v_texCoord;
void main() {
gl_FragColor = texture2D(u_image, v_texCoord);
}
</script>
<script>
'use strict';
window.onload = main;
function main() {
var image = new Image();
image.src =
'data:image/png;base64,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'; // MUST BE SAME DOMAIN!!!
image.onload = () => render(image);
}
function render(image) {
// Get A WebGL context
var canvas = document.getElementById('c');
var gl = canvas.getContext('webgl');
if (!gl) {
return;
}
// setup GLSL program
var program = webglUtils.createProgramFromScripts(gl, [
'2d-vertex-shader',
'2d-fragment-shader',
]);
gl.useProgram(program);
// look up where the vertex data needs to go.
var positionLocation = gl.getAttribLocation(program, 'a_position');
// look up uniform locations
var u_imageLoc = gl.getUniformLocation(program, 'u_image');
var u_matrixLoc = gl.getUniformLocation(program, 'u_matrix');
// provide texture coordinates for the rectangle.
var positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
0.0,
0.0,
1.0,
0.0,
0.0,
1.0,
0.0,
1.0,
1.0,
0.0,
1.0,
1.0,
]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// Set the parameters so we can render any size image.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
// Upload the image into the texture.
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
var dstX = 20;
var dstY = 30;
var dstWidth = 64;
var dstHeight = 64;
// convert dst pixel coords to clipspace coords
var clipX = (dstX / gl.canvas.width) * 2 - 1;
var clipY = (dstY / gl.canvas.height) * -2 + 1;
var clipWidth = (dstWidth / gl.canvas.width) * 2;
var clipHeight = (dstHeight / gl.canvas.height) * -2;
// build a matrix that will stretch our
// unit quad to our desired size and location
gl.uniformMatrix3fv(u_matrixLoc, false, [
clipWidth,
0,
0,
0,
clipHeight,
0,
clipX,
clipY,
1,
]);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>canvas webgl square</title>
</head>
<body>
<canvas
id="myCanvas"
width="200"
height="100"
style="border: 1px solid #000000"
>
</canvas>
<script id="vertex" type="x-shader">
attribute vec2 aVertexPosition;
void main() {
gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}
</script>
<script id="fragment" type="x-shader">
#ifdef GL_ES
precision highp float;
#endif
uniform vec4 uColor;
void main() {
gl_FragColor = uColor;
}
</script>
<script>
// example from https://www.creativebloq.com/javascript/get-started-webgl-draw-square-7112981
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('webgl2');
ctx.viewport(0, 0, canvas.width, canvas.height);
ctx.clearColor(0, 0.5, 0, 1);
ctx.clear(ctx.COLOR_BUFFER_BIT);
const v = document.getElementById('vertex').firstChild.nodeValue;
const f = document.getElementById('fragment').firstChild.nodeValue;
const vs = ctx.createShader(ctx.VERTEX_SHADER);
ctx.shaderSource(vs, v);
ctx.compileShader(vs);
const fs = ctx.createShader(ctx.FRAGMENT_SHADER);
ctx.shaderSource(fs, f);
ctx.compileShader(fs);
program = ctx.createProgram();
ctx.attachShader(program, vs);
ctx.attachShader(program, fs);
ctx.linkProgram(program);
if (!ctx.getShaderParameter(vs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(vs));
if (!ctx.getShaderParameter(fs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(fs));
if (!ctx.getProgramParameter(program, ctx.LINK_STATUS))
console.log(ctx.getProgramInfoLog(program));
const aspect = canvas.width / canvas.height;
const vertices = new Float32Array([
-0.5,
0.5 * aspect,
0.5,
0.5 * aspect,
0.5,
-0.5 * aspect, // Triangle 1
-0.5,
0.5 * aspect,
0.5,
-0.5 * aspect,
-0.5,
-0.5 * aspect, // Triangle 2
]);
vbuffer = ctx.createBuffer();
ctx.bindBuffer(ctx.ARRAY_BUFFER, vbuffer);
ctx.bufferData(ctx.ARRAY_BUFFER, vertices, ctx.STATIC_DRAW);
itemSize = 2;
numItems = vertices.length / itemSize;
ctx.useProgram(program);
const uColor = ctx.getUniformLocation(program, 'uColor');
ctx.uniform4fv(uColor, [0.0, 0.3, 0.0, 1.0]);
const aVertexPosition = ctx.getAttribLocation(program, 'aVertexPosition');
ctx.enableVertexAttribArray(aVertexPosition);
ctx.vertexAttribPointer(
aVertexPosition,
itemSize,
ctx.FLOAT,
false,
0,
0,
);
ctx.drawArrays(ctx.TRIANGLES, 0, numItems);
</script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>canvas</title>
</head>
<body>
<canvas
id="myCanvas"
width="200"
height="100"
style="border: 1px solid #000000"
>
</canvas>
<script>
setTimeout(() => {
const c = document.getElementById('myCanvas');
const ctx = c.getContext('webgl');
// Set clear color to red, fully opaque
ctx.clearColor(1.0, 0.0, 0.0, 1.0);
// Clear the color buffer with specified clear color
ctx.clear(ctx.COLOR_BUFFER_BIT);
}, 10);
</script>
</body>
</html>

View File

@@ -4,4 +4,5 @@
<ul>
<li></li>
</ul>
</body>
<canvas></canvas>
</body>

View File

@@ -1,14 +1,21 @@
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as puppeteer from 'puppeteer';
import { assertSnapshot, launchPuppeteer } from './utils';
import {
assertSnapshot,
startServer,
getServerURL,
launchPuppeteer,
waitForRAF,
replaceLast,
} from './utils';
import { recordOptions, eventWithTime, EventType } from '../src/types';
import { visitSnapshot, NodeType } from 'rrweb-snapshot';
interface ISuite {
server: http.Server;
serverURL: string;
code: string;
browser: puppeteer.Browser;
}
@@ -17,39 +24,6 @@ interface IMimeType {
[key: string]: string;
}
const startServer = () =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
setTimeout(() => {
res.end(data);
// mock delay
}, 100);
} catch (error) {
res.end();
}
});
s.listen(3030).on('listening', () => {
resolve(s);
});
});
describe('record integration tests', function (this: ISuite) {
jest.setTimeout(10_000);
@@ -59,7 +33,8 @@ describe('record integration tests', function (this: ISuite) {
): string => {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return html.replace(
return replaceLast(
html,
'</body>',
`
<script>
@@ -85,11 +60,13 @@ describe('record integration tests', function (this: ISuite) {
};
let server: ISuite['server'];
let serverURL: string;
let code: ISuite['code'];
let browser: ISuite['browser'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
@@ -197,7 +174,9 @@ describe('record integration tests', function (this: ISuite) {
it('can freeze mutations', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.setContent(
getHtml.call(this, 'mutation-observer.html', { recordCanvas: true }),
);
await page.evaluate(() => {
const li = document.createElement('li');
@@ -209,6 +188,9 @@ describe('record integration tests', function (this: ISuite) {
await page.evaluate('rrweb.freezePage()');
await page.evaluate(() => {
document.body.setAttribute('test', 'bad');
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl') as WebGLRenderingContext;
gl.getExtension('bad');
const ul = document.querySelector('ul') as HTMLUListElement;
const li = document.createElement('li');
li.setAttribute('bad-attr', 'bad');
@@ -216,6 +198,9 @@ describe('record integration tests', function (this: ISuite) {
ul.appendChild(li);
document.body.removeChild(ul);
});
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
@@ -391,7 +376,7 @@ describe('record integration tests', function (this: ISuite) {
recordCanvas: true,
}),
);
await page.waitForTimeout(50);
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
for (const event of snapshots) {
if (event.type === EventType.FullSnapshot) {
@@ -405,6 +390,19 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots);
});
it('should record webgl canvas mutations', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'canvas-webgl.html', {
recordCanvas: true,
}),
);
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
it('will serialize node before record', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
@@ -487,10 +485,17 @@ describe('record integration tests', function (this: ISuite) {
it('should nest record iframe', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto(`http://localhost:3030/html`);
await page.goto(`${serverURL}/html`);
await page.setContent(getHtml.call(this, 'main.html'));
await page.waitForTimeout(500);
await page.waitForSelector('#two');
const frameIdTwo = await page.frames()[2];
await frameIdTwo.waitForSelector('#four');
const frameIdFour = frameIdTwo.childFrames()[1];
await frameIdFour.waitForSelector('#five');
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});

View File

@@ -27,7 +27,14 @@ describe('unpack', () => {
});
it('stop on unknown data format', () => {
const consoleSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
expect(() => unpack('[""]')).toThrow('');
expect(consoleSpy).toHaveBeenCalled();
jest.resetAllMocks();
});
it('can unpack packed data', () => {

View File

@@ -0,0 +1,814 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`record webgl should batch events by RAF 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 1
}
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a canvas element 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a canvas element before the canvas gets added (webgl2) 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 5,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 9,
\\"type\\": 2,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a canvas element before the canvas gets added 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 5,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 9,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a webgl2 canvas element 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 2,
\\"commands\\": [
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record webgl variables 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
},
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 1
}
]
}
]
}
}
]"
`;
exports[`record webgl will record webgl variables in reverse order 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 1
}
]
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
}
]
}
}
]"
`;

View File

@@ -0,0 +1,179 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { serializeArg } from '../../src/record/observers/canvas/serialize-args';
const createContext = () => {
const ctx = new WebGL2RenderingContext();
return ctx;
};
let context: WebGL2RenderingContext;
describe('serializeArg', () => {
beforeEach(() => {
context = createContext();
});
it('should serialize Float32Array values', async () => {
const float32Array = new Float32Array([-1, -1, 3, -1, -1, 3]);
const expected = {
rr_type: 'Float32Array',
args: [[-1, -1, 3, -1, -1, 3]],
};
expect(serializeArg(float32Array, window, context)).toStrictEqual(expected);
});
it('should serialize Float64Array values', async () => {
const float64Array = new Float64Array([-1, -1, 3, -1, -1, 3]);
const expected = {
rr_type: 'Float64Array',
args: [[-1, -1, 3, -1, -1, 3]],
};
expect(serializeArg(float64Array, window, context)).toStrictEqual(expected);
});
it('should serialize ArrayBuffer values', async () => {
const arrayBuffer = new Uint8Array([1, 2, 0, 4]).buffer;
const expected = {
rr_type: 'ArrayBuffer',
base64: 'AQIABA==',
};
expect(serializeArg(arrayBuffer, window, context)).toStrictEqual(expected);
});
it('should serialize Uint8Array values', async () => {
const object = new Uint8Array([1, 2, 0, 4]);
const expected = {
rr_type: 'Uint8Array',
args: [[1, 2, 0, 4]],
};
expect(serializeArg(object, window, context)).toStrictEqual(expected);
});
it('should serialize DataView values', async () => {
const dataView = new DataView(new ArrayBuffer(16), 0, 16);
const expected = {
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
base64: 'AAAAAAAAAAAAAAAAAAAAAA==',
},
0,
16,
],
};
expect(serializeArg(dataView, window, context)).toStrictEqual(expected);
});
it('should leave arrays intact', async () => {
const array = [1, 2, 3, 4];
expect(serializeArg(array, window, context)).toStrictEqual(array);
});
it('should serialize complex objects', async () => {
const dataView = [new DataView(new ArrayBuffer(16), 0, 16), 5, 6];
const expected = [
{
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
base64: 'AAAAAAAAAAAAAAAAAAAAAA==',
},
0,
16,
],
},
5,
6,
];
expect(serializeArg(dataView, window, context)).toStrictEqual(expected);
});
it('should serialize arraybuffer contents', async () => {
const buffer = new Float32Array([1, 2, 3, 4]).buffer;
const expected = {
rr_type: 'ArrayBuffer',
base64: 'AACAPwAAAEAAAEBAAACAQA==',
};
expect(serializeArg(buffer, window, context)).toStrictEqual(expected);
});
it('should leave null as-is', async () => {
expect(serializeArg(null, window, context)).toStrictEqual(null);
});
it('should support indexed variables', async () => {
const webGLProgram = new WebGLProgram();
expect(serializeArg(webGLProgram, window, context)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 0,
});
const webGLProgram2 = new WebGLProgram();
expect(serializeArg(webGLProgram2, window, context)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 1,
});
});
it('should support indexed variables grouped by context', async () => {
const context1 = createContext();
const webGLProgram1 = new WebGLProgram();
expect(serializeArg(webGLProgram1, window, context1)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 0,
});
const context2 = createContext();
const webGLProgram2 = new WebGLProgram();
expect(serializeArg(webGLProgram2, window, context2)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 0,
});
});
it('should support HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
expect(serializeArg(image, window, context)).toStrictEqual({
rr_type: 'HTMLImageElement',
src: 'http://example.com/image.png',
});
});
it('should serialize ImageData', async () => {
const arr = new Uint8ClampedArray(40000);
// Iterate through every pixel
for (let i = 0; i < arr.length; i += 4) {
arr[i + 0] = 0; // R value
arr[i + 1] = 190; // G value
arr[i + 2] = 0; // B value
arr[i + 3] = 255; // A value
}
// Initialize a new ImageData object
let imageData = new ImageData(arr, 200, 50);
const contents = Array.from(arr);
expect(serializeArg(imageData, window, context)).toStrictEqual({
rr_type: 'ImageData',
args: [
{
rr_type: 'Uint8ClampedArray',
args: [contents],
},
200,
50,
],
});
});
});

View File

@@ -0,0 +1,260 @@
/* tslint:disable no-console */
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import {
recordOptions,
listenerHandler,
eventWithTime,
EventType,
IncrementalSource,
CanvasContext,
} from '../../src/types';
import { assertSnapshot, launchPuppeteer, waitForRAF } from '../utils';
import { ICanvas } from 'rrweb-snapshot';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
events: eventWithTime[];
}
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const setup = function (this: ISuite, content: string): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
ctx.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
ctx.code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
ctx.page = await ctx.browser.newPage();
await ctx.page.goto('about:blank');
await ctx.page.setContent(content);
await ctx.page.evaluate(ctx.code);
ctx.events = [];
await ctx.page.exposeFunction('emit', (e: eventWithTime) => {
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
return;
}
ctx.events.push(e);
});
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
recordCanvas: true,
emit: ((window as unknown) as IWindow).emit,
});
});
});
afterEach(async () => {
await ctx.page.close();
});
afterAll(async () => {
await ctx.browser.close();
});
return ctx;
};
describe('record webgl', function (this: ISuite) {
jest.setTimeout(100_000);
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<canvas id="canvas"></canvas>
</body>
</html>
`,
);
it('will record changes to a canvas element', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl')!;
gl.clear(gl.COLOR_BUFFER_BIT);
});
await ctx.page.waitForTimeout(50);
const lastEvent = ctx.events[ctx.events.length - 1];
expect(lastEvent).toMatchObject({
data: {
source: IncrementalSource.CanvasMutation,
type: CanvasContext.WebGL,
commands: [
{
args: [16384],
property: 'clear',
},
],
},
});
assertSnapshot(ctx.events);
});
it('will record changes to a webgl2 canvas element', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl2')!;
gl.clear(gl.COLOR_BUFFER_BIT);
});
await ctx.page.waitForTimeout(50);
const lastEvent = ctx.events[ctx.events.length - 1];
expect(lastEvent).toMatchObject({
data: {
source: IncrementalSource.CanvasMutation,
type: CanvasContext.WebGL2,
commands: [
{
args: [16384],
property: 'clear',
},
],
},
});
assertSnapshot(ctx.events);
});
it('will record changes to a canvas element before the canvas gets added', async () => {
await ctx.page.evaluate(() => {
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl')!;
var program = gl.createProgram()!;
gl.linkProgram(program);
gl.clear(gl.COLOR_BUFFER_BIT);
document.body.appendChild(canvas);
});
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('will record changes to a canvas element before the canvas gets added (webgl2)', async () => {
await ctx.page.evaluate(() => {
return new Promise<void>((resolve) => {
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl2')!;
var program = gl.createProgram()!;
gl.linkProgram(program);
gl.clear(gl.COLOR_BUFFER_BIT);
setTimeout(() => {
document.body.appendChild(canvas);
resolve();
}, 10);
});
});
// FIXME: this wait deeply couples the test to the implementation
// When `pendingCanvasMutations` isn't run on requestAnimationFrame,
// we need to change this
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('will record webgl variables', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl')!;
var program0 = gl.createProgram()!;
gl.linkProgram(program0);
var program1 = gl.createProgram()!;
gl.linkProgram(program1);
});
await ctx.page.waitForTimeout(50);
assertSnapshot(ctx.events);
});
it('will record webgl variables in reverse order', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl')!;
var program0 = gl.createProgram()!;
var program1 = gl.createProgram()!;
// attach them in reverse order
gl.linkProgram(program1);
gl.linkProgram(program0);
});
await ctx.page.waitForTimeout(50);
assertSnapshot(ctx.events);
});
it('sets _context on canvas.getContext()', async () => {
const context = await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
canvas.getContext('webgl')!;
return (canvas as ICanvas).__context;
});
expect(context).toBe('webgl');
});
it('only sets _context on first canvas.getContext() call', async () => {
const context = await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
canvas.getContext('webgl');
canvas.getContext('2d'); // returns null
return (canvas as ICanvas).__context;
});
expect(context).toBe('webgl');
});
it('should batch events by RAF', async () => {
await ctx.page.evaluate(() => {
return new Promise<void>((resolve) => {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl') as WebGLRenderingContext;
const program = gl.createProgram()!;
gl.linkProgram(program);
requestAnimationFrame(() => {
const program2 = gl.createProgram()!;
gl.linkProgram(program2);
gl.clear(gl.COLOR_BUFFER_BIT);
requestAnimationFrame(() => {
gl.clear(gl.COLOR_BUFFER_BIT);
resolve();
});
});
});
});
await ctx.page.waitForTimeout(50);
assertSnapshot(ctx.events);
expect(ctx.events.length).toEqual(5);
});
});

View File

@@ -0,0 +1,133 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { deserializeArg } from '../../src/replay/canvas/webgl';
let context: WebGLRenderingContext | WebGL2RenderingContext;
describe('deserializeArg', () => {
beforeEach(() => {
context = new WebGL2RenderingContext();
});
it('should deserialize Float32Array values', async () => {
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'Float32Array',
args: [[-1, -1, 3, -1, -1, 3]],
}),
).toEqual(new Float32Array([-1, -1, 3, -1, -1, 3]));
});
it('should deserialize Float64Array values', async () => {
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'Float64Array',
args: [[-1, -1, 3, -1, -1, 3]],
}),
).toEqual(new Float64Array([-1, -1, 3, -1, -1, 3]));
});
it('should deserialize ArrayBuffer values', async () => {
const contents = [1, 2, 0, 4];
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'ArrayBuffer',
base64: 'AQIABA==',
}),
).toStrictEqual(new Uint8Array(contents).buffer);
});
it('should deserialize DataView values', async () => {
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
base64: 'AAAAAAAAAAAAAAAAAAAAAA==',
},
0,
16,
],
}),
).toStrictEqual(new DataView(new ArrayBuffer(16), 0, 16));
});
it('should leave arrays intact', async () => {
const array = [1, 2, 3, 4];
expect(deserializeArg(new Map(), context)(array)).toEqual(array);
});
it('should deserialize complex objects', async () => {
const serializedArg = [
{
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
args: [16],
},
0,
16,
],
},
5,
6,
];
expect(deserializeArg(new Map(), context)(serializedArg)).toStrictEqual([
new DataView(new ArrayBuffer(16), 0, 16),
5,
6,
]);
});
it('should leave null as-is', async () => {
expect(deserializeArg(new Map(), context)(null)).toStrictEqual(null);
});
it('should support HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'HTMLImageElement',
src: 'http://example.com/image.png',
}),
).toStrictEqual(image);
});
it('should return image from imageMap for HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
const imageMap = new Map();
imageMap.set(image.src, image);
expect(
deserializeArg(
imageMap,
context,
)({
rr_type: 'HTMLImageElement',
src: 'http://example.com/image.png',
}),
).toBe(image);
});
});

View File

@@ -0,0 +1,125 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { Replayer } from '../../src/replay';
import {} from '../../src/types';
import {
CanvasContext,
SerializedWebGlArg,
IncrementalSource,
EventType,
eventWithTime,
} from '../../src/types';
let replayer: Replayer;
const canvasMutationEventWithArgs = (
args: SerializedWebGlArg[],
): eventWithTime => {
return {
timestamp: 100,
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
property: 'x',
args,
id: 1,
type: CanvasContext.WebGL,
},
};
};
const event = (): eventWithTime => {
return {
timestamp: 1,
type: EventType.DomContentLoaded,
data: {},
};
};
describe('preloadAllImages', () => {
beforeEach(() => {
replayer = new Replayer(
// Get around the error "Replayer need at least 2 events."
[event(), event()],
);
});
it('should preload image', () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'HTMLImageElement',
src: 'http://example.com',
},
]),
];
(replayer as any).preloadAllImages();
const expectedImage = new Image();
expectedImage.src = 'http://example.com';
expect((replayer as any).imageMap.get('http://example.com')).toEqual(
expectedImage,
);
});
it('should preload nested image', () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'something',
args: [
{
rr_type: 'HTMLImageElement',
src: 'http://example.com',
},
],
},
]),
];
(replayer as any).preloadAllImages();
const expectedImage = new Image();
expectedImage.src = 'http://example.com';
expect((replayer as any).imageMap.get('http://example.com')).toEqual(
expectedImage,
);
});
it('should preload multiple images', () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'HTMLImageElement',
src: 'http://example.com/img1.png',
},
{
rr_type: 'HTMLImageElement',
src: 'http://example.com/img2.png',
},
]),
];
(replayer as any).preloadAllImages();
const expectedImage1 = new Image();
expectedImage1.src = 'http://example.com/img1.png';
expect(
(replayer as any).imageMap.get('http://example.com/img1.png'),
).toEqual(expectedImage1);
const expectedImage2 = new Image();
expectedImage1.src = 'http://example.com/img2.png';
expect(
(replayer as any).imageMap.get('http://example.com/img2.png'),
).toEqual(expectedImage1);
});
});

View File

@@ -0,0 +1,47 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import webglMutation, { variableListFor } from '../../src/replay/canvas/webgl';
import { CanvasContext } from '../../src/types';
let canvas: HTMLCanvasElement;
describe('webglMutation', () => {
beforeEach(() => {
canvas = document.createElement('canvas');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create webgl variables', async () => {
const createShaderMock = jest.fn().mockImplementation(() => {
return new WebGLShader();
});
const context = ({
createShader: createShaderMock,
} as unknown) as WebGLRenderingContext;
jest.spyOn(canvas, 'getContext').mockImplementation(() => {
return context;
});
expect(variableListFor(context, 'WebGLShader')).toHaveLength(0);
webglMutation({
mutation: {
property: 'createShader',
args: [35633],
},
type: CanvasContext.WebGL,
target: canvas,
imageMap: new Map(),
errorHandler: () => {},
});
expect(createShaderMock).toHaveBeenCalledWith(35633);
expect(variableListFor(context, 'WebGLShader')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,66 @@
import * as fs from 'fs';
import * as path from 'path';
import { assertDomSnapshot, launchPuppeteer } from '../utils';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import * as puppeteer from 'puppeteer';
import events from '../events/webgl';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
}
expect.extend({ toMatchImageSnapshot });
describe('replayer', function () {
jest.setTimeout(10_000);
let code: ISuite['code'];
let browser: ISuite['browser'];
let page: ISuite['page'];
beforeAll(async () => {
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
page = await browser.newPage();
await page.goto('about:blank');
// mouse cursor canvas is large and pushes the replayer below the fold
// lets hide it...
await page.addStyleTag({
content: '.replayer-mouse-tail{display: none !important;}',
});
await page.evaluate(code);
await page.evaluate(`let events = ${JSON.stringify(events)}`);
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await browser.close();
});
describe('webgl', () => {
it('should output simple webgl object', async () => {
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
replayer.play(2500);
`);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
});
});

View File

@@ -7,6 +7,10 @@ import {
} from '../src/types';
import * as puppeteer from 'puppeteer';
import { format } from 'prettier';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as fs from 'fs';
export async function launchPuppeteer() {
return await puppeteer.launch({
@@ -15,10 +19,65 @@ export async function launchPuppeteer() {
width: 1920,
height: 1080,
},
// devtools: true,
args: ['--no-sandbox'],
});
}
interface IMimeType {
[key: string]: string;
}
export const startServer = (defaultPort: number = 3030) =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
setTimeout(() => {
res.end(data);
// mock delay
}, 100);
} catch (error) {
res.end();
}
});
s.listen(defaultPort)
.on('listening', () => {
resolve(s);
})
.on('error', (e) => {
console.log('port in use, trying next one');
s.listen().on('listening', () => {
resolve(s);
});
});
});
export function getServerURL(server: http.Server): string {
const address = server.address();
if (address && typeof address !== 'string') {
return `http://localhost:${address.port}`;
} else {
return `${address}`;
}
}
/**
* Puppeteer may cast random mouse move which make our tests flaky.
* So we only do snapshot test with filtered events.
@@ -133,9 +192,18 @@ function stringifyDomSnapshot(mhtml: string): string {
}
export function assertSnapshot(snapshots: eventWithTime[]) {
expect(snapshots).toBeDefined();
expect(stringifySnapshots(snapshots)).toMatchSnapshot();
}
export function replaceLast(str: string, find: string, replace: string) {
const index = str.lastIndexOf(find);
if (index === -1) {
return str;
}
return str.substring(0, index) + replace + str.substring(index + find.length);
}
export async function assertDomSnapshot(
page: puppeteer.Page,
filename: string,
@@ -338,3 +406,86 @@ export const sampleStyleSheetRemoveEvents: eventWithTime[] = [
timestamp: now + 2000,
},
];
export const polyfillWebGLGlobals = () => {
// polyfill as jsdom does not have support for these classes
// consider replacing with https://www.npmjs.com/package/canvas
class WebGLActiveInfo {
constructor() {}
}
global.WebGLActiveInfo = WebGLActiveInfo as any;
class WebGLBuffer {
constructor() {}
}
global.WebGLBuffer = WebGLBuffer as any;
class WebGLFramebuffer {
constructor() {}
}
global.WebGLFramebuffer = WebGLFramebuffer as any;
class WebGLProgram {
constructor() {}
}
global.WebGLProgram = WebGLProgram as any;
class WebGLRenderbuffer {
constructor() {}
}
global.WebGLRenderbuffer = WebGLRenderbuffer as any;
class WebGLShader {
constructor() {}
}
global.WebGLShader = WebGLShader as any;
class WebGLShaderPrecisionFormat {
constructor() {}
}
global.WebGLShaderPrecisionFormat = WebGLShaderPrecisionFormat as any;
class WebGLTexture {
constructor() {}
}
global.WebGLTexture = WebGLTexture as any;
class WebGLUniformLocation {
constructor() {}
}
global.WebGLUniformLocation = WebGLUniformLocation as any;
class WebGLVertexArrayObject {
constructor() {}
}
global.WebGLVertexArrayObject = WebGLVertexArrayObject as any;
class ImageData {
public data: Uint8ClampedArray;
public width: number;
public height: number;
constructor(data: Uint8ClampedArray, width: number, height: number) {
this.data = data;
this.width = width;
this.height = height;
}
}
global.ImageData = ImageData as any;
class WebGL2RenderingContext {
constructor() {}
}
global.WebGL2RenderingContext = WebGL2RenderingContext as any;
};
export async function waitForRAF(page: puppeteer.Page) {
return await page.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
});
}