diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js index 3e545aed2b..4bfafad1fc 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js @@ -4,7 +4,7 @@ import { memo } from 'react' import { buildUrlWithDetachRole } from '../../../shared/utils/url-helper' const redirect = function () { - window.location = buildUrlWithDetachRole(null) + window.location = buildUrlWithDetachRole(null).toString() } function PdfOrphanRefreshButton() { diff --git a/services/web/frontend/js/shared/context/detach-context.js b/services/web/frontend/js/shared/context/detach-context.js index fdb951fbf1..41a0acf2cf 100644 --- a/services/web/frontend/js/shared/context/detach-context.js +++ b/services/web/frontend/js/shared/context/detach-context.js @@ -40,7 +40,7 @@ export function DetachProvider({ children }) { if (debugPdfDetach) { console.log('Effect', { role }) } - window.history.replaceState({}, '', buildUrlWithDetachRole(role)) + window.history.replaceState({}, '', buildUrlWithDetachRole(role).toString()) }, [role]) useEffect(() => { @@ -72,11 +72,14 @@ export function DetachProvider({ children }) { data, }) } - sysend.broadcast(SYSEND_CHANNEL, { + const message = { role, event, - data, - }) + } + if (data) { + message.data = data + } + sysend.broadcast(SYSEND_CHANNEL, message) }, [role] ) diff --git a/services/web/frontend/js/shared/hooks/use-detach-layout.js b/services/web/frontend/js/shared/hooks/use-detach-layout.js index 98ce0e6308..1b96c55a42 100644 --- a/services/web/frontend/js/shared/hooks/use-detach-layout.js +++ b/services/web/frontend/js/shared/hooks/use-detach-layout.js @@ -75,7 +75,7 @@ export default function useDetachLayout() { setRole('detacher') setIsLinking(true) - window.open(buildUrlWithDetachRole('detached'), '_blank') + window.open(buildUrlWithDetachRole('detached').toString(), '_blank') }, [setRole, setIsLinking]) const handleEventForDetacherFromDetached = useCallback( diff --git a/services/web/frontend/js/shared/utils/url-helper.js b/services/web/frontend/js/shared/utils/url-helper.js index 4a654896f8..0b3770bcc9 100644 --- a/services/web/frontend/js/shared/utils/url-helper.js +++ b/services/web/frontend/js/shared/utils/url-helper.js @@ -1,11 +1,11 @@ export function buildUrlWithDetachRole(mode) { const url = new URL(window.location) - const cleanPathname = url.pathname + let cleanPathname = url.pathname .replace(/\/(detached|detacher)\/?$/, '') .replace(/\/$/, '') - url.pathname = cleanPathname if (mode) { - url.pathname += `/${mode}` + cleanPathname += `/${mode}` } + url.pathname = cleanPathname return url } diff --git a/services/web/frontend/js/utils/meta.js b/services/web/frontend/js/utils/meta.js index bf707b6af7..f6908a0f75 100644 --- a/services/web/frontend/js/utils/meta.js +++ b/services/web/frontend/js/utils/meta.js @@ -1,10 +1,12 @@ import _ from 'lodash' // cache for parsed values -const cache = new Map() +window.metaAttributesCache = window.metaAttributesCache || new Map() export default function getMeta(name, fallback) { - if (cache.has(name)) return cache.get(name) + if (window.metaAttributesCache.has(name)) { + return window.metaAttributesCache.get(name) + } const element = document.head.querySelector(`meta[name="${name}"]`) if (!element) { return fallback @@ -28,7 +30,7 @@ export default function getMeta(name, fallback) { default: value = plainTextValue } - cache.set(name, value) + window.metaAttributesCache.set(name, value) return value } diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js index 2238822073..08634b5a60 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js @@ -1,7 +1,12 @@ import sinon from 'sinon' +import fetchMock from 'fetch-mock' +import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button' import { renderWithEditorContext } from '../../../helpers/render-with-context' +import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' + +const eventTrackingSpy = sinon.spy(eventTracking) describe('', function () { let openStub @@ -12,10 +17,14 @@ describe('', function () { beforeEach(function () { openStub = sinon.stub(window, 'open') + window.metaAttributesCache = new Map() + fetchMock.post('express:/project/:projectId/compile/stop', () => 204) }) afterEach(function () { openStub.restore() + window.metaAttributesCache = new Map() + fetchMock.restore() }) it('should mark current layout option as selected', function () { @@ -35,16 +44,66 @@ describe('', function () { }) }) - it('should show processing when detaching', function () { - renderWithEditorContext(, { - ui: { ...defaultUi, view: 'editor' }, + describe('on detach', function () { + beforeEach(function () { + renderWithEditorContext(, { + ui: { ...defaultUi, view: 'editor' }, + }) + + const menuItem = screen.getByRole('menuitem', { + name: 'PDF in separate tab', + }) + fireEvent.click(menuItem) }) - const menuItem = screen.getByRole('menuitem', { - name: 'PDF in separate tab', + it('should show processing', function () { + screen.getByText('Layout processing') }) - fireEvent.click(menuItem) - screen.getByText('Layout processing') + it('should stop compile when detaching', function () { + expect(fetchMock.called('express:/project/:projectId/compile/stop')).to.be + .true + }) + + it('should record event', function () { + sinon.assert.calledWith(eventTrackingSpy.sendMB, 'project-layout-detach') + }) + }) + + describe('on layout change / reattach', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + renderWithEditorContext(, { + ui: { ...defaultUi, view: 'editor' }, + }) + + const menuItem = screen.getByRole('menuitem', { + name: 'Editor only (hide PDF)', + }) + fireEvent.click(menuItem) + }) + + it('should not show processing', function () { + const processingText = screen.queryByText('Layout processing') + expect(processingText).to.not.exist + }) + + it('should record events', function () { + sinon.assert.calledWith( + eventTrackingSpy.sendMB, + 'project-layout-reattach' + ) + sinon.assert.calledWith( + eventTrackingSpy.sendMB, + 'project-layout-change', + { layout: 'flat', view: 'editor' } + ) + }) + + it('should select new menu item', function () { + screen.getByRole('menuitem', { + name: 'Selected Editor only (hide PDF)', + }) + }) }) }) diff --git a/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js b/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js new file mode 100644 index 0000000000..6e099a6e19 --- /dev/null +++ b/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js @@ -0,0 +1,70 @@ +import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button' +import { renderWithEditorContext } from '../../../helpers/render-with-context' +import { screen, fireEvent } from '@testing-library/react' +import sysendTestHelper from '../../../helpers/sysend' +import { expect } from 'chai' + +describe('', function () { + afterEach(function () { + window.metaAttributesCache = new Map() + sysendTestHelper.resetHistory() + }) + + it('detacher mode and linked: show button ', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + renderWithEditorContext() + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'connected', + }) + + await screen.getByRole('button', { + name: 'Recompile', + }) + }) + + it('detacher mode and not linked: does not show button ', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + renderWithEditorContext() + + expect( + await screen.queryByRole('button', { + name: 'Recompile', + }) + ).to.not.exist + }) + + it('not detacher mode and linked: does not show button ', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderWithEditorContext() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'connected', + }) + + expect( + await screen.queryByRole('button', { + name: 'Recompile', + }) + ).to.not.exist + }) + + it('send compile clicks via detached action', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + renderWithEditorContext() + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'connected', + }) + + const compileButton = await screen.getByRole('button', { + name: 'Recompile', + }) + fireEvent.click(compileButton) + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: 'action-start-compile', + data: { args: [] }, + }) + }) +}) diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js new file mode 100644 index 0000000000..28eab021ad --- /dev/null +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js @@ -0,0 +1,26 @@ +import PdfPreviewHybridToolbar from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar' +import { renderWithEditorContext } from '../../../helpers/render-with-context' +import { screen } from '@testing-library/react' + +describe('', function () { + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('shows normal mode', async function () { + renderWithEditorContext() + + await screen.getByRole('button', { + name: 'Recompile', + }) + }) + + it('shows orphan mode', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderWithEditorContext() + + await screen.getByRole('button', { + name: 'Redirect to editor', + }) + }) +}) diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js index 0975ae58e2..4dc7f2ba72 100644 --- a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js @@ -1,8 +1,9 @@ import PdfSynctexControls from '../../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls' -import { EditorProviders } from '../../../helpers/render-with-context' +import { renderWithEditorContext } from '../../../helpers/render-with-context' +import sysendTestHelper from '../../../helpers/sysend' import { cloneDeep } from 'lodash' import fetchMock from 'fetch-mock' -import { fireEvent, screen, waitFor, render } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import fs from 'fs' import path from 'path' import { expect } from 'chai' @@ -95,19 +96,20 @@ const mockSynctex = () => describe('', function () { beforeEach(function () { window.showNewPdfPreview = true + window.metaAttributesCache = new Map() fetchMock.restore() + mockCompile() + mockSynctex() + mockBuildFile() }) afterEach(function () { window.showNewPdfPreview = undefined + window.metaAttributesCache = new Map() fetchMock.restore() }) it('handles clicks on sync buttons', async function () { - mockCompile() - mockSynctex() - mockBuildFile() - const Inner = () => { const { setPosition } = useCompileContext() @@ -123,11 +125,12 @@ describe('', function () { return null } - render( - + renderWithEditorContext( + <> - + , + { scope } ) const syncToPdfButton = await screen.findByRole('button', { @@ -164,4 +167,156 @@ describe('', function () { .true }) }) + + describe('with detacher role', async function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + }) + + it('does not have go to PDF location button', async function () { + renderWithEditorContext(, { scope }) + + expect( + await screen.queryByRole('button', { + name: 'Go to PDF location in code', + }) + ).to.not.exist + }) + + it('send go to PDF location action', async function () { + renderWithEditorContext(, { scope }) + sysendTestHelper.resetHistory() + + const syncToPdfButton = await screen.findByRole('button', { + name: 'Go to code location in PDF', + }) + + // mock editor cursor position update + fireEvent( + window, + new CustomEvent('cursor:editor:update', { + detail: { row: 100, column: 10 }, + }) + ) + + fireEvent.click(syncToPdfButton) + + // the button is only disabled when the state is updated via sysend + expect(syncToPdfButton.disabled).to.be.false + + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: 'action-go-to-pdf-location', + data: { args: ['file=&line=101&column=10'] }, + }) + }) + + it('update inflight state', async function () { + renderWithEditorContext(, { scope }) + sysendTestHelper.resetHistory() + + const syncToPdfButton = await screen.findByRole('button', { + name: 'Go to code location in PDF', + }) + + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'state-sync-to-pdf-inflight', + data: { value: true }, + }) + expect(syncToPdfButton.disabled).to.be.true + + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'state-sync-to-pdf-inflight', + data: { value: false }, + }) + expect(syncToPdfButton.disabled).to.be.false + }) + }) + + describe('with detached role', async function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + }) + + it('does not have go to code location button', async function () { + renderWithEditorContext(, { scope }) + + expect( + await screen.queryByRole('button', { + name: 'Go to code location in PDF', + }) + ).to.not.exist + }) + + it('send go to code line action and update inflight state', async function () { + renderWithEditorContext(, { scope }) + sysendTestHelper.resetHistory() + + const syncToCodeButton = await screen.findByRole('button', { + name: 'Go to PDF location in code', + }) + + sysendTestHelper.resetHistory() + + fireEvent.click(syncToCodeButton) + + expect(syncToCodeButton.disabled).to.be.true + + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be + .true + }) + + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detached', + event: 'action-go-to-code-line', + data: { args: ['main.tex', 100] }, + }) + }) + + it('sends PDF exists state', async function () { + renderWithEditorContext(, { scope }) + sysendTestHelper.resetHistory() + + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/compile')).to.be + .true + }) + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detached', + event: 'state-pdf-exists', + data: { value: true }, + }) + }) + + it('reacts to go to PDF location action', async function () { + renderWithEditorContext(, { scope }) + sysendTestHelper.resetHistory() + + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/compile')).to.be + .true + }) + sysendTestHelper.spy.broadcast.resetHistory() + + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'action-go-to-pdf-location', + data: { args: ['file=&line=101&column=10'] }, + }) + + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be + .true + }) + + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detached', + event: 'state-sync-to-pdf-inflight', + data: { value: false }, + }) + }) + }) }) diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js index 9684d288b3..baab046538 100644 --- a/services/web/test/frontend/helpers/render-with-context.js +++ b/services/web/test/frontend/helpers/render-with-context.js @@ -2,6 +2,7 @@ /* eslint-disable react/prop-types */ import { render } from '@testing-library/react' +import { renderHook } from '@testing-library/react-hooks' import sinon from 'sinon' import { UserProvider } from '../../../frontend/js/shared/context/user-context' import { EditorProvider } from '../../../frontend/js/shared/context/editor-context' @@ -122,6 +123,14 @@ export function renderWithEditorContext(component, contextProps) { return render(component, { wrapper: EditorProvidersWrapper }) } +export function renderHookWithEditorContext(hook, contextProps) { + const EditorProvidersWrapper = ({ children }) => ( + {children} + ) + + return renderHook(hook, { wrapper: EditorProvidersWrapper }) +} + export function ChatProviders({ children, ...props }) { return ( diff --git a/services/web/test/frontend/helpers/sysend.js b/services/web/test/frontend/helpers/sysend.js new file mode 100644 index 0000000000..e0fe0fed06 --- /dev/null +++ b/services/web/test/frontend/helpers/sysend.js @@ -0,0 +1,41 @@ +import sysend from 'sysend' +import sinon from 'sinon' + +const sysendSpy = sinon.spy(sysend) + +function resetHistory() { + for (const method of Object.keys(sysendSpy)) { + if (sysendSpy[method].resetHistory) sysendSpy[method].resetHistory() + } +} + +// sysends sends and receives custom calls in the background. This Helps +// filtering them out +function getDetachCalls(method) { + return sysend[method] + .getCalls() + .filter(call => call.args[0].startsWith('detach-')) +} + +function getLastDetachCall(method) { + return getDetachCalls(method).pop() +} + +function getLastBroacastMessage() { + return getLastDetachCall('broadcast').args[1] +} + +// this fakes receiving a message by calling the handler add to `on`. A bit +// funky, but works for now +function receiveMessage(message) { + getLastDetachCall('on').args[1](message) +} + +export default { + spy: sysendSpy, + resetHistory, + getDetachCalls, + getLastDetachCall, + getLastBroacastMessage, + receiveMessage, +} diff --git a/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js b/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js new file mode 100644 index 0000000000..f1ba354cf3 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-callback-handlers.test.js @@ -0,0 +1,34 @@ +import sinon from 'sinon' +import { renderHook } from '@testing-library/react-hooks' +import useCallbackHandlers from '../../../../frontend/js/shared/hooks/use-callback-handlers' + +describe('useCallbackHandlers', function () { + it('adds, removes and calls all handlers without duplicate', async function () { + const handler1 = sinon.stub() + const handler2 = sinon.stub() + const handler3 = sinon.stub() + + const { result } = renderHook(() => useCallbackHandlers()) + + result.current.addHandler(handler1) + result.current.deleteHandler(handler1) + result.current.addHandler(handler1) + + result.current.addHandler(handler2) + result.current.deleteHandler(handler2) + + result.current.addHandler(handler3) + result.current.addHandler(handler3) + + result.current.callHandlers('foo') + result.current.callHandlers(1337) + + sinon.assert.calledTwice(handler1) + sinon.assert.calledWith(handler1, 'foo') + sinon.assert.calledWith(handler1, 1337) + + sinon.assert.notCalled(handler2) + + sinon.assert.calledTwice(handler3) + }) +}) diff --git a/services/web/test/frontend/shared/hooks/use-detach-action.test.js b/services/web/test/frontend/shared/hooks/use-detach-action.test.js new file mode 100644 index 0000000000..9c0bdfd955 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-detach-action.test.js @@ -0,0 +1,82 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { renderHookWithEditorContext } from '../../helpers/render-with-context' +import sysendTestHelper from '../../helpers/sysend' +import useDetachAction from '../../../../frontend/js/shared/hooks/use-detach-action' + +const actionName = 'some-action' +const actionFunction = sinon.stub() + +describe('useDetachAction', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + actionFunction.reset() + }) + + it('broadcast message as sender', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + const { result } = renderHookWithEditorContext(() => + useDetachAction(actionName, actionFunction, 'detacher', 'detached') + ) + const triggerFn = result.current + sysendTestHelper.resetHistory() + + triggerFn('param') + + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: `action-${actionName}`, + data: { args: ['param'] }, + }) + + sinon.assert.notCalled(actionFunction) + }) + + it('call function as non-sender', async function () { + const { result } = renderHookWithEditorContext(() => + useDetachAction(actionName, actionFunction, 'detacher', 'detached') + ) + const triggerFn = result.current + sysendTestHelper.resetHistory() + + triggerFn('param') + + expect(sysendTestHelper.getDetachCalls('broadcast').length).to.equal(0) + + sinon.assert.calledWith(actionFunction, 'param') + }) + + it('receive message and call function as target', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderHookWithEditorContext(() => + useDetachAction(actionName, actionFunction, 'detacher', 'detached') + ) + + sysendTestHelper.receiveMessage({ + role: 'detached', + event: `action-${actionName}`, + data: { args: ['param'] }, + }) + + sinon.assert.calledWith(actionFunction, 'param') + }) + + it('receive message and does not call function as non-target', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + renderHookWithEditorContext(() => + useDetachAction(actionName, actionFunction, 'detacher', 'detached') + ) + + sysendTestHelper.receiveMessage({ + role: 'detached', + event: `action-${actionName}`, + data: { args: [] }, + }) + + sinon.assert.notCalled(actionFunction) + }) +}) diff --git a/services/web/test/frontend/shared/hooks/use-detach-layout.test.js b/services/web/test/frontend/shared/hooks/use-detach-layout.test.js new file mode 100644 index 0000000000..9cdb1f015d --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-detach-layout.test.js @@ -0,0 +1,187 @@ +import { act } from '@testing-library/react-hooks' +import { expect } from 'chai' +import sinon from 'sinon' +import { renderHookWithEditorContext } from '../../helpers/render-with-context' +import sysendTestHelper from '../../helpers/sysend' +import useDetachLayout from '../../../../frontend/js/shared/hooks/use-detach-layout' + +describe('useDetachLayout', function () { + let openStub + let closeStub + + beforeEach(function () { + window.metaAttributesCache = new Map() + openStub = sinon.stub(window, 'open') + closeStub = sinon.stub(window, 'close') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + openStub.restore() + closeStub.restore() + }) + + it('detaching', async function () { + // 1. create hook in normal mode + const { result } = renderHookWithEditorContext(() => useDetachLayout()) + expect(result.current.reattach).to.be.a('function') + expect(result.current.detach).to.be.a('function') + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.be.null + + // 2. detach + act(() => { + result.current.detach() + }) + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.true + expect(result.current.role).to.equal('detacher') + sinon.assert.calledOnce(openStub) + sinon.assert.calledWith( + openStub, + 'https://www.test-overleaf.com/detached', + '_blank' + ) + }) + + it('detacher role', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + + // 1. create hook in detacher mode + const { result } = renderHookWithEditorContext(() => useDetachLayout()) + expect(result.current.reattach).to.be.a('function') + expect(result.current.detach).to.be.a('function') + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detacher') + + // 2. simulate connected detached tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'connected', + }) + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: 'up', + }) + expect(result.current.isLinked).to.be.true + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detacher') + + // 3. simulate closed detached tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'closed', + }) + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detacher') + + // 4. simulate up detached tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'up', + }) + expect(result.current.isLinked).to.be.true + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detacher') + + // 5. simulate closed detacher tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'closed', + }) + expect(result.current.isLinked).to.be.true + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detacher') + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: 'up', + }) + + // 6. reattach + sysendTestHelper.spy.broadcast.resetHistory() + act(() => { + result.current.reattach() + }) + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.be.null + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: 'reattach', + }) + }) + + it('detached role', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + + // 1. create hook in detached mode + const { result } = renderHookWithEditorContext(() => useDetachLayout()) + expect(result.current.reattach).to.be.a('function') + expect(result.current.detach).to.be.a('function') + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detached') + + // 2. simulate up detacher tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'up', + }) + expect(result.current.isLinked).to.be.true + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detached') + + // 3. simulate closed detacher tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'closed', + }) + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detached') + + // 4. simulate up detacher tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'up', + }) + expect(result.current.isLinked).to.be.true + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detached') + + // 5. simulate closed detached tab + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'closed', + }) + expect(result.current.isLinked).to.be.true + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detached') + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detached', + event: 'up', + }) + + // 6. simulate reattach event + sysendTestHelper.spy.broadcast.resetHistory() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'reattach', + }) + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detached') + sinon.assert.called(closeStub) + }) +}) diff --git a/services/web/test/frontend/shared/hooks/use-detach-state.test.js b/services/web/test/frontend/shared/hooks/use-detach-state.test.js new file mode 100644 index 0000000000..520bdd6b22 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-detach-state.test.js @@ -0,0 +1,68 @@ +import { act } from '@testing-library/react-hooks' +import { expect } from 'chai' +import { renderHookWithEditorContext } from '../../helpers/render-with-context' +import sysendTestHelper from '../../helpers/sysend' +import useDetachState from '../../../../frontend/js/shared/hooks/use-detach-state' + +const stateKey = 'some-key' + +describe('useDetachState', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('create and update state', async function () { + const defaultValue = 'foobar' + const { result } = renderHookWithEditorContext(() => + useDetachState(stateKey, defaultValue) + ) + const [value, setValue] = result.current + expect(value).to.equal(defaultValue) + expect(setValue).to.be.a('function') + + const newValue = 'barbaz' + act(() => { + setValue(newValue) + }) + expect(result.current[0]).to.equal(newValue) + }) + + it('broadcast message as sender', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + const { result } = renderHookWithEditorContext(() => + useDetachState(stateKey, null, 'detacher', 'detached') + ) + const [, setValue] = result.current + sysendTestHelper.resetHistory() + + const newValue = 'barbaz' + act(() => { + setValue(newValue) + }) + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: `state-${stateKey}`, + data: { value: newValue }, + }) + }) + + it('receive message as target', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + const { result } = renderHookWithEditorContext(() => + useDetachState(stateKey, null, 'detacher', 'detached') + ) + + const newValue = 'barbaz' + sysendTestHelper.receiveMessage({ + role: 'detached', + event: `state-${stateKey}`, + data: { value: newValue }, + }) + + expect(result.current[0]).to.equal(newValue) + }) +}) diff --git a/services/web/test/frontend/shared/utils/url-helper.test.js b/services/web/test/frontend/shared/utils/url-helper.test.js new file mode 100644 index 0000000000..24c1241734 --- /dev/null +++ b/services/web/test/frontend/shared/utils/url-helper.test.js @@ -0,0 +1,53 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { buildUrlWithDetachRole } from '../../../../frontend/js/shared/utils/url-helper' + +describe('url-helper', function () { + let locationStub + describe('buildUrlWithDetachRole', function () { + beforeEach(function () { + locationStub = sinon.stub(window, 'location') + }) + + afterEach(function () { + locationStub.restore() + }) + + describe('without mode', function () { + it('removes trailing slash', function () { + locationStub.value('https://www.ovelreaf.com/project/1abc/') + expect(buildUrlWithDetachRole().href).to.equal( + 'https://www.ovelreaf.com/project/1abc' + ) + }) + + it('clears the mode from the current URL', function () { + locationStub.value('https://www.ovelreaf.com/project/2abc/detached') + expect(buildUrlWithDetachRole().href).to.equal( + 'https://www.ovelreaf.com/project/2abc' + ) + + locationStub.value('https://www.ovelreaf.com/project/2abc/detacher/') + expect(buildUrlWithDetachRole().href).to.equal( + 'https://www.ovelreaf.com/project/2abc' + ) + }) + }) + + describe('with mode', function () { + it('handles with trailing slash', function () { + locationStub.value('https://www.ovelreaf.com/project/3abc/') + expect(buildUrlWithDetachRole('detacher').href).to.equal( + 'https://www.ovelreaf.com/project/3abc/detacher' + ) + }) + + it('handles without trailing slash', function () { + locationStub.value('https://www.ovelreaf.com/project/4abc') + expect(buildUrlWithDetachRole('detached').href).to.equal( + 'https://www.ovelreaf.com/project/4abc/detached' + ) + }) + }) + }) +})