first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
export const activeEditorLine = () => {
// wait for the selection to be in the editor content DOM
cy.window().then(win => {
cy.get('.cm-content').should($el => {
const contentNode = $el.get(0)
const range = win.getSelection()?.getRangeAt(0)
expect(range?.intersectsNode(contentNode)).to.be.true
})
})
// find the closest line block ancestor of the selection
return cy.window().then(win => {
const activeNode = win.getSelection()?.focusNode
if (!activeNode) {
return cy.wrap(null)
}
// use the parent element if this is a node, e.g. text
const activeElement = (
'closest' in activeNode ? activeNode : activeNode.parentElement
) as HTMLElement | undefined
return cy.wrap(activeElement?.closest('.cm-line'))
})
}

View File

@@ -0,0 +1,249 @@
/* eslint-disable no-dupe-class-members */
import { LanguageSupport } from '@codemirror/language'
import { EditorSelection, Line, SelectionRange } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { Assertion } from 'chai'
import { LaTeXLanguage } from '../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
export class CodemirrorTestSession {
public view: EditorView
constructor(content: string[] | string) {
this.view = createView(content)
}
insert(content: string): void {
this.view.dispatch(
this.view.state.changeByRange(range => {
const changeDescription = [
{
from: range.from,
to: range.to,
insert: content,
},
]
const changes = this.view.state.changes(changeDescription)
return {
range: EditorSelection.cursor(range.head).map(changes),
changes,
}
})
)
}
insertAt(position: number, content: string) {
const changes = [{ from: position, insert: content }]
this.view.dispatch({
changes,
selection: this.view.state.selection.map(
this.view.state.changes(changes),
1
),
})
}
insertAtLine(line: number, offset: number, content: string): void
insertAtLine(line: number, content: string): void
insertAtLine(
lineNumber: number,
offsetOrContent: string | number,
content?: string
) {
const line = this.view.state.doc.line(lineNumber)
if (typeof offsetOrContent === 'string' && typeof content === 'string') {
throw new Error(
'If a third argument is provided, the second must be an integer'
)
}
// Insert at end of line
if (typeof offsetOrContent === 'string') {
content = offsetOrContent
offsetOrContent = line.to
}
if (typeof content !== 'string') {
throw new Error('content must be provided to insertAtLine')
}
if (offsetOrContent < line.from || offsetOrContent > line.to) {
throw new Error('Offset is outside the range of the line')
}
this.insertAt(line.from + offsetOrContent, content)
}
delete(position: number, length: number) {
this.view.dispatch({
changes: [{ from: position - length, to: position }],
})
}
applyCommand(command: (view: EditorView) => any) {
return command(this.view)
}
setCursor(position: number): void
setCursor(line: number, offset: number): void
setCursor(positionOrLine: number, offset?: number) {
if (offset !== undefined) {
const line = this.view.state.doc.line(positionOrLine)
positionOrLine = line.from + offset
}
this.view.dispatch({
selection: EditorSelection.cursor(positionOrLine),
})
}
setSelection(selection: EditorSelection) {
this.view.dispatch({
selection,
})
}
}
const latex = new LanguageSupport(LaTeXLanguage)
function createView(content: string[] | string): EditorView {
if (Array.isArray(content)) {
content = content.join('\n')
}
return new EditorView({
doc: stripSelectionMarkers(content),
selection: createSelections(content) ?? EditorSelection.cursor(0),
extensions: [latex],
})
}
function stripSelectionMarkers(content: string) {
return content.replaceAll(/[<|>]/g, '')
}
function hasSelectionMarkers(content: string) {
return !!content.match(/[<|>]/g)
}
function createSelections(content: string, offset = 0) {
const selections = []
let index = 0
for (let i = 0; i < content.length; i++) {
if (content[i] === '|') {
selections.push(EditorSelection.cursor(index + offset))
}
if (content[i] === '<') {
// find end
const startOfRange = index
let foundEnd = false
for (++i; i < content.length; ++i) {
if (content[i] === '|') {
throw new Error(
"Invalid cursor indicator '|' within a range started with '<'"
)
}
if (content[i] === '<') {
throw new Error(
"Invalid start range indicator '<' inside another range"
)
}
if (content[i] === '>') {
foundEnd = true
selections.push(
EditorSelection.range(startOfRange + offset, index + offset)
)
break
}
index++
}
if (!foundEnd) {
throw new Error("Missing end range indicator '>'")
}
}
index++
}
if (selections.length) {
return EditorSelection.create(selections)
}
return null
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Chai {
interface Assertion {
line(lineNumber: number): Assertion
}
}
}
export function viewHelpers(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
utils.addMethod(
chai.Assertion.prototype,
'line',
function getLine(this: Chai.Assertion, line: number) {
const object = utils.flag(this, 'object')
new Assertion(object).to.be.instanceOf(CodemirrorTestSession)
const testSession = object as CodemirrorTestSession
const lineInEditor = testSession.view.state.doc.line(line)
utils.flag(this, 'object', lineInEditor.text)
utils.flag(this, 'cmSession', testSession)
utils.flag(this, 'line', lineInEditor)
}
)
utils.overwriteMethod(chai.Assertion.prototype, 'equal', (_super: any) => {
return function newEqual(
this: Chai.Assertion,
value: string,
requireSelections?: boolean
) {
const session = utils.flag(this, 'cmSession') as
| CodemirrorTestSession
| undefined
utils.flag(this, 'cmSession', null)
const line = utils.flag(this, 'line') as Line | undefined
utils.flag(this, 'line', null)
if (!session || !line) {
// eslint-disable-next-line prefer-rest-params
return _super.apply(this, arguments)
}
const lineContent = stripSelectionMarkers(value)
if (requireSelections === undefined) {
requireSelections = hasSelectionMarkers(value)
}
// We can now check selections as well
const selections = createSelections(value, line.from)
const contentAssertion = new Assertion(line.text)
utils.transferFlags(this, contentAssertion)
contentAssertion.to.equal(lineContent)
if (selections) {
const selectionAssertion = new Assertion(
session.view.state.selection.ranges
)
utils.transferFlags(this, selectionAssertion, false)
for (const rangeToMatch of selections.ranges) {
selectionAssertion.satisfies(
(ranges: SelectionRange[]) =>
ranges.some(
possibleMatch =>
possibleMatch.eq(rangeToMatch) ||
// Allow reverse selections as well, as we don't syntactically
// distinguish them
EditorSelection.range(
possibleMatch.to,
possibleMatch.from
).eq(rangeToMatch)
),
`Selections [${session.view.state.selection.ranges
.map(range => `{ from: ${range.from}, to: ${range.to}}`)
.join(', ')}] did not include selection {from: ${
rangeToMatch.from
}, to: ${rangeToMatch.to}}`
)
}
}
}
})
}

