e0a5f57793
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
function installRafMocks() {
|
|
vi.stubGlobal('requestAnimationFrame', (cb: any) => {
|
|
return setTimeout(() => cb(Date.now()), 0) as unknown as number
|
|
})
|
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
|
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
|
|
})
|
|
}
|
|
|
|
async function loadUseMonaco() {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('../src/utils/registerMonacoThemes', () => {
|
|
return {
|
|
clearHighlighterCache: () => {},
|
|
getOrCreateHighlighter: async () => null,
|
|
registerMonacoThemes: async () => null,
|
|
}
|
|
})
|
|
|
|
vi.doMock('../src/monaco-shim', () => {
|
|
let lastCreatedModel: any = null
|
|
let lastCreatedEditor: any = null
|
|
|
|
class Range {
|
|
constructor(
|
|
public startLineNumber: number,
|
|
public startColumn: number,
|
|
public endLineNumber: number,
|
|
public endColumn: number,
|
|
) {}
|
|
}
|
|
|
|
function createModel(initialValue: string, initialLanguage: string) {
|
|
let value = initialValue
|
|
let languageId = initialLanguage
|
|
let getValueCallCount = 0
|
|
const contentSizeListeners = new Set<() => void>()
|
|
const contentChangeListeners = new Set<() => void>()
|
|
|
|
function lines() {
|
|
return value.split('\n')
|
|
}
|
|
|
|
function getOffsetAt(lineNumber: number, column: number) {
|
|
const parts = lines()
|
|
let offset = 0
|
|
for (let i = 0; i < lineNumber - 1; i++)
|
|
offset += (parts[i] ?? '').length + 1
|
|
return offset + column - 1
|
|
}
|
|
|
|
function emitChange() {
|
|
contentChangeListeners.forEach(listener => listener())
|
|
contentSizeListeners.forEach(listener => listener())
|
|
}
|
|
|
|
return {
|
|
getValue() {
|
|
getValueCallCount += 1
|
|
return value
|
|
},
|
|
setValue(next: string) {
|
|
value = next
|
|
emitChange()
|
|
},
|
|
getLineCount() {
|
|
return lines().length
|
|
},
|
|
getLineMaxColumn(lineNumber: number) {
|
|
return (lines()[lineNumber - 1] ?? '').length + 1
|
|
},
|
|
getPositionAt(offset: number) {
|
|
const consumed = value.slice(0, offset).split('\n')
|
|
return {
|
|
lineNumber: consumed.length,
|
|
column: consumed[consumed.length - 1].length + 1,
|
|
}
|
|
},
|
|
getLanguageId() {
|
|
return languageId
|
|
},
|
|
setLanguageId(next: string) {
|
|
languageId = next
|
|
},
|
|
applyEdits(edits: Array<{ range: Range, text: string }>) {
|
|
for (const edit of edits) {
|
|
const start = getOffsetAt(
|
|
edit.range.startLineNumber,
|
|
edit.range.startColumn,
|
|
)
|
|
const end = getOffsetAt(
|
|
edit.range.endLineNumber,
|
|
edit.range.endColumn,
|
|
)
|
|
value = value.slice(0, start) + edit.text + value.slice(end)
|
|
}
|
|
emitChange()
|
|
},
|
|
onDidContentSizeChange(listener: () => void) {
|
|
contentSizeListeners.add(listener)
|
|
return {
|
|
dispose() {
|
|
contentSizeListeners.delete(listener)
|
|
},
|
|
}
|
|
},
|
|
onDidChangeContent(listener: () => void) {
|
|
contentChangeListeners.add(listener)
|
|
return {
|
|
dispose() {
|
|
contentChangeListeners.delete(listener)
|
|
},
|
|
}
|
|
},
|
|
__getGetValueCallCount() {
|
|
return getValueCallCount
|
|
},
|
|
}
|
|
}
|
|
|
|
const editor = {
|
|
EditorOption: {
|
|
lineHeight: 'lineHeight',
|
|
readOnly: 'readOnly',
|
|
},
|
|
ScrollType: {
|
|
Smooth: 'smooth',
|
|
},
|
|
create: vi.fn((_: any, options: any) => {
|
|
const model = createModel(options.value ?? '', options.language ?? 'plaintext')
|
|
lastCreatedModel = model
|
|
const scrollListeners = new Set<(e: any) => void>()
|
|
let scrollTop = 0
|
|
const domNode = {
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}
|
|
|
|
const editorApi = {
|
|
getModel() {
|
|
return model
|
|
},
|
|
getValue() {
|
|
return model.getValue()
|
|
},
|
|
getOption(option: string) {
|
|
if (option === editor.EditorOption.lineHeight)
|
|
return 20
|
|
if (option === editor.EditorOption.readOnly)
|
|
return options.readOnly ?? true
|
|
return undefined
|
|
},
|
|
getLayoutInfo() {
|
|
return { height: 240 }
|
|
},
|
|
getScrollTop() {
|
|
return scrollTop
|
|
},
|
|
setScrollTop: vi.fn((next: number) => {
|
|
scrollTop = next
|
|
}),
|
|
getScrollHeight() {
|
|
return model.getLineCount() * 20
|
|
},
|
|
getContentHeight() {
|
|
return model.getLineCount() * 20
|
|
},
|
|
onDidContentSizeChange(listener: () => void) {
|
|
return model.onDidContentSizeChange(listener)
|
|
},
|
|
onDidChangeModelContent(listener: () => void) {
|
|
return model.onDidChangeContent(listener)
|
|
},
|
|
onDidScrollChange(listener: (e: any) => void) {
|
|
scrollListeners.add(listener)
|
|
return {
|
|
dispose() {
|
|
scrollListeners.delete(listener)
|
|
},
|
|
}
|
|
},
|
|
executeEdits(_: string, edits: Array<{ range: Range, text: string }>) {
|
|
model.applyEdits(edits)
|
|
},
|
|
revealLine: vi.fn(),
|
|
revealLineInCenter: vi.fn(),
|
|
revealLineInCenterIfOutsideViewport: vi.fn(),
|
|
dispose() {},
|
|
getDomNode() {
|
|
return domNode as any
|
|
},
|
|
__setScrollTop(next: number) {
|
|
scrollTop = next
|
|
},
|
|
}
|
|
lastCreatedEditor = editorApi
|
|
return editorApi
|
|
}),
|
|
setTheme: vi.fn(),
|
|
setModelLanguage: vi.fn((model: any, language: string) => {
|
|
model.setLanguageId(language)
|
|
}),
|
|
}
|
|
|
|
const languages = {
|
|
getLanguages: () => [],
|
|
register: vi.fn(),
|
|
}
|
|
|
|
return {
|
|
default: { editor, languages, Range },
|
|
editor,
|
|
languages,
|
|
Range,
|
|
ScrollType: editor.ScrollType,
|
|
__getLastModel() {
|
|
return lastCreatedModel
|
|
},
|
|
__getLastEditor() {
|
|
return lastCreatedEditor
|
|
},
|
|
}
|
|
})
|
|
|
|
const base = await import('../src/index.base')
|
|
const monacoModule: any = await import('../src/monaco-shim')
|
|
return {
|
|
...base,
|
|
__getLastModel: monacoModule.__getLastModel,
|
|
__getLastEditor: monacoModule.__getLastEditor,
|
|
}
|
|
}
|
|
|
|
describe('EditorManager update throttling', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'))
|
|
installRafMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.useRealTimers()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('respects updateThrottleMs after createEditor uses EditorManager', async () => {
|
|
const { useMonaco } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['javascript'],
|
|
readOnly: true,
|
|
updateThrottleMs: 50,
|
|
})
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
|
|
monaco.updateCode('a', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
expect(monaco.getCode()).toBe('a')
|
|
|
|
monaco.updateCode('ab', 'javascript')
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
expect(monaco.getCode()).toBe('a')
|
|
|
|
await vi.advanceTimersByTimeAsync(40)
|
|
expect(monaco.getCode()).toBe('ab')
|
|
})
|
|
|
|
it('propagates runtime throttle changes into the active EditorManager', async () => {
|
|
const { useMonaco } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['javascript'],
|
|
readOnly: true,
|
|
updateThrottleMs: 100,
|
|
})
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
|
|
monaco.updateCode('a', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
expect(monaco.getCode()).toBe('a')
|
|
|
|
monaco.updateCode('ab', 'javascript')
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
expect(monaco.getCode()).toBe('a')
|
|
|
|
monaco.setUpdateThrottleMs(0)
|
|
expect(monaco.getUpdateThrottleMs()).toBe(0)
|
|
|
|
await vi.runAllTimersAsync()
|
|
expect(monaco.getCode()).toBe('ab')
|
|
})
|
|
|
|
it('avoids rereading the whole model during programmatic streaming appends', async () => {
|
|
const { useMonaco, __getLastModel } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['javascript'],
|
|
readOnly: true,
|
|
updateThrottleMs: 0,
|
|
})
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
|
|
const model = __getLastModel()
|
|
const beforeFirstUpdate = model.__getGetValueCallCount()
|
|
monaco.updateCode('a', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
expect(model.__getGetValueCallCount() - beforeFirstUpdate).toBe(0)
|
|
|
|
const beforeSecondUpdate = model.__getGetValueCallCount()
|
|
monaco.updateCode('ab', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
expect(model.__getGetValueCallCount() - beforeSecondUpdate).toBe(0)
|
|
expect(monaco.getCode()).toBe('ab')
|
|
})
|
|
|
|
it('keeps frame-by-frame streaming appends byte-for-byte aligned', async () => {
|
|
const { useMonaco } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['json'],
|
|
readOnly: true,
|
|
updateThrottleMs: 0,
|
|
})
|
|
const code = `{
|
|
".c": "C",
|
|
".cpp": "C++",
|
|
".cc": "C++"
|
|
}`
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
for (let i = 1; i <= code.length; i++) {
|
|
monaco.updateCode(code.slice(0, i), 'json')
|
|
await vi.runAllTimersAsync()
|
|
}
|
|
|
|
expect(monaco.getCode()).toBe(code)
|
|
})
|
|
|
|
it('does not reveal while streamed content is still shorter than the editor height', async () => {
|
|
const { useMonaco, __getLastEditor } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['json'],
|
|
readOnly: true,
|
|
updateThrottleMs: 0,
|
|
})
|
|
const code = `{
|
|
".c": "C",
|
|
".cpp": "C++",
|
|
".cc": "C++"
|
|
}`
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
const editor = __getLastEditor()
|
|
for (const line of code.split('\n')) {
|
|
const next = monaco.getCode()
|
|
? `${monaco.getCode()}\n${line}`
|
|
: line
|
|
monaco.updateCode(next, 'json')
|
|
await vi.runAllTimersAsync()
|
|
}
|
|
|
|
expect(editor.revealLine).not.toHaveBeenCalled()
|
|
expect(editor.revealLineInCenter).not.toHaveBeenCalled()
|
|
expect(editor.revealLineInCenterIfOutsideViewport).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses Monaco content height without forcing a minimum host height', async () => {
|
|
const { useMonaco } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['json'],
|
|
readOnly: true,
|
|
updateThrottleMs: 0,
|
|
})
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
expect(container.style.minHeight).toBeUndefined()
|
|
expect(container.style.height).toBe('20px')
|
|
|
|
monaco.updateCode('{\n "a": 1\n}', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
expect(container.style.height).toBe('60px')
|
|
})
|
|
|
|
it('keeps scroll pinned to top before streamed content reaches max height', async () => {
|
|
const { useMonaco, __getLastEditor } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['json'],
|
|
readOnly: true,
|
|
updateThrottleMs: 0,
|
|
})
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
const editor = __getLastEditor()
|
|
editor.__setScrollTop(8)
|
|
monaco.updateCode('{\n "a": 1\n}', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
expect(container.style.height).toBe('60px')
|
|
expect(container.style.overflow).toBe('hidden')
|
|
expect(editor.setScrollTop).toHaveBeenCalledWith(0)
|
|
})
|
|
|
|
it('does not fight an external host height within the height hysteresis', async () => {
|
|
const { useMonaco } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['json'],
|
|
readOnly: true,
|
|
updateThrottleMs: 0,
|
|
})
|
|
|
|
const container = {
|
|
style: {},
|
|
innerHTML: '',
|
|
getBoundingClientRect: () => ({ height: 41 }),
|
|
} as any
|
|
await monaco.createEditor(container, '', 'json')
|
|
await vi.runAllTimersAsync()
|
|
|
|
monaco.updateCode('{\n}', 'json')
|
|
container.style.height = '41px'
|
|
await vi.runAllTimersAsync()
|
|
|
|
expect(container.style.height).toBe('41px')
|
|
})
|
|
|
|
it('still syncs externally edited content before the next streamed update', async () => {
|
|
const { useMonaco, __getLastModel } = await loadUseMonaco()
|
|
const monaco = useMonaco({
|
|
themes: ['vitesse-dark', 'vitesse-light'],
|
|
languages: ['javascript'],
|
|
readOnly: false,
|
|
updateThrottleMs: 0,
|
|
})
|
|
|
|
const container = { style: {}, innerHTML: '' } as any
|
|
await monaco.createEditor(container, '', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
|
|
const model = __getLastModel()
|
|
model.setValue('hello')
|
|
await vi.runAllTimersAsync()
|
|
|
|
monaco.updateCode('hello world', 'javascript')
|
|
await vi.runAllTimersAsync()
|
|
expect(monaco.getCode()).toBe('hello world')
|
|
})
|
|
})
|