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'
+ )
+ })
+ })
+ })
+})