Skip to content

feat: Merge videos generated by playwright into a single mp4 #53

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
node-version-file: ".nvmrc"
cache: "npm"

- name: Setup ffmpeg
uses: FedericoCarboni/setup-ffmpeg@v3

- name: Install Dependencies
run: npm ci

Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const config = {
| `region` | Sets the region. <br> Default: `us-west-1` | `us-west-1` \| `eu-central-1` |
| `upload` | Whether to upload report and assets to Sauce Labs. <br> Default: `true` | `boolean` |
| `outputFile` | The local path to write the Sauce test report. Can be set in env var `SAUCE_REPORT_OUTPUT_NAME`. | `string` |
| `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`

## Limitations

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

## Development

### Running integration tests

There are tests included in `tests/integration` where the reporter is referenced
directly.

```sh
$ npm run build
$ cd tests/integration
$ npx playwright test
```

### Running Locally

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

```sh
$ npm run build
$ npm link
$ npm link @saucelabs/playwright-reporter
$ npx playwright test --reporter=@saucelabs/playwright-reporter
Expand Down
12 changes: 12 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ module.exports = ts.config(
rules: {
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
},
},
{
Expand Down
61 changes: 44 additions & 17 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
TestRunRequestBody,
} from './api';
import { CI, IS_CI } from './ci';
import { Syncer, MergeSyncer, OffsetSyncer } from './video';

export interface Config {
buildName?: string;
Expand All @@ -33,6 +34,7 @@
outputFile?: string;
upload?: boolean;
webAssetsDir: string;
mergeVideos?: boolean;
}

// Types of attachments relevant for UI display.
Expand All @@ -51,13 +53,14 @@
];

export default class SauceReporter implements Reporter {
projects: { [k: string]: any };

Check warning on line 56 in src/reporter.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

buildName: string;
tags: string[];
region: Region;
outputFile?: string;
shouldUpload: boolean;
mergeVideos: boolean;
/*
* When webAssetsDir is set, this reporter syncs web UI-related attachments
* from the Playwright output directory to the specified web assets directory.
Expand Down Expand Up @@ -88,8 +91,6 @@
startedAt?: Date;
endedAt?: Date;

videoStartTime?: number;

constructor(reporterConfig: Config) {
this.projects = {};

Expand All @@ -99,6 +100,7 @@
this.outputFile =
reporterConfig?.outputFile || process.env.SAUCE_REPORT_OUTPUT_NAME;
this.shouldUpload = reporterConfig?.upload !== false;
this.mergeVideos = reporterConfig?.mergeVideos === true;

this.webAssetsDir =
reporterConfig.webAssetsDir || process.env.SAUCE_WEB_ASSETS_DIR;
Expand All @@ -112,7 +114,7 @@
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
);
reporterVersion = packageData.version;
} catch (e) {
} catch (_e) {
/* empty */
}

Expand All @@ -138,12 +140,6 @@
}

this.playwrightVersion = 'unknown';

if (process.env.SAUCE_VIDEO_START_TIME) {
this.videoStartTime = new Date(
process.env.SAUCE_VIDEO_START_TIME,
).getTime();
}
}

onBegin(config: FullConfig, suite: PlaywrightSuite) {
Expand Down Expand Up @@ -181,7 +177,7 @@
});
try {
await this.reportTestRun(projectSuite, report, result?.id);
} catch (e: any) {

Check warning on line 180 in src/reporter.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
console.warn('failed to send report to insights: ', e);
}
}
Expand Down Expand Up @@ -365,7 +361,7 @@
return str.replace(ansiRegex, '');
}

constructSauceSuite(rootSuite: PlaywrightSuite) {
constructSauceSuite(rootSuite: PlaywrightSuite, videoSyncer?: Syncer) {
const suite = new SauceSuite(rootSuite.title);
const assets: Asset[] = [];

Expand Down Expand Up @@ -407,10 +403,6 @@
startTime: lastResult.startTime,
code: new TestCode(lines),
});
if (this.videoStartTime) {
test.videoTimestamp =
(lastResult.startTime.getTime() - this.videoStartTime) / 1000;
}
if (testCase.id) {
test.metadata = {
id: testCase.id,
Expand Down Expand Up @@ -445,12 +437,25 @@
});
}
}

if (videoSyncer) {
const videoAttachment = lastResult.attachments.find((a) =>
a.contentType.includes('video'),
);
videoSyncer.sync(test, {
path: videoAttachment?.path,
duration: test.duration,
});
}
}

for (const subSuite of rootSuite.suites) {
const { suite: s, assets: a } = this.constructSauceSuite(subSuite);
suite.addSuite(s);
const { suite: s, assets: a } = this.constructSauceSuite(
subSuite,
videoSyncer,
);

suite.addSuite(s);
assets.push(...a);
}

Expand All @@ -468,7 +473,29 @@
}

createSauceReport(rootSuite: PlaywrightSuite) {
const { suite: sauceSuite, assets } = this.constructSauceSuite(rootSuite);
let syncer: Syncer | undefined;
if (process.env.SAUCE_VIDEO_START_TIME) {
const offset = new Date(process.env.SAUCE_VIDEO_START_TIME).getTime();
syncer = new OffsetSyncer(offset);
} else if (this.mergeVideos) {
syncer = new MergeSyncer();
}

const { suite: sauceSuite, assets } = this.constructSauceSuite(
rootSuite,
syncer,
);

if (syncer instanceof MergeSyncer) {
const mergedVideo = syncer.mergeVideos();
if (mergedVideo) {
assets.push({
filename: 'video.mp4',
path: mergedVideo,
data: fs.createReadStream(mergedVideo),
});
}
}

const report = new TestRun();
report.addSuite(sauceSuite);
Expand Down
3 changes: 3 additions & 0 deletions src/video/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Syncer } from './sync/types';
export { MergeSyncer } from './sync/merge';
export { OffsetSyncer } from './sync/offset';
80 changes: 80 additions & 0 deletions src/video/sync/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

import { Syncer, VideoFile } from './types';
import { Test } from '@saucelabs/sauce-json-reporter';

/**
* MergeSyncer is used to synchronize the video start time of a test case with
* a collection of video files. Videos are aggregated and their cumulative
* runtime is used to mark the video start time of the next test case to be
* added.
*/
export class MergeSyncer implements Syncer {
duration: number;
videoFiles: VideoFile[];

constructor() {
this.duration = 0;
this.videoFiles = [];
}

public sync(test: Test, video: VideoFile): void {
if (video.path && video.duration) {
test.videoTimestamp = this.duration / 1000;

this.videoFiles.push({ ...video });
this.duration += video.duration;
}
}

public mergeVideos() {
if (this.videoFiles.length === 0) {
return;
}
const hasFFMpeg = spawnSync('ffmpeg', ['-version']).status === 0;
if (!hasFFMpeg) {
console.error(
`Failed to merge videos: ffmpeg could not be found. \
Ensure ffmpeg is available in your PATH.`,
);
return;
}

const tmpDir = mkdtempSync(join(tmpdir(), 'pw-sauce-video-'));
const inputFile = join(tmpDir, 'videos.txt');
const outputFile = join(tmpDir, 'video.mp4');

writeFileSync(
inputFile,
this.videoFiles.map((v) => `file '${v.path}'`).join('\n'),
);

const args = [
'-f',
'concat',
'-safe',
'0',
'-threads',
'1',
'-y',
'-benchmark',
'-i',
inputFile,
outputFile,
];
const result = spawnSync('ffmpeg', args);
if (result.status !== 0) {
console.error('\nFailed to merge videos.');
console.error('Command:', `ffmpeg ${args.join(' ')}`);
console.error(`stdout: ${result.stdout.toString('utf8')}`);
console.error(`stderr: ${result.stderr.toString('utf8')}`);

return;
}

return outputFile;
}
}
18 changes: 18 additions & 0 deletions src/video/sync/offset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test } from '@saucelabs/sauce-json-reporter';
import { Syncer, VideoFile } from './types';

/**
* OffsetSyncer is used to synchronize the video start time of a test case
* against a simple offset.
*/
export class OffsetSyncer implements Syncer {
private videoOffset: number;

constructor(offset: number) {
this.videoOffset = offset;
}

public sync(test: Test, _video: VideoFile): void {
test.videoTimestamp = (test.startTime.getTime() - this.videoOffset) / 1000;
}
}
21 changes: 21 additions & 0 deletions src/video/sync/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Test } from '@saucelabs/sauce-json-reporter';

type Milliseconds = number;

/**
* VideoFile represents a video on disk.
*/
export type VideoFile = {
/**
* The path to the video on disk.
*/
path?: string;
/**
* The duration of the video file in milliseconds.
*/
duration?: Milliseconds;
};

export interface Syncer {
sync(test: Test, video: VideoFile): void;
}
1 change: 1 addition & 0 deletions tests/integration/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const config: PlaywrightTestConfig = {
buildName: 'Playwright Integration Tests',
tags: ['playwright', 'demo', 'e2e'],
outputFile: 'sauce-test-report.json',
mergeVideos: true,
},
],
['line'],
Expand Down
4 changes: 1 addition & 3 deletions tests/integration/playwright.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ describe('runs tests on cloud', function () {
const assets = response.data;
expect(assets['console.log']).toBe('console.log');
expect(assets['sauce-test-report.json']).toBe('sauce-test-report.json');
expect(Object.keys(assets).some((key) => key.indexOf('video') != -1)).toBe(
true,
);
expect(assets['video']).toBe('video.mp4');
});

test('job has name/tags correctly set', async function () {
Expand Down
Loading