From 92f4e0a45245286749e08e94c274d3c840f9a129 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 5 Oct 2025 16:58:09 +0200 Subject: [PATCH 1/3] chore(test): move yjs test from cypress to vitest Signed-off-by: Max --- src/helpers/base64.ts | 2 +- src/helpers/{yjs.js => yjs.ts} | 47 ++++++++++--------- src/services/Outbox.ts | 2 +- src/services/SyncService.ts | 2 +- .../tests/helpers/yjs.spec.ts | 9 ++-- .../tests/upstream/yjs.spec.ts | 7 +-- 6 files changed, 38 insertions(+), 31 deletions(-) rename src/helpers/{yjs.js => yjs.ts} (67%) rename cypress/component/helpers/yjs.cy.js => src/tests/helpers/yjs.spec.ts (86%) rename cypress/component/upstream/Yjs.cy.js => src/tests/upstream/yjs.spec.ts (90%) diff --git a/src/helpers/base64.ts b/src/helpers/base64.ts index d360607718b..5e31eadaf4b 100644 --- a/src/helpers/base64.ts +++ b/src/helpers/base64.ts @@ -17,7 +17,7 @@ import { fromBase64, toBase64 } from 'lib0/buffer' * * @param {ArrayBuffer} data - binary data to encode */ -export function encodeArrayBuffer(data: ArrayBuffer): string { +export function encodeArrayBuffer(data: Uint8Array): string { const view = new Uint8Array(data) return toBase64(view) } diff --git a/src/helpers/yjs.js b/src/helpers/yjs.ts similarity index 67% rename from src/helpers/yjs.js rename to src/helpers/yjs.ts index d1b53b0fb6a..9c59a118470 100644 --- a/src/helpers/yjs.js +++ b/src/helpers/yjs.ts @@ -7,28 +7,31 @@ import * as decoding from 'lib0/decoding.js' import * as encoding from 'lib0/encoding.js' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' -import { decodeArrayBuffer, encodeArrayBuffer } from '../helpers/base64.ts' import { messageSync } from '../services/y-websocket.js' +import { decodeArrayBuffer, encodeArrayBuffer } from './base64' /** * Get Document state encode as base64. * * Used to store yjs state on the server. - * @param {Y.Doc} ydoc - encode state of this doc - * @return {string} + * @param ydoc - encode state of this doc */ -export function getDocumentState(ydoc) { +export function getDocumentState(ydoc: Y.Doc): string { const update = Y.encodeStateAsUpdate(ydoc) return encodeArrayBuffer(update) } /** * - * @param {Y.Doc} ydoc - apply state to this doc - * @param {string} documentState - base64 encoded doc state - * @param {object} origin - initiator object e.g. WebsocketProvider + * @param ydoc - apply state to this doc + * @param documentState - base64 encoded doc state + * @param origin - initiator object e.g. WebsocketProvider */ -export function applyDocumentState(ydoc, documentState, origin) { +export function applyDocumentState( + ydoc: Y.Doc, + documentState: string, + origin: object, +) { const update = decodeArrayBuffer(documentState) Y.applyUpdate(ydoc, update, origin) } @@ -38,23 +41,24 @@ export function applyDocumentState(ydoc, documentState, origin) { * i.e. create a sync protocol update message from it * and encode it and wrap it in a step data structure. * - * @param {string} documentState - base64 encoded doc state - * @return {{step: string}} base64 encoded yjs sync protocol update message + * @param documentState - base64 encoded doc state + * @return base64 encoded yjs sync protocol update message */ -export function documentStateToStep(documentState) { +export function documentStateToStep(documentState: string): { + step: string +} { const message = documentStateToUpdateMessage(documentState) return { step: encodeArrayBuffer(message) } } /** - * Create an update message from a document state + * Create a message from a document state * i.e. decode the base64 encoded yjs update * and create a sync protocol update message from it * - * @param {string} documentState - base64 encoded doc state - * @return {Uint8Array} + * @param documentState - base64 encoded doc state */ -function documentStateToUpdateMessage(documentState) { +function documentStateToUpdateMessage(documentState: string): Uint8Array { const update = decodeArrayBuffer(documentState) const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) @@ -66,11 +70,12 @@ function documentStateToUpdateMessage(documentState) { * Apply a step to the ydoc. * * Only used in tests right now. - * @param {Y.Doc} ydoc - encode state of this doc - * @param {string} step - base64 encoded yjs sync update message - * @param {object} origin - initiator object e.g. WebsocketProvider + * @param ydoc - encode state of this doc + * @param step - step data + * @param step.step - base64 encoded yjs sync update message + * @param origin - initiator object e.g. WebsocketProvider */ -export function applyStep(ydoc, step, origin = 'origin') { +export function applyStep(ydoc: Y.Doc, step: { step: string }, origin = 'origin') { const updateMessage = decodeArrayBuffer(step.step) const decoder = decoding.createDecoder(updateMessage) const messageType = decoding.readVarUint(decoder) @@ -86,9 +91,9 @@ export function applyStep(ydoc, step, origin = 'origin') { /** * Log y.js messages with their type and initiator call stack * - * @param {string} step - Y.js message + * @param step - Y.js message */ -export function logStep(step) { +export function logStep(step: Uint8Array) { // Create error for stack trace const err = new Error() diff --git a/src/services/Outbox.ts b/src/services/Outbox.ts index 325a220aec4..2c45cfb7ef4 100644 --- a/src/services/Outbox.ts +++ b/src/services/Outbox.ts @@ -16,7 +16,7 @@ export default class Outbox { #syncUpdate = '' #syncQuery = '' - storeStep(step: ArrayBuffer) { + storeStep(step: Uint8Array) { const encoded = encodeArrayBuffer(step) if (encoded < 'AAA' || encoded > 'Ag') { logger.warn('Unexpected step type:', { step, encoded }) diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index e9a90e43469..61c96af452b 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -195,7 +195,7 @@ class SyncService { } } - sendStep(step: ArrayBuffer) { + sendStep(step: Uint8Array) { this.#outbox.storeStep(step) this.sendSteps() } diff --git a/cypress/component/helpers/yjs.cy.js b/src/tests/helpers/yjs.spec.ts similarity index 86% rename from cypress/component/helpers/yjs.cy.js rename to src/tests/helpers/yjs.spec.ts index acef5c84685..473d1bc1a82 100644 --- a/cypress/component/helpers/yjs.cy.js +++ b/src/tests/helpers/yjs.spec.ts @@ -3,12 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { describe, expect, it } from 'vitest' import * as Y from 'yjs' import { applyStep, documentStateToStep, getDocumentState, -} from '../../../src/helpers/yjs.js' +} from '../../helpers/yjs.js' describe('Yjs base64 wrapped with our helpers', function () { it('applies step generated from document state', function () { @@ -17,9 +18,9 @@ describe('Yjs base64 wrapped with our helpers', function () { const sourceMap = source.getMap() const targetMap = target.getMap() - target.on('afterTransaction', (tr, doc) => { - // console.log('afterTransaction', tr) - }) + // target.on('afterTransaction', (tr, doc) => { + // console.log('afterTransaction', tr) + // }) // Add keyA to source and apply to target sourceMap.set('keyA', 'valueA') diff --git a/cypress/component/upstream/Yjs.cy.js b/src/tests/upstream/yjs.spec.ts similarity index 90% rename from cypress/component/upstream/Yjs.cy.js rename to src/tests/upstream/yjs.spec.ts index 6aabe1f2bc0..38834512559 100644 --- a/cypress/component/upstream/Yjs.cy.js +++ b/src/tests/upstream/yjs.spec.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { describe, expect, it } from 'vitest' import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs' describe('Yjs', function () { @@ -13,9 +14,9 @@ describe('Yjs', function () { const sourceMap = source.getMap() const targetMap = target.getMap() - target.on('afterTransaction', (tr, doc) => { - // console.log('afterTransaction', tr) - }) + /* target.on('afterTransaction', (tr, doc) => { + console.log('afterTransaction', { tr, doc }) + }) */ // Add keyA to source and apply to target sourceMap.set('keyA', 'valueA') From d8be2cf1fe732c542b8ada60c79c9063a5d6d21a Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 6 Oct 2025 10:49:44 +0200 Subject: [PATCH 2/3] chore(test): move search test from cypress to vitest Signed-off-by: Max --- cypress/component/editor/search.cy.js | 110 ---------------------- src/declarations.d.ts | 9 ++ src/extensions/RichText.js | 2 +- src/extensions/{Search.js => Search.ts} | 19 ++++ src/plugins/searchDecorations.js | 4 +- src/tests/extensions/Search.spec.ts | 66 +++++++++++++ {cypress => src/tests}/fixtures/lorem.txt | 0 7 files changed, 98 insertions(+), 112 deletions(-) delete mode 100644 cypress/component/editor/search.cy.js create mode 100644 src/declarations.d.ts rename src/extensions/{Search.js => Search.ts} (59%) create mode 100644 src/tests/extensions/Search.spec.ts rename {cypress => src/tests}/fixtures/lorem.txt (100%) diff --git a/cypress/component/editor/search.cy.js b/cypress/component/editor/search.cy.js deleted file mode 100644 index f8fc98318f7..00000000000 --- a/cypress/component/editor/search.cy.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { Editor } from '@tiptap/core' -import { Document } from '@tiptap/extension-document' -import { Text } from '@tiptap/extension-text' -import Search from '../../../src/extensions/Search.js' -import HardBreak from '../../../src/nodes/HardBreak.js' -import Paragraph from '../../../src/nodes/Paragraph.js' - -describe('editor search highlighting', () => { - let editor = null - - before(() => { - cy.fixture('lorem.txt').then((text) => { - editor = new Editor({ - element: document.querySelector('div[data-cy-root]'), - content: text, - extensions: [Document, Text, Search, Paragraph, HardBreak], - }) - }) - }) - - it('can highlight a match', () => { - const searchQuery = 'Lorem ipsum dolor sit amet' - editor.commands.setSearchQuery(searchQuery) - - const highlightedElements = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - expect(highlightedElements).to.have.lengthOf(1) - verifyHighlights(highlightedElements, searchQuery) - }) - - it('can highlight multiple matches', () => { - const searchQuery = 'quod' - editor.commands.setSearchQuery(searchQuery) - - const highlightedElements = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - expect(highlightedElements).to.have.lengthOf(3) - verifyHighlights(highlightedElements, searchQuery) - }) - - it('can toggle highlight all', () => { - const searchQuery = 'quod' - let highlightedElements = [] - - // Highlight only first occurrence - editor.commands.setSearchQuery(searchQuery, false) - highlightedElements = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - - expect(highlightedElements).to.have.lengthOf(1) - verifyHighlights(highlightedElements, searchQuery) - }) - - it('can move to next occurrence', () => { - const searchQuery = 'quod' - - editor.commands.setSearchQuery(searchQuery, true) - const allHighlightedElements = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - - editor.commands.nextMatch() - const currentlyHighlightedElement = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - - expect(currentlyHighlightedElement).to.have.lengthOf(1) - expect(allHighlightedElements[1]).to.deep.equal( - currentlyHighlightedElement[0], - ) - }) - - it('can move to previous occurrence', () => { - const searchQuery = 'quod' - - editor.commands.setSearchQuery(searchQuery, true) - const allHighlightedElements = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - - editor.commands.previousMatch() - const currentlyHighlightedElement = document.querySelectorAll( - 'span[data-text-el="search-decoration"]', - ) - - expect(currentlyHighlightedElement).to.have.lengthOf(1) - expect(allHighlightedElements[0]).to.deep.equal( - currentlyHighlightedElement[0], - ) - }) -}) - -/** - * Verifies the Nodes in the given NodeList match the search query - * @param {NodeList} highlightedElements - NodeList of highlighted elements - * @param {string} searchQuery - search query - */ -function verifyHighlights(highlightedElements, searchQuery) { - for (const element of highlightedElements) { - expect(element.innerText.toLowerCase()).to.equal(searchQuery.toLowerCase()) - } -} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 00000000000..f741c0d8e0b --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare module '*?raw' { + const content: string + export default content +} diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index c60b833057f..38ccf819af3 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -23,7 +23,7 @@ import LinkBubble from './../extensions/LinkBubble.js' import LinkPicker from './../extensions/LinkPicker.js' import Markdown from './../extensions/Markdown.js' import Mention from './../extensions/Mention.js' -import Search from './../extensions/Search.js' +import Search from './../extensions/Search.ts' import TextDirection from './../extensions/TextDirection.ts' import Typography from './../extensions/Typography.ts' import BulletList from './../nodes/BulletList.js' diff --git a/src/extensions/Search.js b/src/extensions/Search.ts similarity index 59% rename from src/extensions/Search.js rename to src/extensions/Search.ts index bfb99e0946e..c42d7a5a1ad 100644 --- a/src/extensions/Search.js +++ b/src/extensions/Search.ts @@ -27,3 +27,22 @@ export default Extension.create({ return [searchQuery(), searchDecorations()] }, }) + +declare module '@tiptap/core' { + interface Commands { + Search: { + /** + * Set the text direction attribute + */ + setSearchQuery: (query: string, matchAll?: boolean) => ReturnType + /** + * Unset the text direction attribute + */ + nextMatch: () => ReturnType + /** + * Unset the text direction attribute + */ + previousMatch: () => ReturnType + } + } +} diff --git a/src/plugins/searchDecorations.js b/src/plugins/searchDecorations.js index 57b13f43794..fd6eb9cb561 100644 --- a/src/plugins/searchDecorations.js +++ b/src/plugins/searchDecorations.js @@ -8,6 +8,8 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import { searchQueryPluginKey } from './searchQuery.js' +export const searchDecorationsPluginKey = new PluginKey('searchDecorations') + /** * Search decorations ProseMirror plugin * Handles highlighting search matches for the search TipTap extension @@ -16,7 +18,7 @@ import { searchQueryPluginKey } from './searchQuery.js' */ export default function searchDecorations() { return new Plugin({ - key: new PluginKey('searchDecorations'), + key: searchDecorationsPluginKey, state: { init(_, { doc }) { const search = runSearch(doc, '') diff --git a/src/tests/extensions/Search.spec.ts b/src/tests/extensions/Search.spec.ts new file mode 100644 index 00000000000..fcb7cab588c --- /dev/null +++ b/src/tests/extensions/Search.spec.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' +import Search from '../../extensions/Search' +import HardBreak from '../../nodes/HardBreak.js' +import { searchDecorationsPluginKey } from '../../plugins/searchDecorations.js' +import lorem from '../fixtures/lorem.txt?raw' +import createCustomEditor from '../testHelpers/createCustomEditor' + +describe('editor search highlighting', () => { + const editor = createCustomEditor(lorem, [Search, HardBreak]) + + it('can highlight a match', () => { + const searchQuery = 'Lorem' + editor.commands.setSearchQuery(searchQuery) + const decorationSet = searchDecorationsPluginKey.getState(editor.state) + expect(decorationSet.find().length).toBe(1) + for (const decoration of decorationSet.find()) { + const content = editor.state.doc.textBetween( + decoration.from, + decoration.to, + ) + expect(content.toLowerCase()).to.equal(searchQuery.toLowerCase()) + } + }) + + it('can highlight multiple matches', () => { + const searchQuery = 'quod' + editor.commands.setSearchQuery(searchQuery) + const decorationSet = searchDecorationsPluginKey.getState(editor.state) + expect(decorationSet.find().length).toBe(3) + for (const decoration of decorationSet.find()) { + const content = editor.state.doc.textBetween( + decoration.from, + decoration.to, + ) + expect(content.toLowerCase()).to.equal(searchQuery.toLowerCase()) + } + }) + + it('can toggle highlight all', () => { + const searchQuery = 'quod' + editor.commands.setSearchQuery(searchQuery, false) + const decorationSet = searchDecorationsPluginKey.getState(editor.state) + expect(decorationSet.find().length).toBe(1) + }) + + it('can move to next occurrence', () => { + const searchQuery = 'quod' + editor.commands.setSearchQuery(searchQuery, true) + editor.commands.nextMatch() + const decorationSet = searchDecorationsPluginKey.getState(editor.state) + expect(decorationSet.find().length).toBe(1) + }) + + it('can move to previous occurrence', () => { + const searchQuery = 'quod' + editor.commands.setSearchQuery(searchQuery, true) + editor.commands.previousMatch() + const decorationSet = searchDecorationsPluginKey.getState(editor.state) + expect(decorationSet.find().length).toBe(1) + }) +}) diff --git a/cypress/fixtures/lorem.txt b/src/tests/fixtures/lorem.txt similarity index 100% rename from cypress/fixtures/lorem.txt rename to src/tests/fixtures/lorem.txt From 41400a56ae57de0479b3b959e1b05f5354f6767f Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 6 Oct 2025 10:53:15 +0200 Subject: [PATCH 3/3] chore(cleanup): drop cypress component tests in ci Signed-off-by: Max --- .github/workflows/cypress-component.yml | 69 ------------------------- 1 file changed, 69 deletions(-) delete mode 100644 .github/workflows/cypress-component.yml diff --git a/.github/workflows/cypress-component.yml b/.github/workflows/cypress-component.yml deleted file mode 100644 index 36c6987c8fd..00000000000 --- a/.github/workflows/cypress-component.yml +++ /dev/null @@ -1,69 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -name: Cypress Component Tests - -on: pull_request - -env: - # Adjust APP_NAME if your repository name is different - APP_NAME: ${{ github.event.repository.name }} - - # This represents the server branch to checkout. - # Usually it's the base branch of the PR, but for pushes it's the branch itself. - # e.g. 'main', 'stable27' or 'feature/my-feature - # n.b. server will use head_ref, as we want to test the PR branch. - BRANCH: ${{ github.base_ref || github.ref_name }} - -jobs: - cypress-component: - runs-on: ubuntu-latest - - steps: - - name: Checkout app - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Read package.json node and npm engines version - uses: skjnldsv/read-package-engines-version-actions@8205673bab74a63eb9b8093402fd9e0e018663a1 # v2.2 - id: versions - with: - fallbackNode: "^20" - fallbackNpm: "^10" - - - name: Set up node ${{ steps.versions.outputs.nodeVersion }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version: ${{ steps.versions.outputs.nodeVersion }} - - - name: Set up npm ${{ steps.versions.outputs.npmVersion }} - run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}" - - - name: Install node dependencies - run: | - npm ci - - - name: Cypress component tests - uses: cypress-io/github-action@1b70233146622b69e789ccdd4f9452adc638d25a # v6.6.1 - with: - component: true - env: - # Needs to be prefixed with CYPRESS_ - CYPRESS_BRANCH: ${{ env.BRANCH }} - # https://github.com/cypress-io/github-action/issues/124 - COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} - # Needed for some specific code workarounds - TESTING: true - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CYPRESS_BUILD_ID: ${{ github.sha }}-${{ github.run_number }} - CYPRESS_GROUP: Run component - npm_package_name: ${{ env.APP_NAME }} - - - name: Upload snapshots - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 - if: failure() - with: - name: snapshots - path: | - cypress/screenshots/ - cypress/snapshots/ - retention-days: 5