Skip to content

Commit 16e2dc5

Browse files
mhan83Alex Plischke
and
Alex Plischke
authored
feat: Merge videos generated by playwright into a single mp4 (#53)
* Generate a combined video * Encapsulate video syncing * Module org * timestamp needs to be in seconds * Better support case where there are sparse videos That is, not every test case generated a video * Better naming, maybe? * Update eslint config to ignore `_` prefixed args Matches default exemptions of ts compiler. * Update local dev instructions * Linting * Check for merged video in integration test * Improve error messaging for video conversion * Setup ffmpeg for video conversion testing * lint * Fix readme table * Emphasize ffmpeg requirement * Better optional arg * Apply suggestions from code review Co-authored-by: Alex Plischke <[email protected]> * Use Milliseconds type alias more broadly * Improve error handling Cleanup tmp dir * Use Result pattern and let the caller handle logging errors * lint * More specific naming --------- Co-authored-by: Alex Plischke <[email protected]>
1 parent 1fcfd9c commit 16e2dc5

File tree

10 files changed

+232
-23
lines changed

10 files changed

+232
-23
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ jobs:
3636
node-version-file: ".nvmrc"
3737
cache: "npm"
3838

39+
- name: Setup ffmpeg
40+
uses: FedericoCarboni/setup-ffmpeg@v3
41+
3942
- name: Install Dependencies
4043
run: npm ci
4144

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const config = {
6363
| `region` | Sets the region. <br> Default: `us-west-1` | `us-west-1` \| `eu-central-1` |
6464
| `upload` | Whether to upload report and assets to Sauce Labs. <br> Default: `true` | `boolean` |
6565
| `outputFile` | The local path to write the Sauce test report. Can be set in env var `SAUCE_REPORT_OUTPUT_NAME`. | `string` |
66+
| `mergeVideos` | Whether to merge all video files generated by Playwright. This is useful when used with the `upload` option to upload the test report to Sauce Labs since it will allow you to view the merged video in the Sauce Labs Test Results page. **Requires ffmpeg to be installed.**<br> Default: `false` | `boolean` |
6667

6768
## Limitations
6869

@@ -72,11 +73,24 @@ Some limitations apply to `@saucelabs/playwright-reporter`:
7273

7374
## Development
7475

76+
### Running integration tests
77+
78+
There are tests included in `tests/integration` where the reporter is referenced
79+
directly.
80+
81+
```sh
82+
$ npm run build
83+
$ cd tests/integration
84+
$ npx playwright test
85+
```
86+
7587
### Running Locally
7688

77-
To test the reporter locally, link it to itself and then run a test with the reporter set.
89+
If you have playwright tests outside of the repo, you can link and install the
90+
reporter to run in your external repo.
7891

7992
```sh
93+
$ npm run build
8094
$ npm link
8195
$ npm link @saucelabs/playwright-reporter
8296
$ npx playwright test --reporter=@saucelabs/playwright-reporter

eslint.config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ module.exports = ts.config(
1515
rules: {
1616
'@typescript-eslint/no-var-requires': 'off',
1717
'@typescript-eslint/no-explicit-any': 'warn',
18+
'@typescript-eslint/no-unused-vars': [
19+
'error',
20+
{
21+
args: 'all',
22+
argsIgnorePattern: '^_',
23+
caughtErrors: 'all',
24+
caughtErrorsIgnorePattern: '^_',
25+
destructuredArrayIgnorePattern: '^_',
26+
varsIgnorePattern: '^_',
27+
ignoreRestSiblings: true,
28+
},
29+
],
1830
},
1931
},
2032
{

src/reporter.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
TestRunRequestBody,
2525
} from './api';
2626
import { CI, IS_CI } from './ci';
27+
import { Syncer, MergeSyncer, OffsetSyncer } from './video';
2728

2829
export interface Config {
2930
buildName?: string;
@@ -33,6 +34,7 @@ export interface Config {
3334
outputFile?: string;
3435
upload?: boolean;
3536
webAssetsDir: string;
37+
mergeVideos?: boolean;
3638
}
3739

3840
// Types of attachments relevant for UI display.
@@ -58,6 +60,7 @@ export default class SauceReporter implements Reporter {
5860
region: Region;
5961
outputFile?: string;
6062
shouldUpload: boolean;
63+
mergeVideos: boolean;
6164
/*
6265
* When webAssetsDir is set, this reporter syncs web UI-related attachments
6366
* from the Playwright output directory to the specified web assets directory.
@@ -88,8 +91,6 @@ export default class SauceReporter implements Reporter {
8891
startedAt?: Date;
8992
endedAt?: Date;
9093

91-
videoStartTime?: number;
92-
9394
constructor(reporterConfig: Config) {
9495
this.projects = {};
9596

@@ -99,6 +100,7 @@ export default class SauceReporter implements Reporter {
99100
this.outputFile =
100101
reporterConfig?.outputFile || process.env.SAUCE_REPORT_OUTPUT_NAME;
101102
this.shouldUpload = reporterConfig?.upload !== false;
103+
this.mergeVideos = reporterConfig?.mergeVideos === true;
102104

103105
this.webAssetsDir =
104106
reporterConfig.webAssetsDir || process.env.SAUCE_WEB_ASSETS_DIR;
@@ -112,7 +114,7 @@ export default class SauceReporter implements Reporter {
112114
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
113115
);
114116
reporterVersion = packageData.version;
115-
} catch (e) {
117+
} catch (_e) {
116118
/* empty */
117119
}
118120

@@ -138,12 +140,6 @@ export default class SauceReporter implements Reporter {
138140
}
139141

140142
this.playwrightVersion = 'unknown';
141-
142-
if (process.env.SAUCE_VIDEO_START_TIME) {
143-
this.videoStartTime = new Date(
144-
process.env.SAUCE_VIDEO_START_TIME,
145-
).getTime();
146-
}
147143
}
148144

149145
onBegin(config: FullConfig, suite: PlaywrightSuite) {
@@ -170,7 +166,7 @@ export default class SauceReporter implements Reporter {
170166
const jobUrls = [];
171167
const suites = [];
172168
for await (const projectSuite of this.rootSuite.suites) {
173-
const { report, assets } = this.createSauceReport(projectSuite);
169+
const { report, assets } = await this.createSauceReport(projectSuite);
174170

175171
const result = await this.reportToSauce(projectSuite, report, assets);
176172

@@ -365,7 +361,7 @@ export default class SauceReporter implements Reporter {
365361
return str.replace(ansiRegex, '');
366362
}
367363

368-
constructSauceSuite(rootSuite: PlaywrightSuite) {
364+
constructSauceSuite(rootSuite: PlaywrightSuite, videoSyncer?: Syncer) {
369365
const suite = new SauceSuite(rootSuite.title);
370366
const assets: Asset[] = [];
371367

@@ -407,10 +403,6 @@ export default class SauceReporter implements Reporter {
407403
startTime: lastResult.startTime,
408404
code: new TestCode(lines),
409405
});
410-
if (this.videoStartTime) {
411-
test.videoTimestamp =
412-
(lastResult.startTime.getTime() - this.videoStartTime) / 1000;
413-
}
414406
if (testCase.id) {
415407
test.metadata = {
416408
id: testCase.id,
@@ -445,12 +437,25 @@ export default class SauceReporter implements Reporter {
445437
});
446438
}
447439
}
440+
441+
if (videoSyncer) {
442+
const videoAttachment = lastResult.attachments.find((a) =>
443+
a.contentType.includes('video'),
444+
);
445+
videoSyncer.sync(test, {
446+
path: videoAttachment?.path,
447+
duration: test.duration,
448+
});
449+
}
448450
}
449451

450452
for (const subSuite of rootSuite.suites) {
451-
const { suite: s, assets: a } = this.constructSauceSuite(subSuite);
452-
suite.addSuite(s);
453+
const { suite: s, assets: a } = this.constructSauceSuite(
454+
subSuite,
455+
videoSyncer,
456+
);
453457

458+
suite.addSuite(s);
454459
assets.push(...a);
455460
}
456461

@@ -467,8 +472,32 @@ ${err.stack}
467472
`;
468473
}
469474

470-
createSauceReport(rootSuite: PlaywrightSuite) {
471-
const { suite: sauceSuite, assets } = this.constructSauceSuite(rootSuite);
475+
async createSauceReport(rootSuite: PlaywrightSuite) {
476+
let syncer: Syncer | undefined;
477+
if (process.env.SAUCE_VIDEO_START_TIME) {
478+
const offset = new Date(process.env.SAUCE_VIDEO_START_TIME).getTime();
479+
syncer = new OffsetSyncer(offset);
480+
} else if (this.mergeVideos) {
481+
syncer = new MergeSyncer();
482+
}
483+
484+
const { suite: sauceSuite, assets } = this.constructSauceSuite(
485+
rootSuite,
486+
syncer,
487+
);
488+
489+
if (syncer instanceof MergeSyncer) {
490+
const { kind, value } = await syncer.mergeVideos();
491+
if (kind === 'ok') {
492+
assets.push({
493+
filename: 'video.mp4',
494+
path: value,
495+
data: fs.createReadStream(value),
496+
});
497+
} else if (kind === 'err') {
498+
console.error('Failed to merge video:', value.message);
499+
}
500+
}
472501

473502
const report = new TestRun();
474503
report.addSuite(sauceSuite);

src/video/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { Syncer } from './sync/types';
2+
export { MergeSyncer } from './sync/merge';
3+
export { OffsetSyncer } from './sync/offset';

src/video/sync/merge.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import child_process from 'node:child_process';
2+
import { rmSync } from 'node:fs';
3+
import { mkdtemp, writeFile } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import process from 'node:process';
7+
import { promisify } from 'node:util';
8+
import { Test } from '@saucelabs/sauce-json-reporter';
9+
10+
import { Milliseconds, Syncer, VideoFile } from './types';
11+
12+
const exec = promisify(child_process.exec);
13+
14+
type MergeResult<T, E> =
15+
| { kind: 'ok'; value: T }
16+
| { kind: 'noop'; value: null }
17+
| { kind: 'err'; value: E };
18+
19+
/**
20+
* MergeSyncer is used to synchronize the video start time of a test case with
21+
* a collection of video files. Videos are aggregated and their cumulative
22+
* runtime is used to mark the video start time of the next test case to be
23+
* added.
24+
*/
25+
export class MergeSyncer implements Syncer {
26+
duration: Milliseconds;
27+
videoFiles: VideoFile[];
28+
29+
constructor() {
30+
this.duration = 0;
31+
this.videoFiles = [];
32+
}
33+
34+
public sync(test: Test, video: VideoFile): void {
35+
if (video.path && video.duration) {
36+
test.videoTimestamp = this.duration / 1000;
37+
38+
this.videoFiles.push({ ...video });
39+
this.duration += video.duration;
40+
}
41+
}
42+
43+
public async mergeVideos(): Promise<MergeResult<string, Error>> {
44+
if (this.videoFiles.length === 0) {
45+
return { kind: 'noop', value: null };
46+
}
47+
48+
const hasFFMpeg =
49+
child_process.spawnSync('ffmpeg', ['-version']).status === 0;
50+
if (!hasFFMpeg) {
51+
const e = new Error(
52+
'ffmpeg could not be found. Ensure ffmpeg is available in your PATH',
53+
);
54+
return { kind: 'err', value: e };
55+
}
56+
57+
let tmpDir: string;
58+
try {
59+
tmpDir = await mkdtemp(join(tmpdir(), 'pw-sauce-video-'));
60+
process.on('exit', () => {
61+
// NOTE: exit handler must be synchronous
62+
rmSync(tmpDir, { recursive: true, force: true });
63+
});
64+
} catch (e) {
65+
const error = e as Error;
66+
return { kind: 'err', value: error };
67+
}
68+
69+
const inputFile = join(tmpDir, 'videos.txt');
70+
const outputFile = join(tmpDir, 'video.mp4');
71+
72+
try {
73+
await writeFile(
74+
inputFile,
75+
this.videoFiles.map((v) => `file '${v.path}'`).join('\n'),
76+
);
77+
} catch (e) {
78+
return { kind: 'err', value: e as Error };
79+
}
80+
81+
const args = [
82+
'-f',
83+
'concat',
84+
'-safe',
85+
'0',
86+
'-threads',
87+
'1',
88+
'-y',
89+
'-i',
90+
inputFile,
91+
outputFile,
92+
];
93+
const cmd = ['ffmpeg', ...args].join(' ');
94+
try {
95+
await exec(cmd);
96+
} catch (e) {
97+
const error = e as Error;
98+
let msg = `ffmpeg command: ${cmd}`;
99+
if ('stdout' in error) {
100+
msg = `${msg}\nstdout: ${error.stdout}`;
101+
}
102+
if ('stderr' in error) {
103+
msg = `${msg}\nstderr: ${error.stderr}`;
104+
}
105+
return { kind: 'err', value: new Error(msg) };
106+
}
107+
108+
return { kind: 'ok', value: outputFile };
109+
}
110+
}

src/video/sync/offset.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test } from '@saucelabs/sauce-json-reporter';
2+
import { Milliseconds, Syncer, VideoFile } from './types';
3+
4+
/**
5+
* OffsetSyncer is used to synchronize the video start time of a test case
6+
* against a simple offset.
7+
*/
8+
export class OffsetSyncer implements Syncer {
9+
private videoOffset: Milliseconds;
10+
11+
constructor(offset: Milliseconds) {
12+
this.videoOffset = offset;
13+
}
14+
15+
public sync(test: Test, _video: VideoFile): void {
16+
test.videoTimestamp = (test.startTime.getTime() - this.videoOffset) / 1000;
17+
}
18+
}

src/video/sync/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Test } from '@saucelabs/sauce-json-reporter';
2+
3+
export type Milliseconds = number;
4+
5+
/**
6+
* VideoFile represents a video on disk.
7+
*/
8+
export type VideoFile = {
9+
/**
10+
* The path to the video on disk.
11+
*/
12+
path?: string;
13+
/**
14+
* The duration of the video file in milliseconds.
15+
*/
16+
duration?: Milliseconds;
17+
};
18+
19+
export interface Syncer {
20+
sync(test: Test, video: VideoFile): void;
21+
}

tests/integration/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const config: PlaywrightTestConfig = {
99
buildName: 'Playwright Integration Tests',
1010
tags: ['playwright', 'demo', 'e2e'],
1111
outputFile: 'sauce-test-report.json',
12+
mergeVideos: true,
1213
},
1314
],
1415
['line'],

tests/integration/playwright.spec.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,7 @@ describe('runs tests on cloud', function () {
8080
const assets = response.data;
8181
expect(assets['console.log']).toBe('console.log');
8282
expect(assets['sauce-test-report.json']).toBe('sauce-test-report.json');
83-
expect(Object.keys(assets).some((key) => key.indexOf('video') != -1)).toBe(
84-
true,
85-
);
83+
expect(assets['video']).toBe('video.mp4');
8684
});
8785

8886
test('job has name/tags correctly set', async function () {

0 commit comments

Comments
 (0)