From cf65b0de3de2ebe5ddc2bc55dbb71a15403336f8 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 30 Apr 2026 01:14:34 +0300 Subject: [PATCH 1/4] feat(plugins): add screencast plugin (replaces subtitles) New `screencast` plugin built on Playwright 1.59's page.screencast API. Records WebM video with optional burn-in action captions (showActions) and optional standalone .srt subtitles. Wires into the unified `on=` parameter introduced in #5542 with two valid modes: `on=fail` (default; deletes the recording on pass) and `on=test` (always keeps). - Replaces and removes the legacy `subtitles` plugin and its test - Bumps `playwright` devDependency to 1.59.0 (required for page.screencast) - Adds 8 unit tests covering modes, captions, subtitles, video=false fallback, missing-API guard, invalid-mode rejection - Updates examples/codecept.config.js to swap subtitles for screencast - Documents the plugin in docs/plugins.md and docs/playwright.md, including the empirically-verified "two videos" note when helper video:true and the plugin are enabled together Verified against a real Playwright 1.59 run: produces output/screencast/*.webm plus *.srt; coexists with helper recordVideo without conflict. --- docs/playwright.md | 38 +++- docs/plugins.md | 55 ++++-- examples/codecept.config.js | 4 +- lib/plugin/screencast.js | 287 ++++++++++++++++++++++++++++ lib/plugin/subtitles.js | 89 --------- package.json | 2 +- test/unit/plugin/screencast_test.js | 217 +++++++++++++++++++++ test/unit/plugin/subtitles_test.js | 154 --------------- 8 files changed, 586 insertions(+), 260 deletions(-) create mode 100644 lib/plugin/screencast.js delete mode 100644 lib/plugin/subtitles.js create mode 100644 test/unit/plugin/screencast_test.js delete mode 100644 test/unit/plugin/subtitles_test.js diff --git a/docs/playwright.md b/docs/playwright.md index 03a79bf71..df50a1e4b 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -478,10 +478,46 @@ When a test fails and video was enabled a video file is shown under the `artifac Open video and use it to debug a failed test case. Video helps when running tests on CI. Configure your CI system to enable artifacts storage for `output/video` and review videos of failed test case to understand failures. -It is recommended to enable [subtitles](https://codecept.io/plugins/#subtitles) plugin which will generate subtitles from steps in `.srt` format. Subtitles file will be saved into after a video file so video player (like VLC) would load them automatically: +## Screencast + +For richer evidence than helper-level `video`, enable the [`screencast`](https://codecept.io/plugins/#screencast) plugin. It uses Playwright's `page.screencast` API (Playwright >= 1.59) to record WebM video with optional burned-in action captions and a standalone `.srt` subtitle track. + +```js +plugins: { + screencast: { + enabled: true, + on: 'fail', + } +} +``` + +`on: 'fail'` (default) deletes the recording when the test passes; `on: 'test'` keeps every test's video. + +`captions: true` (default) burns `I.click()` / `I.fillField()` annotations into the video via `page.screencast.showActions()`. `subtitles: true` writes a standalone `.srt` file alongside the video — VLC and most players auto-load it. + +```js +plugins: { + screencast: { + enabled: true, + on: 'test', + captions: true, + subtitles: true, + } +} +``` ![](https://user-images.githubusercontent.com/220264/131644090-38d1ca55-1ba1-41fa-8fd1-7dea2b7ae995.png) +CLI usage: + + npx codeceptjs run -p screencast + npx codeceptjs run -p screencast:on=test + npx codeceptjs run -p screencast:on=test;captions=false;subtitles=true + +The recording is attached to the test as `test.artifacts.screencast`; the `.srt` (when enabled) is attached as `test.artifacts.subtitle`. + +> Enabling helper-level `video: true` **and** the `screencast` plugin produces two independent recordings (one in `output/videos/`, one in `output/screencast/`). Pick one. + ## Trace If video is not enough to descover why a test failed a [trace](https://playwright.dev/docs/trace-viewer/) can be recorded. diff --git a/docs/plugins.md b/docs/plugins.md index 21278f98a..a3e89fc04 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -700,6 +700,47 @@ Scenario('scenario tite', { disableRetryFailedStep: true }, () => { * `config` +## screencast + +Records WebM video of tests using Playwright's screencast API (Playwright >= 1.59). +When `captions` is enabled, action annotations are burned into the video; when +`subtitles` is enabled, a standalone `.srt` file is also produced. + +```js +plugins: { + screencast: { + enabled: true, + on: 'fail', + } +} +``` + +#### `on=` modes + +* **fail** — record while running; delete on pass, keep on fail (default) +* **test** — record and keep every test's video + +CLI examples: + + npx codeceptjs run -p screencast + npx codeceptjs run -p screencast:on=test + npx codeceptjs run -p screencast:on=test;captions=false;subtitles=true + +Possible config options: + +* `captions`: burn-in action overlays via `page.screencast.showActions()`. Default: true. +* `subtitles`: also write a standalone `.srt` file alongside the video. Default: false. +* `video`: record a video. With `video=false, subtitles=true`, only the `.srt` is produced (next to `test.artifacts.video` if a helper recorded one). Default: true. +* `size`: pass-through `{ width, height }` for `screencast.start`. +* `quality`: pass-through 0–100 for `screencast.start`. + +> Enabling Playwright's helper-level `video: true` and this plugin together +> produces two independent recordings. Pick one. + +### Parameters + +* `config` + ## screenshot Saves screenshots from the browser at points triggered by `on=`. Replaces the @@ -812,20 +853,6 @@ plugins: { * `config` -## 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 - } -} -``` - [1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [2]: https://github.com/cenfun/monocart-coverage-reports?tab=readme-ov-file#default-options diff --git a/examples/codecept.config.js b/examples/codecept.config.js index c6da729d7..14215806c 100644 --- a/examples/codecept.config.js +++ b/examples/codecept.config.js @@ -57,8 +57,10 @@ export const config = { retryFailedStep: { enabled: false, }, - subtitles: { + screencast: { enabled: true, + on: 'test', + subtitles: true, }, aiTrace: { enabled: true, diff --git a/lib/plugin/screencast.js b/lib/plugin/screencast.js new file mode 100644 index 000000000..bd0d9a3f0 --- /dev/null +++ b/lib/plugin/screencast.js @@ -0,0 +1,287 @@ +import fs from 'fs' +import path from 'path' +import { mkdirp } from 'mkdirp' +import { v4 as uuidv4 } from 'uuid' + +import Container from '../container.js' +import recorder from '../recorder.js' +import event from '../event.js' +import output from '../output.js' +import store from '../store.js' + +import { testToFileName } from '../mocha/test.js' +import { parsePluginArgs, resolveTrigger, getBrowserHelper } from '../utils/pluginParser.js' + +const defaultConfig = { + on: 'fail', + captions: true, + subtitles: false, + video: true, +} + +/** + * Records WebM video of tests using Playwright's screencast API. + * + * When `captions` is enabled, action annotations are burned into the video; + * when `subtitles` is enabled, a standalone `.srt` is also produced. Default + * `on=fail` keeps videos for failed tests only; `on=test` keeps every test's + * video. + * + * Note: enabling Playwright's helper-level `video: true` together with this + * plugin produces two independent recordings (`output/videos/*.webm` from the + * helper, `output/screencast/*.webm` from this plugin). + * + * #### Configuration + * + * ```js + * plugins: { + * screencast: { + * enabled: true, + * on: 'fail', + * } + * } + * ``` + * + * #### `on=` modes + * + * * **fail** — record while running; delete on pass, keep on fail (default) + * * **test** — record and keep every test's video + * + * Other config options: + * + * * `captions`: burn-in action overlays via `page.screencast.showActions()`. Default: true. + * * `subtitles`: also write a standalone `.srt` file alongside the video. Default: false. + * * `video`: record a video. With `video=false, subtitles=true`, only the `.srt` is produced. Default: true. + * * `size`: pass-through `{ width, height }` for `screencast.start`. + * * `quality`: pass-through 0–100 for `screencast.start`. + * + * CLI examples: + * + * ``` + * npx codeceptjs run -p screencast + * npx codeceptjs run -p screencast:on=test + * npx codeceptjs run -p screencast:on=test;captions=false;subtitles=true + * ``` + */ +export default function (config = {}) { + const helper = getBrowserHelper() + if (!helper) return + + const cliArgs = parsePluginArgs(config._args) + const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { + name: 'screencast', + validModes: ['fail', 'test'], + }) + if (!trigger) return + + const options = Object.assign({}, defaultConfig, config) + options.captions = cliArgs.captions ?? config.captions ?? defaultConfig.captions + options.subtitles = cliArgs.subtitles ?? config.subtitles ?? defaultConfig.subtitles + options.video = cliArgs.video ?? config.video ?? defaultConfig.video + + return wireScreencast(trigger.on, options) +} + +function wireScreencast(mode, options) { + const state = { + test: null, + webmPath: null, + srtPath: null, + steps: null, + startedAt: null, + failed: false, + startQueued: false, + started: false, + warnedNoApi: false, + } + + event.dispatcher.on(event.test.before, test => { + state.test = test + state.failed = false + state.webmPath = null + state.srtPath = null + state.startQueued = false + state.started = false + state.steps = options.subtitles ? {} : null + state.startedAt = options.subtitles ? Date.now() : null + }) + + event.dispatcher.on(event.step.started, step => { + if (state.steps) { + const at = Date.now() + step.id = step.id || uuidv4() + state.steps[step.id] = { + start: formatTimestamp(at - state.startedAt), + startedAt: at, + title: stepTitle(step), + } + } + if (!options.video || state.startQueued || !state.test) return + state.startQueued = true + const test = state.test + recorder.add('screencast:start', async () => startScreencast(test, options, state), true) + }) + + if (options.subtitles) { + event.dispatcher.on(event.step.finished, step => { + if (!state.steps || !step?.id || !state.steps[step.id]) return + state.steps[step.id].end = formatTimestamp(Date.now() - state.startedAt) + }) + } + + event.dispatcher.on(event.test.failed, (test, _err, hookName) => { + if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return + state.failed = true + }) + + event.dispatcher.on(event.test.after, () => { + if (!state.test) return + recorder.add('screencast:stop', async () => finalizeScreencast({ + test: state.test, + webmPath: state.webmPath, + srtPath: state.srtPath, + steps: state.steps, + failed: state.failed, + started: state.started, + options, + mode, + }), true) + }) +} + +async function startScreencast(test, options, state) { + const helper = getBrowserHelper() + if (!helper?.page?.screencast) { + if (!state.warnedNoApi) { + output.plugin('screencast', 'page.screencast not available — requires Playwright >= 1.59. Skipping.') + state.warnedNoApi = true + } + return + } + + const baseDir = path.join(store.outputDir || '_output', 'screencast') + mkdirp.sync(baseDir) + const baseName = testToFileName(test, { suffix: '', unique: true }) + state.webmPath = path.join(baseDir, `${baseName}.webm`) + state.srtPath = path.join(baseDir, `${baseName}.srt`) + + const startOpts = { path: state.webmPath } + if (options.size) startOpts.size = options.size + if (options.quality != null) startOpts.quality = options.quality + + try { + await helper.page.screencast.start(startOpts) + state.started = true + } catch (err) { + output.plugin('screencast', `Failed to start: ${err.message}`) + state.webmPath = null + state.srtPath = null + state.started = false + return + } + + if (options.captions && typeof helper.page.screencast.showActions === 'function') { + try { await helper.page.screencast.showActions() } + catch (err) { output.plugin('screencast', `showActions failed: ${err.message}`) } + } + if (typeof helper.page.screencast.showChapter === 'function') { + try { await helper.page.screencast.showChapter(String(test.title || '')) } + catch (err) { output.plugin('screencast', `showChapter failed: ${err.message}`) } + } +} + +async function finalizeScreencast(snapshot) { + const { test, options, mode, steps } = snapshot + let { webmPath, srtPath } = snapshot + + const helper = getBrowserHelper() + if (snapshot.started && helper?.page?.screencast) { + try { + await helper.page.screencast.stop() + } catch (err) { + output.plugin('screencast', `stop failed: ${err.message}`) + } + } + + const shouldKeep = mode === 'test' || (mode === 'fail' && snapshot.failed) + + if (options.video && webmPath) { + if (!shouldKeep) { + try { fs.unlinkSync(webmPath) } catch { /* file may not exist yet */ } + webmPath = null + } else { + ensureArtifactsObject(test) + test.artifacts.screencast = webmPath + attachJUnitArtifact(test, webmPath) + } + } + + if (options.subtitles && steps) { + if (options.video && !shouldKeep) { + try { srtPath && fs.unlinkSync(srtPath) } catch { /* nothing to delete */ } + return + } + + let target = srtPath + if (!options.video) { + if (test.artifacts && test.artifacts.video) { + const { dir, name } = path.parse(test.artifacts.video) + target = path.join(dir, `${name}.srt`) + } else { + const baseDir = path.join(store.outputDir || '_output', 'screencast') + mkdirp.sync(baseDir) + const baseName = testToFileName(test, { suffix: '', unique: true }) + target = path.join(baseDir, `${baseName}.srt`) + } + } + + if (!target) return + try { + await fs.promises.writeFile(target, buildSrt(steps)) + ensureArtifactsObject(test) + test.artifacts.subtitle = target + } catch (err) { + output.plugin('screencast', `failed to write SRT: ${err.message}`) + } + } +} + +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')}` +} + +function stepTitle(step) { + let title = `${step.actor}.${step.name}(${step.args ? step.args.join(',') : ''})` + if (title.length > 100) title = `${title.substring(0, 100)}...` + return title +} + +function buildSrt(steps) { + const sorted = Object.values(steps).sort((a, b) => a.startedAt - b.startedAt) + let out = '' + let index = 1 + for (const step of sorted) { + if (!step.end) continue + out += `${index}\n${step.start} --> ${step.end}\n${step.title}\n\n` + index++ + } + return out +} + +function ensureArtifactsObject(test) { + if (!test.artifacts || Array.isArray(test.artifacts)) test.artifacts = {} +} + +function attachJUnitArtifact(test, filePath) { + const mocha = Container.mocha?.() + const junit = mocha?.options?.reporterOptions?.['mocha-junit-reporter'] + if (junit?.options?.attachments) { + test.attachments = test.attachments || [] + test.attachments.push(filePath) + } +} diff --git a/lib/plugin/subtitles.js b/lib/plugin/subtitles.js deleted file mode 100644 index 896221536..000000000 --- a/lib/plugin/subtitles.js +++ /dev/null @@ -1,89 +0,0 @@ -import { v4 as uuidv4 } from 'uuid' -import fs from 'fs' -const fsPromise = fs.promises -import path from 'path' -import event from '../event.js' - -// 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 - * } - * } - * ``` - */ -export default 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 c9baefd7f..606a25798 100644 --- a/package.json +++ b/package.json @@ -173,7 +173,7 @@ "jsdoc-typeof-plugin": "1.0.0", "json-server": "0.17.4", "mochawesome": "^7.1.3", - "playwright": "1.55.1", + "playwright": "^1.59.0", "prettier": "^3.3.2", "puppeteer": "24.36.0", "qrcode-terminal": "0.12.0", diff --git a/test/unit/plugin/screencast_test.js b/test/unit/plugin/screencast_test.js new file mode 100644 index 000000000..f84ee09d2 --- /dev/null +++ b/test/unit/plugin/screencast_test.js @@ -0,0 +1,217 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import fs from 'fs' +import { promises as fsPromises } from 'fs' +import path from 'path' + +import screencast from '../../../lib/plugin/screencast.js' +import container from '../../../lib/container.js' +import event from '../../../lib/event.js' +import recorder from '../../../lib/recorder.js' +import store from '../../../lib/store.js' +import { createTest } from '../../../lib/mocha/test.js' + +function makeFakeScreencast() { + return { + start: sinon.stub().resolves(), + stop: sinon.stub().resolves(), + showActions: sinon.stub().resolves(), + hideActions: sinon.stub().resolves(), + showChapter: sinon.stub().resolves(), + } +} + +function fakeHelper(screencastApi) { + return { + options: {}, + page: screencastApi === undefined + ? { screencast: makeFakeScreencast() } + : screencastApi === null + ? null + : { screencast: screencastApi }, + } +} + +function detachAll() { + for (const evt of [ + event.test.before, event.test.after, event.test.failed, + event.step.started, event.step.finished, + ]) { + event.dispatcher.removeAllListeners(evt) + } +} + +describe('screencast', () => { + let unlinkStub + let mkdirpStub + let writeFileStub + let outputDirOriginal + + beforeEach(() => { + recorder.reset() + recorder.start() + outputDirOriginal = store.outputDir + store.outputDir = '/tmp/codeceptjs-screencast-test' + + unlinkStub = sinon.stub(fs, 'unlinkSync') + writeFileStub = sinon.stub(fsPromises, 'writeFile').resolves() + }) + + afterEach(() => { + detachAll() + sinon.restore() + store.outputDir = outputDirOriginal + }) + + const aStep = () => ({ name: 'click', actor: 'I', args: ['x'] }) + + it('on=test keeps the file on pass and sets test.artifacts.screencast', async () => { + const sc = makeFakeScreencast() + container.clear({ Playwright: { options: {}, page: { screencast: sc } } }) + + screencast({ on: 'test' }) + const test = createTest('keep-on-pass') + + event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.step.started, aStep()) + await recorder.promise() + + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + + expect(sc.start.calledOnce).to.equal(true) + expect(sc.stop.calledOnce).to.equal(true) + expect(unlinkStub.called).to.equal(false) + expect(test.artifacts.screencast).to.match(/keep-on-pass.*\.webm$/) + }) + + it('on=fail deletes the file on pass and leaves no artifact', async () => { + const sc = makeFakeScreencast() + container.clear({ Playwright: { options: {}, page: { screencast: sc } } }) + + screencast({ on: 'fail' }) + const test = createTest('delete-on-pass') + + event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.step.started, aStep()) + await recorder.promise() + + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + + expect(sc.start.calledOnce).to.equal(true) + expect(sc.stop.calledOnce).to.equal(true) + expect(unlinkStub.calledOnce).to.equal(true) + expect(unlinkStub.firstCall.args[0]).to.match(/delete-on-pass.*\.webm$/) + expect(test.artifacts.screencast).to.equal(undefined) + }) + + it('on=fail keeps the file when test.failed fires', async () => { + const sc = makeFakeScreencast() + container.clear({ Playwright: { options: {}, page: { screencast: sc } } }) + + screencast({ on: 'fail' }) + const test = createTest('keep-on-fail') + + event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.step.started, aStep()) + await recorder.promise() + + event.dispatcher.emit(event.test.failed, test, new Error('boom')) + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + + expect(unlinkStub.called).to.equal(false) + expect(test.artifacts.screencast).to.match(/keep-on-fail.*\.webm$/) + }) + + it('captions=true triggers showActions; captions=false does not', async () => { + const sc = makeFakeScreencast() + container.clear({ Playwright: { options: {}, page: { screencast: sc } } }) + + screencast({ on: 'test', captions: true }) + let test = createTest('with-captions') + event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.step.started, aStep()) + await recorder.promise() + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + expect(sc.showActions.calledOnce).to.equal(true) + + detachAll() + sc.showActions.resetHistory() + + screencast({ on: 'test', captions: false }) + test = createTest('no-captions') + event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.step.started, aStep()) + await recorder.promise() + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + expect(sc.showActions.called).to.equal(false) + }) + + it('subtitles=true writes an SRT alongside the webm', async () => { + const sc = makeFakeScreencast() + container.clear({ Playwright: { options: {}, page: { screencast: sc } } }) + + screencast({ on: 'test', subtitles: true }) + const test = createTest('with-srt') + + event.dispatcher.emit(event.test.before, test) + await recorder.promise() + + const step = { name: 'click', actor: 'I', args: ['Continue'] } + event.dispatcher.emit(event.step.started, step) + event.dispatcher.emit(event.step.finished, step) + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + + expect(writeFileStub.calledOnce).to.equal(true) + const [target, body] = writeFileStub.firstCall.args + expect(target).to.match(/with-srt.*\.srt$/) + expect(body).to.match(/^1\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}\nI\.click\(Continue\)\n\n$/) + expect(test.artifacts.subtitle).to.equal(target) + }) + + it('video=false, subtitles=true skips screencast.start and writes SRT next to test.artifacts.video', async () => { + const sc = makeFakeScreencast() + container.clear({ Playwright: { options: {}, page: { screencast: sc } } }) + + screencast({ on: 'test', video: false, subtitles: true }) + const test = createTest('srt-only') + test.artifacts.video = '/tmp/some-video-dir/myrun.webm' + + event.dispatcher.emit(event.test.before, test) + const step = { name: 'see', actor: 'I', args: ['Github'] } + event.dispatcher.emit(event.step.started, step) + event.dispatcher.emit(event.step.finished, step) + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + + expect(sc.start.called).to.equal(false) + expect(writeFileStub.calledOnce).to.equal(true) + expect(writeFileStub.firstCall.args[0]).to.equal('/tmp/some-video-dir/myrun.srt') + expect(test.artifacts.subtitle).to.equal('/tmp/some-video-dir/myrun.srt') + }) + + it('warns once and bails when helper.page.screencast is missing', async () => { + container.clear({ Playwright: { options: {}, page: {} } }) + + screencast({ on: 'test' }) + const test = createTest('no-api') + + event.dispatcher.emit(event.test.before, test) + await recorder.promise() + event.dispatcher.emit(event.test.after, test) + await recorder.promise() + + expect(test.artifacts.screencast).to.equal(undefined) + expect(unlinkStub.called).to.equal(false) + }) + + it('rejects invalid mode (on=step) without throwing', () => { + container.clear({ Playwright: { options: {}, page: { screencast: makeFakeScreencast() } } }) + expect(() => screencast({ on: 'step' })).to.not.throw() + }) +}) diff --git a/test/unit/plugin/subtitles_test.js b/test/unit/plugin/subtitles_test.js deleted file mode 100644 index 7ed089fb6..000000000 --- a/test/unit/plugin/subtitles_test.js +++ /dev/null @@ -1,154 +0,0 @@ -import sinon from 'sinon' -import { promises as fsPromises } from 'fs' -import subtitles from '../../../lib/plugin/subtitles.js' -import container from '../../../lib/container.js' -import event from '../../../lib/event.js' -import { createTest } from '../../../lib/mocha/test.js' -import recorder from '../../../lib/recorder.js' - -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 = createTest('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 = createTest('test') - 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 = createTest('test') - 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 separate steps for separate tests', async () => { - const fsMock = sinon.mock(fsPromises) - - const test1 = createTest('test') - 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 = createTest('test') - 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() - }) -}) From 855285cebd2e0e0e47b03776d64a0f08794945ab Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Wed, 29 Apr 2026 22:18:12 +0000 Subject: [PATCH 2/4] Apply suggestion from @DavertMik --- docs/playwright.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/playwright.md b/docs/playwright.md index df50a1e4b..0ecf2f3b2 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -478,7 +478,7 @@ When a test fails and video was enabled a video file is shown under the `artifac Open video and use it to debug a failed test case. Video helps when running tests on CI. Configure your CI system to enable artifacts storage for `output/video` and review videos of failed test case to understand failures. -## Screencast +## Screencast For richer evidence than helper-level `video`, enable the [`screencast`](https://codecept.io/plugins/#screencast) plugin. It uses Playwright's `page.screencast` API (Playwright >= 1.59) to record WebM video with optional burned-in action captions and a standalone `.srt` subtitle track. From ee140638a3660f15a9db9897be959f62331fac9c Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Wed, 29 Apr 2026 22:18:20 +0000 Subject: [PATCH 3/4] Apply suggestion from @DavertMik --- docs/playwright.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/playwright.md b/docs/playwright.md index 0ecf2f3b2..5810b9eaf 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -518,7 +518,7 @@ The recording is attached to the test as `test.artifacts.screencast`; the `.srt` > Enabling helper-level `video: true` **and** the `screencast` plugin produces two independent recordings (one in `output/videos/`, one in `output/screencast/`). Pick one. -## Trace +## Trace If video is not enough to descover why a test failed a [trace](https://playwright.dev/docs/trace-viewer/) can be recorded. From f50bc3ee477dcdb8987b56522c167c47b7dac9ab Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 30 Apr 2026 01:32:31 +0300 Subject: [PATCH 4/4] fix(playwright): port React locator off removed _react= engine; drop Vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright 1.59 removed the experimental `_react=` and `_vue=` selector engines, which broke `{ react: ... }` and `{ vue: ... }` locators with: locator.all: TypeError: Cannot read properties of undefined (reading 'queryAll') at InjectedScript._queryEngineAll Reimplemented React locators on top of the `resq` library + `page.evaluateHandle` (the same approach the Puppeteer helper already uses). Dropped Vue locator support entirely — there is no in-deps equivalent to resq for Vue, and the public surface was minimal. - lib/helper/extras/PlaywrightReactVueLocator.js: rewrite findReact via resq; remove findVue - lib/helper/Playwright.js: remove findVue import and Vue branches in findElements / findElement - test/acceptance/react_test.js: remove the third scenario that used the raw `{ pw: '_react=...' }` form (no longer valid syntax) - test/unit/locator_test.js: drop `pw: '_react=button'` and `pw: '_vue=button'` cases - typings/index.d.ts: drop `{ vue: string }` from ILocator - docs/locators.md: update note to remove `_react`/`_vue` mentions Verified end-to-end against Playwright 1.59: React Selectors ✔ props @Puppeteer @Playwright (3.7s) ✔ component name @Puppeteer @Playwright (3.4s) --- docs/locators.md | 2 +- lib/helper/Playwright.js | 6 +- .../extras/PlaywrightReactVueLocator.js | 81 ++++++++++--------- test/acceptance/react_test.js | 10 --- test/unit/locator_test.js | 14 ---- typings/index.d.ts | 1 - 6 files changed, 47 insertions(+), 67 deletions(-) diff --git a/docs/locators.md b/docs/locators.md index d00ca22f9..df6133506 100644 --- a/docs/locators.md +++ b/docs/locators.md @@ -22,7 +22,7 @@ I.click({ role: 'button', name: 'Submit' }, '#login-form') The context narrows the search to one region of the page, and the semantic string says what the user actually clicks. This is **more precise than ARIA or CSS alone** because it combines structural scope with human-readable intent. -Supported strategies: `css`, `xpath`, `id`, `name`, `role`, `frame`, `shadow`, `pw`. Shadow DOM and React selectors have their own pages — see [Shadow DOM](/shadow) and [React](/react). Playwright-specific locators (`_react`, `_vue`, `data-testid`) use the `pw` strategy: `{ pw: '_react=Button[name="Save"]' }`. +Supported strategies: `css`, `xpath`, `id`, `name`, `role`, `frame`, `shadow`, `pw`. Shadow DOM and React selectors have their own pages — see [Shadow DOM](/shadow) and [React](/react). Playwright-specific locators use the `pw` strategy: `{ pw: '[data-testid="save"]' }`. ## Locator types at a glance diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index a4171e669..0ea5b3cbb 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -36,7 +36,7 @@ import MultipleElementsFound from './errors/MultipleElementsFound.js' import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js' import Popup from './extras/Popup.js' import Console from './extras/Console.js' -import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js' +import { findReact, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js' import { dropFile } from './scripts/dropFile.js' import WebElement from '../element/WebElement.js' import { selectElement } from './extras/elementSelection.js' @@ -4223,13 +4223,10 @@ async function findByRole(context, locator) { } async function findElements(matcher, locator) { - // Check if locator is a Locator object with react/vue type, or a raw object with react/vue property const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react - const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw if (isReactLocator) return findReact(matcher, locator) - if (isVueLocator) return findVue(matcher, locator) if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator) // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true}) @@ -4245,7 +4242,6 @@ async function findElements(matcher, locator) { async function findElement(matcher, locator) { if (locator.react) return findReact(matcher, locator) - if (locator.vue) return findVue(matcher, locator) if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) locator = new Locator(locator, 'css') diff --git a/lib/helper/extras/PlaywrightReactVueLocator.js b/lib/helper/extras/PlaywrightReactVueLocator.js index 7ecb1f002..ebfba1154 100644 --- a/lib/helper/extras/PlaywrightReactVueLocator.js +++ b/lib/helper/extras/PlaywrightReactVueLocator.js @@ -1,52 +1,61 @@ +import fs from 'fs' +import { fileURLToPath } from 'url' + +let resqScript + async function findReact(matcher, locator) { - // Handle both Locator objects and raw locator objects const reactLocator = locator.locator || locator - let _locator = `_react=${reactLocator.react}`; - let props = ''; + const page = typeof matcher.page === 'function' ? matcher.page() : matcher - if (reactLocator.props) { - props += propBuilder(reactLocator.props); - _locator += props; + if (!resqScript) { + resqScript = fs.readFileSync(fileURLToPath(import.meta.resolve('resq'))).toString() } - return matcher.locator(_locator).all(); -} + await page.evaluate(resqScript) + await page.evaluate(() => window.resq.waitToLoadReact()) + + const arrayHandle = await page.evaluateHandle( + ({ selector, props, state }) => { + let elements = window.resq.resq$$(selector) + if (Object.keys(props).length) elements = elements.byProps(props) + if (Object.keys(state).length) elements = elements.byState(state) + if (!elements.length) return [] -async function findVue(matcher, locator) { - // Handle both Locator objects and raw locator objects - const vueLocator = locator.locator || locator - let _locator = `_vue=${vueLocator.vue}`; - let props = ''; + let nodes = [] + elements.forEach(element => { + let { node, isFragment } = element + if (!node) { + isFragment = true + node = element.children + } + if (isFragment) nodes = nodes.concat(node) + else nodes.push(node) + }) + return [...nodes] + }, + { + selector: reactLocator.react, + props: reactLocator.props || {}, + state: reactLocator.state || {}, + }, + ) - if (vueLocator.props) { - props += propBuilder(vueLocator.props); - _locator += props; + const properties = await arrayHandle.getProperties() + await arrayHandle.dispose() + const result = [] + for (const property of properties.values()) { + const elementHandle = property.asElement() + if (elementHandle) result.push(elementHandle) } - return matcher.locator(_locator).all(); + return result } async function findByPlaywrightLocator(matcher, locator) { - // Handle both Locator objects and raw locator objects const pwLocator = locator.locator || locator if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) { - return matcher.getByTestId(pwLocator.pw.value.split('=')[1]); + return matcher.getByTestId(pwLocator.pw.value.split('=')[1]) } const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw - return matcher.locator(pwValue).all(); -} - -function propBuilder(props) { - let _props = ''; - - for (const [key, value] of Object.entries(props)) { - if (typeof value === 'object') { - for (const [k, v] of Object.entries(value)) { - _props += `[${key}.${k} = "${v}"]`; - } - } else { - _props += `[${key} = "${value}"]`; - } - } - return _props; + return matcher.locator(pwValue).all() } -export { findReact, findVue, findByPlaywrightLocator }; +export { findReact, findByPlaywrightLocator } diff --git a/test/acceptance/react_test.js b/test/acceptance/react_test.js index cc67e57dd..27a3c927c 100644 --- a/test/acceptance/react_test.js +++ b/test/acceptance/react_test.js @@ -19,13 +19,3 @@ Scenario('component name @Puppeteer @Playwright', ({ I }) => { I.seeElement({ react: 'Demo' }) }) -Scenario('using playwright locator @Playwright', ({ I }) => { - I.amOnPage('https://codecept.io/test-react-calculator/') - I.click('7') - I.click({ pw: '_react=t[name = "="]' }) - I.seeElement({ pw: '_react=t[value = "7"]' }) - I.click({ pw: '_react=t[name = "+"]' }) - I.click({ pw: '_react=t[name = "3"]' }) - I.click({ pw: '_react=t[name = "="]' }) - I.seeElement({ pw: '_react=t[value = "10"]' }) -}) diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index 3a3b85eec..ed566fb95 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -241,20 +241,6 @@ describe('Locator', () => { expect(l.toString()).to.equal('foo') }) - it('should create playwright locator - _react', () => { - const l = new Locator({ pw: '_react=button' }) - expect(l.type).to.equal('pw') - expect(l.value).to.equal('_react=button') - expect(l.toString()).to.equal('{pw: _react=button}') - }) - - it('should create playwright locator - _vue', () => { - const l = new Locator({ pw: '_vue=button' }) - expect(l.type).to.equal('pw') - expect(l.value).to.equal('_vue=button') - expect(l.toString()).to.equal('{pw: _vue=button}') - }) - it('should create playwright locator - data-testid', () => { const l = new Locator({ pw: '[data-testid="directions"]' }) expect(l.type).to.equal('pw') diff --git a/typings/index.d.ts b/typings/index.d.ts index 18f5ef065..de54e9001 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -461,7 +461,6 @@ declare namespace CodeceptJS { | { ios: string } | { android: string; ios: string } | { react: string } - | { vue: string } | { shadow: string[] } | { custom: string } | { pw: string }