View File

@@ -0,0 +1,3 @@
import { isMac } from '@/shared/utils/os'
export const metaKey = isMac ? 'meta' : 'ctrl'

View File

@@ -0,0 +1,104 @@
import { ShareDoc } from '../../../../../types/share-doc'
import { EventEmitter } from 'events'
export const docId = 'test-doc'
export function mockDocContent(content: string) {
return `
\\documentclass{article}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstracts}
\\section{Introduction}
Your introduction goes here!
\\section{Results}
Your results go here! \\cite{foo}
${content}
\\end{document}`
}
const contentLines = Array.from(Array(100), (e, i) => `contentLine ${i}`)
const defaultContent = mockDocContent(contentLines.join('\n'))
const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength
class MockShareDoc extends EventEmitter {
constructor(public text: string) {
super()
}
getText() {
return this.text
}
insert() {
// do nothing
}
del() {
// do nothing
}
}
export const mockDoc = (content = defaultContent) => {
const mockShareJSDoc: ShareDoc = new MockShareDoc(content)
return {
doc_id: docId,
getSnapshot: () => {
return content
},
attachToCM6: (cm6: any) => {
cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH)
},
detachFromCM6: () => {
// Do nothing
},
on: () => {
// Do nothing
},
off: () => {
// Do nothing
},
ranges: {
changes: [],
comments: [],
getIdSeed: () => '123',
setIdSeed: () => {},
getTrackedDeletesLength: () => 0,
getDirtyState: () => ({
comment: {
moved: {},
removed: {},
added: {},
},
change: {
moved: {},
removed: {},
added: {},
},
}),
resetDirtyState: () => {},
},
setTrackChangesIdSeeds: () => {},
getTrackingChanges: () => true,
setTrackingChanges: () => {},
getInflightOp: () => null,
getPendingOp: () => null,
hasBufferedOps: () => false,
leaveAndCleanUpPromise: () => false,
}
}

View File

@@ -0,0 +1,85 @@
import { docId, mockDoc } from './mock-doc'
import { sleep } from '../../../helpers/sleep'
import { Folder } from '../../../../../types/folder'
export const rootFolderId = '012345678901234567890123'
export const figuresFolderId = '123456789012345678901234'
export const figureId = '234567890123456789012345'
export const mockScope = (content?: string) => {
return {
editor: {
sharejs_doc: mockDoc(content),
open_doc_name: 'test.tex',
open_doc_id: docId,
showVisual: false,
wantTrackChanges: false,
},
pdf: {
logEntryAnnotations: {},
},
project: {
_id: 'test-project',
name: 'Test Project',
spellCheckLanguage: 'en',
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'test.tex',
},
],
folders: [
{
_id: figuresFolderId,
name: 'figures',
docs: [
{
_id: 'fake-nested-doc-id',
name: 'foo.tex',
},
],
folders: [],
fileRefs: [
{
_id: figureId,
name: 'frog.jpg',
hash: '42',
},
{
_id: 'fake-figure-id',
name: 'unicorn.png',
hash: '43',
},
],
},
],
fileRefs: [],
},
] as Folder[],
features: {
trackChanges: true,
},
trackChangesState: {},
members: [],
},
permissions: {
comment: true,
trackedWrite: true,
write: true,
},
ui: {
reviewPanelOpen: false,
},
toggleReviewPanel: cy.stub(),
toggleTrackChangesForEveryone: cy.stub(),
refreshResolvedCommentsDropdown: cy.stub(() => sleep(1000)),
onlineUserCursorHighlights: {},
permissionsLevel: 'owner',
$on: cy.stub().log(false),
$broadcast: cy.stub().log(false),
$emit: cy.stub().log(false),
}
}

View File

@@ -0,0 +1,12 @@
import { FC, ComponentProps, Suspense } from 'react'
const style = { width: 785, height: 785 }
export const TestContainer: FC<ComponentProps<'div'>> = ({
children,
...rest
}) => (
<div style={style} {...rest}>
<Suspense fallback={null}>{children}</Suspense>
</div>
)