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

Remove sysend dependency (#10852)

GitOrigin-RevId: c3d9601256af8720ab41264609cb5c5c810afbba
Alf Eaton 3 лет назад
Родитель
Сommit
cda947d1ac

+ 0 - 12
package-lock.json

@@ -32072,11 +32072,6 @@
       "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
       "dev": true
     },
-    "node_modules/sysend": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/sysend/-/sysend-1.10.0.tgz",
-      "integrity": "sha512-kQDpqW60fvgbNLnWnTRaJ2KGX3aW5fThu9St8T4h+j8XC1YIDXhWVqS8Ho+WYgasmtrP7RvcRRhs+SVCb9o7wA=="
-    },
     "node_modules/table-layout": {
       "version": "0.4.5",
       "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-0.4.5.tgz",
@@ -38406,7 +38401,6 @@
         "rolling-rate-limiter": "^0.2.10",
         "sanitize-html": "^1.27.1",
         "scroll-into-view-if-needed": "^2.2.25",
-        "sysend": "^1.10.0",
         "tsscmp": "^1.0.6",
         "underscore": "^1.13.1",
         "unzipper": "^0.10.11",
@@ -49247,7 +49241,6 @@
         "sinon-chai": "^3.7.0",
         "sinon-mongoose": "^2.3.0",
         "socket.io-mock": "^1.3.1",
-        "sysend": "^1.10.0",
         "terser-webpack-plugin": "^5.3.1",
         "timekeeper": "^2.2.0",
         "to-string-loader": "^1.2.0",
@@ -70904,11 +70897,6 @@
       "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
       "dev": true
     },
