Repository: codeceptjs/CodeceptJS (js)

Issue:
Plugin to record subtitles for video
With a video recording enabled for Playwright it is important also to show the progress of a test within that video.
The easiest solution so far is to generate subtitles to each video recorded by Playwright.

* Subtitles will include all executed steps and saved into *.srt file. 
* This srt file should be named after a video file which was saved (can be obtained from `test.artifacts.video` **after** a test)

The flow is:

* create a plugin
* listen to all `step.before` events
* write all steps into memory
* after a test check for `test.artifacts.video` to be found
* if this is found - write subtitles file

Pull request description:
Feat subtitle plugin
## Motivation/Description of the PR
- This PR is to capture steps of the tests as a subtitle and then report them as an artifact if a video was generated was the corresponding failing test
- Resolves #2976 

Applicable helpers:

- [ ] WebDriver
- [ ] Puppeteer
- [ ] Nightmare
- [ ] REST
- [ ] FileHelper
- [ ] Appium
- [ ] Protractor
- [ ] TestCafe
- [x] Playwright

Applicable plugins:

- [ ] allure
- [ ] autoDelay
- [ ] autoLogin
- [ ] customLocator
- [ ] pauseOnFail
- [ ] puppeteerCoverage
- [ ] retryFailedStep
- [ ] screenshotOnFail
- [ ] selenoid
- [ ] stepByStepReport
- [ ] wdio

## Type of change

- [ ] :fire: Breaking changes
- [x] :rocket: New functionality
- [ ] :bug: Bug fix
- [ ] :clipboard: Documentation changes/updates
- [ ] :hotsprings: Hot fix
- [ ] :hammer: Markdown files fix - not related to source code
- [ ] :nail_care: Polish code

## Checklist:

- [x] Tests have been added
- [x] Documentation has been added (Run `npm run docs`)
- [x] Lint checking (Run `npm run lint`)
- [x] Local tests are passed (Run `npm test`)


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 194360e1..26f98031 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -27,6 +27,7 @@ Applicable plugins:
 - [ ] selenoid
 - [ ] stepByStepReport
 - [ ] wdio
+- [ ] subtitles
 
 ## Type of change
 
diff --git a/docs/plugins.md b/docs/plugins.md
index 410fd73c..9fc0c58e 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -441,7 +441,7 @@ Using `data-test` attribute with `$` prefix:
 // in codecept.conf.js
 plugins: {
  customLocator: {
-   enabled: true
+   enabled: true,
    attribute: 'data-test'
  }
 }
