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 all 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
67 changes: 48 additions & 19 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 All @@ -170,7 +166,7 @@
const jobUrls = [];
const suites = [];
for await (const projectSuite of this.rootSuite.suites) {
const { report, assets } = this.createSauceReport(projectSuite);
const { report, assets } = await this.createSauceReport(projectSuite);

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

Expand All @@ -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 @@ -467,8 +472,32 @@
`;
}

createSauceReport(rootSuite: PlaywrightSuite) {
const { suite: sauceSuite, assets } = this.constructSauceSuite(rootSuite);
async createSauceReport(rootSuite: PlaywrightSuite) {
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 { kind, value } = await syncer.mergeVideos();
if (kind === 'ok') {
assets.push({
filename: 'video.mp4',
path: value,
data: fs.createReadStream(value),
});
} else if (kind === 'err') {
console.error('Failed to merge video:', value.message);
}
}

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';
110 changes: 110 additions & 0 deletions src/video/sync/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import child_process from 'node:child_process';
import { rmSync } from 'node:fs';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';
import { Test } from '@saucelabs/sauce-json-reporter';

import { Milliseconds, Syncer, VideoFile } from './types';

const exec = promisify(child_process.exec);

type MergeResult<T, E> =
| { kind: 'ok'; value: T }
| { kind: 'noop'; value: null }
| { kind: 'err'; value: E };

/**
* 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: Milliseconds;
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 async mergeVideos(): Promise<MergeResult<string, Error>> {
if (this.videoFiles.length === 0) {
return { kind: 'noop', value: null };
}

const hasFFMpeg =
child_process.spawnSync('ffmpeg', ['-version']).status === 0;
if (!hasFFMpeg) {
const e = new Error(
'ffmpeg could not be found. Ensure ffmpeg is available in your PATH',
);
return { kind: 'err', value: e };
}

let tmpDir: string;
try {
tmpDir = await mkdtemp(join(tmpdir(), 'pw-sauce-video-'));
process.on('exit', () => {
// NOTE: exit handler must be synchronous
rmSync(tmpDir, { recursive: true, force: true });
});
} catch (e) {
const error = e as Error;
return { kind: 'err', value: error };
}

const inputFile = join(tmpDir, 'videos.txt');
const outputFile = join(tmpDir, 'video.mp4');

try {
await writeFile(
inputFile,
this.videoFiles.map((v) => `file '${v.path}'`).join('\n'),
);
} catch (e) {
return { kind: 'err', value: e as Error };
}

const args = [
'-f',
'concat',
'-safe',
'0',
'-threads',
'1',
'-y',
'-i',
inputFile,
outputFile,
];
const cmd = ['ffmpeg', ...args].join(' ');
try {
await exec(cmd);
} catch (e) {
const error = e as Error;
let msg = `ffmpeg command: ${cmd}`;
if ('stdout' in error) {
msg = `${msg}\nstdout: ${error.stdout}`;
}
if ('stderr' in error) {
msg = `${msg}\nstderr: ${error.stderr}`;
}
return { kind: 'err', value: new Error(msg) };
}

return { kind: 'ok', value: 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 { Milliseconds, 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: Milliseconds;

constructor(offset: Milliseconds) {
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';

export 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