diff --git a/.github/workflows/pr_ios.yml b/.github/workflows/pr_ios.yml index 009ef56d4..23f7d4c42 100644 --- a/.github/workflows/pr_ios.yml +++ b/.github/workflows/pr_ios.yml @@ -15,7 +15,7 @@ jobs: build: strategy: matrix: - xcode_version: [ '15.4.0' ] + xcode_version: [ '16.1.0' ] platform: [iOS, tvOS] runs-on: macos-latest name: Build for ${{ matrix.platform }} using XCode version ${{ matrix.xcode_version }} @@ -61,7 +61,7 @@ jobs: - name: Start iOS simulator uses: futureware-tech/simulator-action@v4 with: - model: ${{ matrix.platform == 'iOS' && 'iPhone 15' || 'Apple TV' }} + model: ${{ matrix.platform == 'iOS' && 'iPhone 16' || 'Apple TV' }} os: ${{ matrix.platform }} os_version: '>=14.0' @@ -71,4 +71,5 @@ jobs: - name: Summarize results working-directory: e2e + if: always() run: cat cavy_results.md >> $GITHUB_STEP_SUMMARY diff --git a/e2e/patches/cavy+4.0.2.patch b/e2e/patches/cavy+4.0.2.patch index 404d1ddc5..3652ebb7e 100644 --- a/e2e/patches/cavy+4.0.2.patch +++ b/e2e/patches/cavy+4.0.2.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/cavy/src/Reporter.js b/node_modules/cavy/src/Reporter.js -index 1cdeb38..f7e9e35 100644 +index 1cdeb38..24ae1f7 100644 --- a/node_modules/cavy/src/Reporter.js +++ b/node_modules/cavy/src/Reporter.js -@@ -9,7 +9,15 @@ export default class Reporter { +@@ -9,7 +9,31 @@ export default class Reporter { // Internal: Creates a websocket connection to the cavy-cli server. onStart() { const url = 'ws://127.0.0.1:8082/'; @@ -14,11 +14,41 @@ index 1cdeb38..f7e9e35 100644 + } + this.ws.onclose = () => { + console.debug('Closing websocket'); ++ } ++ ++ this.overrideConsole('log'); ++ this.overrideConsole('debug'); ++ this.overrideConsole('warn'); ++ } ++ ++ overrideConsole(fn) { ++ const original = console[fn]; ++ console[fn] = (...args) => { ++ const timestamp = new Date().toISOString(); ++ original(`[${timestamp}]`, ...args); ++ this.sendMessage({ ++ "message": `${fn.toUpperCase()} ${new Date().toLocaleTimeString()} ${args.join(' ')}`, ++ "level": fn ++ }); + } } // Internal: Send a single test result to cavy-cli over the websocket connection. -@@ -34,7 +42,6 @@ export default class Reporter { +@@ -20,6 +44,13 @@ export default class Reporter { + } + } + ++ sendMessage(data) { ++ if (this.websocketReady()) { ++ testData = { event: 'message', data }; ++ this.sendData(testData); ++ } ++ } ++ + // Internal: Send report to cavy-cli over the websocket connection. + onFinish(report) { + if (this.websocketReady()) { +@@ -34,7 +65,6 @@ export default class Reporter { console.log(message); } } @@ -27,13 +57,13 @@ index 1cdeb38..f7e9e35 100644 websocketReady() { // WebSocket.readyState 1 means the web socket connection is OPEN. diff --git a/node_modules/cavy/src/TestRunner.js b/node_modules/cavy/src/TestRunner.js -index 40552bf..5b98f72 100644 +index 40552bf..0af9639 100644 --- a/node_modules/cavy/src/TestRunner.js +++ b/node_modules/cavy/src/TestRunner.js @@ -148,7 +148,7 @@ export default class TestRunner { const stop = new Date(); const time = (stop - start) / 1000; - + - let fullErrorMessage = `${description} ❌\n ${e.message}`; + let fullErrorMessage = `${description} ❌\n ${JSON.stringify(e)}`; console.warn(fullErrorMessage); diff --git a/e2e/patches/cavy-cli+3.0.0.patch b/e2e/patches/cavy-cli+3.0.0.patch index d49fec4a8..af6153dde 100644 --- a/e2e/patches/cavy-cli+3.0.0.patch +++ b/e2e/patches/cavy-cli+3.0.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/cavy-cli/server.js b/node_modules/cavy-cli/server.js -index 64faf22..b453ebb 100644 +index 64faf22..633013b 100644 --- a/node_modules/cavy-cli/server.js +++ b/node_modules/cavy-cli/server.js @@ -2,6 +2,7 @@ const http = require('http'); @@ -10,7 +10,33 @@ index 64faf22..b453ebb 100644 // Initialize a server const server = http.createServer(); -@@ -80,6 +81,7 @@ function finishTesting(reportJson) { +@@ -24,6 +25,9 @@ wss.on('connection', socket => { + const json = JSON.parse(message); + + switch(json.event) { ++ case 'message': ++ logMessage(json.data); ++ break; + case 'singleResult': + logTestResult(json.data); + break; +@@ -62,6 +66,15 @@ function logTestResult(testResultJson) { + } + }; + ++function logMessage(json) { ++ const { message, level } = json; ++ switch (level) { ++ case 'log': console.log(chalk.white(message)); break; ++ case 'debug': console.log(chalk.yellow(message)); break; ++ case 'warn': console.log(chalk.bgYellow(message)); break; ++ } ++} ++ + // Internal: Accepts a json report object, console.logs the overall result of + // the test suite and quits the process with either exit code 1 or 0 depending + // on whether any tests failed. +@@ -80,6 +93,7 @@ function finishTesting(reportJson) { if (server.locals.outputAsXml) { constructXML(fullResults); } @@ -18,7 +44,7 @@ index 64faf22..b453ebb 100644 // If all tests pass, exit with code 0, else code 42. // Code 42 chosen at random so that a test failure can be distinuguished from -@@ -98,4 +100,16 @@ function finishTesting(reportJson) { +@@ -98,4 +112,16 @@ function finishTesting(reportJson) { console.log('--------------------'); }; diff --git a/e2e/src/components/TestableTHEOplayerView.tsx b/e2e/src/components/TestableTHEOplayerView.tsx index 1e3691c3a..3633e9dd0 100644 --- a/e2e/src/components/TestableTHEOplayerView.tsx +++ b/e2e/src/components/TestableTHEOplayerView.tsx @@ -1,8 +1,10 @@ import { useCavy } from 'cavy'; import { THEOplayer, THEOplayerView, THEOplayerViewProps } from 'react-native-theoplayer'; import React, { useCallback } from 'react'; +import { sleep } from '../utils/TimeUtils'; let testPlayer: THEOplayer | undefined = undefined; +let testPlayerId: number = 0; /** * Wait until the player is ready. @@ -11,18 +13,22 @@ let testPlayer: THEOplayer | undefined = undefined; * @param poll Delay before trying again. */ export const getTestPlayer = async (timeout = 5000, poll = 200): Promise => { + await sleep(1000); return new Promise((resolve, reject) => { const start = Date.now(); const checkPlayer = () => { setTimeout(() => { if (testPlayer) { // Player is ready. - resolve(testPlayer); + console.debug(`[checkPlayer] Success: player ${testPlayerId} ready.`); + sleep(1000).then(() => resolve(testPlayer!)); } else if (Date.now() - start > timeout) { // Too late. + console.debug(`[checkPlayer] Failed: timeout reached for ${testPlayerId}.`); reject('Player not ready'); } else { // Wait & try again. + console.debug(`[checkPlayer] Player ${testPlayerId} not ready yet. Retrying...`); checkPlayer(); } }, poll); @@ -34,11 +40,14 @@ export const getTestPlayer = async (timeout = 5000, poll = 200): Promise { const generateTestHook = useCavy(); const onPlayerReady = useCallback((player: THEOplayer) => { + testPlayerId++; testPlayer = player; + console.debug(`[onPlayerReady] id: ${testPlayerId}`); props.onPlayerReady?.(player); }, []); const onPlayerDestroy = useCallback(() => { + console.debug(`[onPlayerDestroy] id: ${testPlayerId}`); testPlayer = undefined; }, []); diff --git a/e2e/src/res/ads.json b/e2e/src/res/ads.json index 2fd324d32..a6c161ca6 100644 --- a/e2e/src/res/ads.json +++ b/e2e/src/res/ads.json @@ -1,6 +1,6 @@ [ { "integration": "google-ima", - "sources": "https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml" + "sources": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_ad_samples&sz=640x480&cust_params=sample_ct%3Dlinear&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=" } ] diff --git a/e2e/src/tests/Ads.spec.ts b/e2e/src/tests/Ads.spec.ts index be4eb680e..85508661c 100644 --- a/e2e/src/tests/Ads.spec.ts +++ b/e2e/src/tests/Ads.spec.ts @@ -1,5 +1,5 @@ import { TestScope } from 'cavy'; -import { AdEventType, PlayerEventType, AdEvent } from 'react-native-theoplayer'; +import { AdEvent, AdEventType, PlayerEventType } from 'react-native-theoplayer'; import { getTestPlayer } from '../components/TestableTHEOplayerView'; import { waitForPlayerEvents, waitForPlayerEventTypes } from '../utils/Actions'; import { TestSourceDescription, TestSources } from '../utils/SourceUtils'; @@ -11,13 +11,20 @@ export default function (spec: TestScope) { spec.describe(`Set ${testSource.description} and auto-play`, function () { spec.it('dispatches sourcechange, play, playing and ad events', async function () { const player = await getTestPlayer(); + const playEventsPromise = waitForPlayerEventTypes(player, [PlayerEventType.SOURCE_CHANGE, PlayerEventType.PLAY, PlayerEventType.PLAYING]); const adEventsPromise = waitForPlayerEvents(player, [ + { type: PlayerEventType.AD_EVENT, subType: AdEventType.AD_LOADED } as AdEvent, { type: PlayerEventType.AD_EVENT, subType: AdEventType.AD_BREAK_BEGIN } as AdEvent, { type: PlayerEventType.AD_EVENT, subType: AdEventType.AD_BEGIN } as AdEvent, ]); + // TEMP + player.addEventListener(Object.values(PlayerEventType), (e) => { + console.log(`[PlayerEvent] received: ${JSON.stringify(e, null, 4)}`); + }); + // Start autoplay player.autoplay = true; player.source = testSource.source; diff --git a/e2e/src/utils/Actions.ts b/e2e/src/utils/Actions.ts index be0548c0c..b13d321eb 100644 --- a/e2e/src/utils/Actions.ts +++ b/e2e/src/utils/Actions.ts @@ -1,31 +1,42 @@ // noinspection JSUnusedGlobalSymbols -import { ErrorEvent, type Event, PlayerEventType, SourceDescription, THEOplayer } from 'react-native-theoplayer'; +import { + AdEvent, + AdEventType, + ErrorEvent, + type Event, + EventMap, + PlayerEventType, + SourceDescription, + StringKeyOf, + THEOplayer, +} from 'react-native-theoplayer'; import { getTestPlayer } from '../components/TestableTHEOplayerView'; +import { logPlayerBuffer } from './PlayerUtils'; export interface TestOptions { timeout: number; } export const defaultTestOptions: TestOptions = { - timeout: 10000, + timeout: 60000, }; export async function preparePlayerWithSource(source: SourceDescription, autoplay: boolean = true): Promise { const player = await getTestPlayer(); - const eventsPromise = waitForPlayerEventType(player, PlayerEventType.SOURCE_CHANGE); - const eventsPromiseAutoPlay = waitForPlayerEventTypes(player, [PlayerEventType.SOURCE_CHANGE, PlayerEventType.PLAY, PlayerEventType.PLAYING]); + let startUpPromise: Promise[]>; + if (autoplay) { + startUpPromise = waitForPlayerEventTypes(player, [PlayerEventType.SOURCE_CHANGE, PlayerEventType.PLAY, PlayerEventType.PLAYING]); + } else { + startUpPromise = waitForPlayerEventType(player, PlayerEventType.SOURCE_CHANGE); + } // Start autoplay player.autoplay = autoplay; player.source = source; - // Wait for `sourcechange`, `play` and `playing` events. - if (autoplay) { - await eventsPromiseAutoPlay; - } else { - await eventsPromise; - } + // Wait for either `sourcechange` only or the `sourcechange`, `play` and `playing` combination. + await startUpPromise; return player; } @@ -67,73 +78,83 @@ export const waitForPlayerEvents = async >( options = defaultTestOptions, ): Promise[]> => { const receivedEvents: Event[] = []; - return withEventTimeOut( - new Promise[]>((resolve, reject) => { - const onError = (err: ErrorEvent) => { + const eventsPromise = new Promise[]>((resolve, reject) => { + const onError = (err: ErrorEvent) => { + console.error('[waitForPlayerEvents]', err); + player.removeEventListener(PlayerEventType.ERROR, onError); + reject(err); + }; + const onAdError = (e: AdEvent) => { + if (e.subType === AdEventType.AD_ERROR) { + const err = 'Ad error'; console.error('[waitForPlayerEvents]', err); - player.removeEventListener(PlayerEventType.ERROR, onError); + player.removeEventListener(PlayerEventType.AD_EVENT, onAdError); reject(err); - }; - - const TAG: string = `[waitForPlayerEvents] eventList ${eventListIndex}:`; - eventListIndex += 1; - - let unReceivedEvents = [...expectedEvents]; - const uniqueEventTypes = [...new Set(unReceivedEvents.map((event) => event.type))]; - uniqueEventTypes.forEach((eventType) => { - const onEvent = (receivedEvent: Event) => { - receivedEvents.push(receivedEvent); - if (inOrder && unReceivedEvents.length) { - const expectedEvent = unReceivedEvents[0]; - console.debug(TAG, `Handling received event ${JSON.stringify(receivedEvent)}`); - console.debug(TAG, `Was waiting for ${JSON.stringify(expectedEvent)}`); - - // Received events must either not be in the expected, or be the first - const index = unReceivedEvents.findIndex((e) => propsMatch(e, receivedEvent)); - if (index > 0) { - const err = `Expected '${expectedEvent.type}' event but received '${receivedEvent.type} event'`; - console.error(TAG, err); - reject(err); - } else { - console.debug(TAG, `Received ${receivedEvent.type} event is allowed.`); - } + } + }; + const TAG: string = `[waitForPlayerEvents] eventList ${eventListIndex}:`; + eventListIndex += 1; + + let unReceivedEvents = [...expectedEvents]; + const uniqueEventTypes = [...new Set(unReceivedEvents.map((event) => event.type))]; + uniqueEventTypes.forEach((eventType) => { + const onEvent = (receivedEvent: Event) => { + receivedEvents.push(receivedEvent); + console.debug(TAG, `Handling received event ${JSON.stringify(receivedEvent)}`); + if (inOrder && unReceivedEvents.length) { + const expectedEvent = unReceivedEvents[0]; + console.debug(TAG, `Was waiting for ${JSON.stringify(expectedEvent)}`); + + // Received events must either not be in the expected, or be the first + const index = unReceivedEvents.findIndex((e) => propsMatch(e, receivedEvent)); + if (index > 0) { + const err = `Expected '${expectedEvent.type}' event but received '${receivedEvent.type} event'`; + console.error(TAG, err); + reject(err); + } else { + console.debug(TAG, `Received ${receivedEvent.type} event is allowed.`); } + } - unReceivedEvents = unReceivedEvents.filter((event) => { - // When found, remove the listener - if (propsMatch(event, receivedEvent)) { - console.debug(TAG, ` -> removing: ${JSON.stringify(event)}`); - return false; - } - // Only keep the unreceived events - console.debug(TAG, ` -> keeping: ${JSON.stringify(event)}`); - return true; - }); - - // remove listener if no other unreceived events require it. - if (!unReceivedEvents.find((event) => event.type === receivedEvent.type)) { - console.debug(TAG, `Removing listener for ${receivedEvent.type} from player`); - player.removeEventListener(receivedEvent.type, onEvent); + unReceivedEvents = unReceivedEvents.filter((event) => { + // When found, remove the listener + if (propsMatch(event, receivedEvent)) { + console.debug(TAG, ` -> removing: ${JSON.stringify(event)}`); + return false; } + // Only keep the unreceived events + console.debug(TAG, ` -> keeping: ${JSON.stringify(event)}`); + return true; + }); + + // remove listener if no other unreceived events require it. + if (!unReceivedEvents.find((event) => event.type === receivedEvent.type)) { + console.debug(TAG, `Removing listener for ${receivedEvent.type} from player`); + player.removeEventListener(receivedEvent.type, onEvent); + } + + if (!unReceivedEvents.length) { + // Finished + console.debug(TAG, `Resolving promise on received events.`); + resolve(receivedEvents); + } + }; - if (!unReceivedEvents.length) { - // Finished - resolve(receivedEvents); - } - }; + player.addEventListener(eventType as PlayerEventType, onEvent); + console.debug(TAG, `Added listener for ${eventType} to the player`); + }); + player.addEventListener(PlayerEventType.ERROR, onError); + player.addEventListener(PlayerEventType.AD_EVENT, onAdError); + }); - player.addEventListener(eventType as PlayerEventType, onEvent); - console.debug(TAG, `Added listener for ${eventType} to the player`); - }); - player.addEventListener(PlayerEventType.ERROR, onError); - }), - options.timeout, - expectedEvents, - receivedEvents, - ); + // Add rejection on time-out + const timeOutPromise = withEventTimeOut(eventsPromise, options.timeout, expectedEvents, receivedEvents); + + // Add extra logging on error + return withPlayerStateLogOnError(player, timeOutPromise); }; -const withEventTimeOut = >( +const withEventTimeOut = >, EType extends Event>( promise: Promise, timeout: number, expectedEvents: Partial[], @@ -157,6 +178,19 @@ const withEventTimeOut = >( }); }; +const withPlayerStateLogOnError = async (player: THEOplayer, promise: Promise) => { + try { + return await promise; + } catch (e) { + throw ( + (typeof e === 'string' ? e : JSON.stringify(e)) + + ` buffer: ${logPlayerBuffer(player)};` + + ` currentTime: ${player.currentTime};` + + ` paused: ${player.paused};` + ); + } +}; + export function expect(actual: any, desc?: string) { const descPrefix = desc ? `${desc}: ` : ''; diff --git a/e2e/src/utils/PlayerUtils.ts b/e2e/src/utils/PlayerUtils.ts new file mode 100644 index 000000000..db4d43cca --- /dev/null +++ b/e2e/src/utils/PlayerUtils.ts @@ -0,0 +1,10 @@ +import { THEOplayer } from 'react-native-theoplayer'; + +export function logPlayerBuffer(player: THEOplayer): string { + let buffer = '[ '; + for (let i = 0; i < player.buffered.length; i++) { + buffer += `${player.buffered[i].start} - ${player.buffered[i].end} ${i === player.buffered.length - 1 ? '' : ', '}`; + } + buffer += ']'; + return buffer; +}