Просмотр исходного кода

Merge pull request #6053 from overleaf/ta-pdf-detach-tests

PDF Detach Misc Tests

GitOrigin-RevId: 9615c8fdfd8964a9c63d7c91e4596d397a1d35dc
Timothée Alby 4 лет назад
Родитель
Сommit
44eca312ff

+ 1 - 1
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() {

+ 7 - 4
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]
   )

+ 1 - 1
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(

+ 3 - 3
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
 }

+ 5 - 3
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
 }
 

+ 66 - 7
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('<LayoutDropdownButton />', function () {
   let openStub
@@ -12,10 +17,14 @@ describe('<LayoutDropdownButton />', 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('<LayoutDropdownButton />', function () {
     })
   })
 
-  it('should show processing when detaching', function () {
-    renderWithEditorContext(<LayoutDropdownButton />, {
-      ui: { ...defaultUi, view: 'editor' },
+  describe('on detach', function () {
+    beforeEach(function () {
+      renderWithEditorContext(<LayoutDropdownButton />, {
+        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')
+    })
+
+    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(<LayoutDropdownButton />, {
+        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' }
+      )
     })
-    fireEvent.click(menuItem)
 
-    screen.getByText('Layout processing')
+    it('should select new menu item', function () {
+      screen.getByRole('menuitem', {
+        name: 'Selected Editor only (hide PDF)',
+      })
+    })
   })
 })

+ 70 - 0
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('<DetachCompileButton/>', 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(<DetachCompileButton />)
+    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(<DetachCompileButton />)
+
+    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(<DetachCompileButton />)
+    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(<DetachCompileButton />)
+    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: [] },
+    })
+  })
+})

+ 26 - 0
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('<PdfPreviewHybridToolbar/>', function () {
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('shows normal mode', async function () {
+    renderWithEditorContext(<PdfPreviewHybridToolbar />)
+
+    await screen.getByRole('button', {
+      name: 'Recompile',
+    })
+  })
+
+  it('shows orphan mode', async function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detached')
+    renderWithEditorContext(<PdfPreviewHybridToolbar />)
+
+    await screen.getByRole('button', {
+      name: 'Redirect to editor',
+    })
+  })
+})

+ 164 - 9
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('<PdfSynctexControls/>', 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('<PdfSynctexControls/>', function () {
       return null
     }
 
-    render(
-      <EditorProviders scope={scope}>
+    renderWithEditorContext(
+      <>
         <Inner />
         <PdfSynctexControls />
-      </EditorProviders>
+      </>,
+      { scope }
     )
 
     const syncToPdfButton = await screen.findByRole('button', {
@@ -164,4 +167,156 @@ describe('<PdfSynctexControls/>', 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(<PdfSynctexControls />, { 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(<PdfSynctexControls />, { 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(<PdfSynctexControls />, { 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(<PdfSynctexControls />, { 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(<PdfSynctexControls />, { 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(<PdfSynctexControls />, { 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(<PdfSynctexControls />, { 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 },
+      })
+    })
+  })
 })

+ 9 - 0
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 }) => (
+    <EditorProviders {...contextProps}>{children}</EditorProviders>
+  )
+
+  return renderHook(hook, { wrapper: EditorProvidersWrapper })
+}
+
 export function ChatProviders({ children, ...props }) {
   return (
     <EditorProviders {...props}>

+ 41 - 0
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,
+}

+ 34 - 0
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)
+  })
+})

+ 82 - 0
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)
+  })
+})

+ 187 - 0
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)
+  })
+})

+ 68 - 0
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)
+  })
+})

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