-    "sysend": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/sysend/-/sysend-1.10.0.tgz",
-      "integrity": "sha512-kQDpqW60fvgbNLnWnTRaJ2KGX3aW5fThu9St8T4h+j8XC1YIDXhWVqS8Ho+WYgasmtrP7RvcRRhs+SVCb9o7wA=="
-    },
     "table-layout": {
       "version": "0.4.5",
       "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-0.4.5.tgz",

+ 21 - 9
services/web/frontend/js/shared/context/detach-context.js

@@ -7,7 +7,6 @@ import {
   useState,
 } from 'react'
 import PropTypes from 'prop-types'
-import sysend from 'sysend'
 import getMeta from '../../utils/meta'
 import { buildUrlWithDetachRole } from '../utils/url-helper'
 import useCallbackHandlers from '../hooks/use-callback-handlers'
@@ -26,7 +25,12 @@ DetachContext.Provider.propTypes = {
 
 const debugPdfDetach = getMeta('ol-debugPdfDetach')
 
-const SYSEND_CHANNEL = `detach-${getMeta('ol-project_id')}`
+const projectId = getMeta('ol-project_id')
+export const detachChannelId = `detach-${projectId}`
+export const detachChannel =
+  'BroadcastChannel' in window
+    ? new BroadcastChannel(detachChannelId)
+    : undefined
 
 export function DetachProvider({ children }) {
   const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState()
@@ -45,13 +49,20 @@ export function DetachProvider({ children }) {
   }, [role])
 
   useEffect(() => {
-    sysend.on(SYSEND_CHANNEL, message => {
-      if (debugPdfDetach) {
-        console.log(`Receiving:`, message)
+    if (detachChannel) {
+      const listener = event => {
+        if (debugPdfDetach) {
+          console.log(`Receiving:`, event.data)
+        }
+        callEventHandlers(event.data)
+      }
+
+      detachChannel.addEventListener('message', listener)
+
+      return () => {
+        detachChannel.removeEventListener('message', listener)
       }
-      callEventHandlers(message)
-    })
-    return () => sysend.off(SYSEND_CHANNEL)
+    }
   }, [callEventHandlers])
 
   const broadcastEvent = useCallback(
@@ -80,7 +91,8 @@ export function DetachProvider({ children }) {
       if (data) {
         message.data = data
       }
-      sysend.broadcast(SYSEND_CHANNEL, message)
+
+      detachChannel?.postMessage(message)
     },
     [role]
   )

+ 0 - 1
services/web/package.json

@@ -232,7 +232,6 @@
     "rolling-rate-limiter": "^0.2.10",
     "sanitize-html": "^1.27.1",
     "scroll-into-view-if-needed": "^2.2.25",
-    "sysend": "^1.10.0",
     "tsscmp": "^1.0.6",
     "underscore": "^1.13.1",
     "unzipper": "^0.10.11",

+ 10 - 0
services/web/test/frontend/bootstrap.js

@@ -98,6 +98,16 @@ globalThis.ResizeObserver =
   window.ResizeObserver =
     require('@juggle/resize-observer').ResizeObserver
 
+// add stub for BroadcastChannel (unused in these tests)
+globalThis.BroadcastChannel =
+  global.BroadcastChannel =
+  window.BroadcastChannel =
+    class BroadcastChannel {
+      addEventListener(type, listener) {}
+      removeEventListener(type, listener) {}
+      postMessage(message) {}
+    }
+
 // node-fetch doesn't accept relative URL's: https://github.com/node-fetch/node-fetch/blob/master/docs/v2-LIMITS.md#known-differences
 const fetch = require('node-fetch')
 globalThis.fetch =

+ 10 - 7
services/web/test/frontend/components/pdf-preview/detach-compile-button.spec.tsx

@@ -1,7 +1,7 @@
-import sysendTestHelper from '../../helpers/sysend'
 import { EditorProviders } from '../../helpers/editor-providers'
 import DetachCompileButton from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
 import { mockScope } from './scope'
+import { testDetachChannel } from '../../helpers/detach-channel'
 
 describe('<DetachCompileButton/>', function () {
   beforeEach(function () {
@@ -11,7 +11,6 @@ describe('<DetachCompileButton/>', function () {
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
   })
 
   it('detacher mode and not linked: does not show button ', function () {
@@ -30,7 +29,7 @@ describe('<DetachCompileButton/>', function () {
     cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
   })
 
-  it('detacher mode and linked: show button ', function () {
+  it('detacher mode and linked: show button', function () {
     cy.window().then(win => {
       win.metaAttributesCache = new Map([['ol-detachRole', 'detacher']])
     })
@@ -41,8 +40,10 @@ describe('<DetachCompileButton/>', function () {
       <EditorProviders scope={scope}>
         <DetachCompileButton />
       </EditorProviders>
-    ).then(() => {
-      sysendTestHelper.receiveMessage({
+    )
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
         role: 'detached',
         event: 'connected',
       })
@@ -62,8 +63,10 @@ describe('<DetachCompileButton/>', function () {
       <EditorProviders scope={scope}>
         <DetachCompileButton />
       </EditorProviders>
-    ).then(() => {
-      sysendTestHelper.receiveMessage({
+    )
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
         role: 'detacher',
         event: 'connected',
       })

+ 31 - 37
services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx

@@ -1,6 +1,6 @@
-import sysendTestHelper from '../../helpers/sysend'
 import { EditorProviders } from '../../helpers/editor-providers'
 import PdfLogsEntries from '../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
+import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
 window.metaAttributesCache = new Map([['ol-debugPdfDetach', true]])
 
 describe('<PdfLogsEntries/>', function () {
@@ -38,7 +38,6 @@ describe('<PdfLogsEntries/>', function () {
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
   })
 
   it('displays human readable hint', function () {
@@ -60,16 +59,13 @@ describe('<PdfLogsEntries/>', function () {
 
     cy.findByRole('button', {
       name: 'Navigate to log position in source code: main.tex, 9',
+    }).click()
+
+    cy.get('@findEntityByPath').should('be.calledOnce')
+    cy.get('@openDoc').should('be.calledOnceWith', fakeEntity, {
+      gotoLine: 9,
+      gotoColumn: 8,
     })
-      .click()
-      .then(() => {
-        expect(props.fileTreeManager.findEntityByPath).to.be.calledOnce
-        expect(props.editorManager.openDoc).to.be.calledOnce
-        expect(props.editorManager.openDoc).to.be.calledWith(fakeEntity, {
-          gotoLine: 9,
-          gotoColumn: 8,
-        })
-      })
   })
 
   it('opens doc via detached action', function () {
@@ -82,7 +78,7 @@ describe('<PdfLogsEntries/>', function () {
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
     ).then(() => {
-      sysendTestHelper.receiveMessage({
+      testDetachChannel.postMessage({
         role: 'detached',
         event: 'action-sync-to-entry',
         data: {
@@ -95,13 +91,12 @@ describe('<PdfLogsEntries/>', function () {
           ],
         },
       })
+    })
 
-      expect(props.fileTreeManager.findEntityByPath).to.be.calledOnce
-      expect(props.editorManager.openDoc).to.be.calledOnce
-      expect(props.editorManager.openDoc).to.be.calledWith(fakeEntity, {
-        gotoLine: 7,
-        gotoColumn: 6,
-      })
+    cy.get('@findEntityByPath').should('be.calledOnce')
+    cy.get('@openDoc').should('be.calledOnceWith', fakeEntity, {
+      gotoLine: 7,
+      gotoColumn: 6,
     })
   })
 
@@ -116,27 +111,26 @@ describe('<PdfLogsEntries/>', function () {
       </EditorProviders>
     )
 
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+
     cy.findByRole('button', {
       name: 'Navigate to log position in source code: main.tex, 9',
-    })
-      .click()
-      .then(() => {
-        expect(props.fileTreeManager.findEntityByPath).not.to.be.called
-        expect(props.editorManager.openDoc).not.to.be.called
-
-        expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-          role: 'detached',
-          event: 'action-sync-to-entry',
-          data: {
-            args: [
-              {
-                file: 'main.tex',
-                line: 9,
-                column: 8,
-              },
-            ],
+    }).click()
+
+    cy.get('@findEntityByPath').should('not.be.called')
+    cy.get('@openDoc').should('not.be.called')
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detached',
+      event: 'action-sync-to-entry',
+      data: {
+        args: [
+          {
+            file: 'main.tex',
+            line: 9,
+            column: 8,
           },
-        })
-      })
+        ],
+      },
+    })
   })
 })

+ 28 - 27
services/web/test/frontend/components/pdf-preview/pdf-preview-detached-root.spec.tsx

@@ -1,10 +1,8 @@
-import sysendTestHelper from '../../helpers/sysend'
 import PdfPreviewDetachedRoot from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
 import { User } from '../../../../types/user'
+import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
 
-// https://github.com/overleaf/internal/issues/10080
-// eslint-disable-next-line mocha/no-skipped-tests
-describe.skip('<PdfPreviewDetachedRoot/>', function () {
+describe('<PdfPreviewDetachedRoot/>', function () {
   beforeEach(function () {
     window.user = { id: 'user1' } as User
 
@@ -21,17 +19,18 @@ describe.skip('<PdfPreviewDetachedRoot/>', function () {
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
   })
 
   it('syncs compiling state', function () {
-    cy.mount(<PdfPreviewDetachedRoot />).then(() => {
-      sysendTestHelper.receiveMessage({
+    cy.mount(<PdfPreviewDetachedRoot />)
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
         role: 'detacher',
         event: 'connected',
       })
 
-      sysendTestHelper.receiveMessage({
+      testDetachChannel.postMessage({
         role: 'detacher',
         event: 'state-compiling',
         data: { value: true },
@@ -39,39 +38,41 @@ describe.skip('<PdfPreviewDetachedRoot/>', function () {
     })
 
     cy.findByRole('button', { name: 'Compiling…' })
-    cy.findByRole('button', { name: 'Recompile' })
-      .should('not.exist')
-      .then(() => {
-        sysendTestHelper.receiveMessage({
-          role: 'detacher',
-          event: 'state-compiling',
-          data: { value: false },
-        })
+    cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detacher',
+        event: 'state-compiling',
+        data: { value: false },
       })
+    })
     cy.findByRole('button', { name: 'Recompile' })
     cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
   })
 
   it('sends a clear cache request when the button is pressed', function () {
-    cy.mount(<PdfPreviewDetachedRoot />).then(() => {
-      sysendTestHelper.receiveMessage({
+    cy.mount(<PdfPreviewDetachedRoot />)
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
         role: 'detacher',
         event: 'state-showLogs',
         data: { value: true },
       })
     })
 
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+
     cy.findByRole('button', { name: 'Clear cached files' })
       .should('not.be.disabled')
       .click()
-      .should(() => {
-        expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-          role: 'detached',
-          event: 'action-clearCache',
-          data: {
-            args: [],
-          },
-        })
-      })
+
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detached',
+      event: 'action-clearCache',
+      data: {
+        args: [],
+      },
+    })
   })
 })

+ 10 - 7
services/web/test/frontend/components/pdf-preview/pdf-preview-hybrid-toolbar.spec.tsx

@@ -1,6 +1,6 @@
-import sysendTestHelper from '../../helpers/sysend'
 import { EditorProviders } from '../../helpers/editor-providers'
 import PdfPreviewHybridToolbar from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
+import { testDetachChannel } from '../../helpers/detach-channel'
 
 describe('<PdfPreviewHybridToolbar/>', function () {
   beforeEach(function () {
@@ -10,7 +10,6 @@ describe('<PdfPreviewHybridToolbar/>', function () {
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
   })
 
   it('shows normal mode', function () {
@@ -47,8 +46,10 @@ describe('<PdfPreviewHybridToolbar/>', function () {
         <EditorProviders>
           <PdfPreviewHybridToolbar />
         </EditorProviders>
-      ).then(() => {
-        sysendTestHelper.receiveMessage({
+      )
+
+      cy.wrap(null).then(() => {
+        testDetachChannel.postMessage({
           role: 'detacher',
           event: 'connected',
         })
@@ -66,12 +67,14 @@ describe('<PdfPreviewHybridToolbar/>', function () {
         <EditorProviders>
           <PdfPreviewHybridToolbar />
         </EditorProviders>
-      ).then(() => {
-        sysendTestHelper.receiveMessage({
+      )
+
+      cy.wrap(null).then(() => {
+        testDetachChannel.postMessage({
           role: 'detacher',
           event: 'connected',
         })
-        sysendTestHelper.receiveMessage({
+        testDetachChannel.postMessage({
           role: 'detacher',
           event: 'closed',
         })

+ 49 - 60
services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx

@@ -1,11 +1,11 @@
 import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
-import sysendTestHelper from '../../helpers/sysend'
 import { cloneDeep } from 'lodash'
 import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
 import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
 import { useEffect } from 'react'
 import { EditorProviders } from '../../helpers/editor-providers'
 import { mockScope } from './scope'
+import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
 
 const mockHighlights = [
   {
@@ -55,7 +55,7 @@ const WithPosition = ({ mockPosition }: { mockPosition: Position }) => {
 
 // mock PDF scroll position update
 const setDetachedPosition = (mockPosition: Position) => {
-  sysendTestHelper.receiveMessage({
+  testDetachChannel.postMessage({
     role: 'detacher',
     event: 'state-position',
     data: { value: mockPosition },
@@ -127,8 +127,7 @@ const interceptSyncPdf = () => {
   }).as('sync-pdf')
 }
 
-// eslint-disable-next-line mocha/no-skipped-tests
-describe.skip('<PdfSynctexControls/>', function () {
+describe('<PdfSynctexControls/>', function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
 
@@ -255,9 +254,7 @@ describe.skip('<PdfSynctexControls/>', function () {
           <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
           <PdfSynctexControls />
         </EditorProviders>
-      ).then(() => {
-        sysendTestHelper.resetHistory()
-      })
+      )
 
       cy.wait('@compile')
 
@@ -270,6 +267,8 @@ describe.skip('<PdfSynctexControls/>', function () {
         )
       })
 
+      cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+
       const syncing = interceptSyncCodeAsync()
 
       cy.findByRole('button', {
@@ -290,18 +289,14 @@ describe.skip('<PdfSynctexControls/>', function () {
 
       cy.findByRole('button', {
         name: 'Go to code location in PDF',
-      }).should(() => {
-        const message = sysendTestHelper.getMessageWithEvent(
-          'action-setHighlights'
-        )
-
-        // synctex is called locally and the result are broadcast for the detached tab
-        // NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
-        expect(message).to.deep.equal({
-          role: 'detacher',
-          event: 'action-setHighlights',
-          data: { args: [mockHighlights] },
-        })
+      }).should('not.be.disabled')
+
+      // synctex is called locally and the result are broadcast for the detached tab
+      // NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
+      cy.get('@postDetachMessage').should('be.calledWith', {
+        role: 'detacher',
+        event: 'action-setHighlights',
+        data: { args: [mockHighlights] },
       })
     })
 
@@ -317,7 +312,7 @@ describe.skip('<PdfSynctexControls/>', function () {
           <PdfSynctexControls />
         </EditorProviders>
       ).then(() => {
-        sysendTestHelper.receiveMessage({
+        testDetachChannel.postMessage({
           role: 'detached',
           event: 'action-sync-to-code',
           data: {
@@ -362,7 +357,7 @@ describe.skip('<PdfSynctexControls/>', function () {
       )
 
       cy.wait('@compile').then(() => {
-        sysendTestHelper.receiveMessage({
+        testDetachChannel.postMessage({
           role: 'detacher',
           event: `state-position`,
           data: { value: mockPosition },
@@ -373,36 +368,28 @@ describe.skip('<PdfSynctexControls/>', function () {
         name: /^Go to PDF location in code/,
       })
 
-      cy.findByRole('button', { name: /^Go to PDF location in code/ })
-        .should('not.be.disabled')
-        .then(() => {
-          sysendTestHelper.resetHistory()
-        })
+      cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
+        'not.be.disabled'
+      )
+
+      cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
 
       cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
 
-      // the button is only disabled when the state is updated via sysend
+      // the button is only disabled when the state is updated
       cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
         'not.be.disabled'
       )
 
       cy.get('.synctex-spin-icon').should('not.exist')
 
-      cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
-        () => {
-          const message = sysendTestHelper.getMessageWithEvent(
-            'action-sync-to-code'
-          )
-
-          expect(message).to.deep.equal({
-            role: 'detached',
-            event: 'action-sync-to-code',
-            data: {
-              args: [mockPosition, 72],
-            },
-          })
-        }
-      )
+      cy.get('@postDetachMessage').should('be.calledWith', {
+        role: 'detached',
+        event: 'action-sync-to-code',
+        data: {
+          args: [mockPosition, 72],
+        },
+      })
     })
 
     it('update inflight state', function () {
@@ -413,8 +400,10 @@ describe.skip('<PdfSynctexControls/>', function () {
           <WithPosition mockPosition={mockPosition} />
           <PdfSynctexControls />
         </EditorProviders>
-      ).then(() => {
-        sysendTestHelper.receiveMessage({
+      )
+
+      cy.wrap(null).then(() => {
+        testDetachChannel.postMessage({
           role: 'detacher',
           event: `state-position`,
           data: { value: mockPosition },
@@ -425,29 +414,29 @@ describe.skip('<PdfSynctexControls/>', function () {
         'not.be.disabled'
       )
 
-      cy.get('.synctex-spin-icon')
-        .should('not.exist')
-        .then(() => {
-          sysendTestHelper.receiveMessage({
-            role: 'detacher',
-            event: 'state-sync-to-code-inflight',
-            data: { value: true },
-          })
+      cy.get('.synctex-spin-icon').should('not.exist')
+
+      cy.wrap(null).then(() => {
+        testDetachChannel.postMessage({
+          role: 'detacher',
+          event: 'state-sync-to-code-inflight',
+          data: { value: true },
         })
+      })
 
       cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
         'be.disabled'
       )
 
-      cy.get('.synctex-spin-icon')
-        .should('have.length', 1)
-        .then(() => {
-          sysendTestHelper.receiveMessage({
-            role: 'detacher',
-            event: 'state-sync-to-code-inflight',
-            data: { value: false },
-          })
+      cy.get('.synctex-spin-icon').should('have.length', 1)
+
+      cy.wrap(null).then(() => {
+        testDetachChannel.postMessage({
+          role: 'detacher',
+          event: 'state-sync-to-code-inflight',
+          data: { value: false },
         })
+      })
 
       cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
         'not.be.disabled'

+ 10 - 0
services/web/test/frontend/helpers/detach-channel.ts

@@ -0,0 +1,10 @@
+import {
+  detachChannelId,
+  detachChannel as _detachChannel,
+} from '../../../frontend/js/shared/context/detach-context'
+
+// for tests, assert that detachChannel is defined, as BroadcastChannel is available
+export const detachChannel = _detachChannel!
+
+// simulate messages from another tab by posting them to this channel
+export const testDetachChannel = new BroadcastChannel(detachChannelId)

+ 0 - 9
services/web/test/frontend/helpers/render-with-context.js

@@ -2,7 +2,6 @@
 /* eslint-disable react/prop-types */
 
 import { render } from '@testing-library/react'
-import { renderHook } from '@testing-library/react-hooks'
 import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context'
 import { EditorProviders } from './editor-providers'
 
@@ -21,14 +20,6 @@ export function renderWithEditorContext(
   })
 }
 
-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}>

+ 0 - 53
services/web/test/frontend/helpers/sysend.js

@@ -1,53 +0,0 @@
-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]
-}
-
-function getAllBroacastMessages() {
-  return getDetachCalls('broadcast')
-}
-
-// 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)
-}
-
-function getMessageWithEvent(eventName) {
-  return getAllBroacastMessages()
-    .map(item => item.args[1])
-    .find(item => item.event === eventName)
-}
-
-export default {
-  spy: sysendSpy,
-  resetHistory,
-  getDetachCalls,
-  getLastDetachCall,
-  getLastBroacastMessage,
-  getAllBroacastMessages,
-  getMessageWithEvent,
-  receiveMessage,
-}

+ 121 - 0
services/web/test/frontend/shared/hooks/use-detach-action.spec.tsx

@@ -0,0 +1,121 @@
+import { FC } from 'react'
+import useDetachAction from '../../../../frontend/js/shared/hooks/use-detach-action'
+import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
+import { EditorProviders } from '../../helpers/editor-providers'
+
+const DetachActionTest: FC<{
+  actionName: string
+  actionFunction: () => void
+  handleClick: (trigger: (value: any) => void) => void
+}> = ({ actionName, actionFunction, handleClick }) => {
+  const trigger = useDetachAction(
+    actionName,
+    actionFunction,
+    'detacher',
+    'detached'
+  )
+
+  return (
+    <button id="trigger" onClick={() => handleClick(trigger)}>
+      trigger
+    </button>
+  )
+}
+
+describe('useDetachAction', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('broadcast message as sender', function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detacher')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachActionTest
+          actionName="some-action"
+          actionFunction={cy.stub().as('actionFunction')}
+          handleClick={trigger => trigger('foo')}
+        />
+      </EditorProviders>
+    )
+
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+    cy.get('#trigger').click()
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detacher',
+      event: 'action-some-action',
+      data: { args: ['foo'] },
+    })
+    cy.get('@actionFunction').should('not.be.called')
+  })
+
+  it('call function as non-sender', function () {
+    cy.mount(
+      <EditorProviders>
+        <DetachActionTest
+          actionName="some-action"
+          actionFunction={cy.stub().as('actionFunction')}
+          handleClick={trigger => trigger('foo')}
+        />
+      </EditorProviders>
+    )
+
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+    cy.get('#trigger').click()
+    cy.get('@postDetachMessage').should('not.be.called')
+    cy.get('@actionFunction').should('be.calledWith', 'foo')
+  })
+
+  it('receive message and call function as target', function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detached')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachActionTest
+          actionName="some-action"
+          actionFunction={cy.stub().as('actionFunction')}
+          handleClick={trigger => trigger('foo')}
+        />
+      </EditorProviders>
+    )
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'action-some-action',
+        data: { args: ['foo'] },
+      })
+    })
+
+    cy.get('@actionFunction').should('be.calledWith', 'foo')
+  })
+
+  it('receive message and does not call function as non-target', function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detacher')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachActionTest
+          actionName="some-action"
+          actionFunction={cy.stub().as('actionFunction')}
+          handleClick={trigger => trigger('foo')}
+        />
+      </EditorProviders>
+    )
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'action-some-action',
+        data: { args: [] },
+      })
+    })
+
+    cy.get('@actionFunction').should('not.be.called')
+  })
+})

+ 0 - 82
services/web/test/frontend/shared/hooks/use-detach-action.test.js

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

+ 244 - 0
services/web/test/frontend/shared/hooks/use-detach-layout.spec.tsx

@@ -0,0 +1,244 @@
+import useDetachLayout from '../../../../frontend/js/shared/hooks/use-detach-layout'
+import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
+import { EditorProviders } from '../../helpers/editor-providers'
+import { Button, Checkbox, ControlLabel, FormGroup } from 'react-bootstrap'
+
+const DetachLayoutTest = () => {
+  const { role, reattach, detach, isLinked, isLinking, isRedundant } =
+    useDetachLayout()
+
+  return (
+    <fieldset>
+      <legend>
+        role: <span id="role">{role || 'none'}</span>
+      </legend>
+      <FormGroup>
+        <Checkbox id="isLinked" inline checked={isLinked} readOnly />
+        <ControlLabel>linked</ControlLabel>
+      </FormGroup>
+      <FormGroup>
+        <Checkbox id="isLinking" inline checked={isLinking} readOnly />
+        <ControlLabel>linking</ControlLabel>
+      </FormGroup>
+      <FormGroup>
+        <Checkbox id="isRedundant" inline checked={isRedundant} readOnly />
+        <ControlLabel>redundant</ControlLabel>
+      </FormGroup>
+      <Button id="reattach" onClick={reattach}>
+        reattach
+      </Button>
+      <Button id="detach" onClick={detach}>
+        detach
+      </Button>
+    </fieldset>
+  )
+}
+
+describe('useDetachLayout', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    cy.stub(window, 'open').as('openWindow')
+    cy.stub(window, 'close').as('closeWindow')
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('detaching', function () {
+    // 1. create hook in normal mode
+    cy.mount(
+      <EditorProviders>
+        <DetachLayoutTest />
+      </EditorProviders>
+    )
+
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'none')
+
+    // 2. detach
+    cy.get('#detach').click()
+    cy.get('@openWindow').should(
+      'be.calledOnceWith',
+      Cypress.sinon.match(/\/detached$/),
+      '_blank'
+    )
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('be.checked')
+    cy.get('#role').should('have.text', 'detacher')
+  })
+
+  it('detacher role', function () {
+    // 1. create hook in detacher mode
+    window.metaAttributesCache.set('ol-detachRole', 'detacher')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachLayoutTest />
+      </EditorProviders>
+    )
+
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detacher')
+
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'connected',
+      })
+    })
+
+    // 2. simulate connected detached tab
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detacher',
+      event: 'up',
+    })
+
+    cy.get('#isLinked').should('be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detacher')
+
+    // 3. simulate closed detached tab
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'closed',
+      })
+    })
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detacher')
+
+    // 4. simulate up detached tab
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'up',
+      })
+    })
+
+    cy.get('#isLinked').should('be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detacher')
+
+    // 5. reattach
+    cy.get('@postDetachMessage').invoke('resetHistory')
+    cy.get('#reattach').click()
+
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'none')
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detacher',
+      event: 'reattach',
+    })
+  })
+
+  it('reset detacher role when other detacher tab connects', function () {
+    // 1. create hook in detacher mode
+    window.metaAttributesCache.set('ol-detachRole', 'detacher')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachLayoutTest />
+      </EditorProviders>
+    )
+
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detacher')
+
+    // 2. simulate other detacher tab
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detacher',
+        event: 'up',
+      })
+    })
+
+    cy.get('#isRedundant').should('be.checked')
+    cy.get('#role').should('have.text', 'none')
+  })
+
+  it('detached role', function () {
+    // 1. create hook in detached mode
+    window.metaAttributesCache.set('ol-detachRole', 'detached')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachLayoutTest />
+      </EditorProviders>
+    )
+
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detached')
+
+    // 2. simulate up detacher tab
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detacher',
+        event: 'up',
+      })
+    })
+
+    cy.get('#isLinked').should('be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detached')
+
+    // 3. simulate closed detacher tab
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detacher',
+        event: 'closed',
+      })
+    })
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detached')
+
+    // 4. simulate up detacher tab
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detacher',
+        event: 'up',
+      })
+    })
+    cy.get('#isLinked').should('be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detached')
+
+    // 5. simulate closed detached tab
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'closed',
+      })
+    })
+    cy.get('#isLinked').should('be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detached')
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detached',
+      event: 'up',
+    })
+
+    // 6. simulate reattach event
+    cy.get('@postDetachMessage').invoke('resetHistory')
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detacher',
+        event: 'reattach',
+      })
+    })
+    cy.get('#isLinked').should('not.be.checked')
+    cy.get('#isLinking').should('not.be.checked')
+    cy.get('#role').should('have.text', 'detached')
+    cy.get('@closeWindow').should('be.called')
+  })
+})

+ 0 - 210
services/web/test/frontend/shared/hooks/use-detach-layout.test.js

@@ -1,210 +0,0 @@
-import { waitFor } from '@testing-library/react'
-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 () {
-    sysendTestHelper.spy.broadcast.resetHistory()
-    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')
-    const broadcastMessagesCount =
-      sysendTestHelper.getAllBroacastMessages().length
-
-    // 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')
-
-    // check that all message were re-broadcast for the new tab
-    await nextTick() // necessary to ensure all event handler have run
-    await waitFor(() => {
-      const reBroadcastMessagesCount =
-        sysendTestHelper.getAllBroacastMessages().length
-      expect(reBroadcastMessagesCount).to.equal(broadcastMessagesCount)
-    })
-    // 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. 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('reset detacher role when other detacher tab connects', 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 other detacher tab
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'up',
-    })
-    expect(result.current.isRedundant).to.be.true
-    expect(result.current.role).to.equal(null)
-  })
-
-  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)
-  })
-})
-
-const nextTick = () => {
-  return new Promise(resolve => {
-    setTimeout(resolve)
-  })
-}

