From d21632f1a1603e236019a96c7577f154229a4795 Mon Sep 17 00:00:00 2001 From: Caleb Sacks <16855387+clabe45@users.noreply.github.com> Date: Sun, 23 Jul 2023 21:44:20 -0400 Subject: [PATCH] :construction_worker: Skip audio tests in GitHub Actions Since the GitHub Actions runner does not have an audio device, and creating a virtual one didn't work, only running integration tests involving audio locally seems like a good idea for now. --- .github/workflows/nodejs.yml | 21 +- .husky/pre-commit | 4 +- CONTRIBUTING.md | 10 +- karma.conf.js | 10 +- package.json | 4 +- scripts/effect/save-effect-samples.js | 2 +- .../assets/effect/brightness.png | Bin .../assets/effect/channels.png | Bin .../assets/effect/chroma-key.png | Bin .../assets/effect/contrast.png | Bin .../effect/gaussian-blur-horizontal.png | Bin .../assets/effect/gaussian-blur-vertical.png | Bin .../assets/effect/grayscale.png | Bin .../assets/effect/original.png | Bin .../assets/effect/pixelate.png | Bin .../assets/effect/shader.png | Bin .../{integration => }/assets/effect/stack.png | Bin .../assets/effect/transform/multiply.png | Bin .../assets/effect/transform/rotate.png | Bin .../effect/transform/scale-fraction.png | Bin .../assets/effect/transform/scale.png | Bin .../assets/effect/transform/translate.png | Bin spec/{integration => }/assets/layer/audio.wav | Bin spec/{integration => }/assets/layer/image.jpg | Bin spec/{integration => }/assets/layer/video.mp4 | Bin spec/integration/movie.spec.ts | 377 +---------------- .../effect/brightness.ts | 0 .../{integration => smoke}/effect/channels.ts | 0 .../effect/chroma-key.ts | 0 .../{integration => smoke}/effect/contrast.ts | 0 .../effect/gaussian-blur-horizontal.ts | 0 .../effect/gaussian-blur-vertical.ts | 0 .../effect/grayscale.ts | 0 .../{integration => smoke}/effect/pixelate.ts | 0 spec/{integration => smoke}/effect/shader.ts | 0 spec/{integration => smoke}/effect/stack.ts | 0 .../effect/transform.ts | 0 spec/{integration => smoke}/layer.spec.ts | 4 +- spec/smoke/movie.spec.ts | 397 ++++++++++++++++++ spec/{integration => smoke}/util.spec.ts | 4 +- spec/unit/layer/video.spec.ts | 2 +- 41 files changed, 444 insertions(+), 391 deletions(-) rename spec/{integration => }/assets/effect/brightness.png (100%) rename spec/{integration => }/assets/effect/channels.png (100%) rename spec/{integration => }/assets/effect/chroma-key.png (100%) rename spec/{integration => }/assets/effect/contrast.png (100%) rename spec/{integration => }/assets/effect/gaussian-blur-horizontal.png (100%) rename spec/{integration => }/assets/effect/gaussian-blur-vertical.png (100%) rename spec/{integration => }/assets/effect/grayscale.png (100%) rename spec/{integration => }/assets/effect/original.png (100%) rename spec/{integration => }/assets/effect/pixelate.png (100%) rename spec/{integration => }/assets/effect/shader.png (100%) rename spec/{integration => }/assets/effect/stack.png (100%) rename spec/{integration => }/assets/effect/transform/multiply.png (100%) rename spec/{integration => }/assets/effect/transform/rotate.png (100%) rename spec/{integration => }/assets/effect/transform/scale-fraction.png (100%) rename spec/{integration => }/assets/effect/transform/scale.png (100%) rename spec/{integration => }/assets/effect/transform/translate.png (100%) rename spec/{integration => }/assets/layer/audio.wav (100%) rename spec/{integration => }/assets/layer/image.jpg (100%) rename spec/{integration => }/assets/layer/video.mp4 (100%) rename spec/{integration => smoke}/effect/brightness.ts (100%) rename spec/{integration => smoke}/effect/channels.ts (100%) rename spec/{integration => smoke}/effect/chroma-key.ts (100%) rename spec/{integration => smoke}/effect/contrast.ts (100%) rename spec/{integration => smoke}/effect/gaussian-blur-horizontal.ts (100%) rename spec/{integration => smoke}/effect/gaussian-blur-vertical.ts (100%) rename spec/{integration => smoke}/effect/grayscale.ts (100%) rename spec/{integration => smoke}/effect/pixelate.ts (100%) rename spec/{integration => smoke}/effect/shader.ts (100%) rename spec/{integration => smoke}/effect/stack.ts (100%) rename spec/{integration => smoke}/effect/transform.ts (100%) rename spec/{integration => smoke}/layer.spec.ts (98%) create mode 100644 spec/smoke/movie.spec.ts rename spec/{integration => smoke}/util.spec.ts (94%) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 7437ec2b..873a45be 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -21,12 +21,25 @@ jobs: - name: Update npm run: | npm i -g npm@^7.x - - name: npm install, lint, build, and test + - name: Install npm dependencies run: | npm ci node node_modules/puppeteer/install.js - npm run lint - npm run build - xvfb-run --auto-servernum npm test + env: + CI: true + - name: lint code + run: npm run lint + env: + CI: true + - name: compile project + run: npm run build + env: + CI: true + - name: run unit tests + run: xvfb-run --auto-servernum npm run test:unit + env: + CI: true + - name: run smoke tests + run: xvfb-run --auto-servernum npm run test:smoke env: CI: true diff --git a/.husky/pre-commit b/.husky/pre-commit index 0920dbe2..7fd044c3 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,6 @@ . "$(dirname -- "$0")/_/husky.sh" npm run lint -npm run test +npm run test:unit +npm run test:smoke +npm run test:integration diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de3b4054..3abe2c14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,9 @@ Thank you for considering contributing to Etro! There are many ways you can cont git clone https://github.com/YOUR_USERNAME/etro.git cd etro npm install - npm test + npm run test:unit + npm run test:smoke + npm run test:integration ``` ## Making your changes @@ -31,10 +33,14 @@ Thank you for considering contributing to Etro! There are many ways you can cont ``` npm run fix npm run build - npm test + npm test:unit + npm test:smoke + npm test:integration ``` to lint and compile the code and run the tests on them. Husky will run these commands automatically when you commit. + - *Note: Unit tests validate the logic of the code in etro, with the DOM and any other external dependencies mocked. Because audio cannot be rendered in the GitHub Actions runner, the end-to-end tests are divided into two suites. All end-to-end tests that render any audio should be placed in **spec/integration/**. All end-to-end tests that do **not** require an audio device should be placed in **spec/smoke/**. The integration tests can only be run locally, but the other two suites can be run anywhere.* + - Please commit to a new branch, not master ## Submitting your changes diff --git a/karma.conf.js b/karma.conf.js index facc71af..b8640bda 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -3,6 +3,12 @@ process.env.CHROME_BIN = require('puppeteer').executablePath() +// Make sure TEST_SUITE is set +if (!process.env.TEST_SUITE) { + console.error('TEST_SUITE environment variable must be set') + process.exit(1) +} + module.exports = function (config) { config.set({ @@ -16,8 +22,8 @@ module.exports = function (config) { // list of files / patterns to load in the browser files: [ 'src/**/*.ts', - 'spec/**/*.ts', - { pattern: 'spec/integration/assets/**/*', included: false } + `spec/${process.env.TEST_SUITE}/**/*.ts`, + { pattern: 'spec/assets/**/*', included: false } ], // list of files / patterns to exclude diff --git a/package.json b/package.json index 7869306e..f0d4377d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,9 @@ "lint:test": "eslint -c eslint.test-conf.js --ext .ts spec", "lint:examples": "eslint -c eslint.example-conf.js --ext .html examples", "start": "http-server", - "test": "karma start", + "test:unit": "TEST_SUITE=unit karma start", + "test:smoke": "TEST_SUITE=smoke karma start", + "test:integration": "TEST_SUITE=integration karma start", "release": "shipjs prepare" }, "repository": { diff --git a/scripts/effect/save-effect-samples.js b/scripts/effect/save-effect-samples.js index 88c86a87..f0a3f4d5 100644 --- a/scripts/effect/save-effect-samples.js +++ b/scripts/effect/save-effect-samples.js @@ -39,7 +39,7 @@ function createDirs(filePath) { // remove prefix and save to png const buffer = Buffer.from(item.data.replace(/^data:image\/png;base64,/, ''), 'base64') console.log(`writing ${item.path} ...`) - const path = projectDir + '/spec/integration/assets/effect/' + item.path + const path = projectDir + '/spec/assets/effect/' + item.path createDirs(path) fs.writeFileSync(path, buffer) }) diff --git a/spec/integration/assets/effect/brightness.png b/spec/assets/effect/brightness.png similarity index 100% rename from spec/integration/assets/effect/brightness.png rename to spec/assets/effect/brightness.png diff --git a/spec/integration/assets/effect/channels.png b/spec/assets/effect/channels.png similarity index 100% rename from spec/integration/assets/effect/channels.png rename to spec/assets/effect/channels.png diff --git a/spec/integration/assets/effect/chroma-key.png b/spec/assets/effect/chroma-key.png similarity index 100% rename from spec/integration/assets/effect/chroma-key.png rename to spec/assets/effect/chroma-key.png diff --git a/spec/integration/assets/effect/contrast.png b/spec/assets/effect/contrast.png similarity index 100% rename from spec/integration/assets/effect/contrast.png rename to spec/assets/effect/contrast.png diff --git a/spec/integration/assets/effect/gaussian-blur-horizontal.png b/spec/assets/effect/gaussian-blur-horizontal.png similarity index 100% rename from spec/integration/assets/effect/gaussian-blur-horizontal.png rename to spec/assets/effect/gaussian-blur-horizontal.png diff --git a/spec/integration/assets/effect/gaussian-blur-vertical.png b/spec/assets/effect/gaussian-blur-vertical.png similarity index 100% rename from spec/integration/assets/effect/gaussian-blur-vertical.png rename to spec/assets/effect/gaussian-blur-vertical.png diff --git a/spec/integration/assets/effect/grayscale.png b/spec/assets/effect/grayscale.png similarity index 100% rename from spec/integration/assets/effect/grayscale.png rename to spec/assets/effect/grayscale.png diff --git a/spec/integration/assets/effect/original.png b/spec/assets/effect/original.png similarity index 100% rename from spec/integration/assets/effect/original.png rename to spec/assets/effect/original.png diff --git a/spec/integration/assets/effect/pixelate.png b/spec/assets/effect/pixelate.png similarity index 100% rename from spec/integration/assets/effect/pixelate.png rename to spec/assets/effect/pixelate.png diff --git a/spec/integration/assets/effect/shader.png b/spec/assets/effect/shader.png similarity index 100% rename from spec/integration/assets/effect/shader.png rename to spec/assets/effect/shader.png diff --git a/spec/integration/assets/effect/stack.png b/spec/assets/effect/stack.png similarity index 100% rename from spec/integration/assets/effect/stack.png rename to spec/assets/effect/stack.png diff --git a/spec/integration/assets/effect/transform/multiply.png b/spec/assets/effect/transform/multiply.png similarity index 100% rename from spec/integration/assets/effect/transform/multiply.png rename to spec/assets/effect/transform/multiply.png diff --git a/spec/integration/assets/effect/transform/rotate.png b/spec/assets/effect/transform/rotate.png similarity index 100% rename from spec/integration/assets/effect/transform/rotate.png rename to spec/assets/effect/transform/rotate.png diff --git a/spec/integration/assets/effect/transform/scale-fraction.png b/spec/assets/effect/transform/scale-fraction.png similarity index 100% rename from spec/integration/assets/effect/transform/scale-fraction.png rename to spec/assets/effect/transform/scale-fraction.png diff --git a/spec/integration/assets/effect/transform/scale.png b/spec/assets/effect/transform/scale.png similarity index 100% rename from spec/integration/assets/effect/transform/scale.png rename to spec/assets/effect/transform/scale.png diff --git a/spec/integration/assets/effect/transform/translate.png b/spec/assets/effect/transform/translate.png similarity index 100% rename from spec/integration/assets/effect/transform/translate.png rename to spec/assets/effect/transform/translate.png diff --git a/spec/integration/assets/layer/audio.wav b/spec/assets/layer/audio.wav similarity index 100% rename from spec/integration/assets/layer/audio.wav rename to spec/assets/layer/audio.wav diff --git a/spec/integration/assets/layer/image.jpg b/spec/assets/layer/image.jpg similarity index 100% rename from spec/integration/assets/layer/image.jpg rename to spec/assets/layer/image.jpg diff --git a/spec/integration/assets/layer/video.mp4 b/spec/assets/layer/video.mp4 similarity index 100% rename from spec/integration/assets/layer/video.mp4 rename to spec/assets/layer/video.mp4 diff --git a/spec/integration/movie.spec.ts b/spec/integration/movie.spec.ts index 54345b65..c1fe3d59 100644 --- a/spec/integration/movie.spec.ts +++ b/spec/integration/movie.spec.ts @@ -1,38 +1,5 @@ import etro from '../../src/index' -function validateVideoData (video: HTMLVideoElement) { - // Now the video is loaded. Create temporary canvas and render first - // frame onto it. - const ctx = document - .createElement('canvas') - .getContext('2d') - ctx.canvas.width = video.videoWidth - ctx.canvas.height = video.videoHeight - ctx.drawImage(video, 0, 0) - // Expect all opaque blue pixels - // Make array of v.videoWidth * v.videoHeight red pixels - const expectedImageData = new Uint8ClampedArray(video.videoWidth * video.videoHeight * 4) - for (let i = 0; i < expectedImageData.length; i += 4) { - expectedImageData[i] = 0 - expectedImageData[i + 1] = 0 - expectedImageData[i + 2] = 255 - expectedImageData[i + 3] = 255 - } - const actualImageData = Array.from( - ctx.getImageData(0, 0, video.videoWidth, video.videoHeight).data - ) - const maxDiff = actualImageData - // Calculate diff image data - .map((x, i) => x - expectedImageData[i]) - // Find max pixel component diff - .reduce((x, max) => Math.max(x, max)) - - // Now, there is going to be variance due to encoding problems. - // Accept an error of 5 for each color component (5 is somewhat - // arbitrary, but it works). - expect(maxDiff).toBeLessThanOrEqual(5) -} - /** * Resolves to true if the audio is completely silent. * @param audio @@ -83,157 +50,12 @@ describe('Integration Tests ->', function () { }) describe('playback ->', function () { - it('should play with an audio layer without errors', async function () { - // Remove all existing layers (optional) - movie.layers.length = 0 - - // Add an audio layer - // movie.layers.push(new etro.layer.Oscillator({ startTime: 0, duration: 1 })); - const audio = new Audio('/base/spec/integration/assets/layer/audio.wav') - await new Promise(resolve => { - audio.onloadeddata = resolve - }) - const layer = new etro.layer.Audio({ - source: audio, - startTime: 0 - }) - movie.layers.push(layer) - - // Record - await movie.play() - }) - - it('should never decrease its currentTime during one playthrough', async function () { - // Suppress console warning for deprecated event - spyOn(console, 'warn') - - let prevTime - etro.event.subscribe(movie, 'movie.timeupdate', () => { - if (prevTime !== undefined && !movie.paused && movie.currentTime > 0) { - expect(movie.currentTime).toBeGreaterThan(prevTime) - } - - prevTime = movie.currentTime - }) - - await movie.play() - }) - - it('should never decrease its currentTime while recording', async function () { - // Suppress console warning for deprecated event - spyOn(console, 'warn') - - let prevTime - etro.event.subscribe(movie, 'movie.timeupdate', () => { - if (prevTime !== undefined && !movie.ended && movie.currentTime > 0) { - expect(movie.currentTime).toBeGreaterThan(prevTime) - } - - prevTime = movie.currentTime - }) - - await movie.record({ frameRate: 10 }) - }) - - it('should return blob after recording', async function () { - const video = await movie.record({ frameRate: 60 }) - expect(video.size).toBeGreaterThan(0) - }) - - it('should return nonempty blob when recording with one audio layer', async function () { - // Remove all existing layers (optional) - movie.layers.length = 0 - - // Add an audio layer - // movie.layers.push(new etro.layer.Oscillator({ startTime: 0, duration: 1 })); - const audio = new Audio('/base/spec/integration/assets/layer/audio.wav') - await new Promise(resolve => { - audio.onloadeddata = resolve - }) - const layer = new etro.layer.Audio({ - source: audio, - startTime: 0, - playbackRate: 1 - }) - movie.layers.push(layer) - - // Record - const video = await movie.record({ frameRate: 30 }) - expect(video.size).toBeGreaterThan(0) - }) - - it('can record with custom MIME type', async function () { - const video = await movie.record({ frameRate: 60, type: 'video/webm;codecs=vp8' }) - expect(video.type).toBe('video/webm;codecs=vp8') - }) - - it('should return a stream with a video track when streaming with default options and without an audio layer', async function () { - await movie.stream({ - frameRate: 10, - onStart (stream: MediaStream) { - expect(stream.getVideoTracks().length).toBe(1) - expect(stream.getAudioTracks().length).toBe(0) - } - }) - }) - - it('should return a stream with a video track when streaming with audio: false', async function () { - await movie.stream({ - frameRate: 10, - audio: false, - onStart (stream: MediaStream) { - expect(stream.getVideoTracks().length).toBe(1) - expect(stream.getAudioTracks().length).toBe(0) - } - }) - }) - - it('should produce correct image data when streaming', async function () { - // Stream movie - await movie.stream({ - frameRate: 10, - onStart (stream: MediaStream) { - // Load stream into html video element - const video = document.createElement('video') - video.srcObject = stream - - // Wait for the current frame to load - video.onloadeddata = () => { - // Render the first frame of the video to a canvas and make sure the - // image data is correct. - validateVideoData(video) - } - } - }) - }) - - it('should produce correct image data when recording', async function () { - // Record movie - const blob = await movie.record({ frameRate: 10 }) - - // Load first frame of blob into html video element - const video = document.createElement('video') - video.src = URL.createObjectURL(blob) - await new Promise(resolve => { - video.addEventListener('loadeddata', () => { - resolve() - }) - }) - - // Render the first frame of the video to a canvas and make sure the - // image data is correct. - validateVideoData(video) - - // Clean up - URL.revokeObjectURL(video.src) - }) - it('should produce audio when recording', async function () { // Remove all existing layers (optional) movie.layers.length = 0 // Add an audio layer - const audio = new Audio('/base/spec/integration/assets/layer/audio.wav') + const audio = new Audio('/base/spec/assets/layer/audio.wav') await new Promise(resolve => { audio.onloadeddata = resolve }) @@ -274,7 +96,7 @@ describe('Integration Tests ->', function () { movie.layers.length = 0 // Add an audio layer - const audio = new Audio('/base/spec/integration/assets/layer/audio.wav') + const audio = new Audio('/base/spec/assets/layer/audio.wav') await new Promise(resolve => { audio.onloadeddata = resolve }) @@ -315,200 +137,5 @@ describe('Integration Tests ->', function () { URL.revokeObjectURL(audioElement.src) }) }) - - describe('events ->', function () { - class CustomLayer extends etro.layer.Base { - private _ready = false - - makeReady () { - this._ready = true - etro.event.publish(this, 'ready', {}) - } - - async whenReady (): Promise { - if (this._ready) { - return - } - - await new Promise(resolve => { - etro.event.subscribe(this, 'ready', () => { - resolve() - }) - }) - } - - get ready () { - return this._ready - } - } - - class CustomEffect extends etro.effect.Base { - private _ready = false - - makeReady () { - this._ready = true - etro.event.publish(this, 'ready', {}) - } - - async whenReady (): Promise { - if (this._ready) { - return - } - - await new Promise(resolve => { - etro.event.subscribe(this, 'ready', () => { - resolve() - }) - }) - } - - get ready () { - return this._ready - } - } - - it("should fire 'movie.play' once when starting to play", async function () { - // Suppress console warnings - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.play', function () { - timesFired++ - }) - await movie.play() - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.pause' when calling pause()", function (done) { - // Suppress console warnings - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.pause', function () { - timesFired++ - }) - // play, pause and check if event was fired - movie.play().then(() => { - done() - }) - movie.pause() - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.pause' when done playing", async function () { - // Suppress console warnings - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.pause', function () { - timesFired++ - }) - await movie.play() - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.play' once when streaming starts", async function () { - // Suppress console warnings - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.play', function () { - timesFired++ - }) - await movie.stream({ - frameRate: 1, - onStart (_stream: MediaStream) {} - }) - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.play' once when recording", async function () { - // Suppress console warnings - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.play', function () { - timesFired++ - }) - await movie.record({ frameRate: 1 }) - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.ended' when done playing", async function () { - // Suppress console warning for deprecated event - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.ended', function () { - timesFired++ - }) - await movie.play() - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.loadeddata'", async function () { - // Suppress console warning for deprecated event - spyOn(console, 'warn') - - /* - * 'movie.loadeddata' gets timesFired when the frame is fully loaded - */ - - let firedOnce = false - etro.event.subscribe(movie, 'movie.loadeddata', () => { - firedOnce = true - }) - await movie.refresh() - expect(firedOnce).toBe(true) - }) - - it("should fire 'movie.seek'", async function () { - // Suppress console warning for deprecated event - spyOn(console, 'warn') - - let timesFired = 0 - etro.event.subscribe(movie, 'movie.seek', () => { - timesFired++ - }) - await movie.seek(0.5) - expect(timesFired).toBe(1) - }) - - it("should fire 'movie.timeupdate'", async function () { - // Suppress console warning for deprecated event - spyOn(console, 'warn') - - let firedOnce = false - etro.event.subscribe(movie, 'movie.timeupdate', function () { - firedOnce = true - }) - await movie.play() - expect(firedOnce).toBe(true) - }) - - it('should be ready when all layers and effects are ready', function (done) { - // Remove all layers and effects - movie.layers.length = 0 - movie.effects.length = 0 - - // Add a layer that is not ready - const layer = new CustomLayer({ - startTime: 0, - duration: 1 - }) - movie.layers.push(layer) - - // Add an effect that is not ready - const effect = new CustomEffect() - movie.effects.push(effect) - - // `play` should not resolve until the movie is ready - movie.play().then(done) - - // Make the layer and effect ready - layer.makeReady() - effect.makeReady() - }) - }) }) }) diff --git a/spec/integration/effect/brightness.ts b/spec/smoke/effect/brightness.ts similarity index 100% rename from spec/integration/effect/brightness.ts rename to spec/smoke/effect/brightness.ts diff --git a/spec/integration/effect/channels.ts b/spec/smoke/effect/channels.ts similarity index 100% rename from spec/integration/effect/channels.ts rename to spec/smoke/effect/channels.ts diff --git a/spec/integration/effect/chroma-key.ts b/spec/smoke/effect/chroma-key.ts similarity index 100% rename from spec/integration/effect/chroma-key.ts rename to spec/smoke/effect/chroma-key.ts diff --git a/spec/integration/effect/contrast.ts b/spec/smoke/effect/contrast.ts similarity index 100% rename from spec/integration/effect/contrast.ts rename to spec/smoke/effect/contrast.ts diff --git a/spec/integration/effect/gaussian-blur-horizontal.ts b/spec/smoke/effect/gaussian-blur-horizontal.ts similarity index 100% rename from spec/integration/effect/gaussian-blur-horizontal.ts rename to spec/smoke/effect/gaussian-blur-horizontal.ts diff --git a/spec/integration/effect/gaussian-blur-vertical.ts b/spec/smoke/effect/gaussian-blur-vertical.ts similarity index 100% rename from spec/integration/effect/gaussian-blur-vertical.ts rename to spec/smoke/effect/gaussian-blur-vertical.ts diff --git a/spec/integration/effect/grayscale.ts b/spec/smoke/effect/grayscale.ts similarity index 100% rename from spec/integration/effect/grayscale.ts rename to spec/smoke/effect/grayscale.ts diff --git a/spec/integration/effect/pixelate.ts b/spec/smoke/effect/pixelate.ts similarity index 100% rename from spec/integration/effect/pixelate.ts rename to spec/smoke/effect/pixelate.ts diff --git a/spec/integration/effect/shader.ts b/spec/smoke/effect/shader.ts similarity index 100% rename from spec/integration/effect/shader.ts rename to spec/smoke/effect/shader.ts diff --git a/spec/integration/effect/stack.ts b/spec/smoke/effect/stack.ts similarity index 100% rename from spec/integration/effect/stack.ts rename to spec/smoke/effect/stack.ts diff --git a/spec/integration/effect/transform.ts b/spec/smoke/effect/transform.ts similarity index 100% rename from spec/integration/effect/transform.ts rename to spec/smoke/effect/transform.ts diff --git a/spec/integration/layer.spec.ts b/spec/smoke/layer.spec.ts similarity index 98% rename from spec/integration/layer.spec.ts rename to spec/smoke/layer.spec.ts index aea7515c..63c4d2ea 100644 --- a/spec/integration/layer.spec.ts +++ b/spec/smoke/layer.spec.ts @@ -124,7 +124,7 @@ describe('Integration Tests ->', function () { beforeEach(function (done) { const image = new Image() - image.src = '/base/spec/integration/assets/layer/image.jpg' + image.src = '/base/spec/assets/layer/image.jpg' image.onload = () => { const movie = new etro.Movie({ canvas: document.createElement('canvas') @@ -291,7 +291,7 @@ describe('Integration Tests ->', function () { beforeEach(function (done) { const audio = new Audio() - audio.src = '/base/spec/integration/assets/layer/audio.wav' + audio.src = '/base/spec/assets/layer/audio.wav' // audio.muted = true // until we figure out how to allow autoplay in headless chrome audio.addEventListener('loadedmetadata', () => { layer = new etro.layer.Audio(0, audio) diff --git a/spec/smoke/movie.spec.ts b/spec/smoke/movie.spec.ts new file mode 100644 index 00000000..b1504202 --- /dev/null +++ b/spec/smoke/movie.spec.ts @@ -0,0 +1,397 @@ +import etro from '../../src/index' + +function validateVideoData (video: HTMLVideoElement) { + // Now the video is loaded. Create temporary canvas and render first + // frame onto it. + const ctx = document + .createElement('canvas') + .getContext('2d') + ctx.canvas.width = video.videoWidth + ctx.canvas.height = video.videoHeight + ctx.drawImage(video, 0, 0) + // Expect all opaque blue pixels + // Make array of v.videoWidth * v.videoHeight red pixels + const expectedImageData = new Uint8ClampedArray(video.videoWidth * video.videoHeight * 4) + for (let i = 0; i < expectedImageData.length; i += 4) { + expectedImageData[i] = 0 + expectedImageData[i + 1] = 0 + expectedImageData[i + 2] = 255 + expectedImageData[i + 3] = 255 + } + const actualImageData = Array.from( + ctx.getImageData(0, 0, video.videoWidth, video.videoHeight).data + ) + const maxDiff = actualImageData + // Calculate diff image data + .map((x, i) => x - expectedImageData[i]) + // Find max pixel component diff + .reduce((x, max) => Math.max(x, max)) + + // Now, there is going to be variance due to encoding problems. + // Accept an error of 5 for each color component (5 is somewhat + // arbitrary, but it works). + expect(maxDiff).toBeLessThanOrEqual(5) +} + +describe('Integration Tests ->', function () { + describe('Movie', function () { + let movie, canvas + + beforeEach(function () { + if (canvas) { + document.body.removeChild(canvas) + } + + canvas = document.createElement('canvas') + // Resolutions lower than 20x20 result in empty blobs. + canvas.width = 20 + canvas.height = 20 + document.body.appendChild(canvas) + + movie = new etro.Movie({ canvas, background: new etro.Color(0, 0, 255) }) + movie.addLayer(new etro.layer.Visual({ startTime: 0, duration: 0.8 })) + }) + + describe('playback ->', function () { + it('should play with an audio layer without errors', async function () { + // Remove all existing layers (optional) + movie.layers.length = 0 + + // Add an audio layer + // movie.layers.push(new etro.layer.Oscillator({ startTime: 0, duration: 1 })); + const audio = new Audio('/base/spec/assets/layer/audio.wav') + await new Promise(resolve => { + audio.onloadeddata = resolve + }) + const layer = new etro.layer.Audio({ + source: audio, + startTime: 0 + }) + movie.layers.push(layer) + + // Record + await movie.play() + }) + + it('should never decrease its currentTime during one playthrough', async function () { + // Suppress console warning for deprecated event + spyOn(console, 'warn') + + let prevTime + etro.event.subscribe(movie, 'movie.timeupdate', () => { + if (prevTime !== undefined && !movie.paused && movie.currentTime > 0) { + expect(movie.currentTime).toBeGreaterThan(prevTime) + } + + prevTime = movie.currentTime + }) + + await movie.play() + }) + + it('should never decrease its currentTime while recording', async function () { + // Suppress console warning for deprecated event + spyOn(console, 'warn') + + let prevTime + etro.event.subscribe(movie, 'movie.timeupdate', () => { + if (prevTime !== undefined && !movie.ended && movie.currentTime > 0) { + expect(movie.currentTime).toBeGreaterThan(prevTime) + } + + prevTime = movie.currentTime + }) + + await movie.record({ frameRate: 10 }) + }) + + it('should return blob after recording', async function () { + const video = await movie.record({ frameRate: 60 }) + expect(video.size).toBeGreaterThan(0) + }) + + it('should return nonempty blob when recording with one audio layer', async function () { + // Remove all existing layers (optional) + movie.layers.length = 0 + + // Add an audio layer + // movie.layers.push(new etro.layer.Oscillator({ startTime: 0, duration: 1 })); + const audio = new Audio('/base/spec/assets/layer/audio.wav') + await new Promise(resolve => { + audio.onloadeddata = resolve + }) + const layer = new etro.layer.Audio({ + source: audio, + startTime: 0, + playbackRate: 1 + }) + movie.layers.push(layer) + + // Record + const video = await movie.record({ frameRate: 30 }) + expect(video.size).toBeGreaterThan(0) + }) + + it('can record with custom MIME type', async function () { + const video = await movie.record({ frameRate: 60, type: 'video/webm;codecs=vp8' }) + expect(video.type).toBe('video/webm;codecs=vp8') + }) + + it('should return a stream with a video track when streaming with default options and without an audio layer', async function () { + await movie.stream({ + frameRate: 10, + onStart (stream: MediaStream) { + expect(stream.getVideoTracks().length).toBe(1) + expect(stream.getAudioTracks().length).toBe(0) + } + }) + }) + + it('should return a stream with a video track when streaming with audio: false', async function () { + await movie.stream({ + frameRate: 10, + audio: false, + onStart (stream: MediaStream) { + expect(stream.getVideoTracks().length).toBe(1) + expect(stream.getAudioTracks().length).toBe(0) + } + }) + }) + + it('should produce correct image data when streaming', async function () { + // Stream movie + await movie.stream({ + frameRate: 10, + onStart (stream: MediaStream) { + // Load stream into html video element + const video = document.createElement('video') + video.srcObject = stream + + // Wait for the current frame to load + video.onloadeddata = () => { + // Render the first frame of the video to a canvas and make sure the + // image data is correct. + validateVideoData(video) + } + } + }) + }) + + it('should produce correct image data when recording', async function () { + // Record movie + const blob = await movie.record({ frameRate: 10 }) + + // Load first frame of blob into html video element + const video = document.createElement('video') + video.src = URL.createObjectURL(blob) + await new Promise(resolve => { + video.addEventListener('loadeddata', () => { + resolve() + }) + }) + + // Render the first frame of the video to a canvas and make sure the + // image data is correct. + validateVideoData(video) + + // Clean up + URL.revokeObjectURL(video.src) + }) + }) + + describe('events ->', function () { + class CustomLayer extends etro.layer.Base { + private _ready = false + + makeReady () { + this._ready = true + etro.event.publish(this, 'ready', {}) + } + + async whenReady (): Promise { + if (this._ready) { + return + } + + await new Promise(resolve => { + etro.event.subscribe(this, 'ready', () => { + resolve() + }) + }) + } + + get ready () { + return this._ready + } + } + + class CustomEffect extends etro.effect.Base { + private _ready = false + + makeReady () { + this._ready = true + etro.event.publish(this, 'ready', {}) + } + + async whenReady (): Promise { + if (this._ready) { + return + } + + await new Promise(resolve => { + etro.event.subscribe(this, 'ready', () => { + resolve() + }) + }) + } + + get ready () { + return this._ready + } + } + + it("should fire 'movie.play' once when starting to play", async function () { + // Suppress console warnings + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.play', function () { + timesFired++ + }) + await movie.play() + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.pause' when calling pause()", function (done) { + // Suppress console warnings + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.pause', function () { + timesFired++ + }) + // play, pause and check if event was fired + movie.play().then(() => { + done() + }) + movie.pause() + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.pause' when done playing", async function () { + // Suppress console warnings + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.pause', function () { + timesFired++ + }) + await movie.play() + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.play' once when streaming starts", async function () { + // Suppress console warnings + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.play', function () { + timesFired++ + }) + await movie.stream({ + frameRate: 1, + onStart (_stream: MediaStream) {} + }) + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.play' once when recording", async function () { + // Suppress console warnings + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.play', function () { + timesFired++ + }) + await movie.record({ frameRate: 1 }) + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.ended' when done playing", async function () { + // Suppress console warning for deprecated event + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.ended', function () { + timesFired++ + }) + await movie.play() + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.loadeddata'", async function () { + // Suppress console warning for deprecated event + spyOn(console, 'warn') + + /* + * 'movie.loadeddata' gets timesFired when the frame is fully loaded + */ + + let firedOnce = false + etro.event.subscribe(movie, 'movie.loadeddata', () => { + firedOnce = true + }) + await movie.refresh() + expect(firedOnce).toBe(true) + }) + + it("should fire 'movie.seek'", async function () { + // Suppress console warning for deprecated event + spyOn(console, 'warn') + + let timesFired = 0 + etro.event.subscribe(movie, 'movie.seek', () => { + timesFired++ + }) + await movie.seek(0.5) + expect(timesFired).toBe(1) + }) + + it("should fire 'movie.timeupdate'", async function () { + // Suppress console warning for deprecated event + spyOn(console, 'warn') + + let firedOnce = false + etro.event.subscribe(movie, 'movie.timeupdate', function () { + firedOnce = true + }) + await movie.play() + expect(firedOnce).toBe(true) + }) + + it('should be ready when all layers and effects are ready', function (done) { + // Remove all layers and effects + movie.layers.length = 0 + movie.effects.length = 0 + + // Add a layer that is not ready + const layer = new CustomLayer({ + startTime: 0, + duration: 1 + }) + movie.layers.push(layer) + + // Add an effect that is not ready + const effect = new CustomEffect() + movie.effects.push(effect) + + // `play` should not resolve until the movie is ready + movie.play().then(done) + + // Make the layer and effect ready + layer.makeReady() + effect.makeReady() + }) + }) + }) +}) diff --git a/spec/integration/util.spec.ts b/spec/smoke/util.spec.ts similarity index 94% rename from spec/integration/util.spec.ts rename to spec/smoke/util.spec.ts index 685c12e1..ad3e35f3 100644 --- a/spec/integration/util.spec.ts +++ b/spec/smoke/util.spec.ts @@ -16,7 +16,7 @@ function getImageData (path: string, targetCanvas?: HTMLCanvasElement): Promise< ctx.drawImage(img, 0, 0) resolve(ctx.getImageData(0, 0, img.width, img.height)) } - img.src = 'base/spec/integration/assets/effect/' + path + img.src = 'base/spec/assets/effect/' + path }) } @@ -36,7 +36,7 @@ export async function compareImageData (original: HTMLCanvasElement, effect: etr const misMatch = await new Promise(resolve => { resemble(result.toDataURL()) - .compareTo('base/spec/integration/assets/effect/' + path) + .compareTo('base/spec/assets/effect/' + path) .ignoreAntialiasing() .onComplete(data => { const misMatch = parseFloat(data.misMatchPercentage) diff --git a/spec/unit/layer/video.spec.ts b/spec/unit/layer/video.spec.ts index 46ba8242..f6b61fe0 100644 --- a/spec/unit/layer/video.spec.ts +++ b/spec/unit/layer/video.spec.ts @@ -10,7 +10,7 @@ describe('Unit Tests ->', function () { video = new etro.layer.Video({ startTime: 0, - source: '/base/spec/integration/assets/layer/video.mp4' + source: '/base/spec/assets/layer/video.mp4' }) })