@@ -460,7 +460,7 @@ Using `data-qa` attribute with `=` prefix:
 // in codecept.conf.js
 plugins: {
  customLocator: {
-   enabled: true
+   enabled: true,
    prefix: '=',
    attribute: 'data-qa'
  }
@@ -823,6 +823,20 @@ Possible config options:
 
 -   `config` **any** 
 
+## subtitles
+
+Automatically captures steps as subtitle, and saves it as an artifact when a video is found for a failed test
+
+#### Configuration
+
+```js
+plugins: {
+ subtitles: {
+   enabled: true
+ }
+}
+```
+
 ## tryTo
 
 Adds global `tryTo` function inside of which all failed steps won't fail a test but will return true/false.
diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js
index 2c9de8bc..a4572c64 100644
--- a/lib/helper/Puppeteer.js
+++ b/lib/helper/Puppeteer.js
@@ -129,8 +129,9 @@ const consoleLogStore = new Console();
  *    }
  * }
  * ```
+ * > Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored.
  *
-* #### Example #5: Target URL with provided basic authentication
+ * #### Example #5: Target URL with provided basic authentication
  *
  * ```js
  * {
@@ -143,11 +144,26 @@ const consoleLogStore = new Console();
  *    }
  * }
  * ```
+ * #### Troubleshooting
+ * 
+ * Error Message:  `No usable sandbox!`
+ * 
+ * When running Puppeteer on CI try to disable sandbox if you see that message
+ * 
+ * ```
+ * helpers: {
+ *  Puppeteer: {
+ *     url: 'http://localhost',
+ *     show: false,
+ *     chrome: {
+ *       args: ['--no-sandbox', '--disable-setuid-sandbox']
+ *     }
+ *   },
+ * }
+ * ```
  *
  *
  *
- * Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored.
- *
  * ## Access From Helpers
  *
  * Receive Puppeteer client from a custom helper by accessing `browser` for the Browser object or `page` for the current Page object:
diff --git a/lib/mochaFactory.js b/lib/mochaFactory.js
index 54ab79e9..35d50c18 100644
--- a/lib/mochaFactory.js
+++ b/lib/mochaFactory.js
@@ -2,7 +2,7 @@ const Mocha = require('mocha');
 const fsPath = require('path');
 const fs = require('fs');
 const reporter = require('./cli');
-const gherkinParser = require('./interfaces/gherkin.js');
+const gherkinParser = require('./interfaces/gherkin');
 const output = require('./output');
 const { genTestId } = require('./utils');
 const ConnectionRefused = require('./helper/errors/ConnectionRefused');
diff --git a/lib/plugin/subtitles.js b/lib/plugin/subtitles.js
new file mode 100644
index 00000000..261aeb41
--- /dev/null
+++ b/lib/plugin/subtitles.js
@@ -0,0 +1,88 @@
+const { v4: uuidv4 } = require('uuid');
+const fsPromise = require('fs').promises;
+const path = require('path');
+const event = require('../event');
+
+// This will convert a given timestamp in milliseconds to
+// an SRT recognized timestamp, ie HH:mm:ss,SSS
+function formatTimestamp(timestampInMs) {
+  const date = new Date(0, 0, 0, 0, 0, 0, timestampInMs);
+  const hours = date.getHours();
+  const minutes = date.getMinutes();
+  const seconds = date.getSeconds();
+  const ms = timestampInMs - (hours * 3600000 + minutes * 60000 + seconds * 1000);
+  return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
+}
+
+let steps = {};
+let testStartedAt;
+/**
+ * Automatically captures steps as subtitle, and saves it as an artifact when a video is found for a failed test
+ *
+ * #### Configuration
+ * ```js
+ * plugins: {
+ *  subtitles: {
+ *    enabled: true
+ *  }
+ * }
+ * ```
+ */
+module.exports = function () {
+  event.dispatcher.on(event.test.before, (_) => {
+    testStartedAt = Date.now();
+    steps = {};
+  });
+
+  event.dispatcher.on(event.step.started, (step) => {
+    const stepStartedAt = Date.now();
+    step.id = uuidv4();
+
+    let title = `${step.actor}.${step.name}(${step.args ? step.args.join(',') : ''})`;
+    if (title.length > 100) {
+      title = `${title.substring(0, 100)}...`;
+    }
+
+    steps[step.id] = {
+      start: formatTimestamp(stepStartedAt - testStartedAt),
+      startedAt: stepStartedAt,
+      title,
+    };
+  });
+
+  event.dispatcher.on(event.step.finished, (step) => {
+    if (step && step.id && steps[step.id]) {
+      steps[step.id].end = formatTimestamp(Date.now() - testStartedAt);
+    }
+  });
+
+  event.dispatcher.on(event.test.after, async (test) => {
+    if (test && test.artifacts && test.artifacts.video) {
+      const stepsSortedByStartTime = Object.values(steps);
+      stepsSortedByStartTime.sort((stepA, stepB) => {
+        return stepA.startedAt - stepB.startedAt;
+      });
+
+      let subtitle = '';
+
+      // For an SRT file, every subtitle has to be in the format as mentioned below:
+      //
+      // 1
+      // HH:mm:ss,SSS --> HH:mm:ss,SSS
+      // [title]
+      stepsSortedByStartTime.forEach((step, index) => {
+        if (step.end) {
+          subtitle = `${subtitle}${index + 1}
+${step.start} --> ${step.end}
+${step.title}
+
+`;
+        }
+      });
+
+      const { dir: artifactsDirectory, name: fileName } = path.parse(test.artifacts.video);
+      await fsPromise.writeFile(`${artifactsDirectory}/${fileName}.srt`, subtitle);
+      test.artifacts.subtitle = `${artifactsDirectory}/${fileName}.srt`;
+    }
+  });
+};
diff --git a/package.json b/package.json
index dc77ed5e..b7bcf99b 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,8 @@
     "promise-retry": "^1.1.1",
     "requireg": "^0.2.2",
     "resq": "^1.10.0",
-    "sprintf-js": "^1.1.1"
+    "sprintf-js": "^1.1.1",
+    "uuid": "^8.3.2"
   },
   "devDependencies": {
     "@codeceptjs/detox-helper": "^1.0.2",


Test patch:
diff --git a/test/unit/plugin/subtitles_test.js b/test/unit/plugin/subtitles_test.js
new file mode 100644
index 00000000..64c59694
--- /dev/null
+++ b/test/unit/plugin/subtitles_test.js
@@ -0,0 +1,147 @@
+const sinon = require('sinon');
+
+const fsPromises = require('fs').promises;
+const subtitles = require('../../../lib/plugin/subtitles');
+const container = require('../../../lib/container');
+const event = require('../../../lib/event');
+const recorder = require('../../../lib/recorder');
+
+function sleep(ms) {
+  return new Promise((resolve) => {
+    setTimeout(resolve, ms);
+  });
+}
+
+describe('subtitles', () => {
+  beforeEach(() => {
+    container.clear({
+      mock: {
+        _session: () => {},
+      },
+    });
+    recorder.start();
+  });
+  before(() => {
+    subtitles({});
+  });
+
+  it('should not capture subtitle as video artifact was missing', async () => {
+    const fsMock = sinon.mock(fsPromises);
+
+    const test = {};
+
+    fsMock.expects('writeFile')
+      .never();
+
+    event.dispatcher.emit(event.test.before, test);
+    const step1 = { name: 'see', actor: 'I', args: ['Test 1'] };
+    event.dispatcher.emit(event.step.started, step1);
+    event.dispatcher.emit(event.step.finished, step1);
+    event.dispatcher.emit(event.test.after, test);
+    fsMock.verify();
+  });
+
+  it('should capture subtitle as video artifact is present', async () => {
+    const fsMock = sinon.mock(fsPromises);
+
+    const test = {
+      artifacts: {
+        video: '../../lib/output/failedTest1.webm',
+      },
+    };
+
+    fsMock.expects('writeFile')
+      .once()
+      .withExactArgs('../../lib/output/failedTest1.srt', sinon.match((value) => {
+        return value.match(/^1\n[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\s-->\s[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\nI\.click\(Continue\)\n\n$/gm);
+      }));
+
+    event.dispatcher.emit(event.test.before, test);
+    const step1 = { name: 'click', actor: 'I', args: ['Continue'] };
+    event.dispatcher.emit(event.step.started, step1);
+    event.dispatcher.emit(event.step.finished, step1);
+    event.dispatcher.emit(event.test.after, test);
+    fsMock.verify();
+  });
+
+  it('should capture mutiple steps as subtitle', async () => {
+    const fsMock = sinon.mock(fsPromises);
+
+    const test = {
+      artifacts: {
+        video: '../../lib/output/failedTest1.webm',
+      },
+    };
+
+    fsMock.expects('writeFile')
+      .once()
+      .withExactArgs('../../lib/output/failedTest1.srt', sinon.match((value) => {
+        return value.match(/^1\n[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\s-->\s[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\nI\.click\(Continue\)\n\n2\n[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\s-->\s[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\nI\.see\(Github\)\n\n$/gm);
+      }));
+
+    event.dispatcher.emit(event.test.before, test);
+    const step1 = { name: 'click', actor: 'I', args: ['Continue'] };
+    const step2 = { name: 'see', actor: 'I', args: ['Github'] };
+    event.dispatcher.emit(event.step.started, step1);
+    event.dispatcher.emit(event.step.started, step2);
+    event.dispatcher.emit(event.step.finished, step2);
+    await sleep(300);
+
+    event.dispatcher.emit(event.step.finished, step1);
+    event.dispatcher.emit(event.test.after, test);
+    fsMock.verify();
+  });
+
+  it('should capture seperate steps for separate tests', async () => {
+    const fsMock = sinon.mock(fsPromises);
+
+    const test1 = {
+      artifacts: {
+        video: '../../lib/output/failedTest1.webm',
+      },
+    };
+
+    fsMock.expects('writeFile')
+      .once()
+      .withExactArgs('../../lib/output/failedTest1.srt', sinon.match((value) => {
+        return value.match(/^1\n[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\s-->\s[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\nI\.click\(Continue\)\n\n2\n[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\s-->\s[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\nI\.see\(Github\)\n\n$/gm);
+      }));
+
+    event.dispatcher.emit(event.test.before, test1);
+    const step1 = { name: 'click', actor: 'I', args: ['Continue'] };
+    const step2 = { name: 'see', actor: 'I', args: ['Github'] };
+    event.dispatcher.emit(event.step.started, step1);
+    event.dispatcher.emit(event.step.started, step2);
+    event.dispatcher.emit(event.step.finished, step2);
+    await sleep(300);
+
+    event.dispatcher.emit(event.step.finished, step1);
+    event.dispatcher.emit(event.test.after, test1);
+    fsMock.verify();
+    fsMock.restore();
+
+    /**
+     * To Ensure that when multiple tests are run steps are not incorrectly captured
+     */
+    const fsMock1 = sinon.mock(fsPromises);
+    fsMock1.expects('writeFile')
+      .once()
+      .withExactArgs('../../lib/output/failedTest2.srt', sinon.match((value) => {
+        return value.match(/^1\n[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\s-->\s[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}\nI\.click\(Login\)\n\n$/gm);
+      }));
+    const test2 = {
+      artifacts: {
+        video: '../../lib/output/failedTest2.webm',
+      },
+    };
+
+    event.dispatcher.emit(event.test.before, test2);
+    const step3 = { name: 'click', actor: 'I', args: ['Login'] };
+    event.dispatcher.emit(event.step.started, step3);
+    await sleep(300);
+
+    event.dispatcher.emit(event.step.finished, step3);
+    event.dispatcher.emit(event.test.after, test2);
+    fsMock1.verify();
+  });
+});