Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 3 additions & 1 deletion .playwright/scripts/__tests__/desktop.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ test('three concurrent instances isolate their data and survive another instance
}, index)
expect(await (await app.browserWindow(page)).evaluate(window => window.getTitle())).toBe('Lexora Buddy Test')
}))
await applications[0].app.evaluate(({ session }) => session.defaultSession.flushStorageData())
await instances[0].stop()
applications[0] = await instances[0].launch()
expect(await applications[0].page.evaluate(() => localStorage.getItem('test-instance'))).toBe('0')
const crashed = applications[0].app.process()
const exited = once(crashed, 'exit')
crashed.kill('SIGKILL')
Expand Down
310 changes: 310 additions & 0 deletions .playwright/scripts/__tests__/extensionAgent.e2e.mjs

Large diffs are not rendered by default.

410 changes: 410 additions & 0 deletions .playwright/scripts/__tests__/settings.e2e.mjs

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions .playwright/scripts/__tests__/workbench.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { expect, test } from '../fixtures/electron.mjs'

test('builtin working copies back up continued edits, veto closure and recover across restart without plugins', async ({ buddy }) => {
const instance = await buddy.createInstance('working-copy')
const directory = path.join(instance.home, 'documents')
const document = path.join(directory, 'README.md')
await fs.mkdir(directory)
await fs.writeFile(document, 'Original document.\n')
let { app, page, diagnostics } = await instance.launch()
expect(await page.evaluate(() => window.lexoraDesktop.extensions.list())).toEqual([])
await app.evaluate(({ dialog }, directory) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] })
}, directory)
const section = page.locator('.desktop-task-sidebar__spaces')
await section.locator('.desktop-task-sidebar__section-heading').hover()
await section.locator('.desktop-task-sidebar__section-add').click()
const spaceDialog = page.locator('.desktop-space-dialog')
await spaceDialog.getByPlaceholder('输入空间名称').fill('工作副本验收')
await spaceDialog.getByRole('button', { name: '选择目录', exact: true }).click()
await expect(spaceDialog.locator('.desktop-space-primary-directory input')).toHaveValue(directory)
await spaceDialog.getByRole('button', { name: '确定', exact: true }).click()
await expect(spaceDialog).toBeHidden()
const space = page.locator('.desktop-task-space-row').filter({ hasText: '工作副本验收' })
await space.hover()
await space.getByRole('button', { name: '更多操作', exact: true }).click()
await page.locator('.n-dropdown-menu:visible').getByText('新任务', { exact: true }).click()
await page.locator('.desktop-workbench-area__tasks .tiptap').click()
await page.keyboard.press('Control+Shift+P')
await page.getByPlaceholder('输入命令名称').fill('文件')
await page.getByPlaceholder('输入命令名称').press('Enter')
await expect(page.locator('.n-modal')).toBeHidden()
await page.getByTestId('context-file-tree').getByText('README.md', { exact: true }).click()
await page.getByTestId('document-mode-edit').click()
let editor = page.getByTestId('workbench-text-editor')
await expect(editor.locator('.view-lines')).toContainText('Original document.')
await editor.locator('.view-lines').click()
await page.keyboard.press('Control+End')
await page.keyboard.insertText('First edit.\n')
await expect(page.locator('.file-editor[data-dirty="true"]')).toBeVisible()
await page.keyboard.insertText('Second edit while dirty.\n')
await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.workbench.read())?.backups.some(backup => backup.text.includes('Second edit while dirty.')))).toBe(true)
await expect.poll(() => page.evaluate(async () => Object.values((await window.lexoraDesktop.workbench.read())?.layout.views ?? {}).some(view => view.type === 'files.editor' && view.state.editor?.cursorState?.[0]?.position.lineNumber === 4))).toBe(true)
const readBackup = () => page.evaluate(async () => (await window.lexoraDesktop.workbench.read())?.backups.find(backup => backup.resource.data.path === 'README.md')?.text)
const beforeUndo = await readBackup()
await page.keyboard.press('Control+z')
await expect.poll(readBackup).not.toBe(beforeUndo)
await page.keyboard.press('Control+y')
await expect.poll(readBackup).toBe(beforeUndo)
await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.')
expect(await fs.readFile(document, 'utf8')).toBe('Original document.\n')
await page.getByTestId('task-context-panel').getByRole('button', { name: '关闭文件', exact: true }).click()
const close = page.locator('.n-dialog').filter({ hasText: '关闭前保存更改?' })
await close.getByRole('button', { name: '取消', exact: true }).click()
await expect(close).toBeHidden()
await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.')
await instance.stop()
;({ app, page, diagnostics } = await instance.launch())
const panelToggle = page.getByTestId('context-panel-toggle')
if (await panelToggle.getAttribute('aria-expanded') === 'false')
await panelToggle.click()
editor = page.getByTestId('workbench-text-editor')
await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.')
await expect(page.locator('.file-editor[data-dirty="true"]')).toBeVisible()
expect(await fs.readFile(document, 'utf8')).toBe('Original document.\n')
await editor.locator('.view-lines').click()
await page.keyboard.press('Control+s')
await expect(page.locator('.file-editor[data-dirty="false"]')).toBeVisible()
await expect.poll(() => fs.readFile(document, 'utf8')).toContain('Second edit while dirty.')
await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.workbench.read())?.backups.length)).toBe(0)
await page.screenshot({ path: path.join(instance.artifactDirectory, 'recovered-and-saved.png'), animations: 'disabled' })
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})
3 changes: 2 additions & 1 deletion apps/buddy/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import UnoCSS from 'unocss/vite'
import { extensionRuntimePlugin } from './electron/extensionRuntimePlugin.ts'