+ 108 - 0
services/web/test/frontend/shared/hooks/use-detach-state.spec.tsx

@@ -0,0 +1,108 @@
+import { FC } from 'react'
+import useDetachState from '../../../../frontend/js/shared/hooks/use-detach-state'
+import { EditorProviders } from '../../helpers/editor-providers'
+import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
+
+const DetachStateTest: FC<{
+  stateKey: string
+  defaultValue: any
+  senderRole?: string
+  targetRole?: string
+  handleClick: (setValue: (value: any) => void) => void
+}> = ({ stateKey, defaultValue, handleClick, senderRole, targetRole }) => {
+  const [value, setValue] = useDetachState(
+    stateKey,
+    defaultValue,
+    senderRole,
+    targetRole
+  )
+
+  return (
+    <div>
+      <div id="value">{value}</div>
+      <button id="setValue" onClick={() => handleClick(setValue)}>
+        set value
+      </button>
+    </div>
+  )
+}
+
+describe('useDetachState', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('create and update state', function () {
+    cy.mount(
+      <EditorProviders>
+        <DetachStateTest
+          stateKey="some-key"
+          defaultValue="foobar"
+          handleClick={setValue => {
+            setValue('barbaz')
+          }}
+        />
+      </EditorProviders>
+    )
+
+    cy.get('#value').should('have.text', 'foobar')
+    cy.get('#setValue').click()
+    cy.get('#value').should('have.text', 'barbaz')
+  })
+
+  it('broadcast message as sender', function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detacher')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachStateTest
+          stateKey="some-key"
+          defaultValue={null}
+          senderRole="detacher"
+          targetRole="detached"
+          handleClick={setValue => {
+            setValue('barbaz1')
+          }}
+        />
+      </EditorProviders>
+    )
+
+    cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
+    cy.get('#setValue').click()
+    cy.get('@postDetachMessage').should('be.calledWith', {
+      role: 'detacher',
+      event: 'state-some-key',
+      data: { value: 'barbaz1' },
+    })
+  })
+
+  it('receive message as target', function () {
+    window.metaAttributesCache.set('ol-detachRole', 'detached')
+
+    cy.mount(
+      <EditorProviders>
+        <DetachStateTest
+          stateKey="some-key"
+          defaultValue={null}
+          senderRole="detacher"
+          targetRole="detached"
+          handleClick={() => {}}
+        />
+      </EditorProviders>
+    )
+
+    cy.wrap(null).then(() => {
+      testDetachChannel.postMessage({
+        role: 'detached',
+        event: 'state-some-key',
+        data: { value: 'barbaz2' },
+      })
+    })
+
+    cy.get('#value').should('have.text', 'barbaz2')
+  })
+})

+ 0 - 68
services/web/test/frontend/shared/hooks/use-detach-state.test.js

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