diff --git a/.env.template b/.env.template index 092c0bf7..8f3f56a8 100644 --- a/.env.template +++ b/.env.template @@ -6,6 +6,10 @@ LOCAL_CONTAINER_NAME="" TIMEOUT_MULTIPLIER= # 1 | 2 | 3 CI="" # "true" | "false" -# Plugin URLs +# Custom plugin URLs ACTION_BUTTON_DROPDOWN_URL="" ACTIONS_BAR_URL="" +AUDIO_SETTINGS_DROPDOWN_URL="" +CAMERA_SETTINGS_DROPDOWN_URL="" +CUSTOM_SUBSCRIPTION_HOOK_URL="" +DATA_CHANNEL_URL="" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..50b3fc56 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "[json]": { + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + }, + "files.associations": { + "package.json": "json", + }, + "editor.formatOnSave": true, +} diff --git a/package-lock.json b/package-lock.json index 4157183d..b35900c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@types/node": "^20.4.4", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/sha.js": "^2.4.4", + "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.2.0", "axios": "^1.8.4", @@ -451,6 +453,26 @@ "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "dev": true }, + "node_modules/@types/sha.js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/sha.js/-/sha.js-2.4.4.tgz", + "integrity": "sha512-Qukd+D6S2Hm0wLVt2Vh+/eWBIoUt+wF8jWjBsG4F8EFQRwKtYvtXCPcNl2OEUQ1R+eTr3xuSaBYUyM3WD1x/Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.2.0.tgz", diff --git a/package.json b/package.json index e3fedd0a..41b7876b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@types/node": "^20.4.4", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/sha.js": "^2.4.4", + "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.2.0", "axios": "^1.8.4", @@ -70,4 +72,4 @@ "@browser-bunyan/console-formatted-stream": "^1.8.0", "browser-bunyan": "^1.8.0" } -} \ No newline at end of file +} diff --git a/samples/sample-action-button-dropdown-plugin/package.json b/samples/sample-action-button-dropdown-plugin/package.json index 19fdcb85..d2e3ac9a 100644 --- a/samples/sample-action-button-dropdown-plugin/package.json +++ b/samples/sample-action-button-dropdown-plugin/package.json @@ -67,4 +67,4 @@ "webpack-cli": "^5.1.1", "webpack-dev-server": "^4.15.1" } -} \ No newline at end of file +} diff --git a/samples/sample-action-button-dropdown-plugin/src/components/sample-action-button-dropdown-plugin/component.tsx b/samples/sample-action-button-dropdown-plugin/src/components/sample-action-button-dropdown-plugin/component.tsx index ff5ffd9b..5e4cdd5a 100644 --- a/samples/sample-action-button-dropdown-plugin/src/components/sample-action-button-dropdown-plugin/component.tsx +++ b/samples/sample-action-button-dropdown-plugin/src/components/sample-action-button-dropdown-plugin/component.tsx @@ -25,7 +25,9 @@ function SampleActionButtonDropdownPlugin( React.useEffect(() => { if (currentUser?.presenter) { pluginApi.setActionButtonDropdownItems([ - new ActionButtonDropdownSeparator(), + new ActionButtonDropdownSeparator({ + dataTest: 'actionDropdownSeparatorPlugin', + }), new ActionButtonDropdownOption({ label: 'Button injected by plugin', icon: 'copy', diff --git a/samples/sample-action-button-dropdown-plugin/tests/elements.ts b/samples/sample-action-button-dropdown-plugin/tests/elements.ts index 7674423f..0d0e410e 100644 --- a/samples/sample-action-button-dropdown-plugin/tests/elements.ts +++ b/samples/sample-action-button-dropdown-plugin/tests/elements.ts @@ -2,6 +2,6 @@ import { coreElements } from '../../../tests/core/coreElements'; export const elements = { ...coreElements, - pluginSeparator: 'hr[data-test="pluginsSeparator"]', + actionButtonDropdownPluginSeparator: 'hr[data-test="actionDropdownSeparatorPlugin"]', pluginButton: 'li[data-test="actionDropdownButtonPlugin"]', }; diff --git a/samples/sample-action-button-dropdown-plugin/tests/test.spec.ts b/samples/sample-action-button-dropdown-plugin/tests/test.spec.ts index c1b04580..36d0e5f5 100644 --- a/samples/sample-action-button-dropdown-plugin/tests/test.spec.ts +++ b/samples/sample-action-button-dropdown-plugin/tests/test.spec.ts @@ -24,7 +24,7 @@ test.describe.parallel('Action button dropdown', () => { { timeout: ELEMENT_WAIT_LONGER_TIME }, ); await sampleTest.modPage.page.click(e.actions); - await sampleTest.modPage.hasElement(e.pluginSeparator, 'should display the separator element injected by the plugin'); + await sampleTest.modPage.hasElement(e.actionButtonDropdownPluginSeparator, 'should display the action button dropdown separator element injected by the plugin'); await sampleTest.modPage.hasElement(e.pluginButton, 'should display the button element injected by the plugin'); await sampleTest.modPage.hasText(e.pluginButton, 'Button injected by plugin', 'should display the correct text on the injected button'); const [consoleMessage] = await Promise.all([ diff --git a/samples/sample-actions-bar-plugin/package.json b/samples/sample-actions-bar-plugin/package.json index a39b3a0e..c9100e0d 100644 --- a/samples/sample-actions-bar-plugin/package.json +++ b/samples/sample-actions-bar-plugin/package.json @@ -53,4 +53,4 @@ "webpack-cli": "^5.1.1", "webpack-dev-server": "^4.15.1" } -} \ No newline at end of file +} diff --git a/samples/sample-actions-bar-plugin/tests/test.spec.ts b/samples/sample-actions-bar-plugin/tests/test.spec.ts index 774c60f0..9ee01e06 100644 --- a/samples/sample-actions-bar-plugin/tests/test.spec.ts +++ b/samples/sample-actions-bar-plugin/tests/test.spec.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; import { createSampleTest } from '../../../tests/core/fixtures/sampleFixture'; import { checkPluginAvailability } from '../../../tests/core/fixtures/sampleBeforeAll'; import { elements as e } from './elements'; -import { extractObject } from './utils/extractObject'; +import { extractObject } from '../../../tests/utils/extractObject'; const { test, setPluginUrl, getPluginUrl } = createSampleTest({ envVarName: 'ACTIONS_BAR_URL', diff --git a/samples/sample-audio-settings-dropdown-plugin/package.json b/samples/sample-audio-settings-dropdown-plugin/package.json index 0ffb151e..5ffc0a7a 100644 --- a/samples/sample-audio-settings-dropdown-plugin/package.json +++ b/samples/sample-audio-settings-dropdown-plugin/package.json @@ -18,7 +18,9 @@ }, "scripts": { "build-bundle": "webpack --mode production", - "start": "webpack serve --mode development" + "start": "webpack serve --mode development", + "test": "npx playwright test -c ../../playwright.config.ts", + "copy-sample-to-container": "../../tests/core/scripts/copy-sample-to-container.sh sample-audio-settings-dropdown-plugin" }, "browserslist": { "production": [ diff --git a/samples/sample-audio-settings-dropdown-plugin/src/sample-audio-settings-dropdown-plugin/component.tsx b/samples/sample-audio-settings-dropdown-plugin/src/sample-audio-settings-dropdown-plugin/component.tsx index db58b2a7..0be6b1a2 100644 --- a/samples/sample-audio-settings-dropdown-plugin/src/sample-audio-settings-dropdown-plugin/component.tsx +++ b/samples/sample-audio-settings-dropdown-plugin/src/sample-audio-settings-dropdown-plugin/component.tsx @@ -23,6 +23,7 @@ function SampleAudioSettingsDropdownPlugin( onClick: () => { pluginLogger.info('Log from audio settings dropdown plugin'); }, + dataTest: 'pluginAudioSettingsDropdownButton', }); const separatorToAudioSettingsDropdown: diff --git a/samples/sample-audio-settings-dropdown-plugin/tests/elements.ts b/samples/sample-audio-settings-dropdown-plugin/tests/elements.ts new file mode 100644 index 00000000..cb188319 --- /dev/null +++ b/samples/sample-audio-settings-dropdown-plugin/tests/elements.ts @@ -0,0 +1,6 @@ +import { coreElements } from '../../../tests/core/coreElements'; + +export const elements = { + ...coreElements, + pluginAudioSettingsDropdownButton: 'li[data-test="pluginAudioSettingsDropdownButton"]', +}; diff --git a/samples/sample-audio-settings-dropdown-plugin/tests/test.spec.ts b/samples/sample-audio-settings-dropdown-plugin/tests/test.spec.ts new file mode 100644 index 00000000..7f62c81f --- /dev/null +++ b/samples/sample-audio-settings-dropdown-plugin/tests/test.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from '@playwright/test'; +import { createSampleTest } from '../../../tests/core/fixtures/sampleFixture'; +import { checkPluginAvailability } from '../../../tests/core/fixtures/sampleBeforeAll'; +import { elements as e } from './elements'; +import { ELEMENT_WAIT_LONGER_TIME } from '../../../tests/core/constants'; + +const { test, setPluginUrl, getPluginUrl } = createSampleTest({ + envVarName: 'AUDIO_SETTINGS_DROPDOWN_URL', + getPluginUrl: () => process.env.AUDIO_SETTINGS_DROPDOWN_URL, +}); + +test.describe.parallel('Audio Settings Dropdown', () => { + test.beforeAll(checkPluginAvailability({ + pluginName: 'sample-audio-settings-dropdown-plugin', + envVarName: 'AUDIO_SETTINGS_DROPDOWN_URL', + setPluginUrl, + getPluginUrl, + })); + + test('should display the button with icon and log when clicked', async ({ sampleTest }): Promise => { + await sampleTest.modPage.page.waitForSelector( + e.whiteboard, + { timeout: ELEMENT_WAIT_LONGER_TIME }, + ); + await sampleTest.modPage.page.click(e.joinAudioButton); + await sampleTest.modPage.page.click(e.microphoneBtn); + await sampleTest.modPage.page.click(e.joinEchoTestButton); + await sampleTest.modPage.hasElement(e.establishingAudioLabel, 'should display the establishing audio label'); + await sampleTest.modPage.wasRemoved(e.establishingAudioLabel, 'should remove the establishing audio label once audio is established'); + await sampleTest.modPage.page.click(e.audioDropdownMenu); + + const audioSettingsButton = sampleTest.modPage.getLocator(e.pluginAudioSettingsDropdownButton); + await expect.soft(audioSettingsButton, 'should display the audio settings dropdown button injected by the plugin').toBeVisible(); + const iconElement = audioSettingsButton.locator('i'); + await expect.soft(iconElement, 'should contain correct icon class').toHaveClass(/icon-bbb-user/); + const [consoleMessage] = await Promise.all([ + sampleTest.modPage.waitForPluginLogger(), + sampleTest.modPage.page.click(e.pluginAudioSettingsDropdownButton), + ]); + expect( + consoleMessage.text(), + 'should display the expected log from the plugin button correctly', + ).toContain('Log from audio settings dropdown plugin'); + }); +}); diff --git a/samples/sample-camera-settings-dropdown-plugin/package.json b/samples/sample-camera-settings-dropdown-plugin/package.json index 19021e34..18688662 100644 --- a/samples/sample-camera-settings-dropdown-plugin/package.json +++ b/samples/sample-camera-settings-dropdown-plugin/package.json @@ -17,7 +17,9 @@ }, "scripts": { "build-bundle": "webpack --mode production", - "start": "webpack serve --mode development" + "start": "webpack serve --mode development", + "test": "npx playwright test -c ../../playwright.config.ts", + "copy-sample-to-container": "../../tests/core/scripts/copy-sample-to-container.sh sample-camera-settings-dropdown-plugin" }, "browserslist": { "production": [ diff --git a/samples/sample-camera-settings-dropdown-plugin/src/components/sample-camera-settings-dropdown-plugin-item/component.tsx b/samples/sample-camera-settings-dropdown-plugin/src/components/sample-camera-settings-dropdown-plugin-item/component.tsx index 4721ae95..f2ea9092 100644 --- a/samples/sample-camera-settings-dropdown-plugin/src/components/sample-camera-settings-dropdown-plugin-item/component.tsx +++ b/samples/sample-camera-settings-dropdown-plugin/src/components/sample-camera-settings-dropdown-plugin-item/component.tsx @@ -18,6 +18,7 @@ function SampleCameraSettingsDropdownPlugin( new CameraSettingsDropdownOption({ label: 'This will log on the console', icon: 'user', + dataTest: 'cameraSettingsDropdownButtonPlugin', onClick: () => { pluginLogger.info('Log from camera settings plugin'); }, diff --git a/samples/sample-camera-settings-dropdown-plugin/tests/elements.ts b/samples/sample-camera-settings-dropdown-plugin/tests/elements.ts new file mode 100644 index 00000000..5fc4d932 --- /dev/null +++ b/samples/sample-camera-settings-dropdown-plugin/tests/elements.ts @@ -0,0 +1,6 @@ +import { coreElements } from '../../../tests/core/coreElements'; + +export const elements = { + ...coreElements, + cameraSettingsDropdownButtonPlugin: 'li[data-test="cameraSettingsDropdownButtonPlugin"]', +}; diff --git a/samples/sample-camera-settings-dropdown-plugin/tests/test.spec.ts b/samples/sample-camera-settings-dropdown-plugin/tests/test.spec.ts new file mode 100644 index 00000000..57b05335 --- /dev/null +++ b/samples/sample-camera-settings-dropdown-plugin/tests/test.spec.ts @@ -0,0 +1,47 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from '@playwright/test'; +import { createSampleTest } from '../../../tests/core/fixtures/sampleFixture'; +import { checkPluginAvailability } from '../../../tests/core/fixtures/sampleBeforeAll'; +import { elements as e } from './elements'; +import { ELEMENT_WAIT_LONGER_TIME } from '../../../tests/core/constants'; + +const { test, setPluginUrl, getPluginUrl } = createSampleTest({ + envVarName: 'CAMERA_SETTINGS_DROPDOWN_URL', + getPluginUrl: () => process.env.CAMERA_SETTINGS_DROPDOWN_URL, +}); + +test.describe.parallel('Camera Settings Dropdown', () => { + test.beforeAll(checkPluginAvailability({ + pluginName: 'sample-camera-settings-dropdown-plugin', + envVarName: 'CAMERA_SETTINGS_DROPDOWN_URL', + setPluginUrl, + getPluginUrl, + })); + + test('should display the button with icon and log when clicked', async ({ sampleTest }): Promise => { + await sampleTest.modPage.page.waitForSelector( + e.whiteboard, + { timeout: ELEMENT_WAIT_LONGER_TIME }, + ); + await sampleTest.modPage.page.click(e.joinVideoButton); + await sampleTest.modPage.page.click(e.startSharingWebcam); + await sampleTest.modPage.hasElement(e.leaveVideo, 'should display the leave video button after joining video'); + await sampleTest.modPage.hasElement('video', 'should display the video element after joining video'); + await sampleTest.modPage.page.click(e.videoDropdownMenu); + + const cameraSettingsButton = sampleTest.modPage.getLocator( + e.cameraSettingsDropdownButtonPlugin, + ); + await expect.soft(cameraSettingsButton, 'should display the camera settings dropdown button injected by the plugin').toBeVisible(); + const iconElement = cameraSettingsButton.locator('i'); + await expect.soft(iconElement, 'should contain correct icon class').toHaveClass(/icon-bbb-user/); + const [consoleMessage] = await Promise.all([ + sampleTest.modPage.waitForPluginLogger(), + sampleTest.modPage.page.click(e.cameraSettingsDropdownButtonPlugin), + ]); + expect( + consoleMessage.text(), + 'should display the expected log from the plugin button correctly', + ).toContain('Log from camera settings plugin'); + }); +}); diff --git a/samples/sample-custom-subscription-hook/package.json b/samples/sample-custom-subscription-hook/package.json index 4457bfdc..691cc381 100644 --- a/samples/sample-custom-subscription-hook/package.json +++ b/samples/sample-custom-subscription-hook/package.json @@ -17,7 +17,9 @@ }, "scripts": { "build-bundle": "webpack --mode production", - "start": "webpack serve --mode development" + "start": "webpack serve --mode development", + "test": "npx playwright test -c ../../playwright.config.ts", + "copy-sample-to-container": "../../tests/core/scripts/copy-sample-to-container.sh sample-custom-subscription-hook" }, "browserslist": { "production": [ diff --git a/samples/sample-custom-subscription-hook/src/components/sample-custom-presentation-subscription-plugin-item/component.tsx b/samples/sample-custom-subscription-hook/src/components/sample-custom-presentation-subscription-plugin-item/component.tsx index e3849a31..1205d546 100644 --- a/samples/sample-custom-subscription-hook/src/components/sample-custom-presentation-subscription-plugin-item/component.tsx +++ b/samples/sample-custom-subscription-hook/src/components/sample-custom-presentation-subscription-plugin-item/component.tsx @@ -14,7 +14,7 @@ import { PresentationFromGraphqlWrapper, SampleCustomSubscriptionPluginProps } f function SampleCustomPresentationSubscriptionPlugin( { pluginUuid: uuid }: SampleCustomSubscriptionPluginProps, ): - React.ReactElement { + React.ReactElement { BbbPluginSdk.initialize(uuid); const pluginApi: PluginApi = BbbPluginSdk.getPluginApi(uuid); @@ -45,6 +45,7 @@ function SampleCustomPresentationSubscriptionPlugin( label: 'Log data for next slide', tooltip: 'It queries data from next slide and logs on the console', style: {}, + dataTest: 'logNextSlideDataButton', onClick: () => { pluginLogger.info('Logging data from sample-custom-presentation-subscription-plugin: ', JSON.stringify(dataResult)); }, diff --git a/samples/sample-custom-subscription-hook/tests/elements.ts b/samples/sample-custom-subscription-hook/tests/elements.ts new file mode 100644 index 00000000..ea3b335e --- /dev/null +++ b/samples/sample-custom-subscription-hook/tests/elements.ts @@ -0,0 +1,6 @@ +import { coreElements } from '../../../tests/core/coreElements'; + +export const elements = { + ...coreElements, + logNextSlideDataButton: 'button[data-test="logNextSlideDataButton"]', +}; diff --git a/samples/sample-custom-subscription-hook/tests/test.spec.ts b/samples/sample-custom-subscription-hook/tests/test.spec.ts new file mode 100644 index 00000000..46bb15e2 --- /dev/null +++ b/samples/sample-custom-subscription-hook/tests/test.spec.ts @@ -0,0 +1,80 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from '@playwright/test'; +import { createSampleTest } from '../../../tests/core/fixtures/sampleFixture'; +import { checkPluginAvailability } from '../../../tests/core/fixtures/sampleBeforeAll'; +import { elements as e } from './elements'; +import { ELEMENT_WAIT_LONGER_TIME } from '../../../tests/core/constants'; +import { extractObject } from '../../../tests/utils/extractObject'; + +interface NextSlideData { + pres_presentation: { + pages: { + num: number; + urlsJson: { + png: string; + svg: string; + text: string; + }; + }[]; + }[]; +} + +const { test, setPluginUrl, getPluginUrl } = createSampleTest({ + envVarName: 'CUSTOM_SUBSCRIPTION_HOOK_URL', + getPluginUrl: () => process.env.CUSTOM_SUBSCRIPTION_HOOK_URL, +}); + +test.describe.parallel('Custom Subscription Hook', () => { + test.beforeAll(checkPluginAvailability({ + pluginName: 'sample-custom-subscription-hook', + envVarName: 'CUSTOM_SUBSCRIPTION_HOOK_URL', + setPluginUrl, + getPluginUrl, + })); + + test('should display the button with icon and log when clicked', async ({ sampleTest }): Promise => { + await sampleTest.modPage.page.waitForSelector( + e.whiteboard, + { timeout: ELEMENT_WAIT_LONGER_TIME }, + ); + + await sampleTest.modPage.hasElement(e.presentationToolbarWrapper, 'should display the presentation toolbar element'); + const logNextSlideDataButton = sampleTest.modPage.getLocator(e.logNextSlideDataButton); + await expect(logNextSlideDataButton, 'should display the log next slide data button injected by the plugin').toBeVisible(); + await expect(logNextSlideDataButton, 'should have the correct text value').toHaveText('Log data for next slide'); + + const [consoleMessage] = await Promise.all([ + sampleTest.modPage.waitForPluginLogger(), + logNextSlideDataButton.click(), + ]); + + const extractedObject = extractObject(consoleMessage.text()); + expect(extractedObject, 'should be a valid JavaScript object').toBeTruthy(); + + const pages = extractedObject?.pres_presentation?.[0]?.pages[0]; + const urlsJson = pages?.urlsJson; + expect(pages?.num, 'should log the correct number of second slide').toBe(2); + expect(urlsJson?.png, 'should log the correct PNG URL for the second slide').toBeDefined(); + expect(urlsJson?.png, 'PNG URL should be a valid URL').toMatch(/^https?:\/\/.+/); + expect(urlsJson?.svg, 'should log the correct SVG URL for the second slide').toBeDefined(); + expect(urlsJson?.svg, 'SVG URL should be a valid URL').toMatch(/^https?:\/\/.+/); + expect(urlsJson?.text, 'should log the correct TEXT URL for the second slide').toBeDefined(); + expect(urlsJson?.text, 'TEXT URL should be a valid URL').toMatch(/^https?:\/\/.+/); + + const skipSlideSelect = sampleTest.modPage.page.locator(e.skipSlide); + const options = await skipSlideSelect.locator('option').all(); + expect(options.length, 'presentation should have more than 1 slide for the plugin to perform as expected').toBeGreaterThan(1); + await skipSlideSelect.selectOption({ index: options.length - 1 }); + await expect(sampleTest.modPage.page.locator(e.nextSlide), 'should disable the next slide button when on the last slide').toBeDisabled(); + + const [consoleMessage2] = await Promise.all([ + sampleTest.modPage.waitForPluginLogger(), + logNextSlideDataButton.click(), + ]); + + const extractedObject2 = extractObject(consoleMessage2.text()); + expect(extractedObject2, 'should be a valid JavaScript object').toBeTruthy(); + const pages2 = extractedObject2?.pres_presentation?.[0]?.pages; + expect(pages2, 'should not log any data of next slide when on the last slide').toEqual([]); + }); +}); diff --git a/samples/sample-data-channel-plugin/package.json b/samples/sample-data-channel-plugin/package.json index 01464f5a..3f641c64 100644 --- a/samples/sample-data-channel-plugin/package.json +++ b/samples/sample-data-channel-plugin/package.json @@ -18,7 +18,9 @@ }, "scripts": { "build-bundle": "webpack --mode production", - "start": "webpack serve --mode development" + "start": "webpack serve --mode development", + "test": "npx playwright test -c ../../playwright.config.ts", + "copy-sample-to-container": "../../tests/core/scripts/copy-sample-to-container.sh sample-data-channel-plugin" }, "browserslist": { "production": [ diff --git a/samples/sample-data-channel-plugin/src/components/sample-data-channel-plugin-item/component.tsx b/samples/sample-data-channel-plugin/src/components/sample-data-channel-plugin-item/component.tsx index 09eab9ef..371a528c 100644 --- a/samples/sample-data-channel-plugin/src/components/sample-data-channel-plugin-item/component.tsx +++ b/samples/sample-data-channel-plugin/src/components/sample-data-channel-plugin-item/component.tsx @@ -24,7 +24,7 @@ function SampleDataChannelPlugin( const { data: dataResponseNewSubChannel, pushEntry: pushToNewSubChannel, deleteEntry: deleteEntryFunctionNewSubChannel } = pluginApi.useDataChannel('public-channel', DataChannelTypes.ALL_ITEMS, 'newSubChannel'); useEffect(() => { - pluginLogger.info('Log to verify the data flow: ', dataResponseDefaultAllItems, dataResponseDefaultLastItem, dataResponseNewSubChannel); + pluginLogger.info('Log to verify the data flow: ', { dataResponseDefaultAllItems, dataResponseDefaultLastItem, dataResponseNewSubChannel }); }, [dataResponseDefaultAllItems, dataResponseNewSubChannel, dataResponseDefaultLastItem]); useEffect(() => { @@ -34,6 +34,7 @@ function SampleDataChannelPlugin( label: 'Click to increment data-channel', icon: 'user', tooltip: 'this is a button injected by plugin', + dataTest: 'incrementDataChannelButtonPlugin', allowed: true, onClick: () => { const currentValue = dataResponseDefaultAllItems.data @@ -58,6 +59,7 @@ function SampleDataChannelPlugin( icon: 'user', tooltip: 'this is a button injected by plugin', allowed: true, + dataTest: 'wipeDataOffButtonPlugin', onClick: () => { if (deleteEntryFunctionDefault) { deleteEntryFunctionDefault([RESET_DATA_CHANNEL]); diff --git a/samples/sample-data-channel-plugin/tests/elements.ts b/samples/sample-data-channel-plugin/tests/elements.ts new file mode 100644 index 00000000..4665af73 --- /dev/null +++ b/samples/sample-data-channel-plugin/tests/elements.ts @@ -0,0 +1,7 @@ +import { coreElements } from '../../../tests/core/coreElements'; + +export const elements = { + ...coreElements, + incrementDataChannelButtonPlugin: 'li[data-test="incrementDataChannelButtonPlugin"]', + wipeDataOffButtonPlugin: 'li[data-test="wipeDataOffButtonPlugin"]', +}; diff --git a/samples/sample-data-channel-plugin/tests/test.spec.ts b/samples/sample-data-channel-plugin/tests/test.spec.ts new file mode 100644 index 00000000..a2061b4f --- /dev/null +++ b/samples/sample-data-channel-plugin/tests/test.spec.ts @@ -0,0 +1,109 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, ConsoleMessage } from '@playwright/test'; +import { createSampleTest } from '../../../tests/core/fixtures/sampleFixture'; +import { checkPluginAvailability } from '../../../tests/core/fixtures/sampleBeforeAll'; +import { elements as e } from './elements'; +import { ELEMENT_WAIT_LONGER_TIME } from '../../../tests/core/constants'; +import { extractObject } from '../../../tests/utils/extractObject'; + +interface PluginDataChannelPayload { + first_example_field: number; + second_example_field: string; +} + +interface PluginDataChannelEntry { + createdAt: string; + channelName: string; + subChannelName: string; + entryId: string; + payloadJson: PluginDataChannelPayload; + createdBy: string; + pluginName: string; + toRoles: string[] | null; +} + +interface DataChannelResponseData { + data: PluginDataChannelEntry[]; + loading: boolean; +} + +interface DataChannelResponse { + dataResponseDefaultAllItems: DataChannelResponseData; + dataResponseDefaultLastItem: DataChannelResponseData; + dataResponseNewSubChannel: DataChannelResponseData; +} + +const { test, setPluginUrl, getPluginUrl } = createSampleTest({ + envVarName: 'DATA_CHANNEL_URL', + getPluginUrl: () => process.env.DATA_CHANNEL_URL, +}); + +test.describe.parallel('Data Channel', () => { + test.beforeAll(checkPluginAvailability({ + pluginName: 'sample-data-channel-plugin', + envVarName: 'DATA_CHANNEL_URL', + setPluginUrl, + getPluginUrl, + })); + + test('should increment data channel values and wipe data correctly', async ({ sampleTest }): Promise => { + await sampleTest.modPage.page.waitForSelector( + e.whiteboard, + { timeout: ELEMENT_WAIT_LONGER_TIME }, + ); + await sampleTest.modPage.page.click(e.actions); + const incrementDataChannelButton = sampleTest.modPage.getLocator( + e.incrementDataChannelButtonPlugin, + ); + const wipeDataOffButtonPlugin = sampleTest.modPage.getLocator(e.wipeDataOffButtonPlugin); + await expect(incrementDataChannelButton, 'should display the increment data channel button injected by the plugin').toBeVisible(); + await expect(wipeDataOffButtonPlugin, 'should display increment data channel button injected by plugin').toBeVisible(); + + // get the last console message as sample plugin logs a few incomplete data first + const consoleMessages: ConsoleMessage[] = []; + const consoleHandler = (msg: ConsoleMessage) => { + if (msg.text().includes('PluginLogger')) { + consoleMessages.push(msg); + } + }; + sampleTest.modPage.page.on('console', consoleHandler); + await incrementDataChannelButton.click(); + await sampleTest.modPage.page.waitForTimeout(1000); + sampleTest.modPage.page.off('console', consoleHandler); + + // check console messages on first increment click + const lastConsoleMessage = consoleMessages[consoleMessages.length - 1]; + const extractedObject = extractObject(lastConsoleMessage.text()); + expect(extractedObject?.dataResponseDefaultAllItems, 'should have dataResponseDefaultAllItems in the first increment response').toBeDefined(); + expect(extractedObject?.dataResponseNewSubChannel, 'should have dataResponseNewSubChannel in the first increment response').toBeDefined(); + expect(typeof extractedObject?.dataResponseDefaultAllItems.loading, 'should have loading property as boolean in dataResponseDefaultAllItems').toBe('boolean'); + expect(extractedObject?.dataResponseDefaultAllItems.data, 'should have data array in dataResponseDefaultAllItems').toBeDefined(); + expect(extractedObject?.dataResponseDefaultAllItems.loading, 'should have loading property defined in dataResponseDefaultAllItems').toBeDefined(); + expect(extractedObject?.dataResponseDefaultLastItem, 'should have dataResponseDefaultLastItem in the first increment response').toBeDefined(); + expect(extractedObject?.dataResponseDefaultLastItem?.data[0]?.payloadJson, 'should have correct payload data after first increment with field value 1').toMatchObject({ first_example_field: 1, second_example_field: 'string as an example' }); + + await sampleTest.modPage.page.click(e.actions); + sampleTest.modPage.page.on('console', consoleHandler); + await incrementDataChannelButton.click(); + await sampleTest.modPage.page.waitForTimeout(1000); + sampleTest.modPage.page.off('console', consoleHandler); + + // check console messages on second increment click + const secondConsoleMessage = consoleMessages[consoleMessages.length - 1]; + const secondExtractedObject = extractObject(secondConsoleMessage.text()); + expect(secondExtractedObject?.dataResponseDefaultLastItem?.data[0]?.payloadJson, 'should have correct payload data after second increment with field value 2').toMatchObject({ first_example_field: 2, second_example_field: 'string as an example' }); + + await sampleTest.modPage.page.click(e.actions); + sampleTest.modPage.page.on('console', consoleHandler); + await wipeDataOffButtonPlugin.click(); + await sampleTest.modPage.page.waitForTimeout(1000); + sampleTest.modPage.page.off('console', consoleHandler); + + // check console messages on wipe data click + const wipeConsoleMessage = consoleMessages[consoleMessages.length - 1]; + const wipeExtractedObject = extractObject(wipeConsoleMessage.text()); + expect(wipeExtractedObject?.dataResponseDefaultAllItems.data, 'should have empty data array in dataResponseDefaultAllItems after wipe').toHaveLength(0); + expect(wipeExtractedObject?.dataResponseNewSubChannel.data, 'should have empty data array in dataResponseNewSubChannel after wipe').toHaveLength(0); + expect(wipeExtractedObject?.dataResponseDefaultLastItem.data, 'should have empty data array in dataResponseDefaultLastItem after wipe').toHaveLength(0); + }); +}); diff --git a/src/extensible-areas/action-button-dropdown-item/component.ts b/src/extensible-areas/action-button-dropdown-item/component.ts index a2fc9994..a40ad00d 100644 --- a/src/extensible-areas/action-button-dropdown-item/component.ts +++ b/src/extensible-areas/action-button-dropdown-item/component.ts @@ -57,10 +57,20 @@ export class ActionButtonDropdownOption implements ActionButtonDropdownInterface export class ActionButtonDropdownSeparator implements ActionButtonDropdownInterface { id: string = ''; + dataTest?: string; + type: ActionButtonDropdownItemType; - constructor() { + /** + * Returns the separator for the action button dropdown + * + * @param dataTest - optional string attribute to be used for testing + * + * @returns the separator to be displayed in the action button dropdown + */ + constructor({ dataTest = '' }) { this.type = ActionButtonDropdownItemType.SEPARATOR; + this.dataTest = dataTest; } setItemId: (id: string) => void = (id: string) => { diff --git a/src/extensible-areas/audio-settings-dropdown-item/component.ts b/src/extensible-areas/audio-settings-dropdown-item/component.ts index 2f666f14..216daf9c 100644 --- a/src/extensible-areas/audio-settings-dropdown-item/component.ts +++ b/src/extensible-areas/audio-settings-dropdown-item/component.ts @@ -14,6 +14,8 @@ export class AudioSettingsDropdownOption implements AudioSettingsDropdownInterfa icon: string; + dataTest: string; + onClick: () => void; /** @@ -22,18 +24,20 @@ export class AudioSettingsDropdownOption implements AudioSettingsDropdownInterfa * * @param label - label to be displayed in audio settings dropdown option * @param icon - icon to be used in the option for the dropdown. It goes in the left side of it + * @param dataTest - data-test attribute to be used in the option for testing purposes * @param onClick - function to be called when clicking the button * * @returns Object that will be interpreted by the core of Bigbluebutton (HTML5) */ constructor({ - id, label = '', icon = '', onClick = () => {}, + id, label = '', icon = '', dataTest = '', onClick = () => {}, }: AudioSettingsDropdownOptionProps) { if (id) { this.id = id; } this.label = label; this.icon = icon; + this.dataTest = dataTest; this.onClick = onClick; this.type = AudioSettingsDropdownItemType.OPTION; } @@ -48,6 +52,8 @@ export class AudioSettingsDropdownSeparator implements AudioSettingsDropdownInte type: AudioSettingsDropdownItemType; + dataTest: string = 'audioSettingsDropdownSeparator'; + /** * Returns object to be used in the setter for the audio settings dropdown. In this case, * a separator. diff --git a/src/extensible-areas/audio-settings-dropdown-item/types.ts b/src/extensible-areas/audio-settings-dropdown-item/types.ts index 35deaa11..4e7b63dd 100644 --- a/src/extensible-areas/audio-settings-dropdown-item/types.ts +++ b/src/extensible-areas/audio-settings-dropdown-item/types.ts @@ -14,5 +14,6 @@ export interface AudioSettingsDropdownOptionProps { id?: string; label: string; icon: string; + dataTest?: string; onClick: () => void; } diff --git a/src/extensible-areas/camera-settings-dropdown-item/component.ts b/src/extensible-areas/camera-settings-dropdown-item/component.ts index 3b8b5e30..1ccf6600 100644 --- a/src/extensible-areas/camera-settings-dropdown-item/component.ts +++ b/src/extensible-areas/camera-settings-dropdown-item/component.ts @@ -14,6 +14,8 @@ export class CameraSettingsDropdownOption implements CameraSettingsDropdownInter icon: string; + dataTest: string = ''; + onClick: () => void; /** @@ -22,18 +24,20 @@ export class CameraSettingsDropdownOption implements CameraSettingsDropdownInter * * @param label - label to be displayed in camera settings dropdown option. * @param icon - icon to be used in the option for the dropdown. It goes in the left side of it. + * @param dataTest - data-test attribute to be used in the option for the dropdown. * @param onClick - function to be called when clicking the button. * * @returns Object that will be interpreted by the core of Bigbluebutton (HTML5). */ constructor({ - id, label = '', icon = '', onClick = () => {}, + id, label = '', icon = '', dataTest = '', onClick = () => {}, }: CameraSettingsDropdownOptionProps) { if (id) { this.id = id; } this.label = label; this.icon = icon; + this.dataTest = dataTest; this.onClick = onClick; this.type = CameraSettingsDropdownItemType.OPTION; } diff --git a/src/extensible-areas/camera-settings-dropdown-item/types.ts b/src/extensible-areas/camera-settings-dropdown-item/types.ts index f901ae8f..9d0be49d 100644 --- a/src/extensible-areas/camera-settings-dropdown-item/types.ts +++ b/src/extensible-areas/camera-settings-dropdown-item/types.ts @@ -14,5 +14,6 @@ export interface CameraSettingsDropdownOptionProps { id?: string; label: string; icon: string; + dataTest?: string; onClick: () => void; } diff --git a/src/extensible-areas/presentation-toolbar-item/component.ts b/src/extensible-areas/presentation-toolbar-item/component.ts index f855c173..750e5ffa 100644 --- a/src/extensible-areas/presentation-toolbar-item/component.ts +++ b/src/extensible-areas/presentation-toolbar-item/component.ts @@ -16,6 +16,8 @@ export class PresentationToolbarButton implements PresentationToolbarInterface { style: React.CSSProperties; + dataTest: string; + onClick: () => void; /** @@ -26,11 +28,12 @@ export class PresentationToolbarButton implements PresentationToolbarInterface { * @param tooltip - tooltip to be displayed when hovering the button * @param onClick - function to be called when clicking the button * @param style - style of the button in the presentation toolbar + * @param dataTest - data-test attribute for testing purposes * * @returns Object that will be interpreted by the core of Bigbluebutton (HTML5) */ constructor({ - id, label = '', tooltip = '', onClick = () => {}, style = {}, + id, label = '', tooltip = '', dataTest = '', onClick = () => {}, style = {}, }: PresentationToolbarButtonProps) { if (id) { this.id = id; @@ -39,6 +42,7 @@ export class PresentationToolbarButton implements PresentationToolbarInterface { this.tooltip = tooltip; this.onClick = onClick; this.style = style; + this.dataTest = dataTest; this.type = PresentationToolbarItemType.BUTTON; } diff --git a/src/extensible-areas/presentation-toolbar-item/types.ts b/src/extensible-areas/presentation-toolbar-item/types.ts index 14696558..71660bf7 100644 --- a/src/extensible-areas/presentation-toolbar-item/types.ts +++ b/src/extensible-areas/presentation-toolbar-item/types.ts @@ -10,5 +10,6 @@ export interface PresentationToolbarButtonProps { label: string; tooltip: string; style: React.CSSProperties; + dataTest?: string; onClick: () => void; } diff --git a/tests/core/coreElements.ts b/tests/core/coreElements.ts index 6d07619d..c2892f02 100644 --- a/tests/core/coreElements.ts +++ b/tests/core/coreElements.ts @@ -4,4 +4,19 @@ export const coreElements = { errorMessageLabel: 'span[id="error-message"]', whiteboard: 'div[data-testid="canvas"]', actions: 'button[data-test="actionsButton"]', + // audio + joinAudioButton: 'button[data-test="joinAudio"]', + microphoneBtn: 'button[data-test="microphoneBtn"]', + joinEchoTestButton: 'button[data-test="joinEchoTestButton"]', + audioDropdownMenu: 'button[data-test="audioDropdownMenu"]', + establishingAudioLabel: 'span[data-test="establishingAudioLabel"]', + // video + joinVideoButton: 'button[data-test="joinVideo"]', + startSharingWebcam: 'button[data-test="startSharingWebcam"]', + leaveVideo: 'button[data-test="leaveVideo"]', + videoDropdownMenu: 'button[data-test="videoDropdownMenu"]', + // presentation + presentationToolbarWrapper: 'div[id="presentationToolbarWrapper"]', + skipSlide: 'select[data-test="skipSlide"]', + nextSlide: 'button[data-test="nextSlide"]', }; diff --git a/tests/core/helpers.ts b/tests/core/helpers.ts index 618ebabc..71fc83ef 100644 --- a/tests/core/helpers.ts +++ b/tests/core/helpers.ts @@ -1,10 +1,11 @@ import { expect, Page } from '@playwright/test'; -import sha from 'sha.js'; +import sha, { Algorithm } from 'sha.js'; import xml2js from 'xml2js'; import axios from 'axios'; import { attendeePW, fullName, moderatorPW, secret, server, } from './parameters'; +import { InitParameters } from './sessionPage'; declare global { interface Window { @@ -62,8 +63,8 @@ export interface SessionSettings { emojiRain: boolean; } -function getChecksum(text) { - let algorithm = (process.env.CHECKSUM || '').toLowerCase(); +function getChecksum(text: string) { + let algorithm: Algorithm = (process.env.CHECKSUM || '').toLowerCase() as Algorithm; if (!['sha1', 'sha256', 'sha512'].includes(algorithm)) { switch (secret?.length) { case 128: @@ -80,13 +81,13 @@ function getChecksum(text) { return sha(algorithm).update(text).digest('hex'); } -function getRandomInt(min, max) { +function getRandomInt(min: number, max: number) { const adjustedMin = Math.ceil(min); const adjustedMax = Math.floor(max); return Math.floor(Math.random() * (adjustedMax - adjustedMin)) + adjustedMin; } -function createMeetingUrl(params, createParameter) { +function createMeetingUrl(params: InitParameters, createParameter: string | undefined) { const meetingID = `random-${getRandomInt(1000000, 10000000).toString()}`; const mp = params.moderatorPW; const ap = params.attendeePW; @@ -99,12 +100,15 @@ function createMeetingUrl(params, createParameter) { return url; } -function createMeetingPromise(params, createParameter) { +function createMeetingPromise(params: InitParameters, createParameter: string | undefined) { const url = createMeetingUrl(params, createParameter); return axios.get(url, { adapter: 'http' }); } -export async function createMeeting(params, createParameter) { +export async function createMeeting( + params: InitParameters, + createParameter: string | undefined, +): Promise { const promise = createMeetingPromise(params, createParameter); const response = await promise; expect(response.status).toEqual(200); diff --git a/tests/core/sample.ts b/tests/core/sample.ts index 3e8c9e88..afd0a42d 100644 --- a/tests/core/sample.ts +++ b/tests/core/sample.ts @@ -9,7 +9,7 @@ interface SampleProps { export class Sample { readonly browser: Browser; readonly context: BrowserContext; - modPage: SessionPage; + modPage!: SessionPage; constructor({ browser, context }: SampleProps) { this.browser = browser; diff --git a/tests/core/sessionPage.ts b/tests/core/sessionPage.ts index 84ba1860..555e1274 100644 --- a/tests/core/sessionPage.ts +++ b/tests/core/sessionPage.ts @@ -28,7 +28,7 @@ interface InitFunctionParameters { initOptions: InitOptions; } -interface InitParameters { +export interface InitParameters { server: string | undefined; secret: string | undefined; welcome: string; @@ -39,15 +39,22 @@ interface InitParameters { export class SessionPage { readonly page: Page; + readonly browser: Browser; + settings: SessionSettings | undefined; + initParameters: InitParameters; + username: string; + meetingId: string; constructor({ browser, page }: PageProps) { this.browser = browser; this.page = page; + this.username = ''; + this.meetingId = ''; this.initParameters = { ...parameters }; } @@ -63,7 +70,7 @@ export class SessionPage { if (!isModerator) this.initParameters.moderatorPW = ''; this.username = fullName || this.initParameters.fullName; - this.meetingId = await createMeeting(parameters, createParameter); + this.meetingId = await createMeeting(this.initParameters, createParameter); const joinUrl = getJoinURL({ meetingID: this.meetingId, isModerator, joinParameter, skipSessionDetailsModal, }); @@ -91,13 +98,18 @@ export class SessionPage { await expect(locator, description).toBeVisible({ timeout }); } + async wasRemoved(selector: string, description: string, timeout = ELEMENT_WAIT_TIME) { + const locator = this.getLocator(selector); + await expect(locator, description).toBeHidden({ timeout }); + } + async checkElement(selector: string, index = 0): Promise { // eslint-disable-next-line @typescript-eslint/no-shadow return this.page.evaluate(([selector, index]) => { if (typeof selector !== 'string') throw new Error('Selector must be a string'); const element = document.querySelectorAll(selector); if (element.length > 0) { - return element[index] !== undefined; + return element[index as number] !== undefined; } return false; }, [selector, index]); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 00000000..c75f1874 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./", + "allowSyntheticDefaultImports": true, + "declaration": true, + "esModuleInterop": true, + "types": ["node"], + "module": "es2015", + "moduleResolution": "node", + "noImplicitAny": true, + "noUnusedLocals": true, + "sourceMap": true, + "strict": true, + "target": "es5", + }, +} diff --git a/samples/sample-actions-bar-plugin/tests/utils/extractObject.ts b/tests/utils/extractObject.ts similarity index 100% rename from samples/sample-actions-bar-plugin/tests/utils/extractObject.ts rename to tests/utils/extractObject.ts