Skip to content

Commit

Permalink
feat: full test suire with github workflow to test on push
Browse files Browse the repository at this point in the history
  • Loading branch information
cgilly2fast committed Sep 3, 2024
1 parent 2e8c2d0 commit b40fb24
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 82 deletions.
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Build

on:
pull_request:
types: [ opened, reopened, synchronize ]
push:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: '18.x'
registry-url: 'https://registry.npmjs.org'

- name: Install root dependencies
run: yarn install

- name: Build
run: yarn build

- name: Run tests
run: |
yarn test
22 changes: 11 additions & 11 deletions src/components/ReactHlsPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
'use client'
import React, { useEffect, RefObject } from 'react'
import Hls, { HlsConfig } from 'hls.js'

Expand Down Expand Up @@ -27,7 +26,7 @@ const ReactHlsPlayer: React.FC<HlsPlayerProps> = ({

useEffect(() => {
let hls: Hls | null
console.log('in player')

function initPlayer() {
if (hls != null) {
hls.destroy()
Expand All @@ -38,10 +37,6 @@ const ReactHlsPlayer: React.FC<HlsPlayerProps> = ({
...hlsConfig,
})

if (internalRef.current) {
newHls.attachMedia(internalRef.current)
}

newHls.on(Hls.Events.MEDIA_ATTACHED, () => {
newHls.loadSource(src)
})
Expand All @@ -50,11 +45,12 @@ const ReactHlsPlayer: React.FC<HlsPlayerProps> = ({
if (!autoPlay) {
return
}
internalRef?.current
?.play()
.catch(() =>
console.warn('Unable to autoplay prior to user interaction with the dom.'),
)

try {
internalRef?.current?.play()
} catch (error) {
console.warn('Play is not supported in this environment', error)
}
})

newHls.on(Hls.Events.ERROR, (event, data) => {
Expand All @@ -75,6 +71,10 @@ const ReactHlsPlayer: React.FC<HlsPlayerProps> = ({
}
})

if (internalRef.current) {
newHls.attachMedia(internalRef.current)
}

hls = newHls
getHLSInstance?.(newHls)
}
Expand Down
215 changes: 155 additions & 60 deletions test/ReactHlsPlayer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,157 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import ReactHlsPlayer from '../src/components/ReactHlsPlayer'
import '@testing-library/jest-dom'
import Hls from 'hls.js'

declare global {
interface Window {
HTMLMediaElement: typeof HTMLMediaElement
}
}

class MockHTMLMediaElement {
load: jest.Mock
play: jest.Mock
pause: jest.Mock

constructor() {
this.load = jest.fn()
this.play = jest.fn().mockResolvedValue(undefined)
this.pause = jest.fn()
}
}

Object.defineProperty(window, 'HTMLMediaElement', {
writable: true,
value: MockHTMLMediaElement,
})
import React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import ReactHlsPlayer from '../src/components/ReactHlsPlayer';
import '@testing-library/jest-dom';
import Hls from 'hls.js';

describe('ReactHlsPlayer', () => {
const mockSrc = 'https://example.com/video.m3u8'

beforeEach(() => {
jest.clearAllMocks()
})

it('renders video element', () => {
render(<ReactHlsPlayer src={mockSrc} />)
const videoElement = screen.getByTestId('react-hls-player')
expect(videoElement).toBeInTheDocument()
})

it('initializes Hls when supported', () => {
let hlsInstance = null as Hls | null
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls
}

render(<ReactHlsPlayer src={mockSrc} getHLSInstance={getHLSInstance} />)
expect(hlsInstance).not.toBeNull()
expect(hlsInstance?.attachMedia).toHaveBeenCalled()
expect(hlsInstance?.loadSource).toHaveBeenCalled()
})

it('renders fallback content when HLS is not supported', () => {
;(Hls.isSupported as jest.Mock).mockReturnValueOnce(false)
render(<ReactHlsPlayer src={mockSrc} />)
const videoElement = screen.getByTestId('react-hls-player-fallback')
expect(videoElement).toHaveAttribute('src', mockSrc)
expect(videoElement).toHaveTextContent('Your browser does not support the video tag.')
})
})
let consoleWarnSpy: jest.SpyInstance;
let capturedWarnings: string[];
const mockSrc =
'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8';

beforeEach(() => {
jest.clearAllMocks();
capturedWarnings = [];
consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation((message) => {
capturedWarnings.push(message);
});
});

afterEach(() => {
consoleWarnSpy.mockRestore();
cleanup();
});

it('renders video element', () => {
render(<ReactHlsPlayer src={mockSrc} />);
const videoElement = screen.getByTestId('react-hls-player');
expect(videoElement).toBeInTheDocument();
expect(capturedWarnings).toHaveLength(0);
});

it('initializes Hls when supported', () => {
let hlsInstance = null as Hls | null;
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls;
};

render(<ReactHlsPlayer src={mockSrc} getHLSInstance={getHLSInstance} />);
expect(hlsInstance).not.toBeNull();
expect(hlsInstance?.attachMedia).toHaveBeenCalled();
expect(hlsInstance?.loadSource).toHaveBeenCalled();
expect(capturedWarnings).toHaveLength(0);
});

it('renders fallback content when HLS is not supported', async () => {
(Hls.isSupported as jest.Mock).mockReturnValueOnce(false);
render(<ReactHlsPlayer src={mockSrc} />);
const videoElement = screen.getByTestId('react-hls-player-fallback');
expect(videoElement).toHaveAttribute('src', mockSrc);
expect(videoElement).toHaveTextContent(
'Your browser does not support the video tag.'
);
expect(capturedWarnings).toHaveLength(0);
});

it('handles autoplay correctly when autoplay is passed as true', () => {
const mockPlay = jest.fn().mockImplementation(async () => {});

let hlsInstance: Hls | null = null;
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls;
};

jest.spyOn(HTMLVideoElement.prototype, 'play').mockImplementation(mockPlay);

render(
<ReactHlsPlayer
src={mockSrc}
getHLSInstance={getHLSInstance}
autoPlay={true}
/>
);

expect(hlsInstance).not.toBeNull();
expect(mockPlay).toHaveBeenCalled();
expect(capturedWarnings).toHaveLength(0);

mockPlay.mockClear();
});

it('handles autoplay correctly when autoplay is passed as false', () => {
const mockPlay = jest.fn().mockImplementation(async () => {});

let hlsInstance: Hls | null = null;
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls;
};

jest.spyOn(HTMLVideoElement.prototype, 'play').mockImplementation(mockPlay);

render(<ReactHlsPlayer src={mockSrc} getHLSInstance={getHLSInstance} />);

expect(hlsInstance).not.toBeNull();
expect(mockPlay).not.toHaveBeenCalled();
expect(capturedWarnings).toHaveLength(0);

mockPlay.mockClear();
});

it('calls recoverMediaError function on fatal MEDIA_ERROR', () => {
let hlsInstance = null as Hls | null;
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls;
};

render(<ReactHlsPlayer src={mockSrc} getHLSInstance={getHLSInstance} />);
expect(hlsInstance).not.toBeNull();
hlsInstance!.emit(Hls.Events.ERROR, Hls.Events.ERROR, {
fatal: true,
type: Hls.ErrorTypes.MEDIA_ERROR,
details: Hls.ErrorDetails.LEVEL_EMPTY_ERROR,
error: { name: 'Media Error', message: 'This is a test media error' },
});
expect(hlsInstance?.recoverMediaError).toHaveBeenCalled();
expect(capturedWarnings).toHaveLength(1);
});

it('calls start load function on fatal MEDIA_ERROR', () => {
let hlsInstance = null as Hls | null;
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls;
};

render(<ReactHlsPlayer src={mockSrc} getHLSInstance={getHLSInstance} />);
expect(hlsInstance).not.toBeNull();
hlsInstance!.emit(Hls.Events.ERROR, Hls.Events.ERROR, {
fatal: true,
type: Hls.ErrorTypes.NETWORK_ERROR,
details: Hls.ErrorDetails.BUFFER_NUDGE_ON_STALL,
error: { name: 'Network Error', message: 'This is a test network error' },
});
expect(hlsInstance?.startLoad).toHaveBeenCalled();
expect(capturedWarnings).toHaveLength(1);
});

