Skip to content

Commit 7fc2cd4

Browse files
authored
feat: implement DOM snapshots replaying (#638)
* feat: implement DOM snapshots replaying
1 parent 4a52e3a commit 7fc2cd4

File tree

68 files changed

+1853
-196
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+1853
-196
lines changed

.mocharc-jsdom.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use strict";
2+
3+
module.exports = {
4+
extension: ["js", "jsx", "ts", "tsx"],
5+
recursive: true,
6+
require: [
7+
"./test/setup/ts-node",
8+
"./test/setup/jsdom",
9+
"./test/setup/globals",
10+
"./test/setup/assert-ext",
11+
"./test/setup/configure-testing-library"
12+
],
13+
};
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
import type {eventWithTime as RrwebEvent} from '@rrweb/types';
5+
import fsExtra from 'fs-extra';
6+
import _ from 'lodash';
7+
import type Testplane from 'testplane';
8+
import {RecordMode} from 'testplane';
9+
import yazl from 'yazl';
10+
11+
import {ReporterTestResult} from '../../test-result';
12+
import {SNAPSHOTS_PATH} from '../../../constants';
13+
import {AttachmentType, SnapshotAttachment} from '../../../types';
14+
import {EventSource} from '../../../gui/event-source';
15+
import {ClientEvents} from '../../../gui/constants';
16+
17+
export interface TestContext {
18+
testPath: string[];
19+
browserId: string;
20+
}
21+
22+
export interface SnapshotsData {
23+
rrwebSnapshots: RrwebEvent[];
24+
}
25+
26+
export const snapshotsInProgress: Record<string, RrwebEvent[]> = {};
27+
28+
export const getSnapshotHashWithoutAttempt = (context: TestContext): string => {
29+
return `${context.testPath.join()}.${context.browserId}`;
30+
};
31+
32+
interface CreateSnapshotFilePathParams {
33+
attempt: number;
34+
hash: string;
35+
browserId: string;
36+
}
37+
38+
export function createSnapshotFilePath({attempt: attemptInput, hash, browserId}: CreateSnapshotFilePathParams): string {
39+
const attempt: number = attemptInput || 0;
40+
const imageDir = _.compact([SNAPSHOTS_PATH, hash]);
41+
const components = imageDir.concat(`${browserId}_${attempt}.zip`);
42+
43+
return path.join(...components);
44+
}
45+
46+
export const handleDomSnapshotsEvent = (client: EventSource | null, context: TestContext, data: SnapshotsData): void => {
47+
try {
48+
const hash = getSnapshotHashWithoutAttempt(context);
49+
if (!snapshotsInProgress[hash]) {
50+
snapshotsInProgress[hash] = [];
51+
}
52+
53+
// We need to number snapshots during live streaming for a case when user switches in UI to some test while it's running
54+
// In this case we need to merge 2 parts: snapshots that were taken before user switched and ones that we receive live
55+
// Since they can overlap, we introduce sequence numbering to guarantee smooth experience
56+
let seqNo = snapshotsInProgress[hash].length;
57+
const rrwebSnapshotsNumbered = data.rrwebSnapshots.map(snapshot => Object.assign({}, snapshot, {seqNo: seqNo++}));
58+
59+
snapshotsInProgress[hash].push(...rrwebSnapshotsNumbered);
60+
61+
if (client) {
62+
client.emit(ClientEvents.DOM_SNAPSHOTS, {context, data: {rrwebSnapshots: rrwebSnapshotsNumbered}});
63+
}
64+
} catch (e) {
65+
console.warn(`Failed to handle DOM_SNAPSHOTS event for test "${context?.testPath?.join(' ')}.${context?.browserId}" in html-reporter due to an error.`, e);
66+
}
67+
};
68+
69+
interface FinalizeSnapshotsParams {
70+
testResult: ReporterTestResult;
71+
attempt: number;
72+
recordConfig: Testplane['config']['record'];
73+
reportPath: string;
74+
eventName: Testplane['events'][keyof Testplane['events']];
75+
events: Testplane['events'];
76+
}
77+
78+
export const finalizeSnapshotsForTest = async ({testResult, attempt, reportPath, recordConfig, events, eventName}: FinalizeSnapshotsParams): Promise<SnapshotAttachment[]> => {
79+
try {
80+
const hash = getSnapshotHashWithoutAttempt(testResult);
81+
const snapshots = snapshotsInProgress[hash];
82+
83+
delete snapshotsInProgress[hash];
84+
85+
if (!snapshots || snapshots.length === 0) {
86+
console.warn(`No snapshots found for test hash: ${hash}`);
87+
return [];
88+
}
89+
90+
const shouldSave = recordConfig.mode !== RecordMode.LastFailedRun || (eventName === events.TEST_FAIL);
91+
if (!shouldSave) {
92+
return [];
93+
}
94+
95+
const snapshotsSerialized = snapshots.map(s => JSON.stringify(s)).join('\n');
96+
97+
const zipFilePath = createSnapshotFilePath({
98+
attempt,
99+
hash: testResult.imageDir,
100+
browserId: testResult.browserId
101+
});
102+
const absoluteZipFilePath = path.resolve(reportPath, zipFilePath);
103+
await fsExtra.ensureDir(path.dirname(absoluteZipFilePath));
104+
105+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
106+
let done = (_attachments: SnapshotAttachment[]): void => {
107+
};
108+
const resultPromise = new Promise<SnapshotAttachment[]>((resolve) => {
109+
done = resolve;
110+
});
111+
112+
const zipfile = new yazl.ZipFile();
113+
const output = fs.createWriteStream(absoluteZipFilePath);
114+
zipfile.outputStream.pipe(output).on('close', () => {
115+
done([{
116+
type: AttachmentType.Snapshot,
117+
path: zipFilePath
118+
}]);
119+
});
120+
121+
zipfile.addBuffer(Buffer.from(snapshotsSerialized), 'snapshots.json');
122+
123+
zipfile.end();
124+
125+
return resultPromise;
126+
} catch (e) {
127+
console.warn(`Failed to finalize DOM snapshots for test "${testResult?.testPath?.join(' ')}.${testResult?.browserId}" in html-reporter due to an error.`, e);
128+
return [];
129+
}
130+
};

lib/adapters/test-result/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {TestStatus} from '../../constants';
2-
import {ErrorDetails, ImageBase64, ImageFile, ImageInfoFull, TestError, TestStepCompressed} from '../../types';
2+
import {ErrorDetails, ImageBase64, ImageFile, ImageInfoFull, TestError, TestStepCompressed, Attachment} from '../../types';
33

44
export interface ReporterTestResult {
55
readonly attempt: number;
@@ -26,4 +26,5 @@ export interface ReporterTestResult {
2626
readonly url?: string;
2727
/** Test duration in ms */
2828
readonly duration: number;
29+
readonly attachments: Attachment[];
2930
}

lib/adapters/test-result/jest.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ErrorDetails,
55
ImageFile,
66
ImageInfoFull,
7+
SnapshotAttachment,
78
TestError, TestStepCompressed
89
} from '../../types';
910

@@ -37,6 +38,10 @@ export class JestTestResultAdapter implements ReporterTestResult {
3738
this._attempt = attempt;
3839
}
3940

41+
get attachments(): SnapshotAttachment[] {
42+
return [];
43+
}
44+
4045
get attempt(): number {
4146
return this._attempt;
4247
}

lib/adapters/test-result/playwright.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {getError, getShortMD5, isImageDiffError, isNoRefImageError} from '../../
99
import {ERROR, FAIL, SUCCESS, UPDATED, TestStatus, DEFAULT_TITLE_DELIMITER} from '../../constants';
1010
import {ErrorName} from '../../errors';
1111
import {
12+
Attachment,
1213
DiffOptions,
1314
ErrorDetails,
1415
ImageFile,
@@ -158,6 +159,7 @@ const getHistory = (steps?: PlaywrightTestResult['steps']): TestStepCompressed[]
158159
[TestStepKey.Name]: step.title,
159160
[TestStepKey.Args]: [],
160161
[TestStepKey.IsFailed]: Boolean(step.error),
162+
[TestStepKey.TimeStart]: step.startTime.getTime(),
161163
[TestStepKey.Duration]: step.duration,
162164
[TestStepKey.Children]: getHistory(step.steps),
163165
[TestStepKey.IsGroup]: step.steps?.length > 0
@@ -368,4 +370,8 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {
368370
get duration(): number {
369371
return this._testResult.duration;
370372
}
373+
374+
get attachments(): Attachment[] {
375+
return [];
376+
}
371377
}

lib/adapters/test-result/reporter.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import {TestStatus} from '../../constants';
2-
import {TestError, ErrorDetails, ImageInfoFull, ImageBase64, ImageFile, TestStepCompressed} from '../../types';
2+
import {
3+
TestError,
4+
ErrorDetails,
5+
ImageInfoFull,
6+
ImageBase64,
7+
ImageFile,
8+
TestStepCompressed,
9+
Attachment
10+
} from '../../types';
311
import {ReporterTestResult} from './index';
412
import _ from 'lodash';
513
import {extractErrorDetails} from './utils';
@@ -111,4 +119,8 @@ export class ReporterTestAdapter implements ReporterTestResult {
111119
get duration(): number {
112120
return this._testResult.duration;
113121
}
122+
123+
get attachments(): Attachment[] {
124+
return this._testResult.attachments;
125+
}
114126
}

lib/adapters/test-result/sqlite.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ImageInfoFull,
88
ImageBase64,
99
ImageFile,
10-
RawSuitesRow, TestStepCompressed
10+
RawSuitesRow, TestStepCompressed, Attachment
1111
} from '../../types';
1212
import {ReporterTestResult} from './index';
1313
import {Writable} from 'type-fest';
@@ -155,4 +155,12 @@ export class SqliteTestResultAdapter implements ReporterTestResult {
155155
get duration(): number {
156156
return this._testResult[DB_COLUMN_INDEXES.duration];
157157
}
158+
159+
get attachments(): Attachment[] {
160+
if (!_.has(this._parsedTestResult, 'attachments')) {
161+
this._parsedTestResult.attachments = tryParseJson(this._testResult[DB_COLUMN_INDEXES.attachments]) as Attachment[];
162+
}
163+
164+
return this._parsedTestResult.attachments as Attachment[];
165+
}
158166
}

lib/adapters/test-result/testplane.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
ImageInfoPageSuccess,
2626
ImageInfoSuccess,
2727
ImageInfoUpdated,
28-
TestError, TestStepCompressed, TestStepKey
28+
TestError, TestStepCompressed, TestStepKey, Attachment
2929
} from '../../types';
3030
import {ReporterTestResult} from './index';
3131
import {getSuitePath} from '../../plugin-utils';
@@ -58,6 +58,7 @@ const getHistory = (history?: TestplaneTestResult['history']): TestStepCompresse
5858
[TestStepKey.Name]: h[TestStepKey.Name],
5959
[TestStepKey.Args]: h[TestStepKey.Args],
6060
[TestStepKey.Duration]: h[TestStepKey.Duration],
61+
[TestStepKey.TimeStart]: h[TestStepKey.TimeStart],
6162
[TestStepKey.IsFailed]: h[TestStepKey.IsFailed],
6263
[TestStepKey.IsGroup]: h[TestStepKey.IsGroup]
6364
};
@@ -270,4 +271,8 @@ export class TestplaneTestResultAdapter implements ReporterTestResult {
270271
get duration(): number {
271272
return this._duration;
272273
}
274+
275+
get attachments(): Attachment[] {
276+
return [];
277+
}
273278
}

lib/adapters/test-result/transformers/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ export class DbTestResultTransformer {
3939
multipleTabs: testResult.multipleTabs,
4040
status: testResult.status,
4141
timestamp: testResult.timestamp ?? Date.now(),
42-
duration: testResult.duration
42+
duration: testResult.duration,
43+
attachments: testResult.attachments
4344
};
4445
}
4546
}

lib/adapters/test-result/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export const copyAndUpdate = (
3232
'testPath',
3333
'timestamp',
3434
'url',
35-
'duration'
35+
'duration',
36+
'attachments'
3637
] as const;
3738

3839
// Type-level check that we didn't forget to include any keys

0 commit comments

Comments
 (0)