const PRODUCTION_CONNECT_SRC = 'connect-src \'self\';'
const DEVELOPMENT_CONNECT_SRC
Expand Down Expand Up @@ -33,7 +34,7 @@ const electronCacheRoot = fileURLToPath(
export default defineConfig({
main: {
cacheDir: join(electronCacheRoot, 'main'),
plugins: [externalizeDepsPlugin({ exclude: ['typescript'] })],
plugins: [extensionRuntimePlugin(), externalizeDepsPlugin({ exclude: ['typescript'] })],
build: {
outDir: join(electronOutputRoot, 'main'),
rollupOptions: {
Expand Down
31 changes: 31 additions & 0 deletions apps/buddy/electron/extensionRuntimePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Plugin } from 'vite'
import { fileURLToPath } from 'node:url'
import { build } from 'vite'

export function extensionRuntimePlugin(): Plugin {
const prefix = 'virtual:extension-runtime/'
return {
name: 'lexora-extension-runtime',
resolveId(id) {
if (id === `${prefix}host` || id === `${prefix}view`)
return `\0${id}`
},
async load(id) {
if (!id.startsWith(`\0${prefix}`))
return
const kind = id.slice(prefix.length + 1)
const result = await build({
configFile: false,
envFile: false,
logLevel: 'silent',
build: { write: false, minify: false, target: 'esnext', lib: { entry: fileURLToPath(new URL(`./main/extensions/runtime/${kind}.js`, import.meta.url)), formats: ['es'] } },
})
const chunks = (Array.isArray(result) ? result.flatMap(item => item.output) : 'output' in result ? result.output : []).filter(item => item.type === 'chunk')
if (chunks.length !== 1 || chunks[0]!.imports.length)
throw new Error('Extension runtime must be a self-contained browser module')
for (const path of Object.keys(chunks[0]!.modules))
this.addWatchFile(path)
return `export default ${JSON.stringify(chunks[0]!.code)}`
},
}
}
50 changes: 49 additions & 1 deletion apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { PrivateDirectoryError } from '../../../platform/windows/privateDirectories'
import { ServiceHost } from '../../../shared/lifecycle/ServiceHost'
import { ApplicationEvents } from '../../../shared/observability/ApplicationEvents'
import { observeLifecycleDiagnostics } from '../../../shared/observability/lifecycleDiagnostics'
import { closeDesktopDiagnostics } from '../app/closeDesktopDiagnostics'
import { DesktopStartup } from '../app/DesktopStartup'
import { observeStartupDiagnostics } from '../app/startupDiagnostics'
import { DesktopDiagnosticLogger } from '../desktopDiagnostics'
import { ApplicationLogReader } from '../diagnostics/ApplicationLogReader'
import { DiagnosticFile } from '../diagnostics/diagnosticFile'
Expand Down Expand Up @@ -52,7 +56,8 @@ describe('desktop diagnostics', () => {
const { directory, logger } = await createLogger()
const events = new ApplicationEvents()
events.subscribe(event => logger.record({ ...event, scope: 'desktop' }))
const host = new ServiceHost(events)
const host = new ServiceHost()
observeLifecycleDiagnostics(host.lifecycle, events)
const failure = { kind: 'private_directories', operation: 'open_directory', directoryRole: 'session_data', systemError: { domain: 'ntstatus', code: 0xC0000022 }, exitCode: 1 } as const
const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_FAILED', failure, { cause: new Error('token=fixture-secret') })
await expect(host.step('desktop.environment', () => {
Expand All @@ -67,6 +72,37 @@ describe('desktop diagnostics', () => {
expect(page.records[0]).toMatchObject({ failure })
})

it('records the final cleanup result before closing the logger', async () => {
const { directory, logger } = await createLogger()
const events = new ApplicationEvents()
const startup = new DesktopStartup()
const host = new ServiceHost()
events.subscribe(event => logger.record({ ...event, scope: 'desktop' }))
startup.bindDesktop(host.lifecycle)
observeLifecycleDiagnostics(host.lifecycle, events)
observeStartupDiagnostics(startup, events)
await host.start('desktop', ({ defer }) => {
defer(() => {
throw new Error('fixture-private-cleanup')
})
})
startup.stopping()
try {
await host.stop()
}
catch (error) {
startup.stopped(error)
}
await closeDesktopDiagnostics(logger)
const records = await readRecords(directory)
expect(records.at(-1)?.event).toBe('app.stop_failed')
expect(records.filter(record => record.event === 'app.stop_failed')).toHaveLength(1)
expect(records.some(record => record.event === 'component.stop_failed')).toBe(true)
expect(JSON.stringify(records)).not.toContain('fixture-private-cleanup')
expect(logger.status).toMatchObject({ state: 'closed', unconfirmed: 0, dropped: 0, failed: 0 })
expect(startup.state.status).toBe('stopped')
})

it('retains run and turn identities while excluding arbitrary operation details', async () => {
const { directory, logger } = await createLogger()
const input = {
Expand Down Expand Up @@ -213,6 +249,18 @@ describe('desktop diagnostics', () => {
})
})

it('reports bounded close loss once through stderr without writing back to the closed sink', async () => {
const { directory, logger } = await createLogger()
logger.record({ ...event, message: 'fixture-private'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await closeDesktopDiagnostics(logger)
await closeDesktopDiagnostics(logger)
expect(stderr).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(stderr.mock.calls[0]![0]))).toEqual({ event: 'recorder.close_incomplete', dropped: 1, failed: 0, unconfirmed: 0, closeTimedOut: false, ioFailed: false })
expect((await readRecords(directory)).map(record => record.event)).toEqual(['recorder.loss'])
expect(logger.status).toMatchObject({ state: 'closed', accepted: 1, written: 1, dropped: 1 })
})

it('bounds file size and count while retaining the latest complete records', async () => {
const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 3 })
for (let index = 0; index < 8; index++) {
Expand Down
42 changes: 28 additions & 14 deletions apps/buddy/electron/main/app/DesktopApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { localTransports } from '../../../platform/ipc/localTransport'
import { DEFAULT_BROWSER_PREFERENCES } from '../../../shared/browser/browserPreferences'
import { readDiagnosticErrorCode } from '../../../shared/diagnostics/applicationDiagnostic'
import { ServiceHost } from '../../../shared/lifecycle/ServiceHost'
import { ServiceLifecycleSource } from '../../../shared/lifecycle/ServiceLifecycleSource'
import { observeLifecycleDiagnostics } from '../../../shared/observability/lifecycleDiagnostics'
import { BrowserIntegration } from '../browser/BrowserIntegration'
import { resolveDesktopLaunchIntent } from '../startupIntent'
import { closeDesktopDiagnostics } from './closeDesktopDiagnostics'
import { confirmDesktopQuit, showBackgroundCloseNotice, showDesktopStartupFailure, showLegacyPowerShellNotice } from './desktopDialogs'
import { DesktopIntegrations } from './DesktopIntegrations'
import { describeProcessExit } from './desktopProcessDiagnostics'
Expand All @@ -27,15 +30,21 @@ class DesktopApplication {
readonly #runtime: DesktopRuntimeHost
readonly #integrations: DesktopIntegrations
readonly #host: ServiceHost
readonly #lifecycleSubscriptions: (() => void)[]
readonly #quit: ReturnType<typeof createDesktopQuitLifecycle>
#disposePromise: Promise<void> | null = null
#rendererRecoveryPrompt: Promise<void> | null = null

constructor(environment: DesktopEnvironment) {
this.#environment = environment
this.#host = new ServiceHost(environment.events)
this.#host = new ServiceHost(new ServiceLifecycleSource(() => environment.events.publish({ event: 'observer.failed', component: 'desktop.lifecycle', level: 'warn' })))
this.#lifecycleSubscriptions = [
environment.startup.bindDesktop(this.#host.lifecycle),
observeLifecycleDiagnostics(this.#host.lifecycle, environment.events),
]
this.#windows = new DesktopWindowHost(environment)
this.#browser = new BrowserIntegration({
report: event => environment.events.publish(event),
isTaskLinked: () => this.#runtime.config?.desktop.contextPanelMode === 'task',
onActivityError: () => environment.events.publish({ level: 'warn', event: 'browser.activity.failed', errorCode: 'BROWSER_ACTIVITY_FAILED' }),
endpoint: localTransports[currentPlatform.transport](environment.paths.browserAdapterSocket),
Expand All @@ -53,7 +62,7 @@ class DesktopApplication {
try {
await this.#dispose()
}
finally { await environment.diagnostics.close() }
finally { await closeDesktopDiagnostics(environment.diagnostics) }
},
quit: (restart) => {
if (restart)
Expand All @@ -75,8 +84,9 @@ class DesktopApplication {
nativeTheme.on('updated', () => this.#windows.updateAppearance())
process.once('SIGINT', () => {
void this.#quit.request({ discardDraftsOnFailure: true }).catch(async (error) => {
this.#environment.events.publish({ level: 'error', event: 'app.interrupt_failed', errorCode: readDiagnosticErrorCode(error) })
await this.#environment.diagnostics.close()
if (!this.#quit.quitting)
this.#environment.events.publish({ level: 'error', event: 'app.interrupt_failed', errorCode: readDiagnosticErrorCode(error) })
await closeDesktopDiagnostics(this.#environment.diagnostics)
app.exit(1)
})
})
Expand Down Expand Up @@ -150,7 +160,7 @@ class DesktopApplication {
}
finally {
try {
await this.#environment.diagnostics.close()
await closeDesktopDiagnostics(this.#environment.diagnostics)
}
finally { app.exit(1) }
}
Expand All @@ -159,15 +169,17 @@ class DesktopApplication {

#requestQuit(): void {
void this.#quit.request().catch((error) => {
this.#environment.events.publish({ level: 'error', event: 'app.stop_failed', errorCode: readDiagnosticErrorCode(error) })
if (!this.#quit.quitting)
this.#environment.events.publish({ level: 'error', event: 'app.stop_failed', errorCode: readDiagnosticErrorCode(error) })
if (this.#quit.quitting)
app.exit(1)
})
}

#requestRestart(): void {
void this.#quit.request({ restart: true }).catch((error) => {
this.#environment.events.publish({ level: 'error', event: 'app.restart_failed', errorCode: readDiagnosticErrorCode(error) })
if (!this.#quit.quitting)
this.#environment.events.publish({ level: 'error', event: 'app.restart_failed', errorCode: readDiagnosticErrorCode(error) })
if (this.#quit.quitting)
app.exit(1)
})
Expand Down Expand Up @@ -196,7 +208,6 @@ class DesktopApplication {

#dispose(): Promise<void> {
this.#disposePromise ??= (async () => {
this.#environment.events.publish({ level: 'info', event: 'app.stopping' })
this.#environment.startup.stopping()
const failures: unknown[] = []
try {
Expand All @@ -205,10 +216,12 @@ class DesktopApplication {
catch (error) {
failures.push(error)
}
this.#environment.events.publish({ level: failures.length ? 'error' : 'info', event: failures.length ? 'app.stop_failed' : 'app.stopped' })
this.#environment.startup.stopped()
if (failures.length)
throw new AggregateError(failures, 'Desktop application cleanup failed')
const failure = failures.length ? new AggregateError(failures, 'Desktop application cleanup failed') : undefined
this.#environment.startup.stopped(failure)
for (const stop of this.#lifecycleSubscriptions)
stop()
if (failure)
throw failure
})()
return this.#disposePromise
}
Expand All @@ -223,7 +236,7 @@ export async function startDesktopApplication(): Promise<void> {
if (!app.requestSingleInstanceLock()) {
environment.diagnostics.record({ scope: 'desktop', level: 'info', event: 'startup.single_instance_lock_unavailable' })
await checkDesktopCoreDirectories(environment)
await environment.diagnostics.close()
await closeDesktopDiagnostics(environment.diagnostics)
app.quit()
return
}
Expand All @@ -245,7 +258,8 @@ export async function startDesktopApplication(): Promise<void> {
const failedEnvironment = environment
void showDesktopStartupFailure(error, 'zh-CN', environment, failedEnvironment ? () => prepareDesktopReady(failedEnvironment) : undefined).finally(async () => {
try {
await environment?.diagnostics.close()
if (environment)
await closeDesktopDiagnostics(environment.diagnostics)
}
finally {
app.exit(1)
Expand Down
Loading