diff --git a/.github/workflows/tracker-tests.yaml b/.github/workflows/tracker-tests.yaml new file mode 100644 index 000000000..e2f07b9b8 --- /dev/null +++ b/.github/workflows/tracker-tests.yaml @@ -0,0 +1,65 @@ +# Checking unit tests for tracker and assist +name: Tracker tests +on: + workflow_dispatch: + push: + branches: [ "main" ] + paths: + - tracker/** + pull_request: + branches: [ "dev", "main" ] + paths: + - frontend/** + - tracker/** +jobs: + build-and-test: + runs-on: macos-latest + name: Build and test Tracker + strategy: + matrix: + node-version: [ 16.x ] + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Cache tracker modules + uses: actions/cache@v3 + with: + path: tracker/tracker/node_modules + key: ${{ runner.OS }}-test_tracker_build-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + test_tracker_build{{ runner.OS }}-build- + test_tracker_build{{ runner.OS }}- + - name: Cache tracker-assist modules + uses: actions/cache@v3 + with: + path: tracker/tracker-assist/node_modules + key: ${{ runner.OS }}-test_tracker_build-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + test_tracker_build{{ runner.OS }}-build- + test_tracker_build{{ runner.OS }}- + - name: Setup Testing packages + run: | + cd tracker/tracker + npm i -g yarn + yarn + - name: Setup Testing packages + run: | + cd tracker/tracker-assist + yarn + - name: Jest tests + run: | + cd tracker/tracker + yarn test: + - name: Jest tests + run: | + cd tracker/tracker-assist + yarn test:ci + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: tracker + name: tracker \ No newline at end of file diff --git a/.github/workflows/ui-tests.js.yml b/.github/workflows/ui-tests.js.yml index 56e2f95b4..94cdc183b 100644 --- a/.github/workflows/ui-tests.js.yml +++ b/.github/workflows/ui-tests.js.yml @@ -47,16 +47,6 @@ jobs: cd tracker/tracker npm i -g yarn yarn - - name: Jest tests - run: | - cd tracker/tracker - yarn test:ci - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: tracker - name: tracker - name: Build tracker inst run: | cd tracker/tracker diff --git a/tracker/tracker-assist/.gitignore b/tracker/tracker-assist/.gitignore index b5c5ddbce..2e68528fc 100644 --- a/tracker/tracker-assist/.gitignore +++ b/tracker/tracker-assist/.gitignore @@ -6,3 +6,4 @@ cjs .cache *.cache *.DS_Store +coverage \ No newline at end of file diff --git a/tracker/tracker-assist/jest.config.js b/tracker/tracker-assist/jest.config.js new file mode 100644 index 000000000..62b94c152 --- /dev/null +++ b/tracker/tracker-assist/jest.config.js @@ -0,0 +1,13 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +const config = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + collectCoverage: true, + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts',], + // .js file extension fix + moduleNameMapper: { + '(.+)\\.js': '$1', + }, +} + +export default config diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index 61b9f5432..395bae34f 100644 --- a/tracker/tracker-assist/package.json +++ b/tracker/tracker-assist/package.json @@ -23,7 +23,9 @@ "replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='3.5.14'", "prepublishOnly": "npm run build", "prepare": "cd ../../ && husky install tracker/.husky/", - "lint-front": "lint-staged" + "lint-front": "lint-staged", + "test": "jest --coverage=false", + "test:ci": "jest --coverage=true" }, "dependencies": { "csstype": "^3.0.10", @@ -44,7 +46,10 @@ "lint-staged": "^13.0.3", "prettier": "^2.7.1", "replace-in-files-cli": "^1.0.0", - "typescript": "^4.6.0-dev.20211126" + "typescript": "^4.6.0-dev.20211126", + "jest": "^29.3.1", + "jest-environment-jsdom": "^29.3.1", + "ts-jest": "^29.0.3" }, "husky": { "hooks": { diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index e0efa1701..2653d603a 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -513,6 +513,11 @@ export default class Assist { }) call.answer(lStreams[call.peer].stream) + + document.addEventListener('visibilitychange', () => { + initiateCallEnd() + }) + this.setCallingState(CallingState.True) if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() } diff --git a/tracker/tracker-assist/src/RemoteControl.ts b/tracker/tracker-assist/src/RemoteControl.ts index b8e556925..2f139c68d 100644 --- a/tracker/tracker-assist/src/RemoteControl.ts +++ b/tracker/tracker-assist/src/RemoteControl.ts @@ -18,8 +18,8 @@ if (nativeInputValueDescriptor && nativeInputValueDescriptor.set) { export default class RemoteControl { - private mouse: Mouse | null - status: RCStatus = RCStatus.Disabled + private mouse: Mouse | null = null + public status: RCStatus = RCStatus.Disabled private agentID: string | null = null constructor( @@ -114,7 +114,9 @@ export default class RemoteControl { input = (id, value: string) => { if (id !== this.agentID || !this.mouse || !this.focused) { return } if (this.focused instanceof HTMLTextAreaElement - || this.focused instanceof HTMLInputElement) { + || this.focused instanceof HTMLInputElement + || this.focused.tagName === 'INPUT' + || this.focused.tagName === 'TEXTAREA') { setInputValue.call(this.focused, value) const ev = new Event('input', { bubbles: true,}) this.focused.dispatchEvent(ev) diff --git a/tracker/tracker-assist/tests/AnnotationCanvas.test.ts b/tracker/tracker-assist/tests/AnnotationCanvas.test.ts new file mode 100644 index 000000000..ba09e2840 --- /dev/null +++ b/tracker/tracker-assist/tests/AnnotationCanvas.test.ts @@ -0,0 +1,148 @@ +import AnnotationCanvas from '../src/AnnotationCanvas' +import { describe, expect, test, it, jest, beforeEach, afterEach, } from '@jest/globals' + + +describe('AnnotationCanvas', () => { + let annotationCanvas + let documentBody + let canvasMock + let contextMock + + beforeEach(() => { + canvasMock = { + width: 0, + height: 0, + style: {}, + getContext: jest.fn(() => contextMock as unknown as HTMLCanvasElement), + parentNode: document, + } + + contextMock = { + globalAlpha: 1.0, + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + lineWidth: 8, + lineCap: 'round', + lineJoin: 'round', + strokeStyle: 'red', + stroke: jest.fn(), + globalCompositeOperation: '', + fillStyle: '', + fillRect: jest.fn(), + clearRect: jest.fn(), + } + + documentBody = document.body + // @ts-ignore + document['removeChild'] = (el) => jest.fn(el) + // @ts-ignore + document['createElement'] = () => canvasMock + + jest.spyOn(documentBody, 'appendChild').mockImplementation(jest.fn()) + jest.spyOn(documentBody, 'removeChild').mockImplementation(jest.fn()) + jest.spyOn(window, 'addEventListener').mockImplementation(jest.fn()) + jest.spyOn(window, 'removeEventListener').mockImplementation(jest.fn()) + annotationCanvas = new AnnotationCanvas() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should create a canvas element with correct styles when initialized', () => { + const createElSpy = jest.spyOn(document, 'createElement') + annotationCanvas = new AnnotationCanvas() + expect(createElSpy).toHaveBeenCalledWith('canvas') + expect(canvasMock.style.position).toBe('fixed') + expect(canvasMock.style.left).toBe(0) + expect(canvasMock.style.top).toBe(0) + expect(canvasMock.style.pointerEvents).toBe('none') + expect(canvasMock.style.zIndex).toBe(2147483647 - 2) + }) + + it('should resize the canvas when calling resizeCanvas method', () => { + annotationCanvas.resizeCanvas() + + expect(canvasMock.width).toBe(window.innerWidth) + expect(canvasMock.height).toBe(window.innerHeight) + }) + + it('should start painting and set the last position when calling start method', () => { + const position = [10, 20,] + + annotationCanvas.start(position) + + expect(annotationCanvas.painting).toBe(true) + expect(annotationCanvas.clrTmID).toBeNull() + expect(annotationCanvas.lastPosition).toEqual(position) + }) + + it('should stop painting and call fadeOut method when calling stop method', () => { + annotationCanvas.painting = true + const fadeOutSpy = jest.spyOn(annotationCanvas, 'fadeOut') + + annotationCanvas.stop() + + expect(annotationCanvas.painting).toBe(false) + expect(fadeOutSpy).toHaveBeenCalled() + }) + + it('should not stop painting or call fadeOut method when calling stop method while not painting', () => { + annotationCanvas.painting = false + const fadeOutSpy = jest.spyOn(annotationCanvas, 'fadeOut') + annotationCanvas.stop() + + expect(fadeOutSpy).not.toHaveBeenCalled() + }) + + it('should draw a line on the canvas when calling move method', () => { + annotationCanvas.painting = true + annotationCanvas.ctx = contextMock + const initialLastPosition = [0, 0,] + const position = [10, 20,] + + annotationCanvas.move(position) + + expect(contextMock.globalAlpha).toBe(1.0) + expect(contextMock.beginPath).toHaveBeenCalled() + expect(contextMock.moveTo).toHaveBeenCalledWith(initialLastPosition[0], initialLastPosition[1]) + expect(contextMock.lineTo).toHaveBeenCalledWith(position[0], position[1]) + expect(contextMock.stroke).toHaveBeenCalled() + expect(annotationCanvas.lastPosition).toEqual(position) + }) + + it('should not draw a line on the canvas when calling move method while not painting', () => { + annotationCanvas.painting = false + annotationCanvas.ctx = contextMock + const position = [10, 20,] + + annotationCanvas.move(position) + + expect(contextMock.beginPath).not.toHaveBeenCalled() + expect(contextMock.stroke).not.toHaveBeenCalled() + expect(annotationCanvas.lastPosition).toEqual([0, 0,]) + }) + + it('should fade out the canvas when calling fadeOut method', () => { + annotationCanvas.ctx = contextMock + jest.useFakeTimers() + const timerSpy = jest.spyOn(window, 'setTimeout') + annotationCanvas.fadeOut() + + expect(timerSpy).toHaveBeenCalledTimes(2) + expect(contextMock.globalCompositeOperation).toBe('source-over') + expect(contextMock.fillStyle).toBe('rgba(255, 255, 255, 0.1)') + expect(contextMock.fillRect).toHaveBeenCalledWith(0, 0, canvasMock.width, canvasMock.height) + jest.runOnlyPendingTimers() + expect(contextMock.clearRect).toHaveBeenCalledWith(0, 0, canvasMock.width, canvasMock.height) + }) + + it('should remove the canvas element when calling remove method', () => { + const spyOnRemove = jest.spyOn(document, 'removeChild') + annotationCanvas.remove() + + expect(spyOnRemove).toHaveBeenCalledWith(canvasMock) + expect(window.removeEventListener).toHaveBeenCalledWith('resize', annotationCanvas.resizeCanvas) + }) +}) \ No newline at end of file diff --git a/tracker/tracker-assist/tests/RemoteControl.test.ts b/tracker/tracker-assist/tests/RemoteControl.test.ts new file mode 100644 index 000000000..f8679576d --- /dev/null +++ b/tracker/tracker-assist/tests/RemoteControl.test.ts @@ -0,0 +1,208 @@ +import RemoteControl, { RCStatus, } from '../src/RemoteControl' +import ConfirmWindow from '../src/ConfirmWindow/ConfirmWindow' +import { describe, expect, test, jest, beforeEach, afterEach, } from '@jest/globals' + +describe('RemoteControl', () => { + let remoteControl + let options + let onGrand + let onRelease + let confirmWindowMountMock + let confirmWindowRemoveMock + + beforeEach(() => { + options = { + /* mock options */ + } + onGrand = jest.fn() + onRelease = jest.fn() + confirmWindowMountMock = jest.fn(() => Promise.resolve(true)) + confirmWindowRemoveMock = jest.fn() + + jest.spyOn(window, 'HTMLInputElement').mockImplementation((): any => ({ + value: '', + dispatchEvent: jest.fn(), + })) + + jest.spyOn(window, 'HTMLTextAreaElement').mockImplementation((): any => ({ + value: '', + dispatchEvent: jest.fn(), + })) + + jest + .spyOn(ConfirmWindow.prototype, 'mount') + .mockImplementation(confirmWindowMountMock) + jest + .spyOn(ConfirmWindow.prototype, 'remove') + .mockImplementation(confirmWindowRemoveMock) + + remoteControl = new RemoteControl(options, onGrand, onRelease) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('should initialize with disabled status', () => { + expect(remoteControl.status).toBe(RCStatus.Disabled) + expect(remoteControl.agentID).toBeNull() + expect(remoteControl.confirm).toBeNull() + expect(remoteControl.mouse).toBeNull() + }) + + test('should request control when calling requestControl method', () => { + const id = 'agent123' + remoteControl.requestControl(id) + + expect(remoteControl.agentID).toBe(id) + expect(remoteControl.status).toBe(RCStatus.Requesting) + expect(confirmWindowMountMock).toHaveBeenCalled() + }) + + test('should grant control when calling grantControl method', () => { + const id = 'agent123' + remoteControl.grantControl(id) + + expect(remoteControl.agentID).toBe(id) + expect(remoteControl.status).toBe(RCStatus.Enabled) + expect(onGrand).toHaveBeenCalledWith(id) + expect(remoteControl.mouse).toBeDefined() + }) + + test('should release control when calling releaseControl method', () => { + const isDenied = true + remoteControl['confirm'] = { remove: jest.fn(), } as unknown as ConfirmWindow + const confirmSpy = jest.spyOn(remoteControl['confirm'], 'remove') + + remoteControl.releaseControl(isDenied) + expect(remoteControl.agentID).toBeNull() + expect(remoteControl.status).toBe(RCStatus.Disabled) + expect(onRelease).toHaveBeenCalledWith(null, isDenied) + expect(confirmSpy).toHaveBeenCalled() + expect(remoteControl.mouse).toBeNull() + }) + + test('should reset mouse when calling resetMouse method', () => { + remoteControl.resetMouse() + + expect(remoteControl.mouse).toBeNull() + }) + + test('should call mouse.scroll when calling scroll method with correct agentID', () => { + const id = 'agent123' + const d = 10 + remoteControl.agentID = id + remoteControl.mouse = { + scroll: jest.fn(), + } + + remoteControl.scroll(id, d) + + expect(remoteControl.mouse.scroll).toHaveBeenCalledWith(d) + }) + + test('should not call mouse.scroll when calling scroll method with incorrect agentID', () => { + const id = 'agent123' + const d = 10 + remoteControl.agentID = 'anotherAgent' + remoteControl.mouse = { + scroll: jest.fn(), + } + + remoteControl.scroll(id, d) + + expect(remoteControl.mouse.scroll).not.toHaveBeenCalled() + }) + + test('should call mouse.move when calling move method with correct agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = id + remoteControl.mouse = { + move: jest.fn(), + } + + remoteControl.move(id, xy) + + expect(remoteControl.mouse.move).toHaveBeenCalledWith(xy) + }) + + test('should not call mouse.move when calling move method with incorrect agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = 'anotherAgent' + remoteControl.mouse = { + move: jest.fn(), + } + + remoteControl.move(id, xy) + + expect(remoteControl.mouse.move).not.toHaveBeenCalled() + }) + + test('should call mouse.click when calling click method with correct agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = id + remoteControl.mouse = { + click: jest.fn(), + } + + remoteControl.click(id, xy) + + expect(remoteControl.mouse.click).toHaveBeenCalledWith(xy) + }) + + test('should not call mouse.click when calling click method with incorrect agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = 'anotherAgent' + remoteControl.mouse = { + click: jest.fn(), + } + + remoteControl.click(id, xy) + + expect(remoteControl.mouse.click).not.toHaveBeenCalled() + }) + + test('should set the focused element when calling focus method', () => { + const id = 'agent123' + const element = document.createElement('div') + + remoteControl.focus(id, element) + + expect(remoteControl.focused).toBe(element) + }) + + test('should call setInputValue and dispatch input event when calling input method with HTMLInputElement', () => { + const id = 'agent1234' + const value = 'test_test' + const element = document.createElement('input') + const dispatchSpy = jest.spyOn(element, 'dispatchEvent') + remoteControl.agentID = id + remoteControl.mouse = true + remoteControl.focused = element + + remoteControl.input(id, value) + + expect(element.value).toBe(value) + expect(dispatchSpy).toHaveBeenCalledWith( + new Event('input', { bubbles: true, }) + ) + }) + + test('should update innerText when calling input method with content editable element', () => { + const id = 'agent123' + const value = 'test' + const element = document.createElement('div') + // @ts-ignore + element['isContentEditable'] = true + remoteControl.agentID = id + remoteControl.mouse = true + remoteControl.focused = element + + remoteControl.input(id, value) + expect(element.innerText).toBe(value) + }) +})