diff --git a/features/stepDefinitions/sync.ts b/features/stepDefinitions/sync.ts new file mode 100644 index 00000000..4a9c607e --- /dev/null +++ b/features/stepDefinitions/sync.ts @@ -0,0 +1,128 @@ +import { Then, When } from '@cucumber/cucumber'; +import { exec as gitExec } from 'dugite'; +import fs from 'fs-extra'; +import path from 'path'; +import { wikiTestRootPath } from '../supports/paths'; +import type { ApplicationWorld } from './application'; + +/** + * Create a bare git repository to use as a local remote for testing sync + */ +When('I create a bare git repository at {string}', async function(this: ApplicationWorld, repoPath: string) { + const actualPath = repoPath.replace('{tmpDir}', wikiTestRootPath); + + // Remove if exists + if (await fs.pathExists(actualPath)) { + await fs.remove(actualPath); + } + + // Create bare repository + await fs.ensureDir(actualPath); + await gitExec(['init', '--bare'], actualPath); +}); + +/** + * Verify that a commit with specific message exists in remote repository + */ +Then('the remote repository {string} should contain commit with message {string}', async function(this: ApplicationWorld, remotePath: string, commitMessage: string) { + const actualRemotePath = remotePath.replace('{tmpDir}', wikiTestRootPath); + + // Clone the remote to a temporary location to inspect it + const temporaryClonePath = path.join(wikiTestRootPath, `temp-clone-${Date.now()}`); + + try { + await gitExec(['clone', actualRemotePath, temporaryClonePath], wikiTestRootPath); + + // Check all branches for the commit message + const branchResult = await gitExec(['branch', '-a'], temporaryClonePath); + if (branchResult.exitCode !== 0) { + throw new Error(`Failed to list branches: ${branchResult.stderr}`); + } + + // Try to find commits in any branch + let foundCommit = false; + const branches = branchResult.stdout.split('\n').filter(b => b.trim()); + + for (const branch of branches) { + const branchName = branch.trim().replace('* ', '').replace('remotes/origin/', ''); + if (!branchName) continue; + + try { + // Checkout the branch + await gitExec(['checkout', branchName], temporaryClonePath); + + // Get commit log + const result = await gitExec(['log', '--oneline', '-10'], temporaryClonePath); + if (result.exitCode === 0 && result.stdout.includes(commitMessage)) { + foundCommit = true; + break; + } + } catch { + // Branch might not exist or be checkable, continue to next + continue; + } + } + + if (!foundCommit) { + // Get all logs from all branches for error message + const allLogsResult = await gitExec(['log', '--all', '--oneline', '-20'], temporaryClonePath); + throw new Error(`Commit with message "${commitMessage}" not found in any branch. Available commits:\n${allLogsResult.stdout}\n\nBranches:\n${branchResult.stdout}`); + } + } finally { + // Clean up temporary clone + if (await fs.pathExists(temporaryClonePath)) { + await fs.remove(temporaryClonePath); + } + } +}); + +/** + * Verify that a file exists in remote repository + */ +Then('the remote repository {string} should contain file {string}', async function(this: ApplicationWorld, remotePath: string, filePath: string) { + const actualRemotePath = remotePath.replace('{tmpDir}', wikiTestRootPath); + + // Clone the remote to a temporary location to inspect it + const temporaryClonePath = path.join(wikiTestRootPath, `temp-clone-${Date.now()}`); + + try { + await gitExec(['clone', actualRemotePath, temporaryClonePath], wikiTestRootPath); + + // Check all branches for the file + const branchResult = await gitExec(['branch', '-a'], temporaryClonePath); + if (branchResult.exitCode !== 0) { + throw new Error(`Failed to list branches: ${branchResult.stderr}`); + } + + let foundFile = false; + const branches = branchResult.stdout.split('\n').filter(b => b.trim()); + + for (const branch of branches) { + const branchName = branch.trim().replace('* ', '').replace('remotes/origin/', ''); + if (!branchName) continue; + + try { + // Checkout the branch + await gitExec(['checkout', branchName], temporaryClonePath); + + const fileFullPath = path.join(temporaryClonePath, filePath); + if (await fs.pathExists(fileFullPath)) { + foundFile = true; + break; + } + } catch { + // Branch might not exist or be checkable, continue to next + continue; + } + } + + if (!foundFile) { + throw new Error(`File "${filePath}" not found in any branch of remote repository`); + } + } finally { + // Clean up temporary clone + if (await fs.pathExists(temporaryClonePath)) { + await fs.remove(temporaryClonePath); + } + } +}); diff --git a/features/stepDefinitions/ui.ts b/features/stepDefinitions/ui.ts index 95385d00..74876d27 100644 --- a/features/stepDefinitions/ui.ts +++ b/features/stepDefinitions/ui.ts @@ -1,4 +1,5 @@ import { DataTable, Then, When } from '@cucumber/cucumber'; +import { wikiTestRootPath } from '../supports/paths'; import type { ApplicationWorld } from './application'; When('I wait for {float} seconds', async function(seconds: number) { @@ -243,15 +244,54 @@ When('I type {string} in {string} element with selector {string}', async functio throw new Error('No current window is available'); } + // Replace {tmpDir} placeholder with actual test root path + const actualText = text.replace('{tmpDir}', wikiTestRootPath); + try { await currentWindow.waitForSelector(selector, { timeout: 10000 }); const element = currentWindow.locator(selector); - await element.fill(text); + await element.fill(actualText); } catch (error) { throw new Error(`Failed to type in ${elementComment} element with selector "${selector}": ${error as Error}`); } }); +When('I type in {string} elements with selectors:', async function(this: ApplicationWorld, elementDescriptions: string, dataTable: DataTable) { + const currentWindow = this.currentWindow; + if (!currentWindow) { + throw new Error('No current window is available'); + } + + const descriptions = elementDescriptions.split(' and ').map(d => d.trim()); + const rows = dataTable.raw(); + const errors: string[] = []; + + if (descriptions.length !== rows.length) { + throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${rows.length} text/selector pairs provided`); + } + + // Type in elements sequentially to maintain order + for (let index = 0; index < rows.length; index++) { + const [text, selector] = rows[index]; + const elementComment = descriptions[index]; + + // Replace {tmpDir} placeholder with actual test root path + const actualText = text.replace('{tmpDir}', wikiTestRootPath); + + try { + await currentWindow.waitForSelector(selector, { timeout: 10000 }); + const element = currentWindow.locator(selector); + await element.fill(actualText); + } catch (error) { + errors.push(`Failed to type in "${elementComment}" with selector "${selector}": ${error as Error}`); + } + } + + if (errors.length > 0) { + throw new Error(`Failed to type in some elements:\n${errors.join('\n')}`); + } +}); + When('I clear text in {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) { const currentWindow = this.currentWindow; if (!currentWindow) { diff --git a/features/sync.feature b/features/sync.feature new file mode 100644 index 00000000..161bb58e --- /dev/null +++ b/features/sync.feature @@ -0,0 +1,55 @@ +Feature: Git Sync + As a user + I want to sync my wiki to a remote repository + So that I can backup and share my content + + Background: + Given I cleanup test wiki so it could create a new one on start + And I launch the TidGi application + And I wait for the page to load completely + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + And I wait for "git initialization" log marker "[test-id-git-init-complete]" + + @git @sync + Scenario: Sync to local remote repository via application menu (commit and push) + # Setup a bare git repository as local remote + When I create a bare git repository at "{tmpDir}/remote-repo-menu.git" + # Configure sync via edit workspace window + When I open edit workspace window for workspace with name "wiki" + And I switch to "editWorkspace" window + And I wait for the page to load completely + When I click on "saveAndSyncOptions accordion and syncToCloud toggle" elements with selectors: + | [data-testid='preference-section-saveAndSyncOptions'] | + | [data-testid='synced-local-workspace-switch'] | + And I wait for 1 seconds + When I type in "git url input and github username input and github email input and github token input" elements with selectors: + | {tmpDir}/remote-repo-menu.git | label:has-text('Git仓库线上网址') + * input, label:has-text('Git Repo URL') + * input, input[aria-label='Git仓库线上网址'], input[aria-label='Git Repo URL'] | + | test-user | [data-testid='github-userName-input'] input | + | test@tidgi.test | [data-testid='github-email-input'] input | + | test-token | [data-testid='github-token-input'] input | + When I click on a "save workspace button" element with selector "[data-testid='edit-workspace-save-button']" + # Wait for workspace to be saved (workspace.update triggers a restart which takes time) + And I wait for 5 seconds + When I switch to "main" window + # Create a new tiddler to trigger sync + When I create file "{tmpDir}/wiki/tiddlers/SyncMenuTestTiddler.tid" with content: + """ + created: 20250226090000000 + modified: 20250226090000000 + title: SyncMenuTestTiddler + tags: SyncTest + + This is a test tiddler for sync via menu feature. + """ + Then I wait for tiddler "SyncMenuTestTiddler" to be added by watch-fs + # Clear previous test markers to ensure we're testing fresh sync operation + When I clear test-id markers from logs + # Use application menu to sync (commit and push) + When I click menu "知识库 > 立即同步云端" + # Wait for git sync to complete (not just commit) + Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]" + # Verify the commit was pushed to remote by cloning the remote and checking + Then the remote repository "{tmpDir}/remote-repo-menu.git" should contain commit with message "使用太记桌面版备份" + And the remote repository "{tmpDir}/remote-repo-menu.git" should contain file "tiddlers/SyncMenuTestTiddler.tid" diff --git a/src/services/git/index.ts b/src/services/git/index.ts index f6cdbe2d..672becc0 100644 --- a/src/services/git/index.ts +++ b/src/services/git/index.ts @@ -326,17 +326,35 @@ export class Git implements IGitService { return; } - observable.subscribe(this.getWorkerMessageObserver(wikiFolderPath, () => {}, reject, workspaceID)); let hasChanges = false; observable.subscribe({ next: (messageObject: IGitLogMessage) => { + // Log the message if (messageObject.level === 'error') { + const errorMessage = (messageObject.error).message; + // if workspace exists, show notification in workspace, else use dialog instead + if (workspaceID === undefined) { + this.createFailedDialog(errorMessage, wikiFolderPath); + } else { + this.createFailedNotification(errorMessage, workspaceID); + } + // Reject the promise on error to prevent service restart + reject(messageObject.error); return; } - const { meta } = messageObject; - if (typeof meta === 'object' && meta !== null && 'step' in meta && stepsAboutChange.includes((meta as { step: GitStep }).step)) { - hasChanges = true; + const { message, meta, level } = messageObject; + if (typeof meta === 'object' && meta !== null && 'step' in meta) { + this.popGitErrorNotificationToUser((meta as { step: GitStep }).step, message); + // Check if this step indicates changes + if (stepsAboutChange.includes((meta as { step: GitStep }).step)) { + hasChanges = true; + } } + logger.log(level, translateMessage(message), meta); + }, + error: (error) => { + // this normally won't happen. And will become unhandled error. Because Observable error can't be catch, don't know why. + reject(error as Error); }, complete: () => { resolve(hasChanges); diff --git a/src/services/git/menuItems.ts b/src/services/git/menuItems.ts index 748408d8..d9181fa1 100644 --- a/src/services/git/menuItems.ts +++ b/src/services/git/menuItems.ts @@ -2,7 +2,7 @@ * Utility functions for creating Git-related menu items * This file is safe to import from both frontend and backend code */ -import type { IGitService } from '@services/git/interface'; +import type { IGitService, IGitUserInfos } from '@services/git/interface'; import { DeferredMenuItemConstructorOptions } from '@services/menu/interface'; import { IWindowService } from '@services/windows/interface'; import { WindowNames } from '@services/windows/WindowProperties'; @@ -118,6 +118,7 @@ export function createBackupMenuItems( * @param gitService Git service instance (or Pick with commitAndSync) * @param aiEnabled Whether AI-generated commit messages are enabled * @param isOnline Whether the network is online (optional, defaults to true) + * @param userInfo User authentication info for git operations * @returns Array of menu items */ export function createSyncMenuItems( @@ -125,7 +126,8 @@ export function createSyncMenuItems( t: TFunction, gitService: Pick, aiEnabled: boolean, - isOnline?: boolean, + isOnline: boolean, + userInfo: IGitUserInfos | undefined, ): DeferredMenuItemConstructorOptions[]; /** @@ -135,6 +137,7 @@ export function createSyncMenuItems( * @param gitService Git service instance (or Pick with commitAndSync) * @param aiEnabled Whether AI-generated commit messages are enabled * @param isOnline Whether the network is online (optional, defaults to true) + * @param userInfo User authentication info for git operations * @param useDeferred Set to false for context menu * @returns Array of menu items */ @@ -144,6 +147,7 @@ export function createSyncMenuItems( gitService: Pick, aiEnabled: boolean, isOnline: boolean, + userInfo: IGitUserInfos | undefined, useDeferred: false, ): import('electron').MenuItemConstructorOptions[]; @@ -152,14 +156,15 @@ export function createSyncMenuItems( t: TFunction, gitService: Pick, aiEnabled: boolean, - isOnline: boolean = true, + isOnline: boolean, + userInfo: IGitUserInfos | undefined, _useDeferred: boolean = true, ): DeferredMenuItemConstructorOptions[] | import('electron').MenuItemConstructorOptions[] { if (!isWikiWorkspace(workspace) || !workspace.gitUrl) { return []; } - const { wikiFolderLocation } = workspace; + const { wikiFolderLocation, gitUrl } = workspace; const offlineText = isOnline ? '' : ` (${t('ContextMenu.NoNetworkConnection')})`; if (aiEnabled) { @@ -172,6 +177,8 @@ export function createSyncMenuItems( dir: wikiFolderLocation, commitOnly: false, commitMessage: t('LOG.CommitBackupMessage'), + remoteUrl: gitUrl, + userInfo, }); }, }, @@ -183,6 +190,8 @@ export function createSyncMenuItems( dir: wikiFolderLocation, commitOnly: false, // Don't provide commitMessage to trigger AI generation + remoteUrl: gitUrl, + userInfo, }); }, }, @@ -197,6 +206,8 @@ export function createSyncMenuItems( await gitService.commitAndSync(workspace, { dir: wikiFolderLocation, commitOnly: false, + remoteUrl: gitUrl, + userInfo, }); }, }, diff --git a/src/services/git/registerMenu.ts b/src/services/git/registerMenu.ts index 23009a23..a49989ba 100644 --- a/src/services/git/registerMenu.ts +++ b/src/services/git/registerMenu.ts @@ -1,9 +1,12 @@ +import type { IAuthenticationService } from '@services/auth/interface'; import { container } from '@services/container'; +import type { IContextService } from '@services/context/interface'; import type { IGitService } from '@services/git/interface'; import { i18n } from '@services/libs/i18n'; import type { IMenuService } from '@services/menu/interface'; import { DeferredMenuItemConstructorOptions } from '@services/menu/interface'; import serviceIdentifier from '@services/serviceIdentifier'; +import { SupportedStorageServices } from '@services/types'; import type { IWindowService } from '@services/windows/interface'; import { WindowNames } from '@services/windows/WindowProperties'; import type { IWorkspaceService } from '@services/workspaces/interface'; @@ -14,12 +17,23 @@ export async function registerMenu(): Promise { const windowService = container.get(serviceIdentifier.Window); const workspaceService = container.get(serviceIdentifier.Workspace); const gitService = container.get(serviceIdentifier.Git); + const authService = container.get(serviceIdentifier.Authentication); + const contextService = container.get(serviceIdentifier.Context); const hasActiveWikiWorkspace = async (): Promise => { const activeWorkspace = await workspaceService.getActiveWorkspace(); return activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace); }; + const hasActiveSyncableWorkspace = async (): Promise => { + const activeWorkspace = await workspaceService.getActiveWorkspace(); + if (!activeWorkspace || !isWikiWorkspace(activeWorkspace)) return false; + if (activeWorkspace.storageService === SupportedStorageServices.local) return false; + if (!activeWorkspace.gitUrl) return false; + const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService); + return userInfo !== undefined; + }; + // Build commit and sync menu items with dynamic enabled/click that checks activeWorkspace at runtime const commitMenuItems: DeferredMenuItemConstructorOptions[] = [ { @@ -67,8 +81,84 @@ export async function registerMenu(): Promise { }); } + // Build sync menu items const syncMenuItems: DeferredMenuItemConstructorOptions[] = []; + const isOnline = await contextService.isOnline(); + const offlineText = isOnline ? '' : ` (${i18n.t('ContextMenu.NoNetworkConnection')})`; + + if (aiGenerateBackupTitleEnabled) { + syncMenuItems.push( + { + label: () => i18n.t('ContextMenu.SyncNow') + offlineText, + id: 'sync-now', + visible: hasActiveSyncableWorkspace, + enabled: async () => { + const online = await contextService.isOnline(); + return online && await hasActiveSyncableWorkspace(); + }, + click: async () => { + const activeWorkspace = await workspaceService.getActiveWorkspace(); + if (activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace)) { + const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService); + await gitService.commitAndSync(activeWorkspace, { + dir: activeWorkspace.wikiFolderLocation, + commitOnly: false, + commitMessage: i18n.t('LOG.CommitBackupMessage'), + remoteUrl: activeWorkspace.gitUrl, + userInfo, + }); + } + }, + }, + { + label: () => i18n.t('ContextMenu.SyncNow') + i18n.t('ContextMenu.WithAI') + offlineText, + id: 'sync-now-ai', + visible: hasActiveSyncableWorkspace, + enabled: async () => { + const online = await contextService.isOnline(); + return online && await hasActiveSyncableWorkspace(); + }, + click: async () => { + const activeWorkspace = await workspaceService.getActiveWorkspace(); + if (activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace)) { + const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService); + await gitService.commitAndSync(activeWorkspace, { + dir: activeWorkspace.wikiFolderLocation, + commitOnly: false, + // Don't provide commitMessage to trigger AI generation + remoteUrl: activeWorkspace.gitUrl, + userInfo, + }); + } + }, + }, + ); + } else { + syncMenuItems.push({ + label: () => i18n.t('ContextMenu.SyncNow') + offlineText, + id: 'sync-now', + visible: hasActiveSyncableWorkspace, + enabled: async () => { + const online = await contextService.isOnline(); + return online && await hasActiveSyncableWorkspace(); + }, + click: async () => { + const activeWorkspace = await workspaceService.getActiveWorkspace(); + if (activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace)) { + const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService); + await gitService.commitAndSync(activeWorkspace, { + dir: activeWorkspace.wikiFolderLocation, + commitOnly: false, + commitMessage: i18n.t('LOG.CommitBackupMessage'), + remoteUrl: activeWorkspace.gitUrl, + userInfo, + }); + } + }, + }); + } + // Add to Wiki menu - basic items (each item checks for active wiki workspace) await menuService.insertMenu( 'Wiki', diff --git a/src/services/workspaces/getWorkspaceMenuTemplate.ts b/src/services/workspaces/getWorkspaceMenuTemplate.ts index 3e68567b..90365a2f 100644 --- a/src/services/workspaces/getWorkspaceMenuTemplate.ts +++ b/src/services/workspaces/getWorkspaceMenuTemplate.ts @@ -204,7 +204,7 @@ export async function getWorkspaceMenuTemplate( if (userInfo !== undefined) { const isOnline = await service.context.isOnline(); - const syncItems = createSyncMenuItems(workspace, t, service.git, aiGenerateBackupTitleEnabled, isOnline, false); + const syncItems = createSyncMenuItems(workspace, t, service.git, aiGenerateBackupTitleEnabled, isOnline, userInfo, false); template.push(...syncItems); } } diff --git a/src/windows/EditWorkspace/useForm.ts b/src/windows/EditWorkspace/useForm.ts index 4e1efb12..7978822a 100644 --- a/src/windows/EditWorkspace/useForm.ts +++ b/src/windows/EditWorkspace/useForm.ts @@ -22,6 +22,7 @@ export function useForm( return; } await window.service.workspace.update(workspace.id, workspace); + await window.service.native.log('info', '[test-id-WORKSPACE_SAVED]', { workspaceId: workspace.id, workspaceName: workspace.name }); if (requestRestartAfterSave) { requestRestartCountDown(); }