Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f354a67
chore(test): split setOnline and setOffline
max-nextcloud Nov 3, 2025
79a075b
chore(test): add offline fixture to playwright
max-nextcloud Nov 3, 2025
c612b18
chore(test): get request token from /csrftoken
max-nextcloud Nov 3, 2025
f22c61c
chore(test): split request token fixture from random user
max-nextcloud Nov 3, 2025
2e96c26
chore(test): cleanup unused type and context
max-nextcloud Nov 3, 2025
1df8359
chore(test): mimetype change in playwright
max-nextcloud Nov 3, 2025
5c00d5a
chore(test): basic editor page object model
max-nextcloud Nov 3, 2025
8035dfb
chore(test): file class for handling uploaded file
max-nextcloud Nov 3, 2025
a9f995f
chore(test): file.open()
max-nextcloud Nov 3, 2025
e2f52b3
chore(test): with file.move() and file.close()
max-nextcloud Nov 3, 2025
93652d7
chore(test): with editor.typeHeading()
max-nextcloud Nov 3, 2025
d8eb8f5
chore(test): change mimetype both ways
max-nextcloud Nov 3, 2025
b227e3a
chore(test): store test-results folder as artifacts
max-nextcloud Nov 4, 2025
86ecbe3
chore(test): move File class to separate file
max-nextcloud Nov 4, 2025
4b6ebc0
chore(test): use EditorSection in offline test
max-nextcloud Nov 7, 2025
e174949
chore(test) .withOpenMenu() to access submenus
max-nextcloud Nov 7, 2025
5c0da42
chore(test): write offline and come back online
max-nextcloud Nov 7, 2025
3f211d5
chore(test): reduce nesting in offline test
max-nextcloud Nov 7, 2025
46dde73
chore(test): wait for close request when closing
max-nextcloud Nov 7, 2025
cc70324
wip: try y-indexeddb
max-nextcloud Jun 13, 2025
754ba13
chore(split) useIndexedDbProvider from Editor.vue
max-nextcloud Sep 4, 2025
46235fb
fix(cron): do not reset document
max-nextcloud Sep 4, 2025
1f1b05d
enh(yjs): store baseVersionEtag alongside doc
max-nextcloud Sep 4, 2025
e4430a8
fix(offline): persist dirty state in indexed db
max-nextcloud Oct 14, 2025
dde4d14
chore(test): explore empty changesets
max-nextcloud Oct 22, 2025
c29d66e
chore(rename): use privateMethods for emitError and emitDocumentState…
max-nextcloud Oct 26, 2025
d7d029e
chore(cleanup): _getContent alias for serialize
max-nextcloud Oct 26, 2025
ff9a63f
chore(refactor): handle open data in websocket polyfill
max-nextcloud Oct 26, 2025
207b4c5
fix(sync): only accept sync protocol and return sync step 2
max-nextcloud Oct 26, 2025
4f06117
enh(sync): recover automatically from outdated / renamed doc
max-nextcloud Oct 27, 2025
f79f518
fix(sync): ensure dirty is updated when saving in onDestroy
max-nextcloud Nov 4, 2025
a981174
chore(logging): some optional debug logging
max-nextcloud Nov 5, 2025
0f295b9
fix(sync): actually disable browser broadcast
max-nextcloud Nov 5, 2025
e3aa456
chore(test): conflict and sync with autoreload
max-nextcloud Nov 5, 2025
073a747
chore(type) a few more files
max-nextcloud Nov 6, 2025
1ed464c
chore(test): add initial test for indexed db
max-nextcloud Nov 7, 2025
d1d57d9
fix(sync): Cleanup sessions even with unsaved changes
max-nextcloud Nov 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,5 @@ jobs:
if: always()
with:
name: playwright-report
path: playwright-report/
path: test-results/
retention-days: 30
51 changes: 21 additions & 30 deletions cypress/e2e/api/SessionApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,34 +73,33 @@ describe('The session Api', function () {
cy.closeConnection(connection)
})