it('calls no functions on non-fatal ERROR', () => {
let hlsInstance = null as Hls | null;
const getHLSInstance = (hls: Hls) => {
hlsInstance = hls;
};

render(<ReactHlsPlayer src={mockSrc} getHLSInstance={getHLSInstance} />);
expect(hlsInstance).not.toBeNull();
hlsInstance!.emit(Hls.Events.ERROR, Hls.Events.ERROR, {
fatal: false,
type: Hls.ErrorTypes.OTHER_ERROR,
details: Hls.ErrorDetails.UNKNOWN,
error: { name: 'Unknown Error', message: 'This is a test unknown error' },
});
expect(hlsInstance?.startLoad).not.toHaveBeenCalled();
expect(hlsInstance?.recoverMediaError).not.toHaveBeenCalled();
expect(capturedWarnings).toHaveLength(0);
});
});
66 changes: 55 additions & 11 deletions test/mocks/hls.mock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from 'eventemitter3';
import { HlsConfig } from 'hls.js';
import { HlsConfig, ManifestParsedData, HlsEventEmitter } from 'hls.js';

const Events = {
MEDIA_ATTACHED: 'hlsMediaAttached',
Expand All @@ -10,12 +10,63 @@ const Events = {
const ErrorTypes = {
NETWORK_ERROR: 'networkError',
MEDIA_ERROR: 'mediaError',
KEY_SYSTEM_ERROR: 'keySystemError',
MUX_ERROR: 'muxError',
OTHER_ERROR: 'otherError',
};

class MockHls extends EventEmitter {
const ErrorDetails = {
KEY_SYSTEM_NO_KEYS: 'keySystemNoKeys',
KEY_SYSTEM_NO_ACCESS: 'keySystemNoAccess',
KEY_SYSTEM_NO_SESSION: 'keySystemNoSession',
KEY_SYSTEM_NO_CONFIGURED_LICENSE: 'keySystemNoConfiguredLicense',
KEY_SYSTEM_LICENSE_REQUEST_FAILED: 'keySystemLicenseRequestFailed',
KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED:
'keySystemServerCertificateRequestFailed',
KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED:
'keySystemServerCertificateUpdateFailed',
KEY_SYSTEM_SESSION_UPDATE_FAILED: 'keySystemSessionUpdateFailed',
KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: 'keySystemStatusOutputRestricted',
KEY_SYSTEM_STATUS_INTERNAL_ERROR: 'keySystemStatusInternalError',
MANIFEST_LOAD_ERROR: 'manifestLoadError',
MANIFEST_LOAD_TIMEOUT: 'manifestLoadTimeOut',
MANIFEST_PARSING_ERROR: 'manifestParsingError',
MANIFEST_INCOMPATIBLE_CODECS_ERROR: 'manifestIncompatibleCodecsError',
LEVEL_EMPTY_ERROR: 'levelEmptyError',
LEVEL_LOAD_ERROR: 'levelLoadError',
LEVEL_LOAD_TIMEOUT: 'levelLoadTimeOut',
LEVEL_PARSING_ERROR: 'levelParsingError',
LEVEL_SWITCH_ERROR: 'levelSwitchError',
AUDIO_TRACK_LOAD_ERROR: 'audioTrackLoadError',
AUDIO_TRACK_LOAD_TIMEOUT: 'audioTrackLoadTimeOut',
SUBTITLE_LOAD_ERROR: 'subtitleTrackLoadError',
SUBTITLE_TRACK_LOAD_TIMEOUT: 'subtitleTrackLoadTimeOut',
FRAG_LOAD_ERROR: 'fragLoadError',
FRAG_LOAD_TIMEOUT: 'fragLoadTimeOut',
FRAG_DECRYPT_ERROR: 'fragDecryptError',
FRAG_PARSING_ERROR: 'fragParsingError',
FRAG_GAP: 'fragGap',
REMUX_ALLOC_ERROR: 'remuxAllocError',
KEY_LOAD_ERROR: 'keyLoadError',
KEY_LOAD_TIMEOUT: 'keyLoadTimeOut',
BUFFER_ADD_CODEC_ERROR: 'bufferAddCodecError',
BUFFER_INCOMPATIBLE_CODECS_ERROR: 'bufferIncompatibleCodecsError',
BUFFER_APPEND_ERROR: 'bufferAppendError',
BUFFER_APPENDING_ERROR: 'bufferAppendingError',
BUFFER_STALLED_ERROR: 'bufferStalledError',
BUFFER_FULL_ERROR: 'bufferFullError',
BUFFER_SEEK_OVER_HOLE: 'bufferSeekOverHole',
BUFFER_NUDGE_ON_STALL: 'bufferNudgeOnStall',
INTERNAL_EXCEPTION: 'internalException',
INTERNAL_ABORTED: 'aborted',
UNKNOWN: 'unknown',
};

class MockHls extends EventEmitter implements HlsEventEmitter {
static isSupported = jest.fn().mockReturnValue(true);
static Events = Events;
static ErrorTypes = ErrorTypes;
static ErrorDetails = ErrorDetails;

attachMedia: jest.Mock;
loadSource: jest.Mock;
Expand All @@ -29,18 +80,11 @@ class MockHls extends EventEmitter {
this.attachMedia = jest
.fn()
.mockImplementation((media: HTMLMediaElement) => {
console.log('attachMedia called');
setTimeout(() => {
const emitResult = this.emit(Events.MEDIA_ATTACHED, { media });
console.log(`MEDIA_ATTACHED emit result: ${emitResult}`);
}, 0);
this.emit(Events.MEDIA_ATTACHED, { media });
});

this.loadSource = jest.fn().mockImplementation((src: string) => {
console.log('loadSource called with:', src);
setTimeout(() => {
this.emit(Events.MANIFEST_PARSED);
}, 0);
this.emit(Events.MANIFEST_PARSED, {} as ManifestParsedData);
});

this.startLoad = jest.fn();
Expand Down

0 comments on commit b40fb24

Please sign in to comment.