// Echoes all message types but queries
Object.entries(messages)
.filter(([key, _value]) => key !== 'query')
.forEach(([type, sample]) => {
it(`echos ${type} messages`, function () {
const steps = [sample]
const version = 0
cy.pushSteps({ connection, steps, version })
.its('version')
.should('eql', 0)
cy.syncSteps(connection)
.its('steps[0].data')
.should('eql', steps)
})
// Echoes updates and responses
;['update', 'response'].forEach((type) => {
it(`echos ${type} messages`, function () {
const steps = [messages[type]]
const version = 0
cy.pushSteps({ connection, steps, version })
.its('version')
.should('eql', 0)
cy.syncSteps(connection).its('steps[0].data').should('eql', steps)
})
})

it('responds to queries', function () {
it('responds to queries with updates and responses', function () {
const version = 0
Object.entries(messages).forEach(([type, sample]) => {
cy.pushSteps({ connection, steps: [sample], version })
})
cy.pushSteps({ connection, steps: [messages.query], version }).then(
(response) => {
cy.wrap(response).its('version').should('eql', 0)
cy.wrap(response).its('steps.length').should('eql', 1)
cy.wrap(response).its('steps.length').should('eql', 2)
cy.wrap(response)
.its('steps[0].data')
.should('eql', [messages.update])
cy.wrap(response)
.its('steps[1].data')
.should('eql', [messages.response])
},
)
})
Expand All @@ -111,7 +110,6 @@ describe('The session Api', function () {
let connection
let fileId
let filePath
let joining

beforeEach(function () {
cy.testName().then((name) => {
Expand Down Expand Up @@ -155,13 +153,10 @@ describe('The session Api', function () {
manualSave: true,
})
cy.openConnection({ fileId, filePath })
.then(({ connection: con, data }) => {
joining = con
return data
})
.its('documentState')
.as('joining')
.its('data.documentState')
.should('eql', documentState)
cy.closeConnection(joining)
cy.get('@joining').its('connection').then(cy.closeConnection)
})

afterEach(function () {
Expand All @@ -174,7 +169,6 @@ describe('The session Api', function () {
let connection
let filePath
let shareToken
let joining

beforeEach(function () {
cy.testName().then((name) => {
Expand Down Expand Up @@ -230,13 +224,10 @@ describe('The session Api', function () {
manualSave: true,
})
cy.openConnection({ filePath: '', token: shareToken })
.then(({ connection: con, data }) => {
joining = con
return data
})
.its('documentState')
.as('joining')
.its('data.documentState')
.should('eql', documentState)
cy.closeConnection(joining)
cy.get('@joining').its('connection').then(cy.closeConnection)
})
})

Expand Down
21 changes: 13 additions & 8 deletions cypress/e2e/api/SyncServiceProvider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,20 @@ describe('Sync service provider', function () {
*/
function createProvider(ydoc) {
const relativePath = '.'
const { connection, openConnection, baseVersionEtag } = provideConnection({
fileId,
relativePath,
})
const { syncService } = provideSyncService(
connection,
openConnection,
baseVersionEtag,
let baseVersionEtag
const setBaseVersionEtag = (val) => {
baseVersionEtag = val
}
const getBaseVersionEtag = () => baseVersionEtag
const { connection, openConnection } = provideConnection(
{
fileId,
relativePath,
},
getBaseVersionEtag,
setBaseVersionEtag,
)
const { syncService } = provideSyncService(connection, openConnection)
const queue = []
syncService.bus.on('opened', () => syncService.startSync())
return createSyncServiceProvider({
Expand Down
11 changes: 3 additions & 8 deletions cypress/e2e/conflict.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,9 @@ variants.forEach(function ({ fixture, mime }) {
cy.getContent().should('contain', 'Heading')

cy.uploadFile(fileName, mime, testName + '/' + fileName)
cy.get('#editor-container .document-status', {
timeout: 40000,
}).should('contain', 'session has expired')

// Reload button works
cy.get('#editor-container .document-status a.button')
.contains('Reload')
.click()
cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push')
cy.wait('@push', { timeout: 20_000 })
// Autoreload works
getWrapper().should('not.exist')
cy.getContent().should('contain', 'Hello world')
cy.getContent().should('not.contain', 'Heading')
Expand Down
22 changes: 8 additions & 14 deletions cypress/e2e/sync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ describe('Sync', () => {
'contain',
'The document could not be loaded.',
)
cy.intercept('**/apps/text/session/*/create').as('create')
cy.get('#editor-container .document-status').find('.button.primary').click()
// let first attempt fail
cy.wait('@create', { timeout: 10000 })
cy.get('#editor-container .document-status', { timeout: 30000 }).should(
'contain',
'The document could not be loaded.',
Expand All @@ -117,13 +120,13 @@ describe('Sync', () => {
cy.intercept('**/apps/text/session/*/*', (req) => {
req.continue()
}).as('alive')
cy.intercept('**/apps/text/session/*/create').as('create')
cy.get('#editor-container .document-status').find('.button.primary').click()
// this is the create request... - now with the alive alias
cy.wait('@alive', { timeout: 30000 })
cy.wait('@create', { timeout: 10000 })
.its('request.body')
.should('have.property', 'baseVersionEtag')
.should('not.be.empty')
cy.getContent().should('contain', 'Hello world')
})

it('recovers from a lost and closed connection', () => {
Expand Down Expand Up @@ -176,18 +179,9 @@ describe('Sync', () => {
cy.wait('@save')
cy.uploadTestFile('test.md')

cy.get('#editor-container .document-status', { timeout: 30000 }).should(
'contain',
'Editing session has expired.',
)

// Reload button works
cy.get('#editor-container .document-status a.button')
.contains('Reload')
.click()

cy.getContent()
cy.get('#editor-container .document-status .notecard').should('not.exist')
cy.getContent().should('not.exist')
cy.getContent().find('h2').should('contain', 'Hello world')
cy.getContent().find('li').should('not.exist') // was overwritten after the save
})

it('passes the doc content from one session to the next', () => {
Expand Down
6 changes: 0 additions & 6 deletions lib/Cron/Cleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace OCA\Text\Cron;

use OCA\Text\Exception\DocumentHasUnsavedChangesException;
use OCA\Text\Service\AttachmentService;
use OCA\Text\Service\DocumentService;
use OCA\Text\Service\SessionService;
Expand Down Expand Up @@ -42,11 +41,6 @@ protected function run($argument): void {
// Inactive sessions will get removed further down and will trigger a reset next time
continue;
}

try {
$this->documentService->resetDocument($document->getId());
} catch (DocumentHasUnsavedChangesException) {
}
$this->attachmentService->cleanupAttachments($document->getId());
}

Expand Down
4 changes: 2 additions & 2 deletions lib/Listeners/BeforeNodeWrittenListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ public function handle(Event $event): void {
}
// Reset document session to avoid manual conflict resolution if there's no unsaved steps
try {
$this->documentService->resetDocument($node->getId());
$this->documentService->resetDocument($node->getId(), true);
} catch (DocumentHasUnsavedChangesException|NotFoundException $e) {
// Do not throw during event handling in this is expected to happen
// DocumentHasUnsavedChangesException: A document editing session is likely ongoing, someone can resolve the conflict
// NotFoundException: The event was called oin a file that was just created so a NonExistingFile object is used that has no id yet
$this->logger->debug('Reset document skipped in BeforeNodeWrittenEvent', ['exception' => $e]);
$this->logger->warning('Reset document skipped in BeforeNodeWrittenEvent', ['exception' => $e]);
}
}
}
8 changes: 6 additions & 2 deletions lib/Service/DocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,12 @@ public function addStep(Document $document, Session $session, array $steps, int
if ($readOnly && $message->isUpdate()) {
continue;
}
// Only accept sync protocol
if ($message->getYjsMessageType() !== YjsMessage::YJS_MESSAGE_SYNC) {
continue;
}
// Filter out query steps as they would just trigger clients to send their steps again
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
if ($message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
$stepsIncludeQuery = true;
} else {
$stepsToInsert[] = $step;
Expand Down Expand Up @@ -257,7 +261,7 @@ public function addStep(Document $document, Session $session, array $steps, int
$stepsToReturn = [];
foreach ($allSteps as $step) {
$message = YjsMessage::fromBase64($step->getData());
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_UPDATE) {
if ($message->isUpdate()) {
$stepsToReturn[] = $step;
}
}
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"vue-click-outside": "^1.1.0",
"vue-material-design-icons": "^5.3.1",
"webdav": "^5.8.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27"
Expand Down
39 changes: 39 additions & 0 deletions playwright/e2e/change-mime-type.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, mergeTests } from '@playwright/test'
import { test as editorTest } from '../support/fixtures/editor'
import { test as randomUserTest } from '../support/fixtures/random-user'
import { test as uploadFileTest } from '../support/fixtures/upload-file'

const test = mergeTests(editorTest, randomUserTest, uploadFileTest)

test.beforeEach(async ({ file }) => {
await file.open()
})

test.describe('Changing mimetype from markdown to plaintext', () => {
test('resets the document session and indexed db', async ({ editor, file }) => {
await editor.typeHeading('Hello world')
await file.close()
await file.move('test.txt')
await file.open()
await expect(editor.content).toHaveText('## Hello world')
await expect(editor.getHeading()).not.toBeVisible()
})
})

test.describe('Changing mimetype from plain to markdown', () => {
test.use({ fileName: 'empty.txt' })

test('resets the document session and indexed db', async ({ editor, file }) => {
await editor.type('## Hello world')
await expect(editor.content).toHaveText('## Hello world')
await file.close()
await file.move('test.md')
await file.open()
await expect(editor.getHeading({ name: 'Hello world' })).toBeVisible()
})
})
Loading
Loading