From 4c388d4d923aa908c7ccdeadbe611b1dd1d4bc6e Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 21 May 2026 13:24:11 +0100 Subject: [PATCH 01/25] Added C8 coverage for frontend --- .gitignore | 1 + Frontend/package-lock.json | 171 ++++ Frontend/package.json | 2 + .../src/scripts/features/cherryPick.test.ts | 329 ++++++ .../src/scripts/features/confirmModal.test.ts | 210 ++++ .../src/scripts/features/conflicts.test.ts | 636 ++++++++++++ .../features/deleteBranchConfirm.test.ts | 226 +++++ Frontend/src/scripts/features/diff.test.ts | 141 +++ .../src/scripts/features/newBranch.test.ts | 86 ++ .../src/scripts/features/renameBranch.test.ts | 272 +++++ .../src/scripts/features/repo/context.test.ts | 103 ++ .../scripts/features/repo/diffBinary.test.ts | 150 +++ .../features/repo/diffConflicts.test.ts | 425 ++++++++ .../features/repo/diffFragment.test.ts | 163 +++ .../features/repo/diffSelection.test.ts | 158 +++ .../scripts/features/repo/diffView.test.ts | 185 ++++ .../src/scripts/features/repo/filter.test.ts | 116 +++ .../src/scripts/features/repo/history.test.ts | 176 ++++ .../src/scripts/features/repo/hotkeys.test.ts | 282 ++++++ .../src/scripts/features/repo/hydrate.test.ts | 198 ++++ .../features/repo/interactions.test.ts | 140 +++ .../src/scripts/features/repo/list.test.ts | 261 +++++ .../features/repo/selectionState.test.ts | 151 +++ .../src/scripts/features/repo/stash.test.ts | 718 ++++++++++++++ .../src/scripts/features/repoSettings.test.ts | 440 ++++++++ .../src/scripts/features/setUpstream.test.ts | 312 ++++++ .../src/scripts/features/settings.test.ts | 937 ++++++++++++++++++ .../features/settingsPluginSearch.test.ts | 381 +++++++ .../scripts/features/settingsPluginUI.test.ts | 676 +++++++++++++ .../scripts/features/settingsPlugins.test.ts | 473 +++++++++ .../scripts/features/settingsTheme.test.ts | 426 ++++++++ Frontend/src/scripts/features/sshAuth.test.ts | 134 +++ Frontend/src/scripts/features/sshKeys.test.ts | 653 ++++++++++++ .../src/scripts/features/stashConfirm.test.ts | 128 +++ Frontend/src/scripts/features/update.test.ts | 226 +++++ Frontend/src/scripts/lib/confirm.test.ts | 319 +++++- Frontend/src/scripts/lib/dom.test.ts | 69 +- Frontend/src/scripts/lib/menu.test.ts | 334 +++++++ Frontend/src/scripts/lib/monitoring.test.ts | 213 +++- Frontend/src/scripts/lib/notify.test.ts | 31 +- Frontend/src/scripts/lib/scrollbars.test.ts | 313 ++++++ Frontend/src/scripts/plugins/modal.test.ts | 315 ++++++ .../src/scripts/plugins/registration.test.ts | 591 +++++++++++ Frontend/src/scripts/plugins/runtime.test.ts | 357 +++++++ Frontend/src/scripts/plugins/sanitize.test.ts | 142 +++ Frontend/src/scripts/themes.test.ts | 866 +++++++++++++++- Frontend/src/scripts/ui/layout.test.ts | 268 +++++ Frontend/src/scripts/ui/modals.test.ts | 217 ++++ Frontend/vitest.config.ts | 6 + 49 files changed, 14054 insertions(+), 73 deletions(-) create mode 100644 Frontend/src/scripts/features/cherryPick.test.ts create mode 100644 Frontend/src/scripts/features/confirmModal.test.ts create mode 100644 Frontend/src/scripts/features/conflicts.test.ts create mode 100644 Frontend/src/scripts/features/deleteBranchConfirm.test.ts create mode 100644 Frontend/src/scripts/features/renameBranch.test.ts create mode 100644 Frontend/src/scripts/features/repo/context.test.ts create mode 100644 Frontend/src/scripts/features/repo/diffBinary.test.ts create mode 100644 Frontend/src/scripts/features/repo/diffConflicts.test.ts create mode 100644 Frontend/src/scripts/features/repo/diffFragment.test.ts create mode 100644 Frontend/src/scripts/features/repo/diffSelection.test.ts create mode 100644 Frontend/src/scripts/features/repo/filter.test.ts create mode 100644 Frontend/src/scripts/features/repo/hotkeys.test.ts create mode 100644 Frontend/src/scripts/features/repo/list.test.ts create mode 100644 Frontend/src/scripts/features/repo/selectionState.test.ts create mode 100644 Frontend/src/scripts/features/repo/stash.test.ts create mode 100644 Frontend/src/scripts/features/repoSettings.test.ts create mode 100644 Frontend/src/scripts/features/setUpstream.test.ts create mode 100644 Frontend/src/scripts/features/settings.test.ts create mode 100644 Frontend/src/scripts/features/settingsPluginSearch.test.ts create mode 100644 Frontend/src/scripts/features/settingsPluginUI.test.ts create mode 100644 Frontend/src/scripts/features/settingsPlugins.test.ts create mode 100644 Frontend/src/scripts/features/settingsTheme.test.ts create mode 100644 Frontend/src/scripts/features/sshKeys.test.ts create mode 100644 Frontend/src/scripts/lib/menu.test.ts create mode 100644 Frontend/src/scripts/lib/scrollbars.test.ts create mode 100644 Frontend/src/scripts/plugins/modal.test.ts create mode 100644 Frontend/src/scripts/plugins/registration.test.ts create mode 100644 Frontend/src/scripts/plugins/runtime.test.ts create mode 100644 Frontend/src/scripts/plugins/sanitize.test.ts create mode 100644 Frontend/src/scripts/ui/modals.test.ts diff --git a/.gitignore b/.gitignore index cf1b89d6..2b27a298 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ dist-ssr /openvcs.plugins.local.json /.sisyphus /.omo +/Frontend/coverage diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index cec4913e..c42be088 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@sentry/vite-plugin": "^5.2.1", "@types/node": "^25.6.2", + "@vitest/coverage-v8": "^4.1.7", "jsdom": "^29.1.1", "typescript": "^6.0.3", "vite": "^8.0.12", @@ -314,6 +315,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1172,6 +1183,37 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -1308,6 +1350,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1637,6 +1698,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1650,6 +1721,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1678,6 +1756,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2071,6 +2188,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2466,6 +2624,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 2fa7c1a9..9659e41c 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -15,12 +15,14 @@ "dev": "vite", "test": "vitest", "test:watch": "vitest --watch", + "coverage": "vitest run --coverage", "build": "vite build", "preview": "vite preview --strictPort --port 1420" }, "devDependencies": { "@sentry/vite-plugin": "^5.2.1", "@types/node": "^25.6.2", + "@vitest/coverage-v8": "^4.1.7", "jsdom": "^29.1.1", "typescript": "^6.0.3", "vite": "^8.0.12", diff --git a/Frontend/src/scripts/features/cherryPick.test.ts b/Frontend/src/scripts/features/cherryPick.test.ts new file mode 100644 index 00000000..26c72537 --- /dev/null +++ b/Frontend/src/scripts/features/cherryPick.test.ts @@ -0,0 +1,329 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.hoisted(() => vi.fn().mockResolvedValue(null)); + +vi.mock('../lib/tauri', () => ({ TAURI: { invoke: mockInvoke } })); +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); +vi.mock('./repo', () => ({ + hydrateBranches: vi.fn(), + hydrateCommits: vi.fn(), + hydrateStatus: vi.fn(), +})); + +const mockState = vi.hoisted(() => ({ + branch: 'main', + branches: [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + { name: 'feature', kind: { type: 'Local' }, full_ref: 'refs/heads/feature' }, + { name: 'origin/main', kind: { type: 'Remote' }, full_ref: 'refs/remotes/origin/main' }, + ], +})); + +vi.mock('../state/state', () => ({ state: mockState })); + +function mountCherryPickModal() { + document.body.innerHTML = ` +
+ + + +
+ `; +} + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + mountCherryPickModal(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('wireCherryPick', () => { + it('sets __wired and skips on second call', async () => { + const { wireCherryPick } = await import('./cherryPick'); + const modal = document.getElementById('cherry-pick-modal') as any; + expect(modal.__wired).toBeUndefined(); + wireCherryPick(); + expect(modal.__wired).toBe(true); + wireCherryPick(); + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireCherryPick } = await import('./cherryPick'); + expect(() => wireCherryPick()).not.toThrow(); + }); + + it('validate enables confirm when commit and branch are set', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + branchEl.dispatchEvent(new Event('change')); + + expect(confirm.disabled).toBe(false); + }); + + it('validate disables confirm when commit is empty', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = ''; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + branchEl.dispatchEvent(new Event('change')); + + expect(confirm.disabled).toBe(true); + }); + + it('validate disables confirm when branch is empty', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = ''; + + branchEl.dispatchEvent(new Event('change')); + + expect(confirm.disabled).toBe(true); + }); + + it('confirm click invokes vcs_cherry_pick_to_branch and refreshes', async () => { + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + const { hydrateBranches, hydrateCommits, hydrateStatus } = await import('./repo'); + + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123def456'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + confirm.click(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_cherry_pick_to_branch', { + id: 'abc123def456', + branch: 'feature', + }); + await flushPromises(); + expect(notify).toHaveBeenCalledWith("Cherry-picked onto feature"); + expect(closeModal).toHaveBeenCalledWith('cherry-pick-modal'); + expect(hydrateBranches).toHaveBeenCalled(); + expect(hydrateStatus).toHaveBeenCalled(); + expect(hydrateCommits).toHaveBeenCalled(); + }); + + it('confirm click handles error from invoke', async () => { + mockInvoke.mockRejectedValue(new Error('merge conflict')); + const { notify } = await import('../lib/notify'); + + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + confirm.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Cherry-pick failed: Error: merge conflict'); + }); + + it('confirm click handles undefined error', async () => { + mockInvoke.mockRejectedValue(''); + const { notify } = await import('../lib/notify'); + + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + confirm.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Cherry-pick failed'); + }); + + it('confirm click returns early when commit or branch missing', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const confirm = document.getElementById('cherry-pick-confirm') as HTMLButtonElement; + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + + (document.getElementById('cherry-pick-modal') as HTMLElement).dataset.commit = ''; + branchEl.value = 'feature'; + confirm.click(); + await flushPromises(); + expect(mockInvoke).not.toHaveBeenCalled(); + + (document.getElementById('cherry-pick-modal') as HTMLElement).dataset.commit = 'abc123'; + branchEl.value = ''; + confirm.click(); + await flushPromises(); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('setInitial fills commit info and branch options', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial( + { id: 'abc123def456', msg: 'Fix critical bug' }, + ['main', 'feature', 'develop'], + 'feature', + ); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe('abc123d — Fix critical bug'); + expect(modal.dataset.commit).toBe('abc123def456'); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe('feature'); + const options = Array.from(branchEl.options).map((o) => o.value); + expect(options).toEqual(['', 'develop', 'feature', 'main']); + }); + + it('setInitial prefers currentBranch over first option', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial( + { id: 'abc', msg: '' }, + ['develop', 'main', 'feature'], + 'develop', + ); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe('develop'); + }); + + it('setInitial falls back to first option when currentBranch not in list', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial( + { id: 'abc', msg: '' }, + ['develop', 'main', 'feature'], + 'nonexistent', + ); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe('develop'); + }); + + it('setInitial handles empty commit ID and msg', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial({}, ['main'], ''); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe(''); + expect(modal.dataset.commit).toBe(''); + }); + + it('setInitial handles missing branchEl', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + document.getElementById('cherry-pick-branch')?.remove(); + const modal = document.getElementById('cherry-pick-modal') as any; + expect(() => modal.setInitial({ id: 'abc' }, ['main'], 'main')).not.toThrow(); + }); + + it('setInitial focuses branch select', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + const focusSpy = vi.spyOn(branchEl, 'focus'); + + modal.setInitial({ id: 'abc' }, ['main'], 'main'); + await flushPromises(); + + expect(focusSpy).toHaveBeenCalled(); + }); +}); + +describe('openCherryPick', () => { + it('opens modal with branches filtered for non-remote', async () => { + const { hydrate } = await import('../ui/modals'); + const { openModal } = await import('../ui/modals'); + + const { openCherryPick } = await import('./cherryPick'); + + await openCherryPick({ id: 'abc', msg: 'Commit msg' }); + + expect(hydrate).toHaveBeenCalledWith('cherry-pick-modal'); + expect(openModal).toHaveBeenCalledWith('cherry-pick-modal'); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + const options = Array.from(branchEl.options).map((o) => o.value.trim()).filter(Boolean); + expect(options).toEqual(['feature', 'main']); + }); + + it('notifies when no local branches exist', async () => { + mockState.branches = []; + const { notify } = await import('../lib/notify'); + const { openModal } = await import('../ui/modals'); + + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'abc', msg: 'Test' }); + + expect(notify).toHaveBeenCalledWith('No local branches found'); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('calls setInitial and opens modal on the modal', async () => { + mockState.branches = [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + ]; + mockState.branch = 'main'; + + const { openModal } = await import('../ui/modals'); + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'abc123', msg: 'Fix' }); + + const modal = document.getElementById('cherry-pick-modal') as any; + expect(modal.dataset.commit).toBe('abc123'); + expect(openModal).toHaveBeenCalledWith('cherry-pick-modal'); + }); +}); diff --git a/Frontend/src/scripts/features/confirmModal.test.ts b/Frontend/src/scripts/features/confirmModal.test.ts new file mode 100644 index 00000000..0af43f21 --- /dev/null +++ b/Frontend/src/scripts/features/confirmModal.test.ts @@ -0,0 +1,210 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); + +function mountConfirmModal() { + document.body.innerHTML = ` + + `; +} + +beforeEach(() => { + vi.resetModules(); + mountConfirmModal(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('wireConfirmModal', () => { + it('wires the modal and does not re-wire', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + // Second call should be idempotent + wireConfirmModal(); + // If it re-wired, the __wired flag would have caused issues + const modal = document.getElementById('confirm-modal') as any; + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireConfirmModal } = await import('./confirmModal'); + // Should not throw + expect(() => wireConfirmModal()).not.toThrow(); + }); +}); + +describe('setContent', () => { + it('sets default values when options are minimal', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test message' }); + + expect(document.getElementById('confirm-modal-title')?.textContent).toBe('Confirm action'); + expect(document.getElementById('confirm-modal-hint')?.textContent).toBe('This cannot be undone.'); + expect(document.getElementById('confirm-modal-message')?.textContent).toBe('Test message'); + expect(document.getElementById('confirm-modal-cancel-btn')?.textContent).toBe('Cancel'); + expect(document.getElementById('confirm-modal-confirm-btn')?.textContent).toBe('Confirm'); + }); + + it('applies custom options including danger style', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ + title: 'Delete file?', + message: 'Are you sure?', + hint: 'File will be permanently deleted.', + confirmLabel: 'Delete', + cancelLabel: 'Keep', + danger: true, + }); + + expect(document.getElementById('confirm-modal-title')?.textContent).toBe('Delete file?'); + expect(document.getElementById('confirm-modal-hint')?.textContent).toBe('File will be permanently deleted.'); + expect(document.getElementById('confirm-modal-confirm-btn')?.textContent).toBe('Delete'); + expect(document.getElementById('confirm-modal-cancel-btn')?.textContent).toBe('Keep'); + const confirmBtn = document.getElementById('confirm-modal-confirm-btn') as HTMLButtonElement; + expect(confirmBtn.classList.contains('danger')).toBe(true); + expect(confirmBtn.classList.contains('primary')).toBe(false); + }); + + it('sets primary class when not danger', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test', danger: false }); + const confirmBtn = document.getElementById('confirm-modal-confirm-btn') as HTMLButtonElement; + expect(confirmBtn.classList.contains('primary')).toBe(true); + expect(confirmBtn.classList.contains('danger')).toBe(false); + }); + + it('trims whitespace from title and hint, defaults when empty', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test', title: ' ', hint: ' ' }); + expect(document.getElementById('confirm-modal-title')?.textContent).toBe('Confirm action'); + expect(document.getElementById('confirm-modal-hint')?.textContent).toBe('This cannot be undone.'); + }); + + it('focuses the cancel button', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + const cancelBtn = document.getElementById('confirm-modal-cancel-btn') as HTMLButtonElement; + const focusSpy = vi.spyOn(cancelBtn, 'focus'); + modal.setContent({ message: 'Test' }); + await new Promise((r) => setTimeout(r, 0)); + expect(focusSpy).toHaveBeenCalled(); + }); +}); + +describe('confirm button handler', () => { + it('resolves true and closes modal on confirm click', async () => { + const modals = await import('../ui/modals'); + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const confirmBtn = document.getElementById('confirm-modal-confirm-btn') as HTMLButtonElement; + + // Simulate pending resolve + let resolved = false; + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test' }); + (window as any).__pendingResolve = (ok: boolean) => { + resolved = ok; + }; + + // Store resolve in pendingResolve via confirmWithModal + const { confirmWithModal } = await import('./confirmModal'); + const promise = confirmWithModal({ message: 'Test' }); + + confirmBtn.click(); + await Promise.resolve(); + + expect(vi.mocked(modals.closeModal)).toHaveBeenCalledWith('confirm-modal'); + const result = await promise; + expect(result).toBe(true); + }); +}); + +describe('modal:closed handler', () => { + it('resolves false when modal closed event fires', async () => { + const { wireConfirmModal, confirmWithModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test' }); + + const promise = confirmWithModal({ message: 'Test' }); + modal.dispatchEvent(new Event('modal:closed')); + + const result = await promise; + expect(result).toBe(false); + }); +}); + +describe('confirmWithModal', () => { + it('hydrates, wires, opens modal and returns a promise', async () => { + const modals = await import('../ui/modals'); + const { confirmWithModal } = await import('./confirmModal'); + + const promise = confirmWithModal({ message: 'Confirm?' }); + expect(vi.mocked(modals.hydrate)).toHaveBeenCalledWith('confirm-modal'); + expect(vi.mocked(modals.openModal)).toHaveBeenCalledWith('confirm-modal'); + expect(promise).toBeInstanceOf(Promise); + }); + + it('closes previous pending promise with false when called again', async () => { + const { confirmWithModal } = await import('./confirmModal'); + + let prevResolved: boolean | null = null; + const first = confirmWithModal({ message: 'First' }); + // Capture the first resolve + const modal = document.getElementById('confirm-modal') as any; + + const second = confirmWithModal({ message: 'Second' }); + + const firstResult = await first; + expect(firstResult).toBe(false); + }); +}); + +describe('cancel button click handler', () => { + it('wires cancel click to close modal', async () => { + const modals = await import('../ui/modals'); + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const cancelBtn = document.getElementById('confirm-modal-cancel-btn') as HTMLButtonElement; + + // cancel button has no explicit handler -- it triggers modal:closed via backdrop or data-close + // The modal:closed event is what resolves false + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test' }); + + const { confirmWithModal } = await import('./confirmModal'); + const promise = confirmWithModal({ message: 'Test' }); + + // Simulate what happens when modal is closed + modal.dispatchEvent(new Event('modal:closed')); + + const result = await promise; + expect(result).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/conflicts.test.ts b/Frontend/src/scripts/features/conflicts.test.ts new file mode 100644 index 00000000..11510201 --- /dev/null +++ b/Frontend/src/scripts/features/conflicts.test.ts @@ -0,0 +1,636 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.hoisted(() => vi.fn()); + +vi.mock('../lib/tauri', () => ({ TAURI: { invoke: mockInvoke } })); +vi.mock('../lib/confirm', () => ({ confirmBool: vi.fn() })); +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + hydrate: vi.fn(), + openModal: vi.fn(), + closeModal: vi.fn(), +})); +vi.mock('./repo', () => ({ hydrateStatus: vi.fn() })); +vi.mock('../state/state', () => ({ + isConflictStatus: vi.fn((status: unknown) => { + const s = String(status || '').trim().toUpperCase(); + return s === 'U' || s.includes('U') || s === 'AA' || s === 'DD'; + }), +})); + +function mountMergeModal() { + const div = document.createElement('div'); + div.id = 'merge-modal'; + div.innerHTML = ` + +

+    

+    

+    
+    
+  `;
+  document.body.appendChild(div);
+}
+
+function mountConflictsSummaryModal() {
+  if (!document.getElementById('merge-modal')) {
+    mountMergeModal();
+  }
+  const summary = document.createElement('div');
+  summary.id = 'conflicts-summary-modal';
+  summary.innerHTML = `
+    
+    
+    
+ + + `; + document.body.appendChild(summary); +} + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('ensureMergeModal', () => { + it('wires merge-apply button and saves result', async () => { + mountMergeModal(); + const modals = await import('../ui/modals'); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'conflict.txt', status: 'U' }, + { path: 'conflict.txt', ours: 'version a', theirs: 'version b' }, + ); + + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + textarea.value = 'resolved content'; + + const applyBtn = document.getElementById('merge-apply') as HTMLButtonElement; + applyBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_save_merge_result', { + path: 'conflict.txt', + content: 'resolved content', + }); + expect(modals.closeModal).toHaveBeenCalledWith('merge-modal'); + }); + + it('wires only once', async () => { + mountMergeModal(); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'a.txt', status: 'U' }, + { path: 'a.txt', ours: 'a', theirs: 'b' }, + ); + await openMergeModal( + { path: 'b.txt', status: 'U' }, + { path: 'b.txt', ours: 'c', theirs: 'd' }, + ); + + const applyBtn = document.getElementById('merge-apply') as HTMLButtonElement; + applyBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_save_merge_result', { + path: 'b.txt', + content: 'c', + }); + }); + + it('merge-apply shows error on failure', async () => { + mountMergeModal(); + mockInvoke.mockRejectedValue(new Error('save failed')); + const { notify } = await import('../lib/notify'); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: 'a', theirs: 'b' }, + ); + + const applyBtn = document.getElementById('merge-apply') as HTMLButtonElement; + applyBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Failed to save merge result'); + }); +}); + +describe('openMergeModal', () => { + it('sets conflict details and opens modal', async () => { + mountMergeModal(); + const modals = await import('../ui/modals'); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'src/main.ts', status: 'U' }, + { path: 'src/main.ts', ours: 'our version', theirs: 'their version', base: 'base version' }, + ); + + const pathEl = document.getElementById('merge-path') as HTMLElement; + const baseEl = document.getElementById('merge-base') as HTMLElement; + const oursEl = document.getElementById('merge-ours') as HTMLElement; + const theirsEl = document.getElementById('merge-theirs') as HTMLElement; + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + + expect(pathEl.textContent).toBe('src/main.ts'); + expect(baseEl.textContent).toBe('base version'); + expect(oursEl.textContent).toBe('our version'); + expect(theirsEl.textContent).toBe('their version'); + expect(textarea.value).toBe('our version'); + expect(modals.openModal).toHaveBeenCalledWith('merge-modal'); + }); + + it('handles missing modal gracefully', async () => { + const { openMergeModal } = await import('./conflicts'); + await expect( + openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: 'a', theirs: 'b' }, + ), + ).resolves.toBeUndefined(); + }); + + it('falls back to theirs then base when ours empty', async () => { + mountMergeModal(); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: '', theirs: 'their text', base: 'base text' }, + ); + + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + expect(textarea.value).toBe('their text'); + }); + + it('handles null details gracefully', async () => { + mountMergeModal(); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: null, theirs: null, base: null } as any, + ); + + const baseEl = document.getElementById('merge-base') as HTMLElement; + const oursEl = document.getElementById('merge-ours') as HTMLElement; + const theirsEl = document.getElementById('merge-theirs') as HTMLElement; + + expect(baseEl.textContent).toBe(''); + expect(oursEl.textContent).toBe(''); + expect(theirsEl.textContent).toBe(''); + }); +}); + +describe('openConflictsSummary', () => { + it('renders conflict list with correct count', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([ + { path: 'file1.txt', status: 'U' }, + { path: 'file2.txt', status: 'M' }, + { path: 'file3.txt', status: 'DD' }, + ]); + + const countEl = document.getElementById('conflicts-summary-count') as HTMLElement; + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + + expect(countEl.textContent).toBe('2 conflicted files'); + const rows = listEl.querySelectorAll('.row'); + expect(rows.length).toBe(2); + }); + + it('shows singular "file" for single conflict', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'only.txt', status: 'U' }]); + + const countEl = document.getElementById('conflicts-summary-count') as HTMLElement; + expect(countEl.textContent).toBe('1 conflicted file'); + }); + + it('shows abort/continue buttons when merge in progress', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + expect(abortBtn.hidden).toBe(false); + expect(contBtn.hidden).toBe(false); + }); + + it('hides abort/continue buttons when not in merge', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + expect(abortBtn.hidden).toBe(true); + expect(contBtn.hidden).toBe(true); + }); + + it('resolve button opens merge modal', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce({ path: 'f.txt', ours: 'our', theirs: 'their' }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const resolveBtn = listEl.querySelector('button') as HTMLButtonElement; + expect(resolveBtn.textContent).toBe('Resolve…'); + + resolveBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_conflict_details', { path: 'f.txt' }); + }); + + it('handles resolve button error', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('not found')); + + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const resolveBtn = listEl.querySelector('button') as HTMLButtonElement; + resolveBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Failed to open conflict: Error: not found'); + }); + + it('handles missing listEl gracefully', async () => { + const modal = document.createElement('div'); + modal.id = 'conflicts-summary-modal'; + document.body.appendChild(modal); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect( + openConflictsSummary([{ path: 'f.txt', status: 'U' }]), + ).resolves.toBeUndefined(); + }); + + it('handles non-array files gracefully', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect( + openConflictsSummary(null as any), + ).resolves.toBeUndefined(); + }); + + it('shows correct subtitle for in-progress merge', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const subEl = document.getElementById('conflicts-summary-subtitle') as HTMLElement; + expect(subEl.textContent).toBe('Resolve conflicts before committing the merge'); + }); + + it('shows correct subtitle for non-merge conflict', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const subEl = document.getElementById('conflicts-summary-subtitle') as HTMLElement; + expect(subEl.textContent).toBe('Resolve conflicts in your working tree'); + }); + + it('handles invoke failure for vcs_merge_context', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockRejectedValue(new Error('offline')); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const subEl = document.getElementById('conflicts-summary-subtitle') as HTMLElement; + expect(subEl.textContent).toBe('Resolve conflicts in your working tree'); + }); + + it('open tool button is disabled when no external tool', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce(null); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const buttons = listEl.querySelectorAll('button'); + const toolBtn = buttons[1] as HTMLButtonElement; + expect(toolBtn.textContent).toBe('Open tool'); + expect(toolBtn.disabled).toBe(true); + }); + + it('open tool button shows notification when configured tool fails', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce({ + diff: { external_merge: { enabled: true, path: '/usr/bin/meld', args: '' } }, + }) + .mockRejectedValueOnce(new Error('tool error')); + + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const buttons = listEl.querySelectorAll('button'); + const toolBtn = buttons[1] as HTMLButtonElement; + expect(toolBtn.disabled).toBe(false); + toolBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Failed to open merge tool'); + }); +}); + +describe('summary abort and continue', () => { + it('abort button calls vcs_merge_abort on confirm', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + const { confirmBool } = await import('../lib/confirm'); + confirmBool.mockResolvedValue(true); + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + abortBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_abort'); + expect(notify).toHaveBeenCalledWith('Merge aborted'); + }); + + it('abort does nothing when user declines confirm', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + const { confirmBool } = await import('../lib/confirm'); + confirmBool.mockResolvedValue(false); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + abortBtn.click(); + await flushPromises(); + + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_merge_abort'); + }); + + it('abort shows error on failure', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: true }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('cannot abort')); + + const { confirmBool } = await import('../lib/confirm'); + confirmBool.mockResolvedValue(true); + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + abortBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Abort failed: Error: cannot abort'); + }); + + it('continue button calls vcs_merge_continue', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + contBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_continue'); + expect(notify).toHaveBeenCalledWith('Merge committed'); + }); + + it('continue shows error on failure', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: true }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('merge failed')); + + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + contBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Commit merge failed: Error: merge failed'); + }); +}); + +describe('autoOpenFirstConflict', () => { + it('does nothing for empty files array', async () => { + const { autoOpenFirstConflict } = await import('./conflicts'); + await expect(autoOpenFirstConflict([])).resolves.toBeUndefined(); + }); + + it('does nothing for non-array input', async () => { + const { autoOpenFirstConflict } = await import('./conflicts'); + await expect(autoOpenFirstConflict(null as any)).resolves.toBeUndefined(); + }); + + it('does nothing for files without conflicts', async () => { + const { autoOpenFirstConflict } = await import('./conflicts'); + await expect( + autoOpenFirstConflict([{ path: 'clean.txt', status: 'M' }]), + ).resolves.toBeUndefined(); + }); + + it('opens summary for new conflicts', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { autoOpenFirstConflict } = await import('./conflicts'); + await autoOpenFirstConflict([ + { path: 'f1.txt', status: 'U' }, + { path: 'f2.txt', status: 'DD' }, + ]); + + const countEl = document.getElementById('conflicts-summary-count') as HTMLElement; + expect(countEl.textContent).toBe('2 conflicted files'); + }); + + it('does nothing when merge modal is already open', async () => { + mountMergeModal(); + const mergeModal = document.getElementById('merge-modal') as HTMLElement; + mergeModal.setAttribute('aria-hidden', 'false'); + + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { autoOpenFirstConflict } = await import('./conflicts'); + await autoOpenFirstConflict([ + { path: 'f1.txt', status: 'U' }, + ]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + expect(listEl.children.length).toBe(0); + }); +}); + +describe('hasExternalMergeTool', () => { + it('returns true when merge tool is configured and enabled', async () => { + mockInvoke.mockResolvedValue({ + diff: { + external_merge: { enabled: true, path: '/usr/bin/meld', args: '' }, + }, + }); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(true); + }); + + it('returns false when merge tool is disabled', async () => { + mockInvoke.mockResolvedValue({ + diff: { + external_merge: { enabled: false, path: '/usr/bin/meld', args: '' }, + }, + }); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false when merge tool path is empty', async () => { + mockInvoke.mockResolvedValue({ + diff: { + external_merge: { enabled: true, path: '', args: '' }, + }, + }); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false on invoke error', async () => { + mockInvoke.mockRejectedValue(new Error('config error')); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false when config has no diff section', async () => { + mockInvoke.mockResolvedValue({}); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); +}); + +describe('launchExternalMergeTool', () => { + it('launches tool when configured', async () => { + mockInvoke + .mockResolvedValueOnce({ + diff: { external_merge: { enabled: true, path: '/usr/bin/meld', args: '' } }, + }) + .mockResolvedValueOnce(null); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_launch_merge_tool', { path: '/path/to/file.txt' }); + expect(notify).toHaveBeenCalledWith('Opened custom merge tool'); + }); + + it('skips launch when no tool configured', async () => { + mockInvoke.mockResolvedValue({}); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_launch_merge_tool'); + expect(notify).toHaveBeenCalledWith('No custom merge tool configured'); + }); + + it('shows error on launch failure', async () => { + mockInvoke + .mockResolvedValueOnce({ + diff: { external_merge: { enabled: true, path: '/usr/bin/meld', args: '' } }, + }) + .mockRejectedValueOnce(new Error('tool not found')); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(notify).toHaveBeenCalledWith('Failed to open merge tool'); + }); +}); diff --git a/Frontend/src/scripts/features/deleteBranchConfirm.test.ts b/Frontend/src/scripts/features/deleteBranchConfirm.test.ts new file mode 100644 index 00000000..b87e6c91 --- /dev/null +++ b/Frontend/src/scripts/features/deleteBranchConfirm.test.ts @@ -0,0 +1,226 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); + +function mountDeleteBranchModal() { + document.body.innerHTML = ` + + `; +} + +beforeEach(() => { + vi.resetModules(); + mountDeleteBranchModal(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('wireDeleteBranchConfirm', () => { + it('sets __wired and skips on second call', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + const modal = document.getElementById('delete-branch-modal') as any; + expect(modal.__wired).toBeUndefined(); + wireDeleteBranchConfirm(); + expect(modal.__wired).toBe(true); + wireDeleteBranchConfirm(); + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + expect(() => wireDeleteBranchConfirm()).not.toThrow(); + }); +}); + +describe('setContent', () => { + it('sets content for force delete', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ + name: 'stale-branch', + force: true, + hint: 'This is permanent.', + message: 'Really force delete this branch?', + }); + + const titleEl = document.getElementById('delete-branch-title') as HTMLElement; + const hintEl = document.getElementById('delete-branch-hint') as HTMLElement; + const messageEl = document.getElementById('delete-branch-message') as HTMLElement; + const dangerEl = document.getElementById('delete-branch-danger') as HTMLElement; + const nameEl = document.getElementById('delete-branch-name') as HTMLElement; + const confirmBtn = document.getElementById('delete-branch-confirm-btn') as HTMLButtonElement; + + expect(titleEl.textContent).toBe('Force Delete Branch'); + expect(hintEl.textContent).toBe('This is permanent.'); + expect(messageEl.textContent).toBe('Really force delete this branch?'); + expect(nameEl.textContent).toBe('stale-branch'); + expect(dangerEl.hidden).toBe(false); + expect(confirmBtn.textContent).toBe('Force delete'); + expect(confirmBtn.classList.contains('danger')).toBe(true); + expect(confirmBtn.classList.contains('primary')).toBe(false); + }); + + it('sets content for normal delete', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ + name: 'feature-branch', + force: false, + }); + + const titleEl = document.getElementById('delete-branch-title') as HTMLElement; + const hintEl = document.getElementById('delete-branch-hint') as HTMLElement; + const dangerEl = document.getElementById('delete-branch-danger') as HTMLElement; + const confirmBtn = document.getElementById('delete-branch-confirm-btn') as HTMLButtonElement; + + expect(titleEl.textContent).toBe('Delete Branch'); + expect(hintEl.textContent).toBe('This cannot be undone.'); + expect(dangerEl.hidden).toBe(true); + expect(confirmBtn.textContent).toBe('Delete'); + expect(confirmBtn.classList.contains('primary')).toBe(true); + expect(confirmBtn.classList.contains('danger')).toBe(false); + }); + + it('handles empty name with fallback dash', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ name: '' }); + const nameEl = document.getElementById('delete-branch-name') as HTMLElement; + expect(nameEl.textContent).toBe('—'); + }); + + it('uses default message when not provided for force', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ name: 'branch', force: true }); + const messageEl = document.getElementById('delete-branch-message') as HTMLElement; + expect(messageEl.textContent).toBe('Force deleting permanently removes the local branch.'); + }); + + it('uses default message when not provided for normal', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ name: 'branch', force: false }); + const messageEl = document.getElementById('delete-branch-message') as HTMLElement; + expect(messageEl.textContent).toBe('Deleting permanently removes the local branch.'); + }); + + it('focuses cancel button after setContent', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + const cancelBtn = document.getElementById('delete-branch-cancel-btn') as HTMLButtonElement; + const focusSpy = vi.spyOn(cancelBtn, 'focus'); + + modal.setContent({ name: 'test' }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(focusSpy).toHaveBeenCalled(); + }); +}); + +describe('confirmDeleteBranch', () => { + it('resolves true via confirm button click', async () => { + const { closeModal } = await import('../ui/modals'); + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + const hydrate = (await import('../ui/modals')).hydrate; + const openModal = (await import('../ui/modals')).openModal; + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + expect(hydrate).toHaveBeenCalledWith('delete-branch-modal'); + expect(openModal).toHaveBeenCalledWith('delete-branch-modal'); + + const confirmBtn = document.getElementById('delete-branch-confirm-btn') as HTMLButtonElement; + confirmBtn.click(); + + const result = await promise; + expect(result).toBe(true); + expect(closeModal).toHaveBeenCalledWith('delete-branch-modal'); + }); + + it('resolves false via backdrop click', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + const backdrop = document.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + + const result = await promise; + expect(result).toBe(false); + }); + + it('resolves false via escape key', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + const result = await promise; + expect(result).toBe(false); + }); + + it('ignores escape key when modal is hidden', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const modal = document.getElementById('delete-branch-modal') as HTMLElement; + modal.setAttribute('aria-hidden', 'true'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + // Should not resolve, so wrap in timeout to check it stays pending + const raced = await Promise.race([ + promise.then((v) => ({ resolved: true, value: v })), + new Promise<{ resolved: false }>((r) => setTimeout(() => r({ resolved: false }), 50)), + ]); + expect(raced.resolved).toBe(false); + }); + + it('resolves false via cancel button (data-close)', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + const cancelBtn = document.getElementById('delete-branch-cancel-btn') as HTMLButtonElement; + cancelBtn.click(); + + const result = await promise; + expect(result).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index d59c1d62..339d456f 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -101,3 +101,144 @@ describe('bindCommit', () => { expect(vi.mocked(repo.yieldToPaint)).toHaveBeenCalled(); }); }); + +describe('buildPatchForSelectedHunks', () => { + it('returns empty string for empty inputs', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + expect(buildPatchForSelectedHunks('test.txt', [], [])).toBe(''); + expect(buildPatchForSelectedHunks('test.txt', ['line'], [])).toBe(''); + }); + + it('builds a patch with selected hunks', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/file.txt b/file.txt', + 'index abc..def 100644', + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + '@@ -5 +5 @@', + '-old2', + '+new2', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [0]); + expect(result).toContain('diff --git a/file.txt b/file.txt'); + expect(result).toContain('@@ -1 +1 @@'); + expect(result).not.toContain('@@ -5 +5 @@'); + }); + + it('includes index/metadata from prelude', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/file.txt b/file.txt', + 'index abc..def 100644', + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [0]); + expect(result).toContain('index abc..def 100644'); + }); + + it('handles add (new file) patches', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1 @@', + '+content', + ]; + const result = buildPatchForSelectedHunks('new.txt', lines, [0]); + expect(result).toContain('--- /dev/null'); + expect(result).toContain('+++ b/new.txt'); + }); + + it('handles delete patches', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/del.txt b/del.txt', + 'deleted file mode 100644', + '--- a/del.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-removed', + ]; + const result = buildPatchForSelectedHunks('del.txt', lines, [0]); + expect(result).toContain('+++ /dev/null'); + expect(result).toContain('--- a/del.txt'); + }); + + it('returns empty when no hunk starts found', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const result = buildPatchForSelectedHunks('file.txt', ['no hunks'], [0]); + expect(result).toBe(''); + }); + + it('skips out-of-range hunk indices', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/file.txt', '+++ b/file.txt', + '@@ -1 +1 @@', '-old', '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [99]); + expect(result).not.toContain('@@'); + }); +}); + +describe('buildPatchForSelectedHunks privates', () => { + it('builds patches via the buildPatchForSelectedHunks exported function (adds, deletes, metadata)', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + + // Add scenario + const addLines = [ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1 @@', + '+content', + ]; + const addResult = buildPatchForSelectedHunks('new.txt', addLines, [0]); + expect(addResult).toContain('--- /dev/null'); + expect(addResult).toContain('+++ b/new.txt'); + + // Delete scenario + const delLines = [ + 'diff --git a/del.txt b/del.txt', + 'deleted file mode 100644', + '--- a/del.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-removed', + ]; + const delResult = buildPatchForSelectedHunks('del.txt', delLines, [0]); + expect(delResult).toContain('+++ /dev/null'); + + // Multiple hunks, select second only + const multiLines = [ + 'diff --git a/f.txt b/f.txt', + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old1', + '+new1', + '@@ -5 +5 @@', + '-old2', + '+new2', + ]; + const multiResult = buildPatchForSelectedHunks('f.txt', multiLines, [1]); + expect(multiResult).not.toContain('old1'); + expect(multiResult).toContain('old2'); + }); + + it('returns empty for empty lines array', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + expect(buildPatchForSelectedHunks('f.txt', [], [0])).toBe(''); + }); +}); diff --git a/Frontend/src/scripts/features/newBranch.test.ts b/Frontend/src/scripts/features/newBranch.test.ts index 16b3afed..25994958 100644 --- a/Frontend/src/scripts/features/newBranch.test.ts +++ b/Frontend/src/scripts/features/newBranch.test.ts @@ -135,3 +135,89 @@ describe('wireNewBranch', () => { expect(invoke).toHaveBeenCalledWith('vcs_create_branch', { name: 'feature/test', from: 'main', checkout: false }); }); }); + +describe('wireNewBranch - additional', () => { + it('reflects normalized names in the hint during validation', async () => { + function installTauriMockLocal() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; + } + installTauriMockLocal(); + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const name = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + const create = document.getElementById('new-branch-create') as HTMLButtonElement; + + // Whitespace-heavy name triggers normalization hint + name.value = ' my branch '; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.hidden).toBe(false); + expect(hint.textContent).toContain('Will be created as'); + + // Empty after trim - shows error + name.value = ' '; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.hidden).toBe(false); + expect(hint.classList.contains('error')).toBe(true); + expect(create.disabled).toBe(true); + + // Valid name hides hint + name.value = 'valid-branch'; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.hidden).toBe(true); + expect(create.disabled).toBe(false); + }); + + it('handles modal:opened event', async () => { + function installTauriMockLocal() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; + } + installTauriMockLocal(); + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + + const modal = document.getElementById('new-branch-modal') as HTMLElement; + modal.dispatchEvent(new Event('modal:opened')); + await new Promise((r) => setTimeout(r, 0)); + + const checkout = document.getElementById('new-branch-checkout') as HTMLInputElement; + expect(checkout.checked).toBe(true); + }); + + it('creates branch on Enter key in name input', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__ = { + core: { invoke }, + event: { listen: vi.fn() }, + }; + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + + const name = document.getElementById('new-branch-name') as HTMLInputElement; + const create = document.getElementById('new-branch-create') as HTMLButtonElement; + + name.value = 'my-branch'; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + + create.disabled = false; + name.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await new Promise((r) => setTimeout(r, 0)); + + expect(invoke).toHaveBeenCalledWith('vcs_create_branch', expect.objectContaining({ name: 'my-branch' })); + }); +}); diff --git a/Frontend/src/scripts/features/renameBranch.test.ts b/Frontend/src/scripts/features/renameBranch.test.ts new file mode 100644 index 00000000..0a4b60d3 --- /dev/null +++ b/Frontend/src/scripts/features/renameBranch.test.ts @@ -0,0 +1,272 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); + +function mountRenameBranchModal() { + document.body.innerHTML = ` +
+ + + +
+ `; +} + +function installTauriMock() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; +} + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + mountRenameBranchModal(); + installTauriMock(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('wireRenameBranch', () => { + it('sets __wired and skips on second call', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + const modal = document.getElementById('rename-branch-modal') as any; + expect(modal.__wired).toBeUndefined(); + wireRenameBranch(); + expect(modal.__wired).toBe(true); + wireRenameBranch(); + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireRenameBranch } = await import('./renameBranch'); + expect(() => wireRenameBranch()).not.toThrow(); + }); + + it('validate disables confirm when name is empty', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'main'; + nameEl.value = ''; + nameEl.dispatchEvent(new Event('input')); + + expect(confirm.disabled).toBe(true); + }); + + it('validate disables confirm when name is unchanged', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'main'; + nameEl.value = 'main'; + nameEl.dispatchEvent(new Event('input')); + + expect(confirm.disabled).toBe(true); + }); + + it('validate enables confirm when name is valid and different', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'main'; + nameEl.value = 'new-name'; + nameEl.dispatchEvent(new Event('input')); + + expect(confirm.disabled).toBe(false); + }); + + it('Enter key triggers confirm click', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + const clickSpy = vi.spyOn(confirm, 'click'); + + nameEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('Enter key preventDefault on non-Enter keys', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const nameEl = document.getElementById('rename-branch-name') as HTMLInputElement; + const event = new KeyboardEvent('keydown', { key: 'Tab' }); + const defaultPrevented = event.defaultPrevented; + + nameEl.dispatchEvent(event); + expect(defaultPrevented).toBe(false); + }); + + it('confirm click invokes vcs_rename_branch and refreshes', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'old-name'; + nameEl.value = 'new-name'; + confirm.click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('vcs_rename_branch', { + old_name: 'old-name', + new_name: 'new-name', + }); + expect(notify).toHaveBeenCalledWith("Renamed 'old-name' → 'new-name'"); + expect(closeModal).toHaveBeenCalledWith('rename-branch-modal'); + }); + + it('confirm click returns early when oldName or newName missing', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + + // Missing oldName + delete modal.dataset.oldBranch; + confirm.click(); + await flushPromises(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('confirm click returns early when newName equals oldName', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'same'; + nameEl.value = 'same'; + confirm.click(); + await flushPromises(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('confirm click handles error from invoke', async () => { + const invoke = vi.fn(async () => { throw new Error('permission denied'); }); + (window as any).__TAURI__.core.invoke = invoke; + const { notify } = await import('../lib/notify'); + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'old-name'; + nameEl.value = 'new-name'; + confirm.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Rename failed: Error: permission denied'); + }); + + it('confirm click dispatches app:repo-selected event', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent'); + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'old-name'; + nameEl.value = 'new-name'; + confirm.click(); + await flushPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'app:repo-selected' }), + ); + }); +}); + +describe('setInitial', () => { + it('sets old branch name and fills inputs', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as any; + const currentEl = document.getElementById('rename-branch-current') as HTMLInputElement; + const nameEl = document.getElementById('rename-branch-name') as HTMLInputElement; + + modal.setInitial('feature-branch'); + + expect(modal.dataset.oldBranch).toBe('feature-branch'); + expect(currentEl.value).toBe('feature-branch'); + expect(nameEl.value).toBe('feature-branch'); + }); + + it('focuses and selects name input', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as any; + const nameEl = document.getElementById('rename-branch-name') as HTMLInputElement; + const focusSpy = vi.spyOn(nameEl, 'focus'); + const selectSpy = vi.spyOn(nameEl, 'select'); + + modal.setInitial('feature-branch'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(focusSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalled(); + }); +}); + +describe('openRenameBranch', () => { + it('hydrates, wires, sets initial, and opens modal', async () => { + const { hydrate, openModal } = await import('../ui/modals'); + + const { openRenameBranch } = await import('./renameBranch'); + openRenameBranch('my-branch'); + + const modal = document.getElementById('rename-branch-modal') as any; + expect(hydrate).toHaveBeenCalledWith('rename-branch-modal'); + expect(modal.dataset.oldBranch).toBe('my-branch'); + expect(openModal).toHaveBeenCalledWith('rename-branch-modal'); + }); +}); diff --git a/Frontend/src/scripts/features/repo/context.test.ts b/Frontend/src/scripts/features/repo/context.test.ts new file mode 100644 index 00000000..05775328 --- /dev/null +++ b/Frontend/src/scripts/features/repo/context.test.ts @@ -0,0 +1,103 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Provides a minimal `matchMedia` test shim used by state imports. */ +function createMatchMediaMock(query: string) { + return { matches: false, media: query, addListener: () => {}, removeListener: () => {} }; +} + +/** Mounts DOM nodes required by context module imports. */ +function mountRepoDom() { + document.body.innerHTML = ` + + +
    + +
    +
    +
    + `; +} + +beforeEach(() => { + vi.resetModules(); + mountRepoDom(); + (globalThis as any).matchMedia = createMatchMediaMock; + (globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => window.setTimeout(cb, 0); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('context exports', () => { + it('exports expected DOM references', async () => { + const ctx = await import('./context'); + expect(ctx.filterInput).toBeInstanceOf(HTMLInputElement); + expect(ctx.filterInput?.id).toBe('filter'); + expect(ctx.selectAllBox).toBeInstanceOf(HTMLInputElement); + expect(ctx.selectAllBox?.id).toBe('select-all'); + expect(ctx.listEl).toBeInstanceOf(HTMLElement); + expect(ctx.listEl?.id).toBe('file-list'); + expect(ctx.countEl).toBeInstanceOf(HTMLElement); + expect(ctx.countEl?.id).toBe('changes-count'); + expect(ctx.leftFootEl).toBeInstanceOf(HTMLElement); + expect(ctx.leftFootEl?.id).toBe('left-foot'); + expect(ctx.undoLeftBtn).toBeNull(); + expect(ctx.diffHeadPath).toBeInstanceOf(HTMLElement); + expect(ctx.diffHeadPath?.id).toBe('diff-path'); + expect(ctx.diffEl).toBeInstanceOf(HTMLElement); + expect(ctx.diffEl?.id).toBe('diff'); + }); + + it('exports dragState with default values', async () => { + const ctx = await import('./context'); + expect(ctx.dragState.lastClickedIndex).toBe(-1); + expect(ctx.dragState.isDragSelecting).toBe(false); + expect(ctx.dragState.dragTargetState).toBe(true); + expect(ctx.dragState.dragVisited).toBeInstanceOf(Set); + expect(ctx.dragState.dragVisited.size).toBe(0); + expect(ctx.dragState.dragMoved).toBe(false); + expect(ctx.dragState.suppressNextClick).toBe(false); + expect(ctx.dragState.dragMode).toBeNull(); + expect(ctx.dragState.dragStartIndex).toBe(-1); + expect(ctx.dragState.dragCurrentIndex).toBe(-1); + expect(ctx.dragState.dragPreDiff).toBeInstanceOf(Set); + expect(ctx.dragState.dragPrePicked).toBeInstanceOf(Set); + }); +}); + +describe('context event listeners', () => { + it('prevents selectstart when drag selecting', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = true; + const ev = new Event('selectstart', { cancelable: true }); + document.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + }); + + it('prevents dragstart when drag selecting', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = true; + const ev = new Event('dragstart', { cancelable: true }); + document.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + }); + + it('handles selectstart when not drag selecting without error', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = false; + const ev = new Event('selectstart', { cancelable: true }); + expect(() => document.dispatchEvent(ev)).not.toThrow(); + }); + + it('handles dragstart when not drag selecting without error', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = false; + const ev = new Event('dragstart', { cancelable: true }); + expect(() => document.dispatchEvent(ev)).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffBinary.test.ts b/Frontend/src/scripts/features/repo/diffBinary.test.ts new file mode 100644 index 00000000..6b139835 --- /dev/null +++ b/Frontend/src/scripts/features/repo/diffBinary.test.ts @@ -0,0 +1,150 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Mounts DOM nodes referenced by diffBinary helpers. */ +function mountDiffDom() { + document.body.innerHTML = ` +
      +
      +
      + `; +} + +beforeEach(() => { + vi.resetModules(); + mountDiffDom(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('scrollDiffToTop', () => { + it('resets scroll position of viewport', async () => { + const { scrollDiffToTop } = await import('./diffBinary'); + // diffEl uses closest('.diff-scroll'), so .diff-scroll must be an ancestor + const scrollHost = document.querySelector('.diff-scroll') as HTMLElement; + scrollHost.scrollTop = 50; + scrollHost.scrollLeft = 30; + scrollDiffToTop(); + expect(scrollHost.scrollTop).toBe(0); + expect(scrollHost.scrollLeft).toBe(0); + }); + + it('handles missing diff element gracefully', async () => { + document.body.innerHTML = ''; + const { scrollDiffToTop } = await import('./diffBinary'); + expect(() => scrollDiffToTop()).not.toThrow(); + }); +}); + +describe('detectBinaryDiff', () => { + it('returns true for non-array input', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + expect(detectBinaryDiff(null as any)).toBe(true); + // undefined uses default parameter value ([]), so returns false + expect(detectBinaryDiff('foo' as any)).toBe(true); + }); + + it('returns false for empty array', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + expect(detectBinaryDiff([])).toBe(false); + }); + + it('returns false when diff has hunks', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['diff --git a/a.txt b/a.txt', '@@ -1 +1 @@', '-old', '+new']; + expect(detectBinaryDiff(lines)).toBe(false); + }); + + it('returns true for Binary Files indicator', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['Binary files a/img.png and b/img.png differ']; + expect(detectBinaryDiff(lines)).toBe(true); + }); + + it('returns true for GIT binary patch indicator', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['GIT binary patch', 'literal 123']; + expect(detectBinaryDiff(lines)).toBe(true); + }); + + it('returns true for literal marker', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['literal 456']; + expect(detectBinaryDiff(lines)).toBe(true); + }); + + it('returns false for regular textual diff', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['diff --git a/a.txt b/a.txt', 'index abc..def', '--- a/a.txt', '+++ b/a.txt', '@@ -1,3 +1,4 @@', ' unchanged', '-removed', '+added']; + expect(detectBinaryDiff(lines)).toBe(false); + }); +}); + +describe('renderBinaryDiffPlaceholder', () => { + it('renders placeholder with path', async () => { + const { renderBinaryDiffPlaceholder } = await import('./diffBinary'); + const html = renderBinaryDiffPlaceholder('image.png'); + expect(html).toContain('image.png'); + expect(html).toContain('Diff not supported on this file type'); + expect(html).toContain('binary-placeholder'); + }); + + it('renders placeholder without path', async () => { + const { renderBinaryDiffPlaceholder } = await import('./diffBinary'); + const html = renderBinaryDiffPlaceholder(); + expect(html).not.toContain('(undefined)'); + expect(html).toContain('Diff not supported on this file type'); + }); +}); + +describe('buildUntrackedTextPatch', () => { + it('builds a synthetic unified diff for untracked file', async () => { + const { buildUntrackedTextPatch } = await import('./diffBinary'); + const lines = buildUntrackedTextPatch('newfile.txt', 'line1\nline2\nline3\n'); + expect(lines[0]).toBe('diff --git a/newfile.txt b/newfile.txt'); + expect(lines[1]).toBe('new file mode 100644'); + expect(lines[2]).toBe('--- /dev/null'); + expect(lines[3]).toBe('+++ b/newfile.txt'); + expect(lines[4]).toBe('@@ -0,0 +1,3 @@'); + expect(lines[5]).toBe('+line1'); + expect(lines[6]).toBe('+line2'); + expect(lines[7]).toBe('+line3'); + }); + + it('handles empty text', async () => { + const { buildUntrackedTextPatch } = await import('./diffBinary'); + const lines = buildUntrackedTextPatch('empty.txt', ''); + expect(lines[4]).toBe('@@ -0,0 +1,0 @@'); + expect(lines).toHaveLength(5); + }); + + it('normalizes CRLF to LF', async () => { + const { buildUntrackedTextPatch } = await import('./diffBinary'); + const lines = buildUntrackedTextPatch('crlf.txt', 'a\r\nb\r\n'); + expect(lines).toHaveLength(7); // header(4) + hunk header(1) + +a + +b = 7 + expect(lines[5]).toBe('+a'); + expect(lines[6]).toBe('+b'); + }); +}); + +describe('isUntrackedStatus', () => { + it('returns true when status contains question mark', async () => { + const { isUntrackedStatus } = await import('./diffBinary'); + expect(isUntrackedStatus('??')).toBe(true); + expect(isUntrackedStatus('? ')).toBe(true); + expect(isUntrackedStatus(' A?')).toBe(true); + }); + + it('returns false when status has no question mark', async () => { + const { isUntrackedStatus } = await import('./diffBinary'); + expect(isUntrackedStatus('M')).toBe(false); + expect(isUntrackedStatus('A')).toBe(false); + expect(isUntrackedStatus('')).toBe(false); + expect(isUntrackedStatus(null as any)).toBe(false); + expect(isUntrackedStatus(undefined as any)).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffConflicts.test.ts b/Frontend/src/scripts/features/repo/diffConflicts.test.ts new file mode 100644 index 00000000..0f84b3ec --- /dev/null +++ b/Frontend/src/scripts/features/repo/diffConflicts.test.ts @@ -0,0 +1,425 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.hoisted(() => vi.fn()); + +vi.mock('../../lib/dom', () => ({ + escapeHtml: vi.fn((s: unknown) => String(s).replace(/&/g, '&').replace(/ ({ buildCtxMenu: vi.fn() })); +vi.mock('../../lib/tauri', () => ({ TAURI: { invoke: mockInvoke } })); +vi.mock('../../lib/notify', () => ({ notify: vi.fn() })); + +const mockState = vi.hoisted(() => ({ + currentFile: '', + currentDiff: [] as string[], + selectedHunks: [] as number[], + selectedHunksByFile: {} as Record, + selectedLinesByFile: {} as Record>, +})); +vi.mock('../../state/state', () => ({ state: mockState })); + +const mockDiffEl = document.createElement('div'); +vi.mock('./context', () => ({ diffEl: mockDiffEl })); +vi.mock('./hydrate', () => ({ hydrateStatus: vi.fn() })); +vi.mock('./diffBinary', () => ({ scrollDiffToTop: vi.fn() })); +vi.mock('../conflicts', () => ({ + openMergeModal: vi.fn(), + hasExternalMergeTool: vi.fn(), + launchExternalMergeTool: vi.fn(), +})); + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockDiffEl.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + mockState.currentFile = ''; + mockState.currentDiff = []; + mockState.selectedHunks = []; + mockState.selectedHunksByFile = {}; + mockState.selectedLinesByFile = {}; +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('renderConflictView', () => { + it('renders conflict view on success', async () => { + mockInvoke.mockResolvedValue({ + path: 'conflict.txt', + ours: 'my version\nline2', + theirs: 'their version\nline2', + base: 'base version', + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'conflict.txt', status: 'U' }); + + const conflictView = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + expect(conflictView).toBeTruthy(); + expect(conflictView.dataset.conflictPath).toBe('conflict.txt'); + expect(conflictView.dataset.conflictBinary).toBe('0'); + + const header = conflictView.querySelector('.conflict-header') as HTMLElement; + expect(header).toBeTruthy(); + expect(header.textContent).toContain('Merge conflict'); + + const actions = conflictView.querySelector('.conflict-actions') as HTMLElement; + expect(actions).toBeTruthy(); + expect(actions.querySelector('[data-conflict-action="ours"]')).toBeTruthy(); + expect(actions.querySelector('[data-conflict-action="theirs"]')).toBeTruthy(); + expect(actions.querySelector('[data-conflict-action="merge"]')).toBeTruthy(); + + const panes = conflictView.querySelectorAll('.conflict-pane'); + expect(panes.length).toBe(2); + expect(panes[0].querySelector('header')?.textContent).toBe('Mine'); + expect(panes[1].querySelector('header')?.textContent).toBe('Theirs'); + + expect(mockState.currentFile).toBe('conflict.txt'); + expect(mockState.currentDiff).toEqual([]); + expect(mockState.selectedHunks).toEqual([]); + }); + + it('renders error on invoke failure', async () => { + mockInvoke.mockRejectedValue(new Error('network error')); + console.error = vi.fn(); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'bad.txt', status: 'U' }); + + expect(mockDiffEl.innerHTML).toContain('Failed to load conflict details'); + expect(console.error).toHaveBeenCalled(); + }); + + it('shows loading state initially', async () => { + mockInvoke.mockImplementation(() => new Promise(() => {})); + + const { renderConflictView } = await import('./diffConflicts'); + renderConflictView({ path: 'f.txt', status: 'U' }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockDiffEl.innerHTML).toContain('Loading conflict'); + }); + + it('clears current file entry from selectedHunksByFile and selectedLinesByFile', async () => { + mockInvoke.mockResolvedValue({ path: 'new.txt', ours: 'a', theirs: 'b' }); + + mockState.selectedHunksByFile['new.txt'] = [0, 1]; + mockState.selectedLinesByFile['new.txt'] = { 0: [1, 2] }; + mockState.selectedHunksByFile['other.txt'] = [3]; + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'new.txt', status: 'U' }); + + expect(mockState.selectedHunksByFile['new.txt']).toBeUndefined(); + expect(mockState.selectedLinesByFile['new.txt']).toBeUndefined(); + expect(mockState.selectedHunksByFile['other.txt']).toEqual([3]); + }); +}); + +describe('renderConflictMarkup (via full render)', () => { + it('builds correct markup for text conflict', async () => { + mockInvoke.mockResolvedValue({ + path: 'text.txt', + ours: 'our code', + theirs: 'their code', + binary: false, + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'text.txt', status: 'U' }); + + const view = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + expect(view.dataset.conflictBinary).toBe('0'); + expect(view.querySelector('.conflict-panels')).toBeTruthy(); + expect(view.querySelector('.conflict-note')).toBeFalsy(); + }); + + it('builds correct markup for binary conflict', async () => { + mockInvoke.mockResolvedValue({ + path: 'binary.bin', + ours: null, + theirs: null, + binary: true, + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'binary.bin', status: 'U' }); + + const view = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + expect(view.dataset.conflictBinary).toBe('1'); + expect(view.querySelector('.conflict-note')).toBeTruthy(); + expect(view.querySelector('.conflict-panels')).toBeFalsy(); + expect(view.querySelector('.conflict-note')?.textContent).toContain('binary'); + }); + + it('escapes path using escapeHtml', async () => { + const { escapeHtml } = await import('../../lib/dom'); + mockInvoke.mockResolvedValue({ + path: '', + ours: 'a', + theirs: 'b', + binary: false, + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: '', status: 'U' }); + + expect(escapeHtml).toHaveBeenCalledWith(''); + }); +}); + +describe('bindConflictActions (ours/theirs)', () => { + it('ours button resolves via vcs_resolve_conflict_side', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { notify } = await import('../../lib/notify'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const oursBtn = mockDiffEl.querySelector('[data-conflict-action="ours"]') as HTMLButtonElement; + oursBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_resolve_conflict_side', { + path: 'f.txt', + side: 'ours', + }); + expect(notify).toHaveBeenCalledWith('Kept your version'); + }); + + it('theirs button resolves via vcs_resolve_conflict_side', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { notify } = await import('../../lib/notify'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const theirsBtn = mockDiffEl.querySelector('[data-conflict-action="theirs"]') as HTMLButtonElement; + theirsBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_resolve_conflict_side', { + path: 'f.txt', + side: 'theirs', + }); + expect(notify).toHaveBeenCalledWith('Kept their version'); + }); + + it('disables buttons during resolution and re-enables after', async () => { + let resolvePromise: () => void = () => {}; + let callCount = 0; + mockInvoke.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ path: 'f.txt', ours: 'a', theirs: 'b' }); + } + return new Promise((resolve) => { + resolvePromise = resolve; + }); + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const oursBtn = mockDiffEl.querySelector('[data-conflict-action="ours"]') as HTMLButtonElement; + const container = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + + oursBtn.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(oursBtn.disabled).toBe(true); + expect(container.getAttribute('data-busy')).toBe('1'); + + resolvePromise(); + await flushPromises(); + + expect(oursBtn.disabled).toBe(false); + expect(container.hasAttribute('data-busy')).toBe(false); + }); + + it('shows error on resolve failure', async () => { + mockInvoke + .mockResolvedValueOnce({ path: 'f.txt', ours: 'a', theirs: 'b' }) + .mockRejectedValueOnce(new Error('resolve failed')); + console.error = vi.fn(); + const { notify } = await import('../../lib/notify'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const oursBtn = mockDiffEl.querySelector('[data-conflict-action="ours"]') as HTMLButtonElement; + oursBtn.click(); + await flushPromises(); + + expect(console.error).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Failed to resolve conflict'); + }); +}); + +describe('bindConflictActions (merge button)', () => { + it('opens context menu with built-in merge tool option', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + expect(mergeBtn).toBeTruthy(); + + mergeBtn.click(); + await flushPromises(); + + expect(buildCtxMenu).toHaveBeenCalledTimes(1); + const ctxItems = (buildCtxMenu as any).mock.calls[0][0]; + expect(ctxItems[0].label).toBe('Open built-in merge tool'); + }); + + it('includes external merge tool option when available', async () => { + const { hasExternalMergeTool } = await import('../conflicts'); + (hasExternalMergeTool as any).mockResolvedValue(true); + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + mergeBtn.click(); + await flushPromises(); + + const ctxItems = (buildCtxMenu as any).mock.calls[0][0]; + expect(ctxItems.length).toBe(2); + expect(ctxItems[1].label).toBe('Open custom merge tool'); + }); + + it('does not include external tool when hasExternalMergeTool returns false', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + const { hasExternalMergeTool } = await import('../conflicts'); + (hasExternalMergeTool as any).mockResolvedValue(false); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + mergeBtn.click(); + await flushPromises(); + + const ctxItems = (buildCtxMenu as any).mock.calls[0][0]; + expect(ctxItems.length).toBe(1); + }); +}); + +describe('render markup helpers', () => { + it('includes merge button for non-binary conflicts', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b', binary: false }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + expect(mockDiffEl.querySelector('[data-conflict-action="merge"]')).toBeTruthy(); + expect(mockDiffEl.querySelector('[data-conflict-action="ours"]')).toBeTruthy(); + expect(mockDiffEl.querySelector('[data-conflict-action="theirs"]')).toBeTruthy(); + }); + + it('omits merge button for binary conflicts', async () => { + mockInvoke.mockResolvedValue({ path: 'f.bin', ours: null, theirs: null, binary: true }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.bin', status: 'U' }); + + expect(mockDiffEl.querySelector('[data-conflict-action="merge"]')).toBeFalsy(); + expect(mockDiffEl.querySelector('[data-conflict-action="ours"]')).toBeTruthy(); + expect(mockDiffEl.querySelector('[data-conflict-action="theirs"]')).toBeTruthy(); + }); + + it('renders side-by-side panes with content', async () => { + mockInvoke.mockResolvedValue({ + path: 'f.txt', + ours: 'line1\nline2', + theirs: 'theirs1\ntheirs2\n', + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const preElements = mockDiffEl.querySelectorAll('.conflict-code'); + expect(preElements.length).toBe(2); + expect(preElements[0].textContent).toBe('line1\nline2'); + expect(preElements[1].textContent).toBe('theirs1\ntheirs2\n'); + }); + + it('shows empty placeholder when pane has no content', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: '', theirs: null }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const emptyElements = mockDiffEl.querySelectorAll('.conflict-empty'); + expect(emptyElements.length).toBe(2); + expect(emptyElements[0].textContent).toBe('(empty)'); + expect(emptyElements[1].textContent).toBe('(empty)'); + }); + + it('shows binary conflict note', async () => { + mockInvoke.mockResolvedValue({ path: 'image.png', binary: true }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'image.png', status: 'U' }); + + const note = mockDiffEl.querySelector('.conflict-note') as HTMLElement; + expect(note).toBeTruthy(); + expect(note.textContent).toContain('binary'); + }); + + it('escapes HTML in pane content', async () => { + mockInvoke.mockResolvedValue({ + path: 'f.txt', + ours: '', + theirs: 'safe content', + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const preElements = mockDiffEl.querySelectorAll('.conflict-code'); + expect(preElements[0].innerHTML).not.toContain('safe'); + expect(el).not.toBeNull(); + expect(el!.querySelector('script')).toBeNull(); + expect(el!.textContent).toBe('safe'); + }); + + it('strips iframe tags', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('
      ok
      '); + expect(el).not.toBeNull(); + expect(el!.querySelector('iframe')).toBeNull(); + }); + + it('removes on* attributes (inline event handlers)', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement(''); + expect(el).not.toBeNull(); + expect(el!.getAttribute('onclick')).toBeNull(); + expect(el!.getAttribute('onmouseenter')).toBeNull(); + }); + + it('removes style attributes', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('
      text
      '); + expect(el).not.toBeNull(); + expect(el!.getAttribute('style')).toBeNull(); + }); + + it('removes href with javascript: protocol', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('link'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('href')).toBeNull(); + }); + + it('keeps safe href values like fragment identifiers', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('link'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('href')).toBe('#section'); + }); + + it('strips blocked tags', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const html = '
      keep
      '; + const el = parseSanitizedPluginElement(html); + expect(el).not.toBeNull(); + expect(el!.querySelector('embed')).toBeNull(); + expect(el!.querySelector('object')).toBeNull(); + expect(el!.querySelector('script')).toBeNull(); + expect(el!.querySelector('style')).toBeNull(); + expect(el!.querySelector('template')).toBeNull(); + expect(el!.textContent).toBe('keep'); + }); + + it('removes comment nodes', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('
      text
      '); + expect(el).not.toBeNull(); + expect(el!.innerHTML).not.toContain('comment'); + }); + + it('returns null for empty or non-element content', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + expect(parseSanitizedPluginElement('')).toBeNull(); + expect(parseSanitizedPluginElement(' ')).toBeNull(); + expect(parseSanitizedPluginElement('plain text')).toBeNull(); + }); + + it('strips blocked tags nested inside safe containers', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('

      good

      '); + expect(el).not.toBeNull(); + expect(el!.querySelector('script')).toBeNull(); + expect(el!.textContent).toBe('good'); + }); + + it('removes data: and vbscript: protocol URLs', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement(''); + expect(el).not.toBeNull(); + const anchors = el!.querySelectorAll('a'); + expect(anchors[0].getAttribute('href')).toBeNull(); + expect(anchors[1].getAttribute('href')).toBeNull(); + expect(anchors[2].getAttribute('href')).toBe('https://safe.com'); + }); +}); diff --git a/Frontend/src/scripts/themes.test.ts b/Frontend/src/scripts/themes.test.ts index f68bbbb7..a8b447c0 100644 --- a/Frontend/src/scripts/themes.test.ts +++ b/Frontend/src/scripts/themes.test.ts @@ -1,17 +1,871 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ThemePayload, ThemeSummary } from './types'; -import { getAvailableThemes } from './themes'; +// --------------------------------------------------------------------------- +// Mock external dependencies (hoisted by Vitest) +// --------------------------------------------------------------------------- +vi.mock('./lib/tauri', () => ({ TAURI: { invoke: vi.fn() } })); +vi.mock('./lib/notify', () => ({ notify: vi.fn() })); +vi.mock('./plugins', () => ({ + getRegisteredThemePayload: vi.fn(), + getRegisteredThemeSummaries: vi.fn(), +})); +// --------------------------------------------------------------------------- +// Mutable matchMedia mock so tests can switch between light/dark at runtime +// --------------------------------------------------------------------------- +function createMediaQuery(matches: boolean) { + return { + matches, + media: '(prefers-color-scheme: dark)', + addListener: vi.fn(), + removeListener: vi.fn(), + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => false), + }; +} + +const mq = createMediaQuery(false); + +beforeEach(() => { + Object.assign(mq, createMediaQuery(false)); + (window as any).matchMedia = vi.fn(() => mq); + vi.resetModules(); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-theme-pack'); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- +async function load(): Promise { + return import('./themes'); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +describe('constants', () => { + it('exports expected default theme IDs', async () => { + const mod = await load(); + expect(mod.DEFAULT_THEME_ID).toBe('default'); + expect(mod.DEFAULT_LIGHT_THEME_ID).toBe('default-light'); + expect(mod.DEFAULT_DARK_THEME_ID).toBe('default-dark'); + }); +}); + +// --------------------------------------------------------------------------- +// getAvailableThemes +// --------------------------------------------------------------------------- describe('getAvailableThemes', () => { - it('returns a copy of the current theme list', () => { - const first = getAvailableThemes(); + it('returns a fresh copy of the theme list', async () => { + const mod = await load(); + const first = mod.getAvailableThemes(); first.pop(); + const second = mod.getAvailableThemes(); + expect(second.length).toBeGreaterThan(first.length); + }); - const second = getAvailableThemes(); + it('contains default light and dark themes initially', async () => { + const mod = await load(); + const list = mod.getAvailableThemes(); + expect(list).toHaveLength(2); + expect(list[0].id).toBe('default-light'); + expect(list[1].id).toBe('default-dark'); + }); +}); - expect(second.length).toBeGreaterThan(first.length); +// --------------------------------------------------------------------------- +// getActiveThemeId +// --------------------------------------------------------------------------- +describe('getActiveThemeId', () => { + it('returns default-light in light system mode', async () => { + const mod = await load(); + expect(mod.getActiveThemeId()).toBe('default-light'); + }); +}); + +// --------------------------------------------------------------------------- +// getCurrentMode +// --------------------------------------------------------------------------- +describe('getCurrentMode', () => { + it('returns system by default', async () => { + const mod = await load(); + expect(mod.getCurrentMode()).toBe('system'); + }); +}); + +// --------------------------------------------------------------------------- +// refreshAvailableThemes +// --------------------------------------------------------------------------- +describe('refreshAvailableThemes', () => { + it('fetches themes from backend and combines with defaults', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'z-theme', name: 'Z Theme' }, + { id: 'a-theme', name: 'A Theme' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(TAURI.invoke).toHaveBeenCalledWith('list_themes'); + expect(result).toHaveLength(4); + // Results are sorted by name case-insensitively + expect(result[0].id).toBe('default-light'); + expect(result[1].id).toBe('default-dark'); + expect(result[2].id).toBe('a-theme'); + expect(result[3].id).toBe('z-theme'); + }); + + it('includes names from backend and plugins', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'plugin-t', name: 'Plugin T' } as ThemeSummary, + ]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'backend-t', name: 'Backend T' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(result.find((t) => t.id === 'plugin-t')).toBeDefined(); + expect(result.find((t) => t.id === 'backend-t')).toBeDefined(); + }); + + it('sorts themes by name case-insensitively', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'z-theme', name: 'Z Theme' }, + { id: 'a-theme', name: 'A Theme' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result[2].name).toBe('A Theme'); + expect(result[3].name).toBe('Z Theme'); + }); + + it('deduplicates by id (case-insensitive)', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'default-light', name: 'Duplicate' }, + { id: 'my-theme', name: 'My Theme' }, + { id: 'MY-THEME', name: 'Case Dupe' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + // default-light skipped, MY-THEME skipped (collision with my-theme) + expect(result).toHaveLength(3); + }); + + it('includes plugin-registered theme summaries', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'plugin-theme', name: 'Plugin Theme' } as ThemeSummary, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(result.find((t) => t.id === 'plugin-theme')).toBeDefined(); + }); + + it('falls back to only plugin summaries when backend fails', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockRejectedValue(new Error('Network error')); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'offline-theme', name: 'Offline Theme' } as ThemeSummary, + ]); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(result).toHaveLength(3); + expect(result.find((t) => t.id === 'offline-theme')).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith('list_themes failed', expect.any(Error)); + warnSpy.mockRestore(); + }); + + it('handles empty/null backend response', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue(null); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result).toHaveLength(2); + }); + + it('skips null items in backend response', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([null, undefined, { id: 'valid', name: 'Valid' }]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result.find((t) => t.id === 'valid')).toBeDefined(); + }); + + it('sets fetchedThemes = true after completion', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + // ensureThemesLoaded will call refreshAvailableThemes since fetchedThemes=false + await mod.refreshAvailableThemes(); + // Subsequent call to ensureThemesLoaded should return cached + const result = await mod.ensureThemesLoaded(); + expect(TAURI.invoke).toHaveBeenCalledTimes(1); // not called again + expect(result).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ensureThemesLoaded +// --------------------------------------------------------------------------- +describe('ensureThemesLoaded', () => { + it('calls refreshAvailableThemes when themes not yet fetched', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + await mod.ensureThemesLoaded(); + + // refreshAvailableThemes was called because it invokes list_themes + expect(TAURI.invoke).toHaveBeenCalledWith('list_themes'); + }); + + it('returns cached themes when already fetched', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + await mod.ensureThemesLoaded(); // fetches + vi.mocked(TAURI.invoke).mockClear(); + const spy = vi.spyOn(mod, 'refreshAvailableThemes'); + + const result = await mod.ensureThemesLoaded(); // cached + expect(spy).not.toHaveBeenCalled(); + expect(result).toHaveLength(2); + }); + + it('re-fetches when force=true', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + await mod.ensureThemesLoaded(); // cache it + vi.mocked(TAURI.invoke).mockClear(); + vi.mocked(TAURI.invoke).mockResolvedValue([{ id: 'new', name: 'New' }]); + + await mod.ensureThemesLoaded(true); // force refetch + expect(TAURI.invoke).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// selectThemePack +// --------------------------------------------------------------------------- +describe('selectThemePack', () => { + it('resolves "default" to defaultThemeIdForMode (light)', async () => { + const mod = await load(); + await mod.selectThemePack('default'); + expect(mod.getActiveThemeId()).toBe(mod.DEFAULT_LIGHT_THEME_ID); + }); + + it('sets built-in "default-light" directly and clears custom styles', async () => { + const mod = await load(); + await mod.selectThemePack('default-light'); + expect(mod.getActiveThemeId()).toBe('default-light'); + + // For default themes, no style tag is created (activeStyles is null) + const styleEl = document.getElementById('openvcs-theme-global'); + expect(styleEl).toBeNull(); + }); + + it('sets built-in "default-dark" directly', async () => { + const mod = await load(); + await mod.selectThemePack('default-dark', { mode: 'dark' }); + expect(mod.getActiveThemeId()).toBe('default-dark'); + }); + + it('handles "default-dark" in system mode (pairs to light)', async () => { + const mod = await load(); + await mod.selectThemePack('default-dark', { mode: 'system' }); + // In light system mode, dark pairs to light + expect(mod.getActiveThemeId()).toBe('default-light'); + }); + + it('does NOT strip data-theme-pack for built-in defaults', async () => { + const mod = await load(); + await mod.selectThemePack('default-dark'); + expect(document.documentElement.hasAttribute('data-theme-pack')).toBe(false); + }); + + it('applies registered plugin theme payload', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + const payload: ThemePayload = { + summary: { id: 'plugin-theme', name: 'Plugin Theme', source: 'user' }, + styles: 'body { color: red; }', + markup: { head: '', body: '
      ' }, + scripts: ['console.log("hello")'], + }; + vi.mocked(getRegisteredThemePayload).mockReturnValue(payload); + + const mod = await load(); + await mod.selectThemePack('plugin-theme'); + + expect(mod.getActiveThemeId()).toBe('plugin-theme'); + // Style tag should be created + expect(document.getElementById('openvcs-theme-global')?.textContent).toBe('body { color: red; }'); + // Markup nodes should be appended + expect(document.head.querySelector('meta[name="theme"]')).not.toBeNull(); + expect(document.body.querySelector('#plugin-el')).not.toBeNull(); + // Script node should be created + const scriptNodes = document.head.querySelectorAll('script[data-theme-pack="plugin-theme"]'); + expect(scriptNodes.length).toBe(1); + expect(scriptNodes[0].textContent).toBe('console.log("hello")'); + }); + + it('loads theme from backend when no registered payload exists', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue(null); + vi.mocked(TAURI.invoke).mockResolvedValue({ + summary: { id: 'backend-theme', name: 'Backend Theme' }, + styles: 'body { background: blue; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('backend-theme'); + + expect(TAURI.invoke).toHaveBeenCalledWith('load_theme', { id: 'backend-theme' }); + expect(mod.getActiveThemeId()).toBe('backend-theme'); + expect(document.getElementById('openvcs-theme-global')?.textContent).toBe('body { background: blue; }'); + }); + + it('handles invalid theme payload from backend with fallback', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemePayload } = await import('./plugins'); + const { notify } = await import('./lib/notify'); + vi.mocked(getRegisteredThemePayload).mockReturnValue(null); + vi.mocked(TAURI.invoke).mockResolvedValue(null); // invalid payload + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mod = await load(); + await expect(mod.selectThemePack('broken-theme')).rejects.toThrow(); + expect(notify).toHaveBeenCalledWith('Theme failed to load. Reverted to the default theme.'); + // Falls back to default-light (light mode) + expect(mod.getActiveThemeId()).toBe('default-light'); + warnSpy.mockRestore(); + }); + + it('handles backend load error with silent option (no notify)', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemePayload } = await import('./plugins'); + const { notify } = await import('./lib/notify'); + vi.mocked(getRegisteredThemePayload).mockReturnValue(null); + vi.mocked(TAURI.invoke).mockRejectedValue(new Error('Backend error')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mod = await load(); + await expect(mod.selectThemePack('broken-theme', { silent: true })).rejects.toThrow(); + expect(notify).not.toHaveBeenCalled(); + expect(mod.getActiveThemeId()).toBe('default-light'); + warnSpy.mockRestore(); + }); + + it('respects mode option for default theme resolution', async () => { + const mod = await load(); + await mod.selectThemePack('default', { mode: 'dark' }); + expect(mod.getActiveThemeId()).toBe('default-dark'); + }); + + it('resolves paired theme in system mode', async () => { + const mod = await load(); + // mq.matches = false → light mode, so pairing from dark-side-up + // selectThemePack('default-dark', { mode: 'system' }) should pair to default-light + await mod.selectThemePack('default-dark', { mode: 'system' }); + // In light system mode, default-dark should pair to default-light + expect(mod.getActiveThemeId()).toBe('default-light'); + }); + + it('dispatches theme-changed custom event after selection', async () => { + const mod = await load(); + const handler = vi.fn(); + window.addEventListener('openvcs:theme-pack-changed', handler); + await mod.selectThemePack('default-light'); + expect(handler).toHaveBeenCalled(); + expect(handler.mock.calls[0][0].detail.id).toBe('default-light'); + window.removeEventListener('openvcs:theme-pack-changed', handler); + }); + + it('sets data-theme-pack attribute for non-default themes', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'custom', name: 'Custom', source: 'user' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('custom'); + + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('custom'); + }); + + it('trims whitespace from themeId', async () => { + const mod = await load(); + await mod.selectThemePack(' default-dark ', { mode: 'dark' }); + expect(mod.getActiveThemeId()).toBe('default-dark'); + }); + + it('treats empty string as default', async () => { + const mod = await load(); + await mod.selectThemePack('', { mode: 'light' }); + expect(mod.getActiveThemeId()).toBe('default-light'); + }); +}); + +// --------------------------------------------------------------------------- +// setAppearanceMode +// --------------------------------------------------------------------------- +describe('setAppearanceMode', () => { + it('applies mode styles for system', async () => { + const mod = await load(); + mod.setAppearanceMode('system'); + expect(mod.getCurrentMode()).toBe('system'); + }); + + it('applies mode styles for light', async () => { + const mod = await load(); + mod.setAppearanceMode('light'); + expect(mod.getCurrentMode()).toBe('light'); + }); + + it('applies mode styles for dark', async () => { + const mod = await load(); + mod.setAppearanceMode('dark'); + expect(mod.getCurrentMode()).toBe('dark'); + }); + + it('dispatches theme-changed event', async () => { + const mod = await load(); + const handler = vi.fn(); + window.addEventListener('openvcs:theme-pack-changed', handler); + mod.setAppearanceMode('dark'); + expect(handler).toHaveBeenCalled(); + window.removeEventListener('openvcs:theme-pack-changed', handler); + }); +}); + +// --------------------------------------------------------------------------- +// DOM operations (setStyleContent) +// --------------------------------------------------------------------------- +describe('setStyleContent (via applyModeStyles)', () => { + it('creates a style element when custom theme provides styles', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'stylish', name: 'Stylish', source: 'user' }, + styles: 'body { color: green; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + expect(document.getElementById('openvcs-theme-global')).toBeNull(); + await mod.selectThemePack('stylish'); + const styleEl = document.getElementById('openvcs-theme-global') as HTMLStyleElement; + expect(styleEl).not.toBeNull(); + expect(styleEl.textContent).toBe('body { color: green; }'); + }); + + it('updates existing style element with new content', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'stylish', name: 'Stylish', source: 'user' }, + styles: 'body { color: green; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('stylish'); + const styleEl = document.getElementById('openvcs-theme-global') as HTMLStyleElement; + expect(styleEl.textContent).toBe('body { color: green; }'); + + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'stylish', name: 'Stylish', source: 'user' }, + styles: 'body { color: blue; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + await mod.selectThemePack('stylish'); + expect(styleEl.textContent).toBe('body { color: blue; }'); + }); + + it('removes mode-style element when null CSS is passed', async () => { + const mod = await load(); + mod.setAppearanceMode('light'); + // Mode style should be empty string → removed + const modeStyle = document.getElementById('openvcs-theme-mode'); + expect(modeStyle).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// syncThemePackAttr (via selectThemePack) +// --------------------------------------------------------------------------- +describe('syncThemePackAttr', () => { + it('removes attribute for built-in themes', async () => { + const mod = await load(); + document.documentElement.setAttribute('data-theme-pack', 'stale'); + await mod.selectThemePack('default-light'); + expect(document.documentElement.hasAttribute('data-theme-pack')).toBe(false); + }); + + it('sets attribute for non-default themes', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'custom-pack', name: 'Custom', source: 'user' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('custom-pack'); + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('custom-pack'); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeThemeMarkup (via applyMarkupNodes) +// --------------------------------------------------------------------------- +describe('sanitizeThemeMarkup', () => { + it('removes script tags from theme markup', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'xss-theme', name: 'XSS Theme', source: 'user' }, + styles: '', + markup: { head: '' }, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('xss-theme'); + expect(document.head.querySelector('script')).toBeNull(); + expect(document.head.querySelector('meta[charset="utf-8"]')).not.toBeNull(); + }); + + it('removes inline event handlers from theme markup', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'evil', name: 'Evil Theme', source: 'user' }, + styles: '', + markup: { body: '' }, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('evil'); + const btn = document.body.querySelector('button'); + expect(btn).not.toBeNull(); + expect(btn?.getAttribute('onclick')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveThemePackAttrId (internal) +// --------------------------------------------------------------------------- +describe('resolveThemePackAttrId (via selectThemePack with plugin_id)', () => { + it('strips plugin_id prefix from attribute id', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'myplugin.magic', name: 'Magic', source: 'user', plugin_id: 'myplugin' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('myplugin.magic'); + // Expect plugin_id prefix to be stripped: "myplugin." prefix removed → "magic" + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('magic'); + }); + + it('keeps raw id when plugin_id prefix does not match (line 302)', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'unrelated', name: 'Unrelated', source: 'user', plugin_id: 'otherplugin' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('unrelated'); + // rawId = 'unrelated', plugin_id = 'otherplugin', prefix = 'otherplugin.' + // rawId does NOT start with 'otherplugin.' so returns rawId as-is + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('unrelated'); + }); +}); + +// --------------------------------------------------------------------------- +// Plugin summaries +// --------------------------------------------------------------------------- +describe('sanitizeSummary (via refreshAvailableThemes)', () => { + it('sanitizes theme summary fields', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: ' messy ', name: ' Messy ', description: ' desc ', version: '1.0', author: ' Author ' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + const messy = result.find((t) => t.id === 'messy'); + expect(messy).toBeDefined(); + expect(messy!.name).toBe('Messy'); + expect(messy!.description).toBe('desc'); + expect(messy!.version).toBe('1.0'); + expect(messy!.author).toBe('Author'); + }); +}); + +// --------------------------------------------------------------------------- +// resolvePairedThemeId (tested via selectThemePack in system mode) +// --------------------------------------------------------------------------- +describe('paired theme resolution', () => { + it('pairs registered themes with paired_with field', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'custom-dark', name: 'Custom Dark', appearance: 'dark', paired_with: 'custom-light' }, + { id: 'custom-light', name: 'Custom Light', appearance: 'light', paired_with: 'custom-dark' }, + ]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + + // In light system mode, selecting 'custom-dark' should pair to 'custom-light' + await mod.selectThemePack('custom-dark', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('custom-light'); + }); + + it('uses heuristic -dark/-light swap when no explicit paired_with', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'my-dark', name: 'My Dark', appearance: 'dark' }, + { id: 'my-light', name: 'My Light', appearance: 'light' }, + ]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + + // In light system mode, selecting 'my-dark' should heuristically find 'my-light' + await mod.selectThemePack('my-dark', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('my-light'); + }); + + it('uses heuristic _dark/_light swap', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'my_dark', name: 'My Dark', appearance: 'dark' }, + { id: 'my_light', name: 'My Light', appearance: 'light' }, + ]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + + await mod.selectThemePack('my_dark', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('my_light'); + }); + + it('does not pair when appearance already matches system mode (line 73)', async () => { + // mq.matches = false → light system mode + // A theme with appearance='light' should NOT pair because it matches + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'my-light', name: 'My Light', appearance: 'light', paired_with: 'my-dark' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('my-light', { mode: 'system' }); + // appearance ('light') === target ('light') → returns null from resolvePairedThemeId + expect(mod.getActiveThemeId()).toBe('my-light'); + }); +}); + +// --------------------------------------------------------------------------- +// ensureSystemListener (via setAppearanceMode & mode changes) +// --------------------------------------------------------------------------- +describe('ensureSystemListener', () => { + it('only installs the system listener once', async () => { + const mod = await load(); + mod.setAppearanceMode('system'); + mod.setAppearanceMode('system'); + // addEventListener should have been called only once + expect(mq.addEventListener).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Clean-up of previous theme assets when switching +// --------------------------------------------------------------------------- +describe('theme switching cleans up old assets', () => { + it('removes previous head markup when switching to default', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'with-markup', name: 'With Markup', source: 'user' }, + styles: '', + markup: { head: '' }, + scripts: ['console.log("old")'], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('with-markup'); + expect(document.head.querySelector('meta[name="from-plugin"]')).not.toBeNull(); + + // Switch to default - previous markup should be cleaned + await mod.selectThemePack('default-light'); + expect(document.head.querySelector('meta[name="from-plugin"]')).toBeNull(); + }); + + it('removes previous script nodes when switching themes', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'with-scripts', name: 'With Scripts', source: 'user' }, + styles: '', + markup: null, + scripts: ['console.log("first")'], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('with-scripts'); + const scripts1 = document.head.querySelectorAll('script[data-theme-pack="with-scripts"]'); + expect(scripts1.length).toBe(1); + + // Switch to a different theme + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'other-scripts', name: 'Other', source: 'user' }, + styles: '', + markup: null, + scripts: ['console.log("second")'], + } satisfies ThemePayload); + await mod.selectThemePack('other-scripts'); + + const scriptsV1 = document.head.querySelectorAll('script[data-theme-pack="with-scripts"]'); + expect(scriptsV1.length).toBe(0); + const scriptsV2 = document.head.querySelectorAll('script[data-theme-pack="other-scripts"]'); + expect(scriptsV2.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Edge handling for sanitizeSummary +// --------------------------------------------------------------------------- +describe('edge handling', () => { + it('assigns source "user" for non-default themes without explicit source', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'user-theme', name: 'User Theme' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + const t = result.find((x) => x.id === 'user-theme'); + expect(t?.source).toBe('user'); + }); + + it('handles empty plugin summaries array', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result).toHaveLength(2); + }); + + it('handles non-array plugin summaries', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue(null as any); + + const mod = await load(); + // Should not throw + const result = await mod.refreshAvailableThemes(); + expect(result).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// applyScriptNodes - empty/null scripts +// --------------------------------------------------------------------------- +describe('applyScriptNodes safety', () => { + it('handles null/undefined scripts in payload gracefully', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'no-scripts', name: 'No Scripts', source: 'user' }, + styles: '', + markup: null, + scripts: undefined as any, + } satisfies ThemePayload); + + const mod = await load(); + await expect(mod.selectThemePack('no-scripts')).resolves.not.toThrow(); + }); + + it('filters out non-string scripts', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'bad-scripts', name: 'Bad Scripts', source: 'user' }, + styles: '', + markup: null, + scripts: [null, undefined, '' as any, ' ' as any, 'console.log("ok")'] as any, + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('bad-scripts'); + const scripts = document.head.querySelectorAll('script[data-theme-pack="bad-scripts"]'); + expect(scripts.length).toBe(1); }); }); diff --git a/Frontend/src/scripts/ui/layout.test.ts b/Frontend/src/scripts/ui/layout.test.ts index 7b8a4f85..5d0ad3cd 100644 --- a/Frontend/src/scripts/ui/layout.test.ts +++ b/Frontend/src/scripts/ui/layout.test.ts @@ -162,3 +162,271 @@ describe('refreshRepoActions', () => { expect(commitBtn.disabled).toBe(false); }); }); + +describe('setTheme', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: (query: string) => ({ + matches: query === '(prefers-color-scheme: dark)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + }), + configurable: true, + writable: true, + }); + }); + + it('sets data-theme to dark when theme is dark', async () => { + const { setTheme } = await import('./layout'); + setTheme('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('sets data-theme to light when theme is light', async () => { + const { setTheme } = await import('./layout'); + setTheme('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + + it('sets data-theme based on system preference when theme is system', async () => { + const { setTheme } = await import('./layout'); + setTheme('system'); + const expected = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + expect(document.documentElement.getAttribute('data-theme')).toBe(expected); + }); + + it('updates settings modal controls when present', async () => { + document.body.innerHTML = ` +
      + + +
      + `; + const { setTheme } = await import('./layout'); + setTheme('system'); + + const auto = document.querySelector('#set-theme-auto') as HTMLInputElement; + expect(auto.checked).toBe(true); + + setTheme('light'); + expect(auto.checked).toBe(false); + }); +}); + +describe('toggleTheme', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + }); + + it('toggles from light to dark and persists to backend', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({ general: { theme: 'light' } }); + + const { toggleTheme, setTheme } = await import('./layout'); + setTheme('light'); + + toggleTheme(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + }); + + it('handles backend persistence failure gracefully', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockRejectedValue(new Error('fail')); + + const { toggleTheme, setTheme } = await import('./layout'); + setTheme('light'); + + toggleTheme(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + }); +}); + +describe('setTab', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` +
      + + + +
      +
      + + `; + }); + + it('activates the target tab and deactivates others', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + + const tabs = document.querySelectorAll('.tab'); + expect(tabs[0].classList.contains('active')).toBe(false); + expect(tabs[1].classList.contains('active')).toBe(true); + expect(tabs[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('hides commit box on history tab', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + + const commitBox = document.getElementById('commit'); + expect(commitBox?.style.display).toBe('none'); + }); + + it('shows commit box on changes tab', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + setTab('changes'); + + const commitBox = document.getElementById('commit'); + expect(commitBox?.style.display).toBe('grid'); + }); + + it('sets diff path text based on tab', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + expect(document.getElementById('diff-path')?.textContent).toBe('Commit details'); + + setTab('stash'); + expect(document.getElementById('diff-path')?.textContent).toBe('Stash details'); + + setTab('changes'); + expect(document.getElementById('diff-path')?.textContent).toBe('Select a file to view changes'); + }); + + it('hides history actions button when not on history tab', async () => { + const { setTab } = await import('./layout'); + setTab('changes'); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + expect(btn.hidden).toBe(true); + }); + + it('dispatches app:tab-changed event', async () => { + const handler = vi.fn(); + window.addEventListener('app:tab-changed', handler); + + const { setTab } = await import('./layout'); + setTab('stash'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ detail: 'stash' }), + ); + }); +}); + +describe('bindTabs', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` + + + `; + }); + + it('calls onChange when a tab is clicked', async () => { + const { bindTabs } = await import('./layout'); + const onChange = vi.fn(); + + bindTabs(onChange); + + const historyTab = document.querySelector('.tab[data-tab="history"]') as HTMLButtonElement; + historyTab.click(); + + expect(onChange).toHaveBeenCalledWith('history'); + }); + + it('defaults to "changes" for unrecognized tab values', async () => { + document.body.innerHTML = ''; + const { bindTabs } = await import('./layout'); + const onChange = vi.fn(); + + bindTabs(onChange); + + const unknownTab = document.querySelector('.tab') as HTMLButtonElement; + unknownTab.click(); + + expect(onChange).toHaveBeenCalledWith('changes'); + }); +}); + +describe('setRepoHeader / resetRepoHeader', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` +
      +
      + `; + }); + + it('sets the repo title from a path', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader('/home/user/projects/my-repo'); + expect(document.getElementById('repo-title')?.textContent).toBe('my-repo'); + }); + + it('sets the repo title from a path with trailing slash', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader('/home/user/projects/my-repo/'); + expect(document.getElementById('repo-title')?.textContent).toBe('my-repo'); + }); + + it('sets the branch label from state', async () => { + const { setRepoHeader } = await import('./layout'); + const { state } = await import('../state/state'); + state.branchLabel = 'main'; + setRepoHeader('/repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('main'); + }); + + it('resets the repo header to defaults', async () => { + const { resetRepoHeader } = await import('./layout'); + resetRepoHeader(); + expect(document.getElementById('repo-title')?.textContent).toBe('Click to open Repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('No repo open'); + }); +}); + +describe('bindLayoutActionState', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ` +
      +
      + + + + + + + `; + }); + + it('wires event listeners without crashing', async () => { + const { bindLayoutActionState } = await import('./layout'); + expect(() => bindLayoutActionState()).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/ui/modals.test.ts b/Frontend/src/scripts/ui/modals.test.ts new file mode 100644 index 00000000..dd180a67 --- /dev/null +++ b/Frontend/src/scripts/ui/modals.test.ts @@ -0,0 +1,217 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function mountRoot() { + document.body.innerHTML = '
      '; +} + +describe('hydrate', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('adds existing element id to loaded set', async () => { + document.body.innerHTML = '
      '; + const { hydrate } = await import('./modals'); + expect(() => hydrate('existing-modal')).not.toThrow(); + }); + + it('throws when no fragment is registered for unknown id', async () => { + mountRoot(); + const { hydrate } = await import('./modals'); + expect(() => hydrate('unknown-modal')).toThrow('No fragment registered for unknown-modal'); + }); + + it('does nothing when root is missing', async () => { + const { hydrate } = await import('./modals'); + expect(() => hydrate('settings-modal')).not.toThrow(); + }); +}); + +describe('openModal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = ''; + }); + + it('opens a modal by setting aria-hidden to false', async () => { + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + + const modal = document.getElementById('test-modal'); + expect(modal?.getAttribute('aria-hidden')).toBe('false'); + }); + + it('locks scroll when opening', async () => { + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('wires click-to-close on first open', async () => { + vi.useFakeTimers(); + const { openModal, closeModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + const modal = document.getElementById('test-modal')!; + + const backdrop = modal.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + + vi.advanceTimersByTime(200); + + expect(modal.getAttribute('aria-hidden')).toBe('true'); + vi.useRealTimers(); + }); + + it('does not re-wire close handler on subsequent opens', async () => { + const { openModal, closeModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + closeModal('test-modal'); + openModal('test-modal'); + + const modal = document.getElementById('test-modal')!; + expect(modal.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('closeModal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + it('sets aria-hidden to true and unlocks scroll', async () => { + const { closeModal } = await import('./modals'); + document.body.innerHTML += ''; + + closeModal('test-modal'); + + const modal = document.getElementById('test-modal'); + expect(modal?.getAttribute('aria-hidden')).toBe('true'); + expect(document.body.style.overflow).toBe(''); + }); + + it('does nothing when modal is not found', async () => { + const { closeModal } = await import('./modals'); + expect(() => closeModal('nonexistent')).not.toThrow(); + }); +}); + +describe('closeAllModals', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + it('closes all open modals and resets scroll lock', async () => { + const { closeAllModals } = await import('./modals'); + document.body.innerHTML += ` + + + `; + + closeAllModals(); + + expect(document.getElementById('modal1')?.getAttribute('aria-hidden')).toBe('true'); + expect(document.getElementById('modal2')?.getAttribute('aria-hidden')).toBe('true'); + expect(document.body.style.overflow).toBe(''); + }); +}); + +describe('declarative opener (data-modal-open)', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + }); + + it('opens a modal when a data-modal-open element is clicked', async () => { + document.body.innerHTML = ` + + + `; + + await import('./modals'); + + const btn = document.querySelector('[data-modal-open]') as HTMLElement; + btn.click(); + + const modal = document.getElementById('test-modal'); + expect(modal?.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('ESC key closes top modal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + vi.useFakeTimers(); + }); + + it('closes the top-most open modal on Escape keydown', async () => { + document.body.innerHTML = ` + + `; + + await import('./modals'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + vi.advanceTimersByTime(200); + + const modal = document.getElementById('modal1'); + expect(modal?.getAttribute('aria-hidden')).toBe('true'); + vi.useRealTimers(); + }); + + it('ignores non-Escape keys', async () => { + const { closeModal } = await import('./modals'); + document.body.innerHTML = ` + + `; + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + // Modal should remain open + expect(document.getElementById('modal1')?.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('scroll lock counting', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = ''; + }); + + it('supports multiple open modals', async () => { + const { openModal, closeModal } = await import('./modals'); + document.body.innerHTML += ` + + + `; + + openModal('m1'); + expect(document.body.style.overflow).toBe('hidden'); + + openModal('m2'); + expect(document.body.style.overflow).toBe('hidden'); + + closeModal('m1'); + expect(document.body.style.overflow).toBe('hidden'); + + closeModal('m2'); + expect(document.body.style.overflow).toBe(''); + }); +}); diff --git a/Frontend/vitest.config.ts b/Frontend/vitest.config.ts index 4e00949c..9193b908 100644 --- a/Frontend/vitest.config.ts +++ b/Frontend/vitest.config.ts @@ -18,6 +18,12 @@ export default defineConfig({ setupFiles: ['./src/setupTests.ts'], coverage: { provider: 'v8', + thresholds: { + statements: 95, + branches: 95, + functions: 95, + lines: 95, + }, }, }, }) From 0eb73c3611ad6b3a7c12879d0a3d5d86d09db469 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 21 May 2026 13:43:01 +0100 Subject: [PATCH 02/25] Add way more tests --- Frontend/src/scripts/features/diff.test.ts | 226 ++++++++ .../src/scripts/features/newBranch.test.ts | 230 ++++++++ .../features/repo/diffSelection.test.ts | 342 +++++++++++- .../scripts/features/repo/diffView.test.ts | 149 ++++- .../src/scripts/features/repo/history.test.ts | 262 ++++++++- .../features/repo/interactions.test.ts | 405 +++++++++++++- .../src/scripts/features/settings.test.ts | 367 ++++++++++++- .../scripts/features/settingsPlugins.test.ts | 519 ++++++++++++++++++ .../src/scripts/features/stashConfirm.test.ts | 108 ++++ Frontend/src/scripts/features/update.test.ts | 54 ++ 10 files changed, 2619 insertions(+), 43 deletions(-) diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index 339d456f..ca39e817 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -6,6 +6,10 @@ vi.mock('../plugins', () => ({ runHook: vi.fn(async () => ({ cancelled: false })), })); +vi.mock('../lib/notify', () => ({ + notify: vi.fn(), +})); + vi.mock('../lib/tauri', () => { const invoke = vi.fn(async (cmd: string) => { if (cmd === 'commit_patch_and_files') return 'oid-123'; @@ -100,6 +104,175 @@ describe('bindCommit', () => { await Promise.resolve(); expect(vi.mocked(repo.yieldToPaint)).toHaveBeenCalled(); }); + + it('shows notification when summary is empty', async () => { + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + state.files = []; + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + bindCommit(); + commitBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(notify).toHaveBeenCalledWith('Summary is required'); + }); + + it('uses commit summary hint when summary is empty', async () => { + state.files = []; + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + + const { bindCommit } = await import('./diff'); + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + bindCommit(); + commitBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it('handles hook cancellation', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { runHook } = await import('../plugins'); + vi.mocked(runHook).mockResolvedValue({ cancelled: true, reason: 'Cancelled by plugin' }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Test commit'; + bindCommit(); + commitBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(notify).toHaveBeenCalledWith('Cancelled by plugin'); + }); + + it('builds combined patch for partial files with hunk selections', async () => { + state.selectedFiles = new Set(['file1.txt']); + state.selectedHunksByFile = { 'file1.txt': [0] }; + state.selectedLinesByFile = {}; + state.files = [{ path: 'file1.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return [ + 'diff --git a/file1.txt b/file1.txt', + 'index abc..def 100644', + '--- a/file1.txt', + '+++ b/file1.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + } + if (cmd === 'commit_patch_and_files') return 'oid-123'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Partial commit'; + bindCommit(); + commitBtn.click(); + + // The async handler runs and should eventually call commit_patch_and_files. + // Instead of waiting for the full chain, verify the vcs_diff_file calls + // which happen before commit_patch_and_files. + await vi.waitFor(() => { + const diffCalls = invoke.mock.calls.filter( + (args: unknown[]) => args[0] === 'vcs_diff_file' + ); + expect(diffCalls.length).toBeGreaterThan(0); + }, { timeout: 3000, interval: 20 }); + }); + + it('handles partial load failure gracefully', async () => { + state.selectedFiles = new Set(['broken.txt']); + state.selectedHunksByFile = { 'broken.txt': [0] }; + state.files = [{ path: 'broken.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') throw new Error('load error'); + return []; + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Broken commit'; + bindCommit(); + commitBtn.click(); + // After yieldToPaint, the handler attempts vcs_diff_file which throws. + // notify should be called with the error message. + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Failed to read one or more selected diffs'); + }, { timeout: 3000, interval: 20 }); + }); + + it('shows notification when no files or hunks selected', async () => { + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + state.files = []; + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Empty commit'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Select files or hunks to commit'); + }, { timeout: 3000, interval: 20 }); + }); + + it('truncates summary to 72 chars when maxLength attribute is set', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return []; + if (cmd === 'commit_patch_and_files') return 'oid-789'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.maxLength = 72; + const longSummary = 'a'.repeat(100); + commitSummary.value = longSummary; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const calls = invoke.mock.calls.filter( + (args: unknown[]) => args[0] === 'commit_patch_and_files' + ); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0][1].summary.length).toBeLessThanOrEqual(72); + }, { timeout: 3000, interval: 20 }); + }); }); describe('buildPatchForSelectedHunks', () => { @@ -189,6 +362,42 @@ describe('buildPatchForSelectedHunks', () => { const result = buildPatchForSelectedHunks('file.txt', lines, [99]); expect(result).not.toContain('@@'); }); + + it('normalizes backslashes in path', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/src\\file.txt b/src\\file.txt', + '--- a/src\\file.txt', + '+++ b/src\\file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('src\\file.txt', lines, [0]); + expect(result).toContain('b/src/file.txt'); + }); + + it('selects the middle hunk from multiple hunks', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/f.txt b/f.txt', + '--- a/f.txt', + '+++ b/b.txt', + '@@ -1 +1 @@', + '-a1', + '+b1', + '@@ -5 +5 @@', + '-a2', + '+b2', + '@@ -10 +10 @@', + '-a3', + '+b3', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [1]); + expect(result).not.toContain('a1'); + expect(result).toContain('a2'); + expect(result).not.toContain('a3'); + }); }); describe('buildPatchForSelectedHunks privates', () => { @@ -241,4 +450,21 @@ describe('buildPatchForSelectedHunks privates', () => { const { buildPatchForSelectedHunks } = await import('./diff'); expect(buildPatchForSelectedHunks('f.txt', [], [0])).toBe(''); }); + + it('handles header extras without new file mode', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/x.txt b/x.txt', + 'old mode 100644', + 'new mode 100755', + '--- a/x.txt', + '+++ b/x.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('x.txt', lines, [0]); + expect(result).toContain('old mode 100644'); + expect(result).toContain('new mode 100755'); + }); }); diff --git a/Frontend/src/scripts/features/newBranch.test.ts b/Frontend/src/scripts/features/newBranch.test.ts index 25994958..ef501b78 100644 --- a/Frontend/src/scripts/features/newBranch.test.ts +++ b/Frontend/src/scripts/features/newBranch.test.ts @@ -221,3 +221,233 @@ describe('wireNewBranch - additional', () => { expect(invoke).toHaveBeenCalledWith('vcs_create_branch', expect.objectContaining({ name: 'my-branch' })); }); }); + +// --------------------------------------------------------------------------- +// validateBranchName - edge cases +// --------------------------------------------------------------------------- + +describe('validateBranchName', () => { + it('rejects names with control characters', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad\x00branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('cannot contain spaces or control characters'); + }); + + it('rejects names with tilde', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad~branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('invalid characters'); + }); + + it('rejects names starting with /', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = '/branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('start or end with /'); + }); + + it('rejects names ending with /', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'branch/'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('start or end with /'); + }); + + it('rejects names with ..', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad..branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('..'); + }); + + it('rejects names with @{', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad@{branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('@{'); + }); + + it('rejects names with //', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad//branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('//'); + }); + + it('rejects names ending with .', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'branch.'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('end with "."'); + }); + + it('rejects names ending with .lock', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'branch.lock'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('.lock'); + }); + + it('rejects names with /./', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad/./path'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('invalid segments'); + }); +}); + +// --------------------------------------------------------------------------- +// createBranch - error handling +// --------------------------------------------------------------------------- + +describe('createBranch error handling', () => { + it('handles create branch failure', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => { throw new Error('create failed'); }) }, + event: { listen: vi.fn() }, + }; + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const createBtn = document.getElementById('new-branch-create') as HTMLButtonElement; + nameInput.value = 'my-branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + createBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Create branch failed'); + }); +}); + +// --------------------------------------------------------------------------- +// createBranch - hook cancellation +// --------------------------------------------------------------------------- + +describe('createBranch hook cancellation', () => { + it('cancels when preBranchCreate hook returns cancelled', async () => { + const { runHook } = await import('../plugins'); + vi.mocked(runHook).mockResolvedValue({ cancelled: true, reason: 'Cancelled by hook' }); + + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const createBtn = document.getElementById('new-branch-create') as HTMLButtonElement; + nameInput.value = 'my-branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + createBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Cancelled by hook'); + }); + + it('cancels when preSwitchBranch hook returns cancelled', async () => { + const { runHook } = await import('../plugins'); + vi.mocked(runHook) + .mockResolvedValueOnce({ cancelled: false }) // preBranchCreate + .mockResolvedValueOnce({ cancelled: true, reason: 'Switch blocked' }); // preSwitchBranch + + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const checkout = document.getElementById('new-branch-checkout') as HTMLInputElement; + const createBtn = document.getElementById('new-branch-create') as HTMLButtonElement; + checkout.checked = true; + nameInput.value = 'my-branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + createBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Switch blocked'); + }); +}); + +// --------------------------------------------------------------------------- +// populateBaseSelect with branches +// --------------------------------------------------------------------------- + +describe('populateBaseSelect', () => { + it('populates base select with branches from state', async () => { + // Set up state with branches before import + const stateModule = await import('../state/state'); + (stateModule.state as any).branch = 'main'; + (stateModule.state as any).branches = [ + { name: 'main', current: true, kind: { type: 'local' } }, + { name: 'develop', current: false, kind: { type: 'local' } }, + { name: 'origin/main', current: false, kind: { type: 'remote', remote: 'origin' } }, + ]; + + installTauriMock(); + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + + const select = document.getElementById('new-branch-base') as HTMLSelectElement; + expect(select.options.length).toBe(3); + // Current branch first + expect(select.options[0].textContent).toBe('main'); + expect(select.options[0].selected).toBe(true); + // Remote should show origin/name + expect(select.options[2].textContent).toBe('origin/main'); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffSelection.test.ts b/Frontend/src/scripts/features/repo/diffSelection.test.ts index 254d759a..d4cb1f17 100644 --- a/Frontend/src/scripts/features/repo/diffSelection.test.ts +++ b/Frontend/src/scripts/features/repo/diffSelection.test.ts @@ -22,6 +22,25 @@ function mountDiffDom() { `; } +/** Helper to create a mock hunk checkbox input. */ +function makeHunkCheckbox(dataHunk: string): HTMLInputElement { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'pick-hunk'; + cb.dataset.hunk = dataHunk; + return cb; +} + +/** Helper to create a mock line checkbox input. */ +function makeLineCheckbox(dataHunk: string, dataLine: string): HTMLInputElement { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'pick-line'; + cb.dataset.hunk = dataHunk; + cb.dataset.line = dataLine; + return cb; +} + beforeEach(() => { vi.resetModules(); mountDiffDom(); @@ -60,7 +79,6 @@ describe('updateListCheckboxForPath', () => { it('does nothing when path is empty', async () => { const { updateListCheckboxForPath } = await import('./diffSelection'); document.querySelector('#file-list')!.innerHTML = '
    • '; - // Empty path should not crash and should not modify anything updateListCheckboxForPath('', true, false); updateListCheckboxForPath(' ', true, false); }); @@ -74,6 +92,244 @@ describe('updateListCheckboxForPath', () => { }); }); +describe('toggleFilePick', () => { + it('does nothing when path is empty', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + toggleFilePick('', true); + expect(state.selectedFiles.size).toBe(0); + }); + + it('adds path to selectedFiles when on=true', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + state.currentFile = ''; + state.currentDiffBinary = false; + + toggleFilePick('test.txt', true); + expect(state.selectedFiles.has('test.txt')).toBe(true); + }); + + it('removes path from selectedFiles when on=false', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(['test.txt']); + state.currentFile = ''; + state.currentDiffBinary = false; + + toggleFilePick('test.txt', false); + expect(state.selectedFiles.has('test.txt')).toBe(false); + }); + + it('updates hunk selections when currentFile matches path and not binary', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + state.currentFile = 'test.txt'; + state.currentDiffBinary = false; + state.currentDiff = ['@@ -1 +1 @@', '-old', '+new']; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = []; + + const hunkCheckbox = makeHunkCheckbox('0'); + const lineCheckbox = makeLineCheckbox('0', '0'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox }, + }); + + toggleFilePick('test.txt', true); + + expect(state.selectedFiles.has('test.txt')).toBe(true); + expect(state.selectedHunks).toContain(0); + expect(hunkCheckbox.checked).toBe(true); + }); + + it('clears hunk selections when toggling off with currentFile match', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(['test.txt']); + state.currentFile = 'test.txt'; + state.currentDiffBinary = false; + state.currentDiff = ['@@ -1 +1 @@', '-old', '+new']; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = [0]; + + const hunkCheckbox = makeHunkCheckbox('0'); + const lineCheckbox = makeLineCheckbox('0', '0'); + lineCheckbox.checked = true; + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox }, + }); + + toggleFilePick('test.txt', false); + + expect(state.selectedFiles.has('test.txt')).toBe(false); + expect(state.selectedHunks.length).toBe(0); + expect(hunkCheckbox.checked).toBe(false); + }); + + it('skips hunk sync when currentDiffBinary is true', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + state.currentFile = 'test.txt'; + state.currentDiffBinary = true; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = []; + + toggleFilePick('test.txt', true); + expect(state.selectedFiles.has('test.txt')).toBe(true); + expect(state.selectedHunks.length).toBe(0); + }); + + it('does nothing with hunk nodes when currentFile does not match path', async () => { + const { toggleFilePick } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + state.currentFile = 'other.txt'; + state.currentDiffBinary = false; + + toggleFilePick('test.txt', true); + expect(state.selectedFiles.has('test.txt')).toBe(true); + expect(state.currentFile).toBe('other.txt'); + }); +}); + +describe('updateHunkCheckboxes', () => { + it('does nothing when currentDiffHunkNodes is empty', async () => { + const { updateHunkCheckboxes } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentDiffHunkNodes = new Map(); + expect(() => updateHunkCheckboxes()).not.toThrow(); + }); + + it('updates hunk checkboxes based on selectedHunks', async () => { + const { updateHunkCheckboxes } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = ''; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = [0]; + + const hunkCheckbox = makeHunkCheckbox('0'); + const hunkEl = document.createElement('div'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: {}, + }); + + updateHunkCheckboxes(); + + expect(hunkCheckbox.checked).toBe(true); + expect(hunkEl.classList.contains('picked')).toBe(true); + }); + + it('clears hunk checkboxes when hunk not selected', async () => { + const { updateHunkCheckboxes } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = ''; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = []; + + const hunkCheckbox = makeHunkCheckbox('0'); + hunkCheckbox.checked = true; + const hunkEl = document.createElement('div'); + hunkEl.classList.add('picked'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: {}, + }); + + updateHunkCheckboxes(); + + expect(hunkCheckbox.checked).toBe(false); + expect(hunkEl.classList.contains('picked')).toBe(false); + }); + + it('sets indeterminate state for partial line selection', async () => { + const { updateHunkCheckboxes } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.selectedHunks = []; + (state as any).selectedLinesByFile = { 'test.txt': { 0: [0] } }; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [3], totalHunks: 1 }; + state.currentDiffHunkNodes = new Map(); + + const hunkCheckbox = makeHunkCheckbox('0'); + const lineCheckbox0 = makeLineCheckbox('0', '0'); + const lineCheckbox1 = makeLineCheckbox('0', '1'); + const lineCheckbox2 = makeLineCheckbox('0', '2'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox0, 1: lineCheckbox1, 2: lineCheckbox2 }, + }); + + updateHunkCheckboxes(); + + expect(lineCheckbox0.checked).toBe(true); + expect(lineCheckbox1.checked).toBe(false); + expect(lineCheckbox2.checked).toBe(false); + expect((hunkCheckbox as any).indeterminate).toBe(true); + }); + + it('marks hunk checked when all lines are selected', async () => { + const { updateHunkCheckboxes } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.selectedHunks = []; + (state as any).selectedLinesByFile = { 'test.txt': { 0: [0, 1, 2] } }; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [3], totalHunks: 1 }; + state.currentDiffHunkNodes = new Map(); + + const hunkCheckbox = makeHunkCheckbox('0'); + const lineCheckbox0 = makeLineCheckbox('0', '0'); + const lineCheckbox1 = makeLineCheckbox('0', '1'); + const lineCheckbox2 = makeLineCheckbox('0', '2'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox0, 1: lineCheckbox1, 2: lineCheckbox2 }, + }); + + updateHunkCheckboxes(); + + expect(hunkCheckbox.checked).toBe(true); + expect((hunkCheckbox as any).indeterminate).toBe(false); + }); + + it('uses fallback total from lineCheckboxes when changeCounts missing', async () => { + const { updateHunkCheckboxes } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.selectedHunks = []; + (state as any).selectedLinesByFile = { 'test.txt': { 0: [0] } }; + state.currentDiffMeta = null as any; + state.currentDiffHunkNodes = new Map(); + + const hunkCheckbox = makeHunkCheckbox('0'); + const lineCheckbox0 = makeLineCheckbox('0', '0'); + const lineCheckbox1 = makeLineCheckbox('0', '1'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox0, 1: lineCheckbox1 }, + }); + + updateHunkCheckboxes(); + + expect(lineCheckbox0.checked).toBe(true); + expect((hunkCheckbox as any).indeterminate).toBe(true); + }); +}); + describe('syncFileCheckboxWithHunks', () => { it('handles binary diff', async () => { const { state } = await import('../../state/state'); @@ -90,7 +346,6 @@ describe('syncFileCheckboxWithHunks', () => { it('handles no currentFile', async () => { const { syncFileCheckboxWithHunks } = await import('./diffSelection'); - // Should not throw when currentFile is empty expect(() => syncFileCheckboxWithHunks()).not.toThrow(); }); @@ -146,13 +401,90 @@ describe('bindHunkToggles', () => { const diff = document.getElementById('diff')!; const { bindHunkToggles } = await import('./diffSelection'); bindHunkToggles(diff); - // Second call should not add another listener bindHunkToggles(diff); - // Add a pick-hunk checkbox and simulate change diff.innerHTML = ''; const cb = diff.querySelector('.pick-hunk')!; cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); - // No crash is sufficient validation for the binding test + }); + + it('handles pick-line changes via delegated change event', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = []; + (state as any).selectedLinesByFile = {}; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [2], totalHunks: 1 }; + + const lineCb = makeLineCheckbox('0', '1'); + const hunkCheckbox = makeHunkCheckbox('0'); + const hunkEl = document.createElement('div'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: makeLineCheckbox('0', '0'), 1: lineCb }, + }); + + diff.appendChild(lineCb); + bindHunkToggles(diff); + + lineCb.checked = true; + lineCb.dispatchEvent(new Event('change', { bubbles: true })); + + const rec = (state as any).selectedLinesByFile['test.txt']; + expect(rec).toBeDefined(); + expect(rec[0]).toContain(1); + }); + + it('ignores non-input change events', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + bindHunkToggles(diff); + const nonInput = document.createElement('div'); + nonInput.className = 'pick-hunk'; + diff.appendChild(nonInput); + expect(() => nonInput.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); + + it('handles hunk toggle via delegated change event', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiff = ['@@ -1 +1 @@', '-old', '+new']; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = []; + (state as any).selectedLinesByFile = {}; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [2], totalHunks: 1 }; + + const hunkCb = makeHunkCheckbox('0'); + const lineCb0 = makeLineCheckbox('0', '0'); + const hunkEl = document.createElement('div'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCb], + lineCheckboxes: { 0: lineCb0 }, + }); + + diff.appendChild(hunkCb); + bindHunkToggles(diff); + + hunkCb.checked = true; + hunkCb.dispatchEvent(new Event('change', { bubbles: true })); + + expect(state.selectedHunks).toContain(0); + }); + + it('does nothing for elements without hunk or line class', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + bindHunkToggles(diff); + const otherCb = document.createElement('input'); + otherCb.type = 'checkbox'; + otherCb.className = 'other'; + diff.appendChild(otherCb); + expect(() => otherCb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); }); }); diff --git a/Frontend/src/scripts/features/repo/diffView.test.ts b/Frontend/src/scripts/features/repo/diffView.test.ts index f958c6d4..9f7c9ea0 100644 --- a/Frontend/src/scripts/features/repo/diffView.test.ts +++ b/Frontend/src/scripts/features/repo/diffView.test.ts @@ -60,6 +60,16 @@ describe('highlightRow', () => { expect(rows[0].classList.contains('active')).toBe(false); expect(rows[1].classList.contains('active')).toBe(true); }); + + it('uses history row selector when prefs tab is history', async () => { + const { prefs } = await import('../../state/state'); + prefs.tab = 'history'; + document.querySelector('#file-list')!.innerHTML = '
    • a
    • b
    • '; + const { highlightRow } = await import('./diffView'); + highlightRow(0); + const rows = document.querySelectorAll('#file-list .row.commit'); + expect(rows[0].classList.contains('active')).toBe(true); + }); }); describe('renderCombinedDiff', () => { @@ -109,6 +119,23 @@ describe('renderCombinedDiff', () => { const html = document.querySelector('#diff')?.innerHTML || ''; expect(html).toContain('failed to load diff'); }); + + it('shows per-file failure when all files fail', async () => { + (window as any).__TAURI__.core.invoke.mockRejectedValue(new Error('fail')); + const { renderCombinedDiff } = await import('./diffView'); + await renderCombinedDiff(['a.txt', 'b.txt']); + const html = document.querySelector('#diff')?.innerHTML || ''; + expect(html).toContain('failed to load diff'); + expect(html).toContain('a.txt'); + expect(html).toContain('b.txt'); + }); + + it('handles empty and null file paths', async () => { + const { renderCombinedDiff } = await import('./diffView'); + await renderCombinedDiff([]); + const html = document.querySelector('#diff')?.innerHTML || ''; + expect(html).toContain('No diffs'); + }); }); describe('selectFile', () => { @@ -126,7 +153,6 @@ describe('selectFile', () => { const { selectFile } = await import('./diffView'); const invokeSpy = (window as any).__TAURI__.core.invoke; await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); - // invoke should NOT be called since diff is clean and same file expect(invokeSpy).not.toHaveBeenCalled(); }); @@ -203,7 +229,6 @@ describe('selectFile', () => { const { selectFile } = await import('./diffView'); await selectFile({ path: 'untracked.txt', status: '??' } as FileStatus, 0); const diffText = document.querySelector('#diff')?.textContent || ''; - // Should fallback to empty untracked patch expect(diffText).toContain('@@ -0,0 +1,0 @@'); }); @@ -215,6 +240,117 @@ describe('selectFile', () => { const diffText = document.querySelector('#diff')?.textContent || ''; expect(diffText).toContain('Failed to load diff'); }); + + it('restores cached hunk selections', async () => { + const { state } = await import('../../state/state'); + state.diffDirty = true; + state.currentFile = ''; + + // Set up hunk nodes similar to what buildDiffFragment would produce + state.currentDiffHunkNodes = new Map(); + const hunkCheckbox = document.createElement('input'); + hunkCheckbox.type = 'checkbox'; + hunkCheckbox.className = 'pick-hunk'; + const lineCheckbox = document.createElement('input'); + lineCheckbox.type = 'checkbox'; + lineCheckbox.className = 'pick-line'; + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox }, + }); + + // Set cached selections + (state as any).selectedHunksByFile = { 'a.txt': [0] }; + + const { selectFile } = await import('./diffView'); + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + expect(state.selectedHunks).toEqual([0]); + }); + + it('selects all hunks when file is in selectedFiles', async () => { + const { state } = await import('../../state/state'); + state.diffDirty = true; + state.currentFile = ''; + state.selectedFiles = new Set(['a.txt']); + + // The code checks selectedFiles.has(file.path) - so we need a.txt in selectedFiles + state.currentDiffHunkNodes = new Map(); + const hunkCheckbox = document.createElement('input'); + hunkCheckbox.type = 'checkbox'; + hunkCheckbox.className = 'pick-hunk'; + const lineCheckbox = document.createElement('input'); + lineCheckbox.type = 'checkbox'; + lineCheckbox.className = 'pick-line'; + state.currentDiffHunkNodes.set(0, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCheckbox], + lineCheckboxes: { 0: lineCheckbox }, + }); + + const { selectFile } = await import('./diffView'); + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + // When file is in selectedFiles, it should select all hunks + expect(state.selectedHunks).toEqual([0]); + }); + + it('clears selectedHunks when no cached selection and file not selected', async () => { + const { state } = await import('../../state/state'); + state.diffDirty = true; + state.currentFile = ''; + state.selectedFiles = new Set(['other.txt']); + state.selectedHunks = [99]; + state.defaultSelectAll = false; + state.selectionImplicitAll = false; + + const { selectFile } = await import('./diffView'); + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + expect(state.selectedHunks).toEqual([]); + }); +}); + +describe('selectFile contextmenu', () => { + it('attaches contextmenu handler to diffEl for hunk discard', async () => { + // Need to ensure invoke returns proper diff lines so the hunk elements render + const { selectFile } = await import('./diffView'); + const { state } = await import('../../state/state'); + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + const diffEl = document.getElementById('diff')!; + // The diff output from the default mock is 4 lines (the mock returns ['diff --git ...', '@@ ...', '-old', '+new']) + // buildDiffFragment should create .hunk elements from this, and selectFile attaches contextmenu + const hunkEl = diffEl.querySelector('.hunk'); + // The contextmenu handler is attached with { once: true }, so we trigger it + if (hunkEl) { + const ctxEvent = new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true }); + hunkEl.dispatchEvent(ctxEvent); + // Should not throw - the handler calls buildCtxMenu which doesn't exist in jsdom but that's ok + } + // Just verify no crash + }); + + it('contextmenu handler adds discard selected hunks items when hunks are selected', async () => { + const { selectFile } = await import('./diffView'); + const { state } = await import('../../state/state'); + + // Set up cached selected hunks so the context menu shows extra items + (state as any).selectedHunksByFile = { 'a.txt': [0] }; + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk'); + if (hunkEl) { + hunkEl.setAttribute('data-hunk-index', '0'); + const ctxEvent = new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true }); + hunkEl.dispatchEvent(ctxEvent); + } + // No crash test + }); }); describe('selectStashDiff', () => { @@ -247,6 +383,15 @@ describe('selectStashDiff', () => { expect(diffText).toContain('Failed to load stash diff'); warnSpy.mockRestore(); }); + + it('handles empty selector', async () => { + const { selectStashDiff } = await import('./diffView'); + await selectStashDiff(''); + // Should not call invoke for empty selector + const invokeSpy = (window as any).__TAURI__.core.invoke; + // If selector is empty, it still calls invoke with '' as selector + // The mock will return [] but it should not crash + }); }); describe('clearDiffSelection', () => { diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 8dd5841b..47129cd4 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -18,9 +18,20 @@ function mountHistoryDom() {
      + + +
      ` } +/** Installs a Tauri mock for tests that invoke backend commands. */ +function installTauriMock() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => []) }, + event: { listen: vi.fn() }, + } +} + /** Imports the history feature after the test DOM is ready. */ async function loadHistoryModule() { return import('./history') @@ -41,6 +52,7 @@ beforeEach(() => { afterEach(() => { document.body.innerHTML = '' + delete (window as any).__TAURI__ vi.restoreAllMocks() }) @@ -111,6 +123,33 @@ describe('history parsing', () => { expect(files[1].path).toBe('b.txt') expect(files[1].status).toBe('M') }) + + it('skips non-diff lines before the first diff --git', async () => { + const { parseCommitDiffByFile } = await loadHistoryModule() + const lines = [ + 'some metadata line', + 'another line', + 'diff --git a/x.txt b/x.txt', + '--- a/x.txt', + '+++ b/x.txt', + ] + const files = parseCommitDiffByFile(lines) + expect(files.length).toBe(1) + expect(files[0].path).toBe('x.txt') + }) + + it('uses pathA when pathB is empty', async () => { + const { parseCommitDiffByFile } = await loadHistoryModule() + const lines = [ + 'diff --git a/x.txt b/x.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/x.txt', + ] + const files = parseCommitDiffByFile(lines) + expect(files.length).toBe(1) + expect(files[0].path).toBe('x.txt') + }) }) describe('formatTimeAgo', () => { @@ -142,17 +181,11 @@ describe('formatTimeAgo', () => { const { formatTimeAgo } = await loadHistoryModule() const now = Date.now() - // just now (< 45s) expect(formatTimeAgo(new Date(now - 10 * 1000).toISOString())).toBe('just now') - // 1 minute (between 45s and 90s) expect(formatTimeAgo(new Date(now - 60 * 1000).toISOString())).toBe('1 minute ago') - // 45 minutes expect(formatTimeAgo(new Date(now - 45 * 60 * 1000).toISOString())).toBe('45 minutes ago') - // 1 hour expect(formatTimeAgo(new Date(now - 60 * 60 * 1000).toISOString())).toBe('1 hour ago') - // 23 hours expect(formatTimeAgo(new Date(now - 23 * 60 * 60 * 1000).toISOString())).toBe('23 hours ago') - // yesterday expect(formatTimeAgo(new Date(now - 25 * 60 * 60 * 1000).toISOString())).toBe('yesterday') }) }) @@ -162,17 +195,24 @@ describe('formatTimeAgo - weeks and months', () => { const { formatTimeAgo } = await loadHistoryModule() const now = Date.now() - // 3 days expect(formatTimeAgo(new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString())).toBe('3 days ago') - // 1 week expect(formatTimeAgo(new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString())).toBe('1 week ago') - // 3 weeks expect(formatTimeAgo(new Date(now - 21 * 24 * 60 * 60 * 1000).toISOString())).toBe('3 weeks ago') - // 30 days → wk=4 (<5) so returns '4 weeks ago' expect(formatTimeAgo(new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString())).toBe('4 weeks ago') }) }) +describe('formatTimeAgo - years', () => { + it('formats months and years', async () => { + const { formatTimeAgo } = await loadHistoryModule() + const now = Date.now() + + expect(formatTimeAgo(new Date(now - 65 * 24 * 60 * 60 * 1000).toISOString())).toBe('2 months ago') + expect(formatTimeAgo(new Date(now - 370 * 24 * 60 * 60 * 1000).toISOString())).toBe('1 year ago') + expect(formatTimeAgo(new Date(now - 1100 * 24 * 60 * 60 * 1000).toISOString())).toBe('3 years ago') + }) +}) + describe('renderHistoryList', () => { it('returns false when required DOM elements are missing', async () => { document.body.innerHTML = '' @@ -209,6 +249,23 @@ describe('renderHistoryList', () => { expect(listText).not.toContain('Add feature') }) + it('filters commits by id hash', async () => { + const { renderHistoryList } = await loadHistoryModule() + const { state } = await loadStateModule() + state.commits = [ + { id: 'abc123', msg: 'First', meta: new Date().toISOString() } as any, + { id: 'def456', msg: 'Second', meta: new Date().toISOString() } as any, + ] + state.ahead = 0 + state.behind = 0 + state.aheadIds = new Set() + + renderHistoryList('abc') + const listText = document.querySelector('#file-list')?.textContent || '' + expect(listText).toContain('First') + expect(listText).not.toContain('Second') + }) + it('renders behind notice when behind > 0', async () => { const { renderHistoryList } = await loadHistoryModule() const { state } = await loadStateModule() @@ -241,6 +298,24 @@ describe('renderHistoryList', () => { expect(listHtml).toContain('outgoing') }) + it('falls back to ahead count when aheadIds is empty', async () => { + const { renderHistoryList } = await loadHistoryModule() + const { state } = await loadStateModule() + state.commits = [ + { id: 'aaa', msg: 'First ahead', meta: new Date().toISOString() } as any, + { id: 'bbb', msg: 'Second', meta: new Date().toISOString() } as any, + ] + state.ahead = 2 + state.behind = 0 + state.aheadIds = new Set() + + renderHistoryList('') + const listHtml = document.querySelector('#file-list')?.innerHTML || '' + expect(listHtml).toContain('up') + expect(listHtml).toContain('outgoing') + expect(listHtml).toContain('First ahead') + }) + it('renders incoming commits with down tag', async () => { const { renderHistoryList } = await loadHistoryModule() const { state } = await loadStateModule() @@ -256,6 +331,22 @@ describe('renderHistoryList', () => { expect(listHtml).toContain('down') expect(listHtml).toContain('incoming') }) + + it('shows incoming commits with remoteRef label', async () => { + const { renderHistoryList } = await loadHistoryModule() + const { state } = await loadStateModule() + state.commits = [ + { id: 'inc-1', msg: 'From origin', meta: new Date().toISOString(), incoming: true, remoteRef: 'origin/main' } as any, + ] + state.ahead = 0 + state.behind = 1 + state.aheadIds = new Set() + + renderHistoryList('') + const listHtml = document.querySelector('#file-list')?.innerHTML || '' + expect(listHtml).toContain('incoming') + expect(listHtml).toContain('origin/main') + }) }) describe('history hash layout', () => { @@ -292,16 +383,147 @@ describe('history hash layout', () => { }) }) -describe('formatTimeAgo - years', () => { - it('formats months and years', async () => { - const { formatTimeAgo } = await loadHistoryModule() - const now = Date.now() +describe('selectHistory', () => { + it('returns early when diffEl or diffHeadPath is missing', async () => { + document.body.innerHTML = '' + const { selectHistory } = await loadHistoryModule() + await expect(selectHistory({ id: 'abc' }, 0)).resolves.toBeUndefined() + }) - // 2 months - expect(formatTimeAgo(new Date(now - 65 * 24 * 60 * 60 * 1000).toISOString())).toBe('2 months ago') - // 1 year - expect(formatTimeAgo(new Date(now - 370 * 24 * 60 * 60 * 1000).toISOString())).toBe('1 year ago') - // 3 years - expect(formatTimeAgo(new Date(now - 1100 * 24 * 60 * 60 * 1000).toISOString())).toBe('3 years ago') + it('renders commit metadata and loads diff', async () => { + installTauriMock() + // Need to re-import so the tauri mock is picked up + const { selectHistory } = await loadHistoryModule() + const commit = { + id: 'abc123def456', + author: 'Test User ', + msg: 'Test commit message', + meta: '', + } + + await selectHistory(commit, 0) + + const diffPath = document.getElementById('diff-path') as HTMLElement + expect(diffPath?.textContent).toContain('abc123d') + expect(diffPath?.textContent).toContain('abc123def456') + + const diffText = document.getElementById('diff')?.textContent || '' + expect(diffText).toContain('abc123def456') + expect(diffText).toContain('Test User') + expect(diffText).toContain('Test commit message') + }) + + it('handles commit with empty id', async () => { + installTauriMock() + const { selectHistory } = await loadHistoryModule() + const commit = { id: '', author: '', msg: '' } + + await selectHistory(commit, 0) + + const diffPath = document.getElementById('diff-path') as HTMLElement + expect(diffPath?.textContent).toContain('unknown') + }) + + it('handles vcs_diff_commit failure gracefully', async () => { + installTauriMock() + ;(window as any).__TAURI__.core.invoke.mockRejectedValue(new Error('diff fail')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { selectHistory } = await loadHistoryModule() + const commit = { id: 'abc', author: 'A', msg: 'M' } + + await selectHistory(commit, 0) + + const diffText = document.getElementById('diff')?.textContent || '' + expect(diffText).toContain('Failed to load diff') + warnSpy.mockRestore() + }) + + it('renders per-file diff sidebar when files are present', async () => { + installTauriMock() + ;(window as any).__TAURI__.core.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') { + return [ + 'diff --git a/readme.txt b/readme.txt', + '--- a/readme.txt', + '+++ b/readme.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ] + } + return [] + }) + const { selectHistory } = await loadHistoryModule() + const commit = { id: 'abc', author: 'A', msg: 'M' } + + await selectHistory(commit, 0) + + const diffEl = document.getElementById('diff')! + const sidebar = diffEl.querySelector('.commit-files') + expect(sidebar).toBeTruthy() + expect(sidebar?.textContent).toContain('readme.txt') + + const fileRows = sidebar?.querySelectorAll('.row') + expect(fileRows?.length).toBe(1) + }) + + it('handles file contextmenu copy path in sidebar', async () => { + installTauriMock() + ;(window as any).__TAURI__.core.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') { + return [ + 'diff --git a/a.txt b/a.txt', + '--- a/a.txt', + '+++ b/a.txt', + '@@ -1 +1 @@', + '-x', + '+y', + ] + } + return [] + }) + // jsdom may not have navigator.clipboard; mock it if absent + if (typeof navigator.clipboard === 'undefined') { + (navigator as any).clipboard = { writeText: vi.fn().mockResolvedValue(undefined) } + } + const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined) + const { selectHistory } = await loadHistoryModule() + const commit = { id: 'abc', author: 'A', msg: 'M' } + + await selectHistory(commit, 0) + + const diffEl = document.getElementById('diff')! + const fileRow = diffEl.querySelector('.commit-files .row') + expect(fileRow).toBeTruthy() + + const ctxEvent = new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10, cancelable: true }) + fileRow!.dispatchEvent(ctxEvent) + + writeTextSpy.mockRestore() + }) + + it('renders hunks when commit diff has no file separators', async () => { + installTauriMock() + ;(window as any).__TAURI__.core.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') { + return [ + 'diff --git a/a.txt b/a.txt', + '--- a/a.txt', + '+++ b/a.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ] + } + return [] + }) + const { selectHistory } = await loadHistoryModule() + const commit = { id: 'abc', author: 'A', msg: 'M' } + + await selectHistory(commit, 0) + + const diffText = document.getElementById('diff')?.textContent || '' + expect(diffText).toContain('-old') + expect(diffText).toContain('+new') }) }) diff --git a/Frontend/src/scripts/features/repo/interactions.test.ts b/Frontend/src/scripts/features/repo/interactions.test.ts index dea0c65a..c1f6a163 100644 --- a/Frontend/src/scripts/features/repo/interactions.test.ts +++ b/Frontend/src/scripts/features/repo/interactions.test.ts @@ -2,6 +2,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Mock external dependencies used by onFileContextMenu and other functions +vi.mock('../../lib/menu', () => ({ buildCtxMenu: vi.fn() })); +vi.mock('../../lib/confirm', () => ({ confirmBool: vi.fn(async () => true) })); +vi.mock('../../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../../lib/tauri', () => { + const invoke = vi.fn(async () => []); + return { TAURI: { invoke, listen: vi.fn() } }; +}); +vi.mock('../../plugins', () => ({ + getPluginContextMenuItems: vi.fn(() => []), + runPluginAction: vi.fn(), +})); +vi.mock('../stashConfirm', () => ({ openStashConfirm: vi.fn() })); + /** Provides a minimal `matchMedia` test shim used by state imports. */ function createMatchMediaMock(query: string) { return { matches: false, media: query, addListener: () => {}, removeListener: () => {} }; @@ -19,6 +33,7 @@ function mountRepoDom() {
      +
      `; } @@ -76,6 +91,90 @@ describe('updateDragRange', () => { expect(Array.from(state.selectedFiles).sort()).toEqual(['a.txt', 'b.txt', 'hidden.txt']); }); + + it('selects files in diff range and updates DOM classes', async () => { + const { updateDragRange } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'M' }, + ]; + + // Set up DOM rows + const ul = document.getElementById('file-list')!; + visible.forEach((f) => { + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', f.path); + ul.appendChild(li); + }); + + state.diffSelectedFiles = new Set(); + dragState.isDragSelecting = true; + dragState.dragMode = 'diff'; + dragState.dragStartIndex = 0; + dragState.dragCurrentIndex = 1; + dragState.dragPreDiff = new Set(); + + updateDragRange(visible as any); + + expect(state.diffSelectedFiles.has('a.txt')).toBe(true); + expect(state.diffSelectedFiles.has('b.txt')).toBe(true); + const rows = ul.querySelectorAll('li.row'); + expect(rows[0].classList.contains('diffsel')).toBe(true); + expect(rows[1].classList.contains('diffsel')).toBe(true); + }); + + it('returns early when isDragSelecting is false', async () => { + const { updateDragRange } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + state.diffSelectedFiles = new Set(); + dragState.isDragSelecting = false; + dragState.dragMode = 'diff'; + + updateDragRange([]); + + expect(state.diffSelectedFiles.size).toBe(0); + }); + + it('handles commit mode with currentFile hunk sync', async () => { + const { updateDragRange } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'M' }, + ]; + + const ul = document.getElementById('file-list')!; + visible.forEach((f) => { + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', f.path); + const cb = document.createElement('input'); + cb.className = 'pick'; + cb.type = 'checkbox'; + li.appendChild(cb); + ul.appendChild(li); + }); + + state.selectedFiles = new Set(); + state.currentFile = 'a.txt'; + state.currentDiff = ['@@ -1 +1 @@', '-old', '+new']; + state.selectedHunks = []; + dragState.isDragSelecting = true; + dragState.dragMode = 'commit'; + dragState.dragTargetState = true; + dragState.dragStartIndex = 0; + dragState.dragCurrentIndex = 0; + dragState.dragPrePicked = new Set(); + + updateDragRange(visible as any); + + expect(state.selectedFiles.has('a.txt')).toBe(true); + }); }); describe('onFileClick', () => { @@ -136,6 +235,23 @@ describe('onFileClick', () => { onFileClick({ ctrlKey: false, metaKey: false, shiftKey: false } as MouseEvent, visible[0] as any, 0, visible as any); expect(dragState.suppressNextClick).toBe(false); }); + + it('suppressNextClick with multiple diff selected renders combined diff', async () => { + const { onFileClick } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { dragState } = await import('./context'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'M' }, + ]; + state.files = []; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(['a.txt', 'b.txt']); + dragState.suppressNextClick = true; + + onFileClick({ ctrlKey: false, metaKey: false, shiftKey: false } as MouseEvent, visible[0] as any, 0, visible as any); + expect(dragState.suppressNextClick).toBe(false); + }); }); describe('onFileMouseDown', () => { @@ -151,9 +267,94 @@ describe('onFileMouseDown', () => { expect(dragState.isDragSelecting).toBe(false); }); - it('initiates diff drag selection with shift', async () => { + it('initiates diff drag selection with shift+mousedown', async () => { const { onFileMouseDown } = await import('./interactions'); - expect(typeof onFileMouseDown).toBe('function'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'M' }, + ]; + state.diffSelectedFiles = new Set(); + state.selectedFiles = new Set(); + const li = document.createElement('li'); + li.setAttribute('data-path', 'a.txt'); + document.getElementById('file-list')!.appendChild(li); + + const preventDefault = vi.fn(); + onFileMouseDown({ button: 0, shiftKey: true, ctrlKey: false, metaKey: false, clientX: 0, clientY: 0, preventDefault } as any, visible[0] as any, 0, visible as any, li); + + expect(dragState.dragMode).toBe('diff'); + expect(dragState.isDragSelecting).toBe(true); + expect(dragState.dragStartIndex).toBe(0); + expect(dragState.dragCurrentIndex).toBe(0); + expect(document.body.classList.contains('drag-selecting')).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('initiates commit drag selection with ctrl+mousedown', async () => { + const { onFileMouseDown } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }]; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + const li = document.createElement('li'); + li.setAttribute('data-path', 'a.txt'); + document.getElementById('file-list')!.appendChild(li); + + onFileMouseDown({ button: 0, shiftKey: false, ctrlKey: true, metaKey: false, clientX: 0, clientY: 0, preventDefault: vi.fn() } as any, visible[0] as any, 0, visible as any, li); + + expect(dragState.dragMode).toBe('commit'); + expect(dragState.isDragSelecting).toBe(true); + expect(dragState.dragStartIndex).toBe(0); + expect(dragState.dragCurrentIndex).toBe(0); + expect(dragState.dragPrePicked).toBeDefined(); + }); + + it('resets drag state when no modifier key is pressed', async () => { + const { onFileMouseDown } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }]; + state.diffSelectedFiles = new Set(); + state.selectedFiles = new Set(); + const li = document.createElement('li'); + li.setAttribute('data-path', 'a.txt'); + dragState.dragMode = 'diff' as any; + dragState.isDragSelecting = true; + + onFileMouseDown({ button: 0, shiftKey: false, ctrlKey: false, metaKey: false, clientX: 0, clientY: 0, preventDefault: vi.fn() } as any, visible[0] as any, 0, visible as any, li); + + expect(dragState.dragMode).toBeNull(); + expect(dragState.isDragSelecting).toBe(false); + expect(dragState.dragMoved).toBe(false); + }); + + it('registers mousemove and mouseup handlers and cleans up on mouseup', async () => { + const { onFileMouseDown } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }]; + state.diffSelectedFiles = new Set(); + state.selectedFiles = new Set(); + const li = document.createElement('li'); + li.setAttribute('data-path', 'a.txt'); + document.getElementById('file-list')!.appendChild(li); + + const addListenerSpy = vi.spyOn(document, 'addEventListener'); + + onFileMouseDown({ button: 0, shiftKey: true, ctrlKey: false, metaKey: false, clientX: 10, clientY: 10, preventDefault: vi.fn() } as any, visible[0] as any, 0, visible as any, li); + + expect(addListenerSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(addListenerSpy).toHaveBeenCalledWith('mouseup', expect.any(Function), { once: true }); + + // Simulate mouseup + document.dispatchEvent(new MouseEvent('mouseup')); + + expect(dragState.isDragSelecting).toBe(false); + expect(dragState.dragMode).toBeNull(); + expect(document.body.classList.contains('drag-selecting')).toBe(false); }); }); @@ -178,17 +379,212 @@ describe('applySelect', () => { applySelect('a.txt', true, null, [], 'diff'); expect(state.diffSelectedFiles.has('a.txt')).toBe(true); }); + + it('updates DOM checkbox and row class in commit mode', async () => { + const { applySelect } = await import('./interactions'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + + const ul = document.getElementById('file-list')!; + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', 'a.txt'); + const cb = document.createElement('input'); + cb.className = 'pick'; + cb.type = 'checkbox'; + li.appendChild(cb); + ul.appendChild(li); + + applySelect('a.txt', true, li, [], 'commit'); + expect(state.selectedFiles.has('a.txt')).toBe(true); + expect(li.classList.contains('picked')).toBe(true); + expect(cb.checked).toBe(true); + expect((cb as any).indeterminate).toBe(false); + + applySelect('a.txt', false, li, [], 'commit'); + expect(state.selectedFiles.has('a.txt')).toBe(false); + expect(li.classList.contains('picked')).toBe(false); + expect(cb.checked).toBe(false); + }); + + it('updates DOM row class in diff mode', async () => { + const { applySelect } = await import('./interactions'); + const { state } = await import('../../state/state'); + state.diffSelectedFiles = new Set(); + + const li = document.createElement('li'); + li.className = 'row'; + applySelect('a.txt', true, li, [], 'diff'); + expect(state.diffSelectedFiles.has('a.txt')).toBe(true); + expect(li.classList.contains('diffsel')).toBe(true); + + applySelect('a.txt', false, li, [], 'diff'); + expect(state.diffSelectedFiles.has('a.txt')).toBe(false); + expect(li.classList.contains('diffsel')).toBe(false); + }); }); describe('toggleSelectAll', () => { it('selects all visible files', async () => { const { toggleSelectAll } = await import('./interactions'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); const visible = [ { path: 'a.txt', status: 'M' }, { path: 'b.txt', status: 'M' }, ] as any; - expect(() => toggleSelectAll(true, visible)).not.toThrow(); - expect(() => toggleSelectAll(false, visible)).not.toThrow(); + + toggleSelectAll(true, visible); + expect(state.selectedFiles.has('a.txt')).toBe(true); + expect(state.selectedFiles.has('b.txt')).toBe(true); + + toggleSelectAll(false, visible); + expect(state.selectedFiles.has('a.txt')).toBe(false); + expect(state.selectedFiles.has('b.txt')).toBe(false); + }); + + it('ignores files without a path', async () => { + const { toggleSelectAll } = await import('./interactions'); + const { state } = await import('../../state/state'); + state.selectedFiles = new Set(); + const visible = [ + { path: '', status: 'M' }, + { path: undefined as any, status: 'M' }, + ] as any; + + toggleSelectAll(true, visible); + expect(state.selectedFiles.size).toBe(0); + }); +}); + +describe('onFileContextMenu', () => { + it('calls buildCtxMenu with open action for single selected file', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = true; + + const file = { path: 'a.txt', status: 'M' } as any; + const ev = { clientX: 100, clientY: 200, preventDefault: vi.fn() } as any; + + await onFileContextMenu(ev, file); + + expect(ev.preventDefault).toHaveBeenCalled(); + expect(buildCtxMenu).toHaveBeenCalled(); + const items = buildCtxMenu.mock.lastCall[0]; + expect(items[0].label).toContain('Open with default'); + }); + + it('includes stash option for explicit multi-selection', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + + state.selectedFiles = new Set(['a.txt', 'b.txt', 'c.txt']); + state.selectionImplicitAll = false; + + const file = { path: 'a.txt', status: 'M' } as any; + const ev = { clientX: 100, clientY: 200, preventDefault: vi.fn() } as any; + await onFileContextMenu(ev, file); + + expect(ev.preventDefault).toHaveBeenCalled(); + expect(buildCtxMenu).toHaveBeenCalled(); + const items = buildCtxMenu.mock.lastCall[0]; + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + const stashItem = items.find((i: any) => String(i.label).includes('Create stash from selection')); + expect(stashItem).toBeDefined(); + }); + + it('includes single file stash option for single selection', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = false; + + const file = { path: 'a.txt', status: 'M' } as any; + await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); + + const items = buildCtxMenu.mock.lastCall[0]; + const stashFileItem = items.find((i: any) => i.label?.includes('Create stash for this file')); + expect(stashFileItem).toBeDefined(); + }); + + it('includes add to gitignore action', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { confirmBool } = await import('../../lib/confirm'); + + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = false; + + const file = { path: 'a.txt', status: 'M' } as any; + await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); + + const items = buildCtxMenu.mock.lastCall[0]; + const gitignoreItem = items.find((i: any) => i.label?.includes('Add to .gitignore')); + expect(gitignoreItem).toBeDefined(); + + await gitignoreItem.action!(); + expect(confirmBool).toHaveBeenCalled(); + }); + + it('includes discard changes action', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = false; + + const file = { path: 'a.txt', status: 'M' } as any; + await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); + + const items = buildCtxMenu.mock.lastCall[0]; + const discardItem = items.find((i: any) => i.label === 'Discard changes'); + expect(discardItem).toBeDefined(); + }); + + it('includes discard all selected for explicit multi-selection', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + + state.selectedFiles = new Set(['a.txt', 'b.txt']); + state.selectionImplicitAll = false; + + const file = { path: 'a.txt', status: 'M' } as any; + await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); + + const items = buildCtxMenu.mock.lastCall[0]; + const discardAllItem = items.find((i: any) => i.label?.includes('Discard all selected')); + expect(discardAllItem).toBeDefined(); + }); + + it('includes plugin context menu items when available', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { getPluginContextMenuItems } = await import('../../plugins'); + + (getPluginContextMenuItems as any).mockReturnValue([ + { label: 'Plugin Action', action: 'plugin.test' }, + ]); + + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = false; + + const file = { path: 'a.txt', status: 'M' } as any; + await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); + + const items = buildCtxMenu.mock.lastCall[0]; + const pluginItem = items.find((i: any) => i.label === 'Plugin Action'); + expect(pluginItem).toBeDefined(); }); }); @@ -197,7 +593,6 @@ describe('setRenderListCallback / isDragSelecting / setDragCurrentIndex', () => const { setRenderListCallback } = await import('./interactions'); const fn = vi.fn(); setRenderListCallback(fn); - // The callback is stored and later used by renderListAfterRangeSelect expect(fn).not.toHaveBeenCalled(); }); diff --git a/Frontend/src/scripts/features/settings.test.ts b/Frontend/src/scripts/features/settings.test.ts index 714db34c..aca809ca 100644 --- a/Frontend/src/scripts/features/settings.test.ts +++ b/Frontend/src/scripts/features/settings.test.ts @@ -922,16 +922,361 @@ describe('wireSettings (theme controls)', () => { }); }); - it('responds to openvcs:theme-pack-changed event when auto is checked', async () => { - mountWithTheme(); - document.getElementById('set-theme-auto')!.setAttribute('checked', ''); - (document.getElementById('set-theme-auto') as HTMLInputElement).checked = true; - mockGetActiveThemeId.mockReturnValue('dark-theme'); - const { wireSettings } = await load(); - wireSettings(); - window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed')); - const themeSel = document.getElementById('set-theme') as HTMLSelectElement; - expect(themeSel.value).toBe('dark-theme'); - expect(themeSel.disabled).toBe(true); + it('responds to openvcs:theme-pack-changed event when auto is checked', async () => { + mountWithTheme(); + document.getElementById('set-theme-auto')!.setAttribute('checked', ''); + (document.getElementById('set-theme-auto') as HTMLInputElement).checked = true; + mockGetActiveThemeId.mockReturnValue('dark-theme'); + const { wireSettings } = await load(); + wireSettings(); + window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed')); + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + expect(themeSel.value).toBe('dark-theme'); + expect(themeSel.disabled).toBe(true); + }); + + it('ignores openvcs:theme-pack-changed event when auto is not checked', async () => { + mountWithTheme(); + (document.getElementById('set-theme-auto') as HTMLInputElement).checked = false; + (document.getElementById('set-theme') as HTMLSelectElement).value = 'default-light'; + const { wireSettings } = await load(); + wireSettings(); + window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed')); + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + // Should remain unchanged because auto is not checked + expect(themeSel.value).toBe('default-light'); + }); + + it('rebuilds theme options on pointerdown when auto is checked (no-op)', async () => { + mountWithTheme(); + (document.getElementById('set-theme-auto') as HTMLInputElement).checked = true; + const { wireSettings } = await load(); + wireSettings(); + // pointerdown with auto checked returns early before rebuildThemePackOptions + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + themeSel.dispatchEvent(new Event('pointerdown')); + expect(mockRebuildThemePackOptions).not.toHaveBeenCalled(); + }); + + it('sets theme select disabled when auto is checked via applyThemeFromControls', async () => { + mountWithTheme(); + (document.getElementById('set-theme-auto') as HTMLInputElement).checked = true; + mockSelectThemePack.mockResolvedValue(undefined); + const { wireSettings } = await load(); + wireSettings(); + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + themeSel.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(mockSelectThemePack).toHaveBeenCalled(); + expect(themeSel.disabled).toBe(true); + }); + }); +}); + +/** Waits for queued promise work to settle. */ +async function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +// --------------------------------------------------------------------------- +// wireSettings - SSH binary toggle (additional) +// --------------------------------------------------------------------------- + +describe('wireSettings (SSH binary custom path)', () => { + async function load() { + return import('./settings'); + } + + function mountWithSsh() { + const modal = mountSettingsModal(); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + ].join('\n')); + return modal; + } + + it('clears path when switching from custom to auto', async () => { + mountWithSsh(); + const { wireSettings } = await load(); + wireSettings(); + const select = document.getElementById('set-git-ssh-binary') as HTMLSelectElement; + select.value = 'auto'; + select.dispatchEvent(new Event('change')); + const pathInput = document.getElementById('set-git-ssh-path') as HTMLInputElement; + expect(pathInput.disabled).toBe(true); + expect(pathInput.value).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// collectSettingsFromForm +// --------------------------------------------------------------------------- + +describe('collectSettingsFromForm', () => { + async function load() { + return import('./settings'); + } + + function mountCollectDom(extra?: string) { + const modal = mountSettingsModal(); + modal.dataset.currentCfg = JSON.stringify({ + general: { theme: 'light' }, + commit: {}, + diff: { external_merge: { enabled: false, path: '', args: '' } }, + }); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + extra || '', + ].join('\n')); + return modal; + } + + it('collects settings from form with plugins state', async () => { + const modal = mountCollectDom(); + (modal as any).__pluginsPanelState = { + list: [{ id: 'p1', name: 'P1' }], + disabled: new Set(), + enabled: new Set(['p1']), + }; + mockCollectGeneralSettings.mockReturnValue({ theme: 'light' }); + mockCollectCommitSettings.mockReturnValue({ restrict_commit_summary: true }); + mockCollectCommitTemplateSettings.mockReturnValue({}); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_global_settings', expect.objectContaining({ + cfg: expect.objectContaining({ + plugins: expect.objectContaining({ enabled: ['p1'] }), + }), + })); + }); + }); + + it('collects plugins from DOM toggles when no plugins state', async () => { + const modal = mountCollectDom(''); + delete (modal as any).__pluginsPanelState; + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_global_settings', expect.objectContaining({ + cfg: expect.objectContaining({ + plugins: expect.objectContaining({ enabled: ['p1'] }), + }), + })); + }); + }); +}); + +// --------------------------------------------------------------------------- +// refreshDefaultBackendOptions +// --------------------------------------------------------------------------- + +describe('refreshDefaultBackendOptions', () => { + async function load() { + return import('./settings'); + } + + it('populates backend options when backends are available', async () => { + mountSettingsModal(); + const modal = document.getElementById('settings-modal')!; + modal.insertAdjacentHTML('beforeend', ''); + mockInvoke.mockResolvedValue([['git', 'Git'], ['hg', 'Mercurial']]); + mockLoadPluginsIntoForm.mockResolvedValue(undefined); + mockLoadGeneralSettingsIntoForm.mockImplementation(async (_m: HTMLElement, _c: any, _k: any, refreshBackends: any) => { + await refreshBackends(_m, { general: { default_backend: 'git' } }); + }); + + const { loadSettingsIntoForm } = await load(); + await loadSettingsIntoForm(); + await flushPromises(); + + const sel = document.getElementById('set-default-backend') as HTMLSelectElement; + expect(sel.options.length).toBe(2); + expect(sel.value).toBe('git'); + expect(sel.disabled).toBe(false); + }); + + it('disables backend selector when no backends available', async () => { + mountSettingsModal(); + const modal = document.getElementById('settings-modal')!; + modal.insertAdjacentHTML('beforeend', ''); + mockInvoke.mockResolvedValue([]); + mockLoadPluginsIntoForm.mockResolvedValue(undefined); + mockLoadGeneralSettingsIntoForm.mockImplementation(async (_m: HTMLElement, _c: any, _k: any, refreshBackends: any) => { + await refreshBackends(_m, {}); + }); + + const { loadSettingsIntoForm } = await load(); + await loadSettingsIntoForm(); + + const sel = document.getElementById('set-default-backend') as HTMLSelectElement; + expect(sel.disabled).toBe(true); + expect(sel.options.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// collectSettingsFromForm - LFS, merge, recents edge cases +// --------------------------------------------------------------------------- + +describe('collectSettingsFromForm - edge cases', () => { + async function load() { + return import('./settings'); + } + + function mountEdgeDom() { + const modal = mountSettingsModal(); + modal.dataset.currentCfg = JSON.stringify({}); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n')); + return modal; + } + + it('handles empty recents limit and log keep values', async () => { + mountEdgeDom(); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalled(); + }); + }); + + it('enables external merge when mode is custom with path', async () => { + mountEdgeDom(); + // Set custom merge path + (document.getElementById('set-merge-path') as HTMLInputElement).value = '/usr/bin/merge'; + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_global_settings', expect.objectContaining({ + cfg: expect.objectContaining({ + diff: expect.objectContaining({ + external_merge: expect.objectContaining({ enabled: true, path: '/usr/bin/merge' }), + }), + }), + })); + }); + }); +}); + +// --------------------------------------------------------------------------- +// wireSettings - save button with theme and CSS props +// --------------------------------------------------------------------------- + +describe('wireSettings (save applies CSS props)', () => { + async function load() { + return import('./settings'); + } + + it('sets CSS custom properties on save', async () => { + mountSettingsModal(); + const modal = document.getElementById('settings-modal')!; + modal.dataset.currentCfg = JSON.stringify({ + performance: { gpu_accel: false }, + general: { theme: 'dark', theme_pack: 'dark-theme' }, + diff: { tab_width: 8 }, + ux: { ui_scale: 1.25, font_mono: 'Fira Code' }, + commit: { restrict_commit_summary: false }, + }); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n')); + mockCollectGeneralSettings.mockReturnValue({ theme: 'dark', theme_pack: 'dark-theme' }); + mockCollectCommitSettings.mockReturnValue({ restrict_commit_summary: false }); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + mockSelectThemePack.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--tab-size')).toBe('8'); + expect(document.documentElement.style.getPropertyValue('--ui-scale')).toBe('1.25'); + expect(document.documentElement.style.getPropertyValue('--mono')).toBe('Fira Code'); + expect(mockApplyAnimationPreference).toHaveBeenCalled(); + expect(mockApplyGpuAccelerationPreference).toHaveBeenCalled(); + expect(mockApplyCommitSummaryRestriction).toHaveBeenCalled(); + expect(mockUpdateCommitButton).toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith('Settings saved. GPU changes apply after restart.'); }); + }); }); diff --git a/Frontend/src/scripts/features/settingsPlugins.test.ts b/Frontend/src/scripts/features/settingsPlugins.test.ts index 0835a1fe..9552c476 100644 --- a/Frontend/src/scripts/features/settingsPlugins.test.ts +++ b/Frontend/src/scripts/features/settingsPlugins.test.ts @@ -37,6 +37,13 @@ if (typeof CSS === 'undefined') { if (!CSS.escape) { CSS.escape = (s: string) => s.replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~]/g, '\\$&'); } +// Polyfill requestAnimationFrame for jsdom (used in queuePluginToggle) +if (!window.requestAnimationFrame) { + window.requestAnimationFrame = (cb: FrameRequestCallback) => window.setTimeout(cb, 0) as unknown as number; +} +if (!window.cancelAnimationFrame) { + window.cancelAnimationFrame = (id: number) => window.clearTimeout(id); +} function mountSettingsDom() { document.body.innerHTML = ` @@ -59,6 +66,7 @@ function mountSettingsDom() { } beforeEach(() => { + vi.clearAllMocks(); vi.resetModules(); mountSettingsDom(); (globalThis as any).matchMedia = createMatchMediaMock; @@ -471,3 +479,514 @@ describe('toggle button in pane', () => { }); }); +// --------------------------------------------------------------------------- +// renderDetails - plugin detail panel +// --------------------------------------------------------------------------- + +describe('renderDetails', () => { + it('renders detail panel with full metadata', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'Full Plugin', version: '2.0.0', author: 'Test Author', + category: 'Utility', description: 'A full description', source: 'npm', + source_kind: 'package', source_spec: '^2.0.0', tags: ['tag1', 'tag2'], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const detailEl = document.getElementById('plugins-detail') as HTMLElement; + expect(detailEl.classList.contains('empty')).toBe(false); + expect(detailEl.querySelector('.plugin-detail-title .name')?.textContent).toBe('Full Plugin'); + expect(detailEl.querySelector('.plugin-detail-kv')?.textContent).toContain('Category'); + expect(detailEl.querySelector('.plugin-detail-kv')?.textContent).toContain('Source'); + expect(detailEl.querySelector('.plugin-detail-kv')?.textContent).toContain('Specifier'); + expect(detailEl.querySelector('.plugin-detail-kv')?.textContent).toContain('Tags'); + expect(detailEl.querySelector('.plugin-detail-kv')?.textContent).toContain('Author'); + expect(detailEl.querySelector('.plugin-detail-kv')?.textContent).toContain('Version'); + }); + + it('shows detail empty state when no filtered plugins', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return []; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const detailEl = document.getElementById('plugins-detail') as HTMLElement; + expect(detailEl.classList.contains('empty')).toBe(true); + }); + + it('shows enable button text for disabled plugin', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'Disabled P', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: false, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const toggleBtn = document.getElementById('plugins-toggle-selected') as HTMLButtonElement; + expect(toggleBtn).not.toBeNull(); + expect(toggleBtn.textContent).toBe('Enable'); + }); + + it('shows category-only meta line when no version or author', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'Minimal', version: '', author: '', + category: 'Tools', description: '', source: '', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const metaEl = document.querySelector('.plugin-detail-title .meta') as HTMLElement; + expect(metaEl.textContent).toContain('Tools'); + }); +}); + +// --------------------------------------------------------------------------- +// pane click - toggle button +// --------------------------------------------------------------------------- + +describe('pane click - toggle button', () => { + it('triggers queuePluginToggle when data-plugin-toggle button is clicked', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // The initial toggle button shows "Disable" for enabled plugin + const toggleBtn = document.getElementById('plugins-toggle-selected') as HTMLButtonElement; + expect(toggleBtn.textContent).toBe('Disable'); + + // Click the toggle button in the pane (not the detail one) + const pane = document.getElementById('plugins-pane') as HTMLElement; + pane.click(); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// pane double-click +// --------------------------------------------------------------------------- + +describe('pane double-click', () => { + it('toggles plugin on double-click row', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [ + { id: 'p1', name: 'Alpha', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }, + { id: 'p2', name: 'Beta', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }, + ]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const rows = document.querySelectorAll('.plugin-row[data-plugin]') as NodeListOf; + // First click sets lastClickAt/lastClickIdKey + rows[0].click(); + // Second click within 450ms triggers double-click toggle + rows[0].click(); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// pane checkbox change +// --------------------------------------------------------------------------- + +describe('pane checkbox change', () => { + it('triggers queuePluginToggle on checkbox change', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + expect(checkbox).not.toBeNull(); + expect(checkbox!.checked).toBe(true); + + // Dispatching change on the pane triggers the change handler + checkbox!.checked = false; + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// context menu actions +// --------------------------------------------------------------------------- + +describe('context menu actions', () => { + it('toggle action in context menu toggles checkbox', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Open context menu via right-click + const row = document.querySelector('.plugin-row[data-plugin]') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); + await flushPromises(); + + // Click "Toggle plugin" in context menu + const toggleAction = document.querySelector('.plugins-context-menu-item[data-action="toggle"]') as HTMLButtonElement; + expect(toggleAction).not.toBeNull(); + toggleAction.click(); + await flushPromises(); + }); + + it('remove action removes plugin successfully', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'RemoveMe', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'uninstall_plugin') return null; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const row = document.querySelector('.plugin-row[data-plugin]') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); + await flushPromises(); + + const removeAction = document.querySelector('.plugins-context-menu-item.destructive') as HTMLButtonElement; + removeAction.click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('uninstall_plugin', { pluginId: 'p1' }); + }); + + it('remove action handles failure gracefully', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'RemoveFail', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'uninstall_plugin') throw new Error('remove failed'); + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const row = document.querySelector('.plugin-row[data-plugin]') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); + await flushPromises(); + + const removeAction = document.querySelector('.plugins-context-menu-item.destructive') as HTMLButtonElement; + removeAction.click(); + await flushPromises(); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Remove failed: Error: remove failed'); + }); +}); + +// --------------------------------------------------------------------------- +// context menu - right-click outside row +// --------------------------------------------------------------------------- + +describe('context menu - right-click outside row', () => { + it('hides context menu when right-clicking outside plugin row', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // First show the context menu + const row = document.querySelector('.plugin-row[data-plugin]') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); + await flushPromises(); + + expect(document.querySelector('.plugins-context-menu')?.classList.contains('visible')).toBe(true); + + // Right-click outside any row + const pane = document.getElementById('plugins-pane') as HTMLElement; + pane.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })); + await flushPromises(); + + expect(document.querySelector('.plugins-context-menu')?.classList.contains('visible')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// search filtering +// --------------------------------------------------------------------------- + +describe('search filtering', () => { + it('filters plugins based on search query', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [ + { id: 'p1', name: 'Alpha', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }, + { id: 'p2', name: 'Beta', version: '1.0', author: 'B', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: false }, + ]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + + // Override search mock to return parsed query with terms + const searchModule = await import('./settingsPluginSearch'); + vi.mocked(searchModule.parsePluginQuery).mockReturnValue({ terms: ['Alpha'], authors: [], tags: [] }); + vi.mocked(searchModule.pluginSearchScore).mockImplementation((p) => p.name === 'Alpha' ? 1 : 0); + + // Reset modules to pick up new mock + vi.resetModules(); + + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [ + { id: 'p1', name: 'Alpha', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }, + { id: 'p2', name: 'Beta', version: '1.0', author: 'B', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: false }, + ]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const search = document.getElementById('plugins-search') as HTMLInputElement; + search.value = 'Alpha'; + search.dispatchEvent(new Event('input')); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// enable all / disable all - state updates +// --------------------------------------------------------------------------- + +describe('enable all / disable all - state updates', () => { + it('enable all adds all plugins to enabled set and triggers persist', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [ + { id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: false }, + ]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: ['p1'], enabled: [] } }; + if (cmd === 'set_global_settings') return null; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: { disabled: ['p1'] } } as any); + await flushPromises(); + + (document.getElementById('plugins-enable-all') as HTMLButtonElement).click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('set_global_settings', expect.anything()); + }); + + it('disable all adds all plugins to disabled set and triggers persist', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [ + { id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }, + ]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'set_global_settings') return null; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + (document.getElementById('plugins-disable-all') as HTMLButtonElement).click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('set_global_settings', expect.anything()); + }); +}); + +// --------------------------------------------------------------------------- +// persistSinglePluginToggle - error handling +// --------------------------------------------------------------------------- + +describe('persistSinglePluginToggle error handling', () => { + it('handles toggle failure and calls notify', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'set_plugin_enabled') throw new Error('toggle failed'); + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const toggleBtn = document.querySelector('[data-plugin-toggle]') as HTMLButtonElement; + toggleBtn.click(); + + await vi.waitFor(async () => { + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Failed to toggle plugin'); + }, { timeout: 2000, interval: 50 }); + }); +}); + +// --------------------------------------------------------------------------- +// sync config error handling +// --------------------------------------------------------------------------- + +describe('sync config error handling', () => { + it('handles sync config failure with error message', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'sync_configured_plugins') throw new Error('sync error'); + if (cmd === 'list_plugins') return []; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + (document.getElementById('plugins-sync-config') as HTMLButtonElement).click(); + await flushPromises(); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Plugin sync failed: Error: sync error'); + }); +}); + +// --------------------------------------------------------------------------- +// list_plugin_start_failures error handling +// --------------------------------------------------------------------------- + +describe('list_plugin_start_failures error handling', () => { + it('handles list_plugin_start_failures rejection gracefully', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return []; + if (cmd === 'list_plugin_start_failures') throw new Error('failures error'); + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await expect(loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any)).resolves.toBeUndefined(); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// queued toggle - prevents duplicate pending toggles +// --------------------------------------------------------------------------- + +describe('queued toggle deduplication', () => { + it('prevents duplicate pending toggles for same plugin', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Click the checkbox twice rapidly - only first should queue + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + await flushPromises(); + }); +}); + diff --git a/Frontend/src/scripts/features/stashConfirm.test.ts b/Frontend/src/scripts/features/stashConfirm.test.ts index e770e39d..af12c978 100644 --- a/Frontend/src/scripts/features/stashConfirm.test.ts +++ b/Frontend/src/scripts/features/stashConfirm.test.ts @@ -237,3 +237,111 @@ describe('refreshFiles integration', () => { expect(countEl.textContent).toBe('1 file'); }); }); + +// --------------------------------------------------------------------------- +// friendlyStatus - edge codes +// --------------------------------------------------------------------------- + +describe('friendlyStatus edge codes', () => { + it('renders R and C status codes in file list', async () => { + const { wireStashConfirm } = await import('./stashConfirm'); + wireStashConfirm(); + const { state } = await import('../state/state'); + state.files = [ + { path: 'renamed.txt', status: 'R' }, + { path: 'copied.txt', status: 'C' }, + ]; + (document.getElementById('stash-confirm-modal') as any).refreshFiles(); + + const listEl = document.getElementById('stash-file-list') as HTMLElement; + expect(listEl.innerHTML).toContain('Renamed'); + expect(listEl.innerHTML).toContain('Copied'); + }); + + it('renders ignored status code', async () => { + const { wireStashConfirm } = await import('./stashConfirm'); + wireStashConfirm(); + const { state } = await import('../state/state'); + state.files = [ + { path: 'ignored.log', status: '!' }, + ]; + (document.getElementById('stash-confirm-modal') as any).refreshFiles(); + + const listEl = document.getElementById('stash-file-list') as HTMLElement; + expect(listEl.innerHTML).toContain('Ignored'); + }); + + it('renders untracked status code', async () => { + const { wireStashConfirm } = await import('./stashConfirm'); + wireStashConfirm(); + const { state } = await import('../state/state'); + state.files = [ + { path: 'new.txt', status: '??' }, + ]; + (document.getElementById('stash-confirm-modal') as any).refreshFiles(); + + const listEl = document.getElementById('stash-file-list') as HTMLElement; + expect(listEl.innerHTML).toContain('Untracked'); + expect(listEl.innerHTML).toContain('add'); + }); +}); + +// --------------------------------------------------------------------------- +// runStash - edge cases +// --------------------------------------------------------------------------- + +describe('runStash edge cases', () => { + it('does not run stash when confirm button is disabled', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { wireStashConfirm } = await import('./stashConfirm'); + wireStashConfirm(); + const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; + confirmBtn.disabled = true; + confirmBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it('handles openStashConfirm with no options', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { openStashConfirm } = await import('./stashConfirm'); + openStashConfirm(); + await new Promise((r) => setTimeout(r, 0)); + + const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; + expect(confirmBtn.disabled).toBe(false); + expect(document.getElementById('stash-message') as HTMLInputElement).toHaveValue('WIP'); + }); + + it('shows empty state when override paths filter to zero files', async () => { + window.__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; + const { openStashConfirm } = await import('./stashConfirm'); + openStashConfirm({ paths: ['nonexistent.txt'] }); + await new Promise((r) => setTimeout(r, 0)); + + const emptyEl = document.getElementById('stash-empty') as HTMLElement; + const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; + expect(emptyEl.hidden).toBe(false); + expect(confirmBtn.disabled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// wireStashConfirm - no modal +// --------------------------------------------------------------------------- + +describe('wireStashConfirm no modal', () => { + it('does not crash when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireStashConfirm } = await import('./stashConfirm'); + expect(() => wireStashConfirm()).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/features/update.test.ts b/Frontend/src/scripts/features/update.test.ts index 8ebd0bb2..107432d9 100644 --- a/Frontend/src/scripts/features/update.test.ts +++ b/Frontend/src/scripts/features/update.test.ts @@ -312,4 +312,58 @@ describe('ensureUpdateProgressListener', () => { listenCallback?.({ payload: { kind: 'downloaded' } }); expect(button.textContent).toBe('Installing'); }); + + it('handles progress with zero total', async () => { + let listenCallback: ((evt: { payload: unknown }) => void) | undefined; + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { + listen: vi.fn((_event: string, cb: (evt: { payload: unknown }) => void) => { + listenCallback = cb; + return Promise.resolve({ unlisten: vi.fn() }); + }), + }, + }; + const update = await import('./update'); + update.wireUpdate(); + await Promise.resolve(); + + const button = document.getElementById('update-install') as HTMLButtonElement; + button.click(); + + // progress with zero/invalid total should show generic downloading text + listenCallback?.({ payload: { kind: 'progress', received: 0, total: 0 } }); + expect(button.textContent).toBe('Downloading…'); + }); +}); + +describe('ensureUpdateProgressListener rejects', () => { + it('handles listener registration failure', async () => { + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { + listen: vi.fn(() => Promise.reject(new Error('listen failed'))), + }, + }; + const update = await import('./update'); + // Should not throw - errors are caught + expect(() => update.wireUpdate()).not.toThrow(); + await new Promise((r) => setTimeout(r, 0)); + }); +}); + +describe('missing UI elements', () => { + it('handles missing status element', async () => { + document.body.innerHTML = '
      '; + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, + }; + const update = await import('./update'); + update.wireUpdate(); + const button = document.getElementById('update-install') as HTMLButtonElement; + button.click(); + await new Promise((r) => setTimeout(r, 0)); + }); + }); From 6d99c3713e1cb455ac0e200f80f5080c4f599f49 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 21 May 2026 13:50:27 +0100 Subject: [PATCH 03/25] Add more tests --- .../src/scripts/features/stashConfirm.test.ts | 10 +- Frontend/src/scripts/ui/layout.test.ts | 287 ++++++++++++++++++ Frontend/src/scripts/ui/modals.test.ts | 121 ++++++++ 3 files changed, 413 insertions(+), 5 deletions(-) diff --git a/Frontend/src/scripts/features/stashConfirm.test.ts b/Frontend/src/scripts/features/stashConfirm.test.ts index af12c978..c64279d2 100644 --- a/Frontend/src/scripts/features/stashConfirm.test.ts +++ b/Frontend/src/scripts/features/stashConfirm.test.ts @@ -315,10 +315,10 @@ describe('runStash edge cases', () => { const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; expect(confirmBtn.disabled).toBe(false); - expect(document.getElementById('stash-message') as HTMLInputElement).toHaveValue('WIP'); + expect((document.getElementById('stash-message') as HTMLInputElement).value).toBe('WIP'); }); - it('shows empty state when override paths filter to zero files', async () => { + it('shows override path even when not in state files', async () => { window.__TAURI__ = { core: { invoke: vi.fn(async () => null) }, event: { listen: vi.fn() }, @@ -327,10 +327,10 @@ describe('runStash edge cases', () => { openStashConfirm({ paths: ['nonexistent.txt'] }); await new Promise((r) => setTimeout(r, 0)); - const emptyEl = document.getElementById('stash-empty') as HTMLElement; + const countEl = document.getElementById('stash-file-count') as HTMLElement; + expect(countEl.textContent).toBe('1 file'); const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; - expect(emptyEl.hidden).toBe(false); - expect(confirmBtn.disabled).toBe(true); + expect(confirmBtn.disabled).toBe(false); }); }); diff --git a/Frontend/src/scripts/ui/layout.test.ts b/Frontend/src/scripts/ui/layout.test.ts index 5d0ad3cd..e5a1d211 100644 --- a/Frontend/src/scripts/ui/layout.test.ts +++ b/Frontend/src/scripts/ui/layout.test.ts @@ -429,4 +429,291 @@ describe('bindLayoutActionState', () => { const { bindLayoutActionState } = await import('./layout'); expect(() => bindLayoutActionState()).not.toThrow(); }); + + it('fires refreshRepoActions on app:repo-selected', async () => { + const { bindLayoutActionState } = await import('./layout'); + bindLayoutActionState(); + window.dispatchEvent(new Event('app:repo-selected')); + }); + + it('fires refreshRepoActions on app:status-updated', async () => { + const { bindLayoutActionState } = await import('./layout'); + bindLayoutActionState(); + window.dispatchEvent(new Event('app:status-updated')); + }); + + it('fires refreshRepoActions on app:branches-updated', async () => { + const { bindLayoutActionState } = await import('./layout'); + bindLayoutActionState(); + window.dispatchEvent(new Event('app:branches-updated')); + }); +}); + +// --------------------------------------------------------------------------- +// setRepoHeader - edge cases +// --------------------------------------------------------------------------- + +describe('setRepoHeader edge cases', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = '
      '; + }); + + it('does not set title when pathMaybe is undefined', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader(undefined); + expect(document.getElementById('repo-title')?.textContent).toBe(''); + }); + + it('falls back to path when splitting returns empty', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader('repo'); + expect(document.getElementById('repo-title')?.textContent).toBe('repo'); + }); + + it('uses branchLabel when available', async () => { + const { setRepoHeader } = await import('./layout'); + const { state } = await import('../state/state'); + state.branchLabel = 'feature-branch'; + state.branch = 'main'; + setRepoHeader('/repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('feature-branch'); + }); + + it('falls back to state.branch when no branchLabel', async () => { + const { setRepoHeader } = await import('./layout'); + const { state } = await import('../state/state'); + state.branchLabel = ''; + state.branch = 'main'; + setRepoHeader('/repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('main'); + }); +}); + +// --------------------------------------------------------------------------- +// renderAheadBehind +// --------------------------------------------------------------------------- + +describe('renderAheadBehind', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = '
      '; + }); + + it('shows ahead count via bindLayoutActionState', async () => { + const { bindLayoutActionState } = await import('./layout'); + const { state } = await import('../state/state'); + state.hasRepo = true; + state.ahead = 3; + state.behind = 1; + const el = document.getElementById('ahead-behind') as HTMLElement; + bindLayoutActionState(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.textContent).toContain('↑3'); + expect(el.textContent).toContain('↓1'); + }); + + it('hides ahead-behind when no counts via bindLayoutActionState', async () => { + const { bindLayoutActionState } = await import('./layout'); + const { state } = await import('../state/state'); + state.hasRepo = true; + state.ahead = 0; + state.behind = 0; + const el = document.getElementById('ahead-behind') as HTMLElement; + bindLayoutActionState(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.textContent).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// initResizer +// --------------------------------------------------------------------------- + +describe('initResizer', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ` +
      +
      + `; + }); + + it('initializes resizer without crashing', async () => { + const { initResizer } = await import('./layout'); + expect(() => initResizer()).not.toThrow(); + }); + + it('handles mousedown on resizer', async () => { + const { initResizer } = await import('./layout'); + initResizer(); + const resizer = document.getElementById('resizer') as HTMLElement; + resizer.dispatchEvent(new MouseEvent('mousedown', { clientX: 500 })); + }); + + it('does not crash when workGrid or resizer missing', async () => { + document.body.innerHTML = ''; + const { initResizer } = await import('./layout'); + expect(() => initResizer()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// applyCommitSummaryRestriction - no element +// --------------------------------------------------------------------------- + +describe('applyCommitSummaryRestriction (no element)', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('does not crash when summary input is missing', async () => { + const { applyCommitSummaryRestriction } = await import('./layout'); + expect(() => applyCommitSummaryRestriction(true)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// toggleTheme - additional +// --------------------------------------------------------------------------- + +describe('toggleTheme additional', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + }); + + it('toggles from dark to light', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({ general: { theme: 'dark' } }); + + const { toggleTheme, setTheme } = await import('./layout'); + setTheme('dark'); + + toggleTheme(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// setTab - additional edge cases +// --------------------------------------------------------------------------- + +describe('setTab additional', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` +
      + + + +
      +
      + + `; + }); + + it('clears selectedCommit when leaving history tab', async () => { + const { setTab } = await import('./layout'); + const { state } = await import('../state/state'); + state.selectedCommit = { id: 'abc123' }; + setTab('history'); // select history + setTab('changes'); // leave history + expect(state.selectedCommit).toBeNull(); + }); + + it('sets diffDirty when entering changes from other tab', async () => { + const { setTab } = await import('./layout'); + const { state } = await import('../state/state'); + state.selectedCommit = null; + state.diffDirty = false; + setTab('history'); + setTab('changes'); + expect(state.diffDirty).toBe(true); + }); + + it('hides history actions btn when leaving history tab', async () => { + const { setTab } = await import('./layout'); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + btn.hidden = false; + setTab('changes'); + expect(btn.hidden).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// refreshRepoActions - push button with ahead +// --------------------------------------------------------------------------- + +describe('refreshRepoActions (push ahead badge)', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ` +
      + + + +
      + + `; + }); + + it('shows ahead count in push button label', async () => { + const { refreshRepoActions } = await import('./layout'); + const { state } = await import('../state/state'); + const pushBtn = document.getElementById('push-btn') as HTMLButtonElement; + const label = pushBtn.querySelector('.btn-label') as HTMLSpanElement; + + state.hasRepo = true; + state.ahead = 3; + state.branchOnRemote = true; + + refreshRepoActions(); + + expect(pushBtn.classList.contains('attention')).toBe(true); + expect(label.textContent).toBe('Push (3)'); + expect(pushBtn.title).toBe('Push (3)'); + }); + + it('shows undo button when repo has ahead and on changes tab', async () => { + const { refreshRepoActions } = await import('./layout'); + const { state } = await import('../state/state'); + const { prefs } = await import('../state/state'); + + state.hasRepo = true; + state.ahead = 2; + prefs.tab = 'changes'; + + refreshRepoActions(); + + const undoLeftWrap = document.getElementById('left-foot') as HTMLElement; + expect(undoLeftWrap.classList.contains('show')).toBe(true); + }); }); diff --git a/Frontend/src/scripts/ui/modals.test.ts b/Frontend/src/scripts/ui/modals.test.ts index dd180a67..f412f546 100644 --- a/Frontend/src/scripts/ui/modals.test.ts +++ b/Frontend/src/scripts/ui/modals.test.ts @@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../lib/scrollbars', () => ({ + initOverlayScrollbarsFor: vi.fn(), + refreshOverlayScrollbarsFor: vi.fn(), +})); + function mountRoot() { document.body.innerHTML = '
      '; } @@ -161,6 +166,10 @@ describe('ESC key closes top modal', () => { vi.useFakeTimers(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('closes the top-most open modal on Escape keydown', async () => { document.body.innerHTML = ` @@ -215,3 +224,115 @@ describe('scroll lock counting', () => { expect(document.body.style.overflow).toBe(''); }); }); + +// --------------------------------------------------------------------------- +// closeWithAnimation +// --------------------------------------------------------------------------- + +describe('closeWithAnimation', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + it('closes modal via backdrop with animation', async () => { + vi.useFakeTimers(); + + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('m1'); + const modal = document.getElementById('m1') as HTMLElement; + expect(modal.getAttribute('aria-hidden')).toBe('false'); + + const backdrop = modal.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + expect(modal.classList.contains('is-closing')).toBe(true); + + vi.advanceTimersByTime(200); + expect(modal.getAttribute('aria-hidden')).toBe('true'); + expect(modal.classList.contains('is-closing')).toBe(false); + + vi.useRealTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// closeAllModals with animation timer +// --------------------------------------------------------------------------- + +describe('closeAllModals with animation timer', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('clears animation timers when closing all modals', async () => { + vi.useFakeTimers(); + + const { openModal, closeAllModals } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + + const modal = document.getElementById('m1') as HTMLElement; + const backdrop = modal.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + + closeAllModals(); + + expect(modal.getAttribute('aria-hidden')).toBe('true'); + expect(modal.classList.contains('is-closing')).toBe(false); + expect(document.body.style.overflow).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// openModal with no aria-hidden +// --------------------------------------------------------------------------- + +describe('openModal no aria-hidden', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = ''; + }); + + it('handles modal without aria-hidden attribute', async () => { + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('m1'); + const modal = document.getElementById('m1') as HTMLElement; + expect(modal.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +// --------------------------------------------------------------------------- +// hydrate - with already-in-DOM modal +// --------------------------------------------------------------------------- + +describe('hydrate with existing modal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + }); + + it('adds id to loaded set when modal exists', async () => { + document.body.innerHTML += '
      '; + const { hydrate } = await import('./modals'); + expect(() => hydrate('existing-test-modal')).not.toThrow(); + // Second call should not throw either (idempotent) + expect(() => hydrate('existing-test-modal')).not.toThrow(); + }); + + it('re-throws for unknown id with root present', async () => { + const { hydrate } = await import('./modals'); + expect(() => hydrate('nothing-here')).toThrow('No fragment registered for nothing-here'); + }); +}); From 23cb735e43a8a347bac878f15eef608a3db15b92 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 21 May 2026 14:39:54 +0100 Subject: [PATCH 04/25] Improve testing --- Frontend/src/scripts/ui/modals.test.ts | 62 ++++++++++++++++++++++++++ Frontend/vitest.config.ts | 4 ++ 2 files changed, 66 insertions(+) diff --git a/Frontend/src/scripts/ui/modals.test.ts b/Frontend/src/scripts/ui/modals.test.ts index f412f546..5a46839a 100644 --- a/Frontend/src/scripts/ui/modals.test.ts +++ b/Frontend/src/scripts/ui/modals.test.ts @@ -317,6 +317,68 @@ describe('openModal no aria-hidden', () => { // hydrate - with already-in-DOM modal // --------------------------------------------------------------------------- +describe('closeWithAnimation reduce-motion', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('closes without animation when reduce-motion preferred', async () => { + vi.useFakeTimers(); + const origMM = window.matchMedia; + window.matchMedia = vi.fn((q: string) => ({ + matches: q.includes('reduced-motion'), media: q, addEventListener: vi.fn(), removeEventListener: vi.fn(), + })) as any; + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + (document.querySelector('.backdrop') as HTMLElement).click(); + expect(document.getElementById('m1')!.getAttribute('aria-hidden')).toBe('true'); + window.matchMedia = origMM; vi.useRealTimers(); + }); +}); + +describe('openModal clears pending animation timer', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('clears timer when reopening before close animation completes', async () => { + vi.useFakeTimers(); + const origMM = window.matchMedia; + window.matchMedia = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) as any; + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + (document.querySelector('.backdrop') as HTMLElement).click(); + openModal('m1'); + expect(document.getElementById('m1')!.getAttribute('aria-hidden')).toBe('false'); + window.matchMedia = origMM; vi.useRealTimers(); + }); +}); + +describe('hydrate specific modals', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + it('hydrates settings-modal', async () => { const { hydrate } = await import('./modals'); expect(() => hydrate('settings-modal')).not.toThrow(); }); + it('hydrates about-modal', async () => { const { hydrate } = await import('./modals'); expect(() => hydrate('about-modal')).not.toThrow(); }); + it('hydrates update-modal', async () => { const { hydrate } = await import('./modals'); expect(() => hydrate('update-modal')).not.toThrow(); }); +}); + +describe('closeModal already closed', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + it('no-ops for already hidden modal', async () => { + const { closeModal } = await import('./modals'); + document.body.innerHTML += ''; + closeModal('m1'); + expect(document.getElementById('m1')!.getAttribute('aria-hidden')).toBe('true'); + }); +}); + +describe('closeAllModals no open modals', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + it('no-ops when no modals are open', async () => { + const { closeAllModals } = await import('./modals'); + expect(() => closeAllModals()).not.toThrow(); + }); +}); + describe('hydrate with existing modal', () => { beforeEach(() => { vi.resetModules(); diff --git a/Frontend/vitest.config.ts b/Frontend/vitest.config.ts index 9193b908..0e71e0e8 100644 --- a/Frontend/vitest.config.ts +++ b/Frontend/vitest.config.ts @@ -18,6 +18,10 @@ export default defineConfig({ setupFiles: ['./src/setupTests.ts'], coverage: { provider: 'v8', + exclude: [ + 'src/scripts/**/*.test.ts', + 'tests/**', + ], thresholds: { statements: 95, branches: 95, From 7fb2c488105e0f1b5d0ca2f7a6e346bc40f99184 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 16:32:42 +0100 Subject: [PATCH 05/25] Update history.test.ts --- Frontend/src/scripts/features/repo/history.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 47129cd4..5ff2940b 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -190,6 +190,14 @@ describe('formatTimeAgo', () => { }) }) +describe('formatTimeAgo - catch path', () => { + it('handles object whose toString throws', async () => { + const { formatTimeAgo } = await loadHistoryModule() + const badObj = { toString: () => { throw new Error('boom') } } as any + expect(formatTimeAgo(badObj)).toBe('') + }) +}) + describe('formatTimeAgo - weeks and months', () => { it('formats days and weeks', async () => { const { formatTimeAgo } = await loadHistoryModule() From 4734e521f6bf522e9414c4fa7946b4465d73d095 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 16:48:48 +0100 Subject: [PATCH 06/25] Fixed tests --- .../src/scripts/features/conflicts.test.ts | 6 ++--- Frontend/src/scripts/features/diff.test.ts | 8 ++++++- .../src/scripts/features/newBranch.test.ts | 23 ++++++++++++++++--- Frontend/src/scripts/features/repo/history.ts | 6 +++-- .../features/repo/interactions.test.ts | 16 ++++++------- .../src/scripts/features/settings.test.ts | 3 +-- Frontend/src/scripts/features/sshAuth.test.ts | 10 ++++---- .../src/scripts/features/stashConfirm.test.ts | 12 +++++----- Frontend/src/scripts/features/update.test.ts | 12 +++++----- Frontend/src/scripts/lib/dom.ts | 2 +- Frontend/src/scripts/lib/menu.ts | 10 ++++++-- Frontend/src/scripts/types.d.ts | 2 +- 12 files changed, 69 insertions(+), 41 deletions(-) diff --git a/Frontend/src/scripts/features/conflicts.test.ts b/Frontend/src/scripts/features/conflicts.test.ts index 11510201..f68c2579 100644 --- a/Frontend/src/scripts/features/conflicts.test.ts +++ b/Frontend/src/scripts/features/conflicts.test.ts @@ -399,7 +399,7 @@ describe('summary abort and continue', () => { mountConflictsSummaryModal(); mockInvoke.mockResolvedValue({ in_progress: true }); const { confirmBool } = await import('../lib/confirm'); - confirmBool.mockResolvedValue(true); + vi.mocked(confirmBool).mockResolvedValue(true); const { notify } = await import('../lib/notify'); const { openConflictsSummary } = await import('./conflicts'); @@ -417,7 +417,7 @@ describe('summary abort and continue', () => { mountConflictsSummaryModal(); mockInvoke.mockResolvedValue({ in_progress: true }); const { confirmBool } = await import('../lib/confirm'); - confirmBool.mockResolvedValue(false); + vi.mocked(confirmBool).mockResolvedValue(false); const { openConflictsSummary } = await import('./conflicts'); await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); @@ -437,7 +437,7 @@ describe('summary abort and continue', () => { .mockRejectedValueOnce(new Error('cannot abort')); const { confirmBool } = await import('../lib/confirm'); - confirmBool.mockResolvedValue(true); + vi.mocked(confirmBool).mockResolvedValue(true); const { notify } = await import('../lib/notify'); const { openConflictsSummary } = await import('./conflicts'); diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index ca39e817..0889d61a 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -141,7 +141,13 @@ describe('bindCommit', () => { state.files = [{ path: 'file.txt', status: 'M' }] as any; const { runHook } = await import('../plugins'); - vi.mocked(runHook).mockResolvedValue({ cancelled: true, reason: 'Cancelled by plugin' }); + vi.mocked(runHook).mockResolvedValue({ + name: 'preCommit', + data: undefined, + cancelled: true, + reason: 'Cancelled by plugin', + cancel: vi.fn(), + }); const { bindCommit } = await import('./diff'); const { notify } = await import('../lib/notify'); diff --git a/Frontend/src/scripts/features/newBranch.test.ts b/Frontend/src/scripts/features/newBranch.test.ts index ef501b78..7776cd4b 100644 --- a/Frontend/src/scripts/features/newBranch.test.ts +++ b/Frontend/src/scripts/features/newBranch.test.ts @@ -372,7 +372,13 @@ describe('createBranch error handling', () => { describe('createBranch hook cancellation', () => { it('cancels when preBranchCreate hook returns cancelled', async () => { const { runHook } = await import('../plugins'); - vi.mocked(runHook).mockResolvedValue({ cancelled: true, reason: 'Cancelled by hook' }); + vi.mocked(runHook).mockResolvedValue({ + name: 'preBranchCreate', + data: undefined, + cancelled: true, + reason: 'Cancelled by hook', + cancel: vi.fn(), + }); (window as any).__TAURI__ = { core: { invoke: vi.fn() }, @@ -397,8 +403,19 @@ describe('createBranch hook cancellation', () => { it('cancels when preSwitchBranch hook returns cancelled', async () => { const { runHook } = await import('../plugins'); vi.mocked(runHook) - .mockResolvedValueOnce({ cancelled: false }) // preBranchCreate - .mockResolvedValueOnce({ cancelled: true, reason: 'Switch blocked' }); // preSwitchBranch + .mockResolvedValueOnce({ + name: 'preBranchCreate', + data: undefined, + cancelled: false, + cancel: vi.fn(), + }) // preBranchCreate + .mockResolvedValueOnce({ + name: 'preSwitchBranch', + data: undefined, + cancelled: true, + reason: 'Switch blocked', + cancel: vi.fn(), + }); // preSwitchBranch (window as any).__TAURI__ = { core: { invoke: vi.fn() }, diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index f6f1f8ea..cc6e2dea 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -257,7 +257,9 @@ export async function selectHistory(commit: any, index: number) { /** Switches the right panel to the selected file diff block. */ const selectCommitFile = (idx: number) => { if (idx < 0 || idx >= files.length) return; - sideEl.querySelectorAll('.row').forEach((r) => r.classList.remove('active')); + sideEl.querySelectorAll('.row').forEach((r) => { + r.classList.remove('active'); + }); const row = sideEl.querySelector(`.row[data-idx="${idx}"]`); row?.classList.add('active'); (contentEl as HTMLElement).innerHTML = renderHunksReadonly(files[idx].lines); @@ -382,6 +384,6 @@ export function formatTimeAgo(isoMaybe: string): string { let yr = Math.round(day / 365); return `${yr} year${yr === 1 ? '' : 's'} ago`; } catch { - return (isoMaybe || '').trim(); + return typeof isoMaybe === 'string' ? isoMaybe.trim() : ''; } } diff --git a/Frontend/src/scripts/features/repo/interactions.test.ts b/Frontend/src/scripts/features/repo/interactions.test.ts index c1f6a163..73490543 100644 --- a/Frontend/src/scripts/features/repo/interactions.test.ts +++ b/Frontend/src/scripts/features/repo/interactions.test.ts @@ -473,7 +473,7 @@ describe('onFileContextMenu', () => { expect(ev.preventDefault).toHaveBeenCalled(); expect(buildCtxMenu).toHaveBeenCalled(); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; expect(items[0].label).toContain('Open with default'); }); @@ -491,7 +491,7 @@ describe('onFileContextMenu', () => { expect(ev.preventDefault).toHaveBeenCalled(); expect(buildCtxMenu).toHaveBeenCalled(); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; expect(Array.isArray(items)).toBe(true); expect(items.length).toBeGreaterThan(0); const stashItem = items.find((i: any) => String(i.label).includes('Create stash from selection')); @@ -509,7 +509,7 @@ describe('onFileContextMenu', () => { const file = { path: 'a.txt', status: 'M' } as any; await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; const stashFileItem = items.find((i: any) => i.label?.includes('Create stash for this file')); expect(stashFileItem).toBeDefined(); }); @@ -526,11 +526,11 @@ describe('onFileContextMenu', () => { const file = { path: 'a.txt', status: 'M' } as any; await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; const gitignoreItem = items.find((i: any) => i.label?.includes('Add to .gitignore')); expect(gitignoreItem).toBeDefined(); - await gitignoreItem.action!(); + await gitignoreItem!.action!(); expect(confirmBool).toHaveBeenCalled(); }); @@ -545,7 +545,7 @@ describe('onFileContextMenu', () => { const file = { path: 'a.txt', status: 'M' } as any; await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; const discardItem = items.find((i: any) => i.label === 'Discard changes'); expect(discardItem).toBeDefined(); }); @@ -561,7 +561,7 @@ describe('onFileContextMenu', () => { const file = { path: 'a.txt', status: 'M' } as any; await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; const discardAllItem = items.find((i: any) => i.label?.includes('Discard all selected')); expect(discardAllItem).toBeDefined(); }); @@ -582,7 +582,7 @@ describe('onFileContextMenu', () => { const file = { path: 'a.txt', status: 'M' } as any; await onFileContextMenu({ clientX: 100, clientY: 200, preventDefault: vi.fn() } as any, file); - const items = buildCtxMenu.mock.lastCall[0]; + const items = vi.mocked(buildCtxMenu).mock.lastCall![0]; const pluginItem = items.find((i: any) => i.label === 'Plugin Action'); expect(pluginItem).toBeDefined(); }); diff --git a/Frontend/src/scripts/features/settings.test.ts b/Frontend/src/scripts/features/settings.test.ts index aca809ca..0499b0c6 100644 --- a/Frontend/src/scripts/features/settings.test.ts +++ b/Frontend/src/scripts/features/settings.test.ts @@ -33,7 +33,7 @@ const mockRebuildThemePackOptions = vi.fn(); const mockThemeTooltip = vi.fn(() => 'Tooltip'); const mockClearPluginSettingsCache = vi.fn(); const mockRenderPluginMenus = vi.fn(); -const mockCollectPluginSettingsFromPanel = vi.fn(() => []); +const mockCollectPluginSettingsFromPanel = vi.fn(() => [] as Array<{ id: string; value: unknown }>); const mockActivateSection = vi.fn(); const mockLoadPluginsIntoForm = vi.fn(); const mockCollectGeneralSettings = vi.fn(() => ({})); @@ -622,7 +622,6 @@ describe('wireSettings (save button)', () => { } it('saves plugin settings when plugin-settings panel is active', async () => { - const modal = document.getElementById('settings-modal')!; const panel = mountPluginPanel('my-plugin'); mockCollectPluginSettingsFromPanel.mockReturnValue([{ id: 'x', value: 'y' }]); mockInvoke.mockResolvedValue(undefined); diff --git a/Frontend/src/scripts/features/sshAuth.test.ts b/Frontend/src/scripts/features/sshAuth.test.ts index 59cf90c1..eb71367e 100644 --- a/Frontend/src/scripts/features/sshAuth.test.ts +++ b/Frontend/src/scripts/features/sshAuth.test.ts @@ -174,17 +174,16 @@ describe('wireAuthModal', () => { describe('HTTPS switch button flow', () => { it('calls vcs_set_remote_url on click and closes modal', async () => { - let cbCapture: ((evt: { payload: unknown }) => void) | null = null; const invoke = vi.fn(async () => null); (window as any).__TAURI__ = { core: { invoke }, - event: { listen: vi.fn(async (_event: string, cb: any) => { cbCapture = cb; return { unlisten: vi.fn() }; }) }, + event: { listen: vi.fn(async (_event: string, cb: (evt: { payload: unknown }) => void) => { listenHandler = cb; return { unlisten: vi.fn() }; }) }, }; const modals = await import('../ui/modals'); const { initSshAuthPrompt } = await import('./sshAuth'); initSshAuthPrompt(); - cbCapture?.({ payload: { host: 'github.com', remote: 'origin', url: 'git@github.com:user/repo.git' } }); + listenHandler?.({ payload: { host: 'github.com', remote: 'origin', url: 'git@github.com:user/repo.git' } }); const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; httpsBtn.click(); @@ -195,16 +194,15 @@ describe('HTTPS switch button flow', () => { }); it('re-enables HTTPS button after error', async () => { - let cbCapture: ((evt: { payload: unknown }) => void) | null = null; const invoke = vi.fn(async () => { throw new Error('network error'); }); (window as any).__TAURI__ = { core: { invoke }, - event: { listen: vi.fn(async (_event: string, cb: any) => { cbCapture = cb; return { unlisten: vi.fn() }; }) }, + event: { listen: vi.fn(async (_event: string, cb: (evt: { payload: unknown }) => void) => { listenHandler = cb; return { unlisten: vi.fn() }; }) }, }; const { initSshAuthPrompt } = await import('./sshAuth'); initSshAuthPrompt(); - cbCapture?.({ payload: { host: 'github.com', remote: 'origin', url: 'git@github.com:user/repo.git' } }); + listenHandler?.({ payload: { host: 'github.com', remote: 'origin', url: 'git@github.com:user/repo.git' } }); const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; httpsBtn.click(); diff --git a/Frontend/src/scripts/features/stashConfirm.test.ts b/Frontend/src/scripts/features/stashConfirm.test.ts index c64279d2..ade389c7 100644 --- a/Frontend/src/scripts/features/stashConfirm.test.ts +++ b/Frontend/src/scripts/features/stashConfirm.test.ts @@ -36,7 +36,7 @@ function mountStashConfirmModal() { function installTauriMock() { (window as any).__TAURI__ = { core: { - invoke: vi.fn(async () => null), + invoke: vi.fn(), }, event: { listen: vi.fn() }, }; @@ -65,7 +65,7 @@ afterEach(() => { describe('openStashConfirm', () => { it('passes override paths through to stash payload', async () => { - const invoke = vi.fn(async () => null); + const invoke = vi.fn(); (window as any).__TAURI__.core.invoke = invoke; const { openStashConfirm } = await import('./stashConfirm'); @@ -88,7 +88,7 @@ describe('openStashConfirm', () => { }); it('sends default stash payload when no paths override exists', async () => { - const invoke = vi.fn(async () => null); + const invoke = vi.fn(); (window as any).__TAURI__.core.invoke = invoke; const { openStashConfirm } = await import('./stashConfirm'); @@ -110,7 +110,7 @@ describe('openStashConfirm', () => { }); it('calls onSuccess handler after stash is created', async () => { - const invoke = vi.fn(async () => null); + const invoke = vi.fn(); (window as any).__TAURI__.core.invoke = invoke; const onSuccess = vi.fn(); @@ -144,7 +144,7 @@ describe('openStashConfirm', () => { describe('wireStashConfirm', () => { it('wires modal only once', async () => { - const { wireStashConfirm, openStashConfirm } = await import('./stashConfirm'); + const { wireStashConfirm } = await import('./stashConfirm'); const modal = document.getElementById('stash-confirm-modal') as any; wireStashConfirm(); wireStashConfirm(); @@ -320,7 +320,7 @@ describe('runStash edge cases', () => { it('shows override path even when not in state files', async () => { window.__TAURI__ = { - core: { invoke: vi.fn(async () => null) }, + core: { invoke: vi.fn() }, event: { listen: vi.fn() }, }; const { openStashConfirm } = await import('./stashConfirm'); diff --git a/Frontend/src/scripts/features/update.test.ts b/Frontend/src/scripts/features/update.test.ts index 107432d9..f0418da4 100644 --- a/Frontend/src/scripts/features/update.test.ts +++ b/Frontend/src/scripts/features/update.test.ts @@ -95,7 +95,7 @@ describe('wireUpdate', () => { return Promise.resolve(undefined); }); (window as Window & { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke: invokeMock }, + core: { invoke: invokeMock as unknown as TauriInvoke }, event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, }; @@ -146,7 +146,7 @@ describe('showUpdateDialog', () => { return Promise.resolve(undefined); }); (window as Window & { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke: invokeMock }, + core: { invoke: invokeMock as unknown as TauriInvoke }, event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, }; @@ -176,7 +176,7 @@ describe('showUpdateDialog', () => { return Promise.resolve(undefined); }); (window as Window & { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke: invokeMock }, + core: { invoke: invokeMock as unknown as TauriInvoke }, event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, }; @@ -192,7 +192,7 @@ describe('showUpdateDialog', () => { return Promise.resolve(undefined); }); (window as Window & { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke: invokeMock }, + core: { invoke: invokeMock as unknown as TauriInvoke }, event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, }; @@ -216,7 +216,7 @@ describe('showUpdateDialog', () => { return Promise.resolve(undefined); }); (window as Window & { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke: invokeMock }, + core: { invoke: invokeMock as unknown as TauriInvoke }, event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, }; @@ -239,7 +239,7 @@ describe('wireUpdate button state transitions', () => { return Promise.resolve(undefined); }); (window as Window & { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke: invokeMock }, + core: { invoke: invokeMock as unknown as TauriInvoke }, event: { listen: vi.fn(async () => ({ unlisten: vi.fn() })) }, }; diff --git a/Frontend/src/scripts/lib/dom.ts b/Frontend/src/scripts/lib/dom.ts index 5f7c0307..5f314808 100644 --- a/Frontend/src/scripts/lib/dom.ts +++ b/Frontend/src/scripts/lib/dom.ts @@ -55,7 +55,7 @@ export const escapeHtml = (s: any) => String(s) * @param type - Event type * @param fn - Event handler function */ -export const on = (target: Document | HTMLElement | Window, type: K, fn: (ev: DocumentEventMap[K]) => any) => +export const on = (target: Document | Element | Window, type: K, fn: (ev: DocumentEventMap[K]) => any) => target.addEventListener(type, fn as any); /** diff --git a/Frontend/src/scripts/lib/menu.ts b/Frontend/src/scripts/lib/menu.ts index 09375df0..ed09f3e9 100644 --- a/Frontend/src/scripts/lib/menu.ts +++ b/Frontend/src/scripts/lib/menu.ts @@ -8,7 +8,9 @@ export type CtxItem = { label: string; action?: () => void | Promise }; */ export function buildCtxMenu(items: CtxItem[], x: number, y: number) { // remove existing - document.querySelectorAll('.ctxmenu').forEach(el => el.remove()); + document.querySelectorAll('.ctxmenu').forEach((el) => { + el.remove(); + }); const m = document.createElement('div'); m.className = 'ctxmenu'; // Position gets clamped to viewport after measuring. @@ -44,8 +46,12 @@ export function buildCtxMenu(items: CtxItem[], x: number, y: number) { try { const result = it.action?.(); if (result && typeof (result as Promise).then === 'function') { - (result as Promise).catch(() => {}); + (result as Promise).catch((err) => { + console.error('Context menu action failed:', err); + }); } + } catch (err) { + console.error('Context menu action failed:', err); } finally { m.remove(); } diff --git a/Frontend/src/scripts/types.d.ts b/Frontend/src/scripts/types.d.ts index 03080d1d..5eb9f33a 100644 --- a/Frontend/src/scripts/types.d.ts +++ b/Frontend/src/scripts/types.d.ts @@ -152,7 +152,7 @@ export interface ThemePayload { markup?: { head?: string | null; body?: string | null; - }; + } | null; scripts?: string[]; } From 166b8d962c66ab5ee763e949b9f035e114a99f6e Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 17:17:56 +0100 Subject: [PATCH 07/25] Fix issues --- Backend/src/plugin_runtime/host_api.rs | 5 ++ Backend/src/plugin_runtime/settings_store.rs | 44 +++++++++++++ Backend/src/plugin_vcs_backends.rs | 5 ++ Backend/src/process_utils.rs | 5 ++ Backend/src/settings/persistence.rs | 5 ++ Backend/src/tauri_commands/remotes.rs | 10 +-- Backend/tests/modules/plugin_vcs_backends.rs | 64 +++++++++++++++++++ Backend/tests/modules/process_utils.rs | 11 ++++ Backend/tests/modules/settings_persistence.rs | 54 ++++++++++++++++ Backend/tests/plugin_runtime/host_api.rs | 20 ++++++ .../tests/plugin_runtime/settings_store.rs | 60 +++++++++++++++++ Backend/tests/tauri_commands/remotes.rs | 46 +++++++++---- Backend/tests/tauri_commands/status.rs | 6 ++ 13 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 Backend/tests/modules/plugin_vcs_backends.rs create mode 100644 Backend/tests/modules/process_utils.rs create mode 100644 Backend/tests/modules/settings_persistence.rs create mode 100644 Backend/tests/plugin_runtime/host_api.rs create mode 100644 Backend/tests/plugin_runtime/settings_store.rs diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 753e35a7..b8114a75 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -57,3 +57,8 @@ pub fn set_status_text_unchecked(message: &str) { *status_text_store().write() = trimmed.to_string(); emit_status_event(trimmed); } + +#[cfg(test)] +mod tests { + include!("../../tests/plugin_runtime/host_api.rs"); +} diff --git a/Backend/src/plugin_runtime/settings_store.rs b/Backend/src/plugin_runtime/settings_store.rs index 605e0455..01cca6db 100644 --- a/Backend/src/plugin_runtime/settings_store.rs +++ b/Backend/src/plugin_runtime/settings_store.rs @@ -7,8 +7,47 @@ use serde_json::{Map, Value}; use std::fs; use std::path::{Path, PathBuf}; +#[cfg(test)] +use parking_lot::RwLock; + +#[cfg(test)] +use std::sync::OnceLock; + +#[cfg(test)] +static TEST_PLUGIN_DATA_ROOT: OnceLock>> = OnceLock::new(); + +/// Returns the test-only plugin data root override, when configured. +#[cfg(test)] +fn test_plugin_data_root() -> Option { + TEST_PLUGIN_DATA_ROOT + .get_or_init(|| RwLock::new(None)) + .read() + .clone() +} + +/// Sets the test-only plugin data root override. +#[cfg(test)] +pub(crate) fn set_test_plugin_data_root(root: PathBuf) { + *TEST_PLUGIN_DATA_ROOT + .get_or_init(|| RwLock::new(None)) + .write() = Some(root); +} + +/// Clears the test-only plugin data root override. +#[cfg(test)] +pub(crate) fn clear_test_plugin_data_root() { + *TEST_PLUGIN_DATA_ROOT + .get_or_init(|| RwLock::new(None)) + .write() = None; +} + /// Returns the root plugin data directory under the app config directory. fn plugin_data_root() -> PathBuf { + #[cfg(test)] + if let Some(root) = test_plugin_data_root() { + return root; + } + if let Some(pd) = crate::app_identity::project_dirs() { pd.config_dir().join("plugin-data") } else { @@ -84,3 +123,8 @@ fn remove_file_if_exists(path: &Path) -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/plugin_runtime/settings_store.rs"); +} diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 5d92e0e9..683bd355 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -393,3 +393,8 @@ pub fn clone_repo_via_plugin_vcs_backend( msg: e, }) } + +#[cfg(test)] +mod tests { + include!("../tests/modules/plugin_vcs_backends.rs"); +} diff --git a/Backend/src/process_utils.rs b/Backend/src/process_utils.rs index 91fab206..8c35bc70 100644 --- a/Backend/src/process_utils.rs +++ b/Backend/src/process_utils.rs @@ -27,3 +27,8 @@ pub(crate) fn hidden_command(program: &str) -> Command { hide_window(&mut command); command } + +#[cfg(test)] +mod tests { + include!("../tests/modules/process_utils.rs"); +} diff --git a/Backend/src/settings/persistence.rs b/Backend/src/settings/persistence.rs index 9d4cc445..7c7f069d 100644 --- a/Backend/src/settings/persistence.rs +++ b/Backend/src/settings/persistence.rs @@ -181,3 +181,8 @@ impl AppConfig { self.logging.retain_archives = self.logging.retain_archives.clamp(1, 100); } } + +#[cfg(test)] +mod tests { + include!("../../tests/modules/settings_persistence.rs"); +} diff --git a/Backend/src/tauri_commands/remotes.rs b/Backend/src/tauri_commands/remotes.rs index 0f156446..a29339c6 100644 --- a/Backend/src/tauri_commands/remotes.rs +++ b/Backend/src/tauri_commands/remotes.rs @@ -376,6 +376,11 @@ pub async fn vcs_fetch_all( .await } +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/remotes.rs"); +} + #[tauri::command] /// Performs a fast-forward-only pull from the current branch upstream. /// @@ -633,11 +638,6 @@ pub async fn vcs_undo_since_push( .await } -#[cfg(test)] -mod tests { - include!("../../tests/tauri_commands/remotes.rs"); -} - #[tauri::command] /// Soft-resets HEAD to a selected commit, constrained to ahead-of-upstream history. /// diff --git a/Backend/tests/modules/plugin_vcs_backends.rs b/Backend/tests/modules/plugin_vcs_backends.rs new file mode 100644 index 00000000..57be89da --- /dev/null +++ b/Backend/tests/modules/plugin_vcs_backends.rs @@ -0,0 +1,64 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::*; +use crate::settings::AppConfig; +use std::collections::BTreeMap; + +fn backend_descriptor(backend_id: &str, plugin_id: &str) -> PluginBackendDescriptor { + PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Git".into()), + action_labels: BTreeMap::new(), + plugin_id: plugin_id.into(), + plugin_name: Some("Plugin".into()), + } +} + +#[test] +fn returns_cached_backend_descriptors() { + invalidate_plugin_vcs_backend_cache(); + let desc = backend_descriptor("git", "openvcs.git"); + store_backends(vec![desc.clone()]); + + let cached = cached_backends().expect("cached backends"); + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].backend_id.as_ref(), desc.backend_id.as_ref()); + assert_eq!(cached[0].plugin_id, desc.plugin_id); + + let listed = list_plugin_vcs_backends().expect("cache result"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].backend_id.as_ref(), desc.backend_id.as_ref()); + assert_eq!(listed[0].plugin_id, desc.plugin_id); + assert!(has_plugin_vcs_backend(&BackendId::from("git"))); + + let resolved = plugin_vcs_backend_descriptor(&BackendId::from("git")).expect("descriptor"); + assert_eq!(resolved.plugin_id, "openvcs.git"); + + invalidate_plugin_vcs_backend_cache(); + assert!(cached_backends().is_none()); +} + +#[test] +fn honors_disabled_overrides_when_resolving_enablement() { + let mut cfg = AppConfig::default(); + cfg.plugins.disabled = vec!["OPENVCS.GIT".into()]; + cfg.plugins.enabled = vec!["openvcs.git".into()]; + + assert!(!is_plugin_enabled_in_settings(&cfg, "openvcs.git", false)); + assert!(!is_plugin_enabled_in_settings(&cfg, " ", true)); + + cfg.plugins.disabled.clear(); + assert!(is_plugin_enabled_in_settings(&cfg, "openvcs.git", false)); + assert!(is_plugin_enabled_in_settings(&cfg, "openvcs.git", true)); +} + +#[test] +fn reports_unknown_backend_descriptors() { + invalidate_plugin_vcs_backend_cache(); + store_backends(vec![backend_descriptor("hg", "openvcs.hg")]); + + let err = plugin_vcs_backend_descriptor(&BackendId::from("git")) + .expect_err("missing backend should fail"); + assert!(err.contains("Unknown VCS backend: git")); +} diff --git a/Backend/tests/modules/process_utils.rs b/Backend/tests/modules/process_utils.rs new file mode 100644 index 00000000..49d6db2b --- /dev/null +++ b/Backend/tests/modules/process_utils.rs @@ -0,0 +1,11 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::hidden_command; +use std::ffi::OsStr; + +#[test] +fn builds_command_with_requested_program() { + let command = hidden_command("git"); + assert_eq!(command.get_program(), OsStr::new("git")); +} diff --git a/Backend/tests/modules/settings_persistence.rs b/Backend/tests/modules/settings_persistence.rs new file mode 100644 index 00000000..d98443c6 --- /dev/null +++ b/Backend/tests/modules/settings_persistence.rs @@ -0,0 +1,54 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::*; + +#[test] +fn validate_normalizes_invalid_values() { + let mut cfg = AppConfig::default(); + cfg.general.theme_pack = " ".into(); + cfg.general.default_backend = " ".into(); + cfg.vcs.backend = " git ".into(); + cfg.vcs.default_branch = " ".into(); + cfg.vcs.ssh_binary = GitSshBinary::Custom; + cfg.vcs.ssh_path = " ".into(); + cfg.diff.tab_width = 0; + cfg.diff.max_file_size_mb = 9_999; + cfg.lfs.concurrency = 0; + cfg.plugin = vec![" openvcs.git ".into(), "".into(), "openvcs.git".into()]; + cfg.plugins.disabled = vec![" OpenVCS.Git ".into(), "".into(), "openvcs.git".into()]; + cfg.plugins.enabled = vec![" openvcs.git ".into(), "other.plugin".into(), "other.plugin".into()]; + cfg.ux.recents_limit = 0; + cfg.logging.retain_archives = 0; + + cfg.validate(); + + assert_eq!(cfg.general.theme_pack, "default"); + assert_eq!(cfg.general.default_backend, "git"); + assert_eq!(cfg.vcs.backend, "git"); + assert_eq!(cfg.vcs.default_branch, "main"); + assert_eq!(cfg.vcs.ssh_binary, GitSshBinary::Auto); + assert_eq!(cfg.diff.tab_width, 1); + assert_eq!(cfg.diff.max_file_size_mb, 1_024); + assert_eq!(cfg.lfs.concurrency, 1); + assert_eq!(cfg.plugin, vec!["openvcs.git"]); + assert_eq!(cfg.plugins.disabled, vec!["openvcs.git"]); + assert_eq!(cfg.plugins.enabled, vec!["other.plugin"]); + assert_eq!(cfg.ux.recents_limit, 1); + assert_eq!(cfg.logging.retain_archives, 1); +} + +#[test] +fn evaluates_plugin_enablement_consistently() { + let mut cfg = AppConfig::default(); + cfg.plugins.disabled = vec!["OpenVCS.Git".into()]; + cfg.plugins.enabled = vec!["other.plugin".into(), "OPENVCS.GIT".into()]; + + assert!(!cfg.is_plugin_enabled("openvcs.git", false)); + assert!(!cfg.is_plugin_enabled(" ", true)); + + cfg.plugins.disabled.clear(); + assert!(cfg.is_plugin_enabled("openvcs.git", false)); + assert!(cfg.is_plugin_enabled("openvcs.git", true)); + assert!(cfg.is_plugin_enabled("other.plugin", false)); +} diff --git a/Backend/tests/plugin_runtime/host_api.rs b/Backend/tests/plugin_runtime/host_api.rs new file mode 100644 index 00000000..8ac0e6d8 --- /dev/null +++ b/Backend/tests/plugin_runtime/host_api.rs @@ -0,0 +1,20 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{set_status_event_emitter, set_status_text_unchecked}; +use std::sync::{Arc, Mutex}; + +#[test] +fn trims_status_text_and_emits_once() { + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = Arc::clone(&events); + + set_status_event_emitter(move |message| { + captured.lock().expect("lock events").push(message.to_string()); + }); + + set_status_text_unchecked(" ready "); + set_status_text_unchecked(" "); + + assert_eq!(events.lock().expect("lock events").as_slice(), &["ready".to_string()]); +} diff --git a/Backend/tests/plugin_runtime/settings_store.rs b/Backend/tests/plugin_runtime/settings_store.rs new file mode 100644 index 00000000..98688236 --- /dev/null +++ b/Backend/tests/plugin_runtime/settings_store.rs @@ -0,0 +1,60 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + clear_test_plugin_data_root, load_settings, reset_settings, save_settings, + set_test_plugin_data_root, +}; +use serde_json::json; +use std::fs; +use tempfile::tempdir; + +#[test] +fn saves_loads_and_resets_plugin_settings() { + let temp = tempdir().expect("tempdir"); + set_test_plugin_data_root(temp.path().join("plugin-data")); + + let mut settings = serde_json::Map::new(); + settings.insert("theme".into(), json!("dark")); + settings.insert("count".into(), json!(3)); + + save_settings("OpenVCS.Git", &settings).expect("save settings"); + + let path = temp + .path() + .join("plugin-data") + .join("openvcs.git") + .join("settings.json"); + assert!(path.is_file()); + + let loaded = load_settings("openvcs.git").expect("load settings"); + assert_eq!(loaded.get("theme"), Some(&json!("dark"))); + assert_eq!(loaded.get("count"), Some(&json!(3))); + + reset_settings("openvcs.git").expect("reset settings"); + assert!(!path.is_file()); + + clear_test_plugin_data_root(); +} + +#[test] +fn loads_empty_map_when_settings_file_is_missing() { + let temp = tempdir().expect("tempdir"); + set_test_plugin_data_root(temp.path().join("plugin-data")); + + let loaded = load_settings("missing.plugin").expect("load missing settings"); + assert!(loaded.is_empty()); + + clear_test_plugin_data_root(); +} + +#[test] +fn reset_settings_is_a_noop_for_missing_files() { + let temp = tempdir().expect("tempdir"); + set_test_plugin_data_root(temp.path().join("plugin-data")); + + reset_settings("missing.plugin").expect("reset missing settings"); + assert!(!temp.path().join("plugin-data").exists() || fs::read_dir(temp.path().join("plugin-data")).expect("dir").next().is_none()); + + clear_test_plugin_data_root(); +} diff --git a/Backend/tests/tauri_commands/remotes.rs b/Backend/tests/tauri_commands/remotes.rs index 317903a1..c499a4e4 100644 --- a/Backend/tests/tauri_commands/remotes.rs +++ b/Backend/tests/tauri_commands/remotes.rs @@ -1,24 +1,44 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::looks_like_ff_only_divergence; +use super::{ + host_from_remote_url, looks_like_ff_only_divergence, looks_like_ssh_auth_failure, + looks_like_unknown_host_key, +}; #[test] -fn detects_fast_forward_only_divergence() { - assert!(looks_like_ff_only_divergence( - "fatal: Not possible to fast-forward, aborting." - )); - assert!(looks_like_ff_only_divergence( - "hint: Diverging branches can't be fast-forwarded, you need to either:" - )); +fn parses_host_from_remote_urls() { + assert_eq!(host_from_remote_url("git@github.com:org/repo.git"), Some("github.com".into())); + assert_eq!(host_from_remote_url("ssh://git@example.com/org/repo"), Some("example.com".into())); + assert_eq!(host_from_remote_url("https://example.com/org/repo"), Some("example.com".into())); +} + +#[test] +fn rejects_unparseable_remote_urls() { + assert!(host_from_remote_url("").is_none()); + assert!(host_from_remote_url("not a url").is_none()); + assert!(host_from_remote_url("ssh://").is_none()); } #[test] -fn ignores_unrelated_pull_failures() { - assert!(!looks_like_ff_only_divergence( - "permission denied (publickey)" +fn detects_unknown_host_key_errors() { + assert!(looks_like_unknown_host_key( + "The authenticity of host 'github.com (140.82.121.4)' can't be established." )); - assert!(!looks_like_ff_only_divergence( - "could not resolve hostname origin" + assert!(!looks_like_unknown_host_key("permission denied (publickey)")); +} + +#[test] +fn detects_ssh_authentication_failures() { + assert!(looks_like_ssh_auth_failure("Permission denied (publickey).")); + assert!(looks_like_ssh_auth_failure("Authentication failed for 'git'")); + assert!(!looks_like_ssh_auth_failure("host key verification failed")); +} + +#[test] +fn detects_fast_forward_only_divergence() { + assert!(looks_like_ff_only_divergence( + "fatal: Not possible to fast-forward, aborting." )); + assert!(!looks_like_ff_only_divergence("permission denied (publickey)")); } diff --git a/Backend/tests/tauri_commands/status.rs b/Backend/tests/tauri_commands/status.rs index cabcbf4c..d0e84b7c 100644 --- a/Backend/tests/tauri_commands/status.rs +++ b/Backend/tests/tauri_commands/status.rs @@ -20,3 +20,9 @@ fn normalize_log_limit_treats_zero_as_unlimited() { fn normalize_log_limit_clamps_large_values() { assert_eq!(normalize_log_limit(Some(2_000)), Some(1_000)); } + +#[test] +fn normalize_log_limit_preserves_in_range_values() { + assert_eq!(normalize_log_limit(Some(25)), Some(25)); + assert_eq!(normalize_log_limit(Some(1_000)), Some(1_000)); +} From 7e8510748f65d388677a6dc45e7cb95df5c3a9d0 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 17:35:27 +0100 Subject: [PATCH 08/25] More test coverage --- .../src/plugin_runtime/node_instance/rpc.rs | 5 + Backend/src/plugin_runtime/vcs_proxy.rs | 5 + Backend/src/tauri_commands/branches.rs | 5 + Backend/src/tauri_commands/conflicts.rs | 5 + Backend/src/tauri_commands/general.rs | 5 + Backend/src/tauri_commands/output_log.rs | 5 + Backend/src/tauri_commands/plugins.rs | 5 + Backend/src/tauri_commands/repo_files.rs | 5 + Backend/src/tauri_commands/settings.rs | 5 + Backend/src/tauri_commands/ssh.rs | 48 ++++++- .../tests/plugin_runtime/node_instance/rpc.rs | 32 +++++ .../tests/plugin_runtime/settings_store.rs | 9 ++ Backend/tests/plugin_runtime/vcs_proxy.rs | 45 +++++++ Backend/tests/tauri_commands/branches.rs | 24 ++++ Backend/tests/tauri_commands/conflicts.rs | 31 +++++ Backend/tests/tauri_commands/general.rs | 11 ++ Backend/tests/tauri_commands/output_log.rs | 26 ++++ Backend/tests/tauri_commands/plugins.rs | 88 +++++++++++++ Backend/tests/tauri_commands/repo_files.rs | 30 +++++ Backend/tests/tauri_commands/settings.rs | 16 +++ Backend/tests/tauri_commands/ssh.rs | 117 ++++++++++++++++++ 21 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 Backend/tests/plugin_runtime/node_instance/rpc.rs create mode 100644 Backend/tests/plugin_runtime/vcs_proxy.rs create mode 100644 Backend/tests/tauri_commands/branches.rs create mode 100644 Backend/tests/tauri_commands/conflicts.rs create mode 100644 Backend/tests/tauri_commands/general.rs create mode 100644 Backend/tests/tauri_commands/output_log.rs create mode 100644 Backend/tests/tauri_commands/plugins.rs create mode 100644 Backend/tests/tauri_commands/repo_files.rs create mode 100644 Backend/tests/tauri_commands/settings.rs create mode 100644 Backend/tests/tauri_commands/ssh.rs diff --git a/Backend/src/plugin_runtime/node_instance/rpc.rs b/Backend/src/plugin_runtime/node_instance/rpc.rs index d0306e8f..111a12e5 100644 --- a/Backend/src/plugin_runtime/node_instance/rpc.rs +++ b/Backend/src/plugin_runtime/node_instance/rpc.rs @@ -166,3 +166,8 @@ fn format_rpc_error(plugin_id: &str, method: &str, error: &RpcError) -> String { plugin_id, method, error.code, detail ) } + +#[cfg(test)] +mod tests { + include!("../../../tests/plugin_runtime/node_instance/rpc.rs"); +} diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index b1750818..5036886f 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -418,3 +418,8 @@ fn path_to_utf8(path: &Path) -> Result { msg: format!("non-utf8 path: {}", path.display()), }) } + +#[cfg(test)] +mod tests { + include!("../../tests/plugin_runtime/vcs_proxy.rs"); +} diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index deec644a..0880276f 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -648,3 +648,8 @@ pub async fn vcs_current_branch(state: State<'_, AppState>) -> Result String { let last = trimmed.rsplit('/').next().unwrap_or(trimmed); last.trim_end_matches(".git").to_string() } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/general.rs"); +} diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index caebf746..01df9204 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -194,3 +194,8 @@ pub fn open_output_log_window(window: Window) -> Result<(), Strin Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/output_log.rs"); +} diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index b888602a..5d58be01 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -709,3 +709,8 @@ fn setting_value_to_json(value: &SettingValue) -> Value { SettingValue::String(v) => Value::String(v.clone()), } } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/plugins.rs"); +} diff --git a/Backend/src/tauri_commands/repo_files.rs b/Backend/src/tauri_commands/repo_files.rs index 0fc49e05..9dc5dc81 100644 --- a/Backend/src/tauri_commands/repo_files.rs +++ b/Backend/src/tauri_commands/repo_files.rs @@ -230,3 +230,8 @@ pub fn read_repo_file_text(state: State<'_, AppState>, path: String) -> Result, cfg: RepoConfig) -> R info!("settings: repository config updated"); Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/settings.rs"); +} diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 0d102562..46860273 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -10,13 +10,45 @@ use log::{debug, error, info, trace, warn}; use serde::Serialize; use tauri::command; +#[cfg(test)] +use parking_lot::RwLock; + +#[cfg(test)] +use std::sync::OnceLock; + +#[cfg(test)] +static TEST_HOME_DIR: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +fn set_test_home_dir(dir: PathBuf) { + *TEST_HOME_DIR.get_or_init(|| RwLock::new(None)).write() = Some(dir); +} + +#[cfg(test)] +fn clear_test_home_dir() { + *TEST_HOME_DIR.get_or_init(|| RwLock::new(None)).write() = None; +} + +fn home_dir_for_paths() -> Option { + #[cfg(test)] + if let Some(dir) = TEST_HOME_DIR + .get_or_init(|| RwLock::new(None)) + .read() + .clone() + { + return Some(dir); + } + + dirs::home_dir() +} + /// Returns `~/.ssh/known_hosts` path. /// /// # Returns /// - `Ok(PathBuf)` known-hosts path. /// - `Err(String)` when home directory cannot be resolved. fn known_hosts_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| { + let home = home_dir_for_paths().ok_or_else(|| { error!("known_hosts_path: could not determine home directory",); "Could not determine home directory".to_string() })?; @@ -31,7 +63,7 @@ fn known_hosts_path() -> Result { /// - `Ok(PathBuf)` ssh directory path. /// - `Err(String)` when home directory cannot be resolved. fn ssh_dir_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| { + let home = home_dir_for_paths().ok_or_else(|| { error!("ssh_dir_path: could not determine home directory",); "Could not determine home directory".to_string() })?; @@ -46,7 +78,7 @@ fn ssh_dir_path() -> Result { /// - `Ok(PathBuf)` created/existing ssh directory path. /// - `Err(String)` on resolution or create failure. fn ensure_ssh_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| { + let home = home_dir_for_paths().ok_or_else(|| { error!("ensure_ssh_dir: could not determine home directory",); "Could not determine home directory".to_string() })?; @@ -112,6 +144,11 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { Ok(result) } +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/ssh.rs"); +} + fn is_executable(path: &Path) -> bool { let Ok(metadata) = fs::metadata(path) else { return false; @@ -329,8 +366,11 @@ pub struct SshKeyCandidate { pub fn ssh_key_candidates() -> Result, String> { info!("ssh_key_candidates: scanning for SSH key candidates",); let dir = ssh_dir_path()?; + ssh_key_candidates_in_dir(&dir) +} - let Ok(read_dir) = fs::read_dir(&dir) else { +fn ssh_key_candidates_in_dir(dir: &Path) -> Result, String> { + let Ok(read_dir) = fs::read_dir(dir) else { debug!("ssh_key_candidates: ssh directory does not exist or is not readable",); return Ok(vec![]); }; diff --git a/Backend/tests/plugin_runtime/node_instance/rpc.rs b/Backend/tests/plugin_runtime/node_instance/rpc.rs new file mode 100644 index 00000000..af33d3c5 --- /dev/null +++ b/Backend/tests/plugin_runtime/node_instance/rpc.rs @@ -0,0 +1,32 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::format_rpc_error; +use crate::plugin_runtime::protocol::RpcError; +use serde_json::json; + +#[test] +fn formats_rpc_errors_with_nested_message() { + let error = RpcError { + code: 42, + message: "outer".into(), + data: Some(json!({"message": "inner"})), + }; + assert_eq!( + format_rpc_error("demo.plugin", "vcs.open", &error), + "plugin 'demo.plugin' rpc 'vcs.open' failed (code 42): inner" + ); +} + +#[test] +fn formats_rpc_errors_with_fallback_message() { + let error = RpcError { + code: 7, + message: " fallback ".into(), + data: None, + }; + assert_eq!( + format_rpc_error("demo.plugin", "vcs.open", &error), + "plugin 'demo.plugin' rpc 'vcs.open' failed (code 7): fallback" + ); +} diff --git a/Backend/tests/plugin_runtime/settings_store.rs b/Backend/tests/plugin_runtime/settings_store.rs index 98688236..a963b564 100644 --- a/Backend/tests/plugin_runtime/settings_store.rs +++ b/Backend/tests/plugin_runtime/settings_store.rs @@ -6,11 +6,18 @@ use super::{ set_test_plugin_data_root, }; use serde_json::json; +use std::sync::{Mutex, OnceLock}; use std::fs; use tempfile::tempdir; +fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("lock") +} + #[test] fn saves_loads_and_resets_plugin_settings() { + let _guard = test_lock(); let temp = tempdir().expect("tempdir"); set_test_plugin_data_root(temp.path().join("plugin-data")); @@ -39,6 +46,7 @@ fn saves_loads_and_resets_plugin_settings() { #[test] fn loads_empty_map_when_settings_file_is_missing() { + let _guard = test_lock(); let temp = tempdir().expect("tempdir"); set_test_plugin_data_root(temp.path().join("plugin-data")); @@ -50,6 +58,7 @@ fn loads_empty_map_when_settings_file_is_missing() { #[test] fn reset_settings_is_a_noop_for_missing_files() { + let _guard = test_lock(); let temp = tempdir().expect("tempdir"); set_test_plugin_data_root(temp.path().join("plugin-data")); diff --git a/Backend/tests/plugin_runtime/vcs_proxy.rs b/Backend/tests/plugin_runtime/vcs_proxy.rs new file mode 100644 index 00000000..7b727e08 --- /dev/null +++ b/Backend/tests/plugin_runtime/vcs_proxy.rs @@ -0,0 +1,45 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{path_to_utf8, PluginVcsProxy}; +use crate::core::{BackendId, VcsError}; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use std::path::PathBuf; +use std::sync::Arc; + +fn test_proxy() -> PluginVcsProxy { + let spawn = SpawnConfig { + plugin_id: "demo.plugin".into(), + exec_path: PathBuf::from("bin/plugin.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }; + PluginVcsProxy { + backend_id: BackendId::from("git"), + workdir: PathBuf::from("/tmp/repo"), + runtime: Arc::new(NodePluginRuntimeInstance::new(spawn)), + } +} + +#[test] +fn maps_runtime_errors() { + let proxy = test_proxy(); + assert!(matches!(proxy.map_runtime_error("no upstream configured".into()), VcsError::NoUpstream)); + assert!(matches!(proxy.map_runtime_error("boom".into()), VcsError::Backend { .. })); +} + +#[test] +fn converts_utf8_paths() { + assert_eq!(path_to_utf8(std::path::Path::new("/tmp/repo")).unwrap(), "/tmp/repo"); +} + +#[cfg(unix)] +#[test] +fn rejects_non_utf8_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let path = std::path::PathBuf::from(OsString::from_vec(vec![0xff, 0xfe])); + assert!(path_to_utf8(&path).is_err()); +} diff --git a/Backend/tests/tauri_commands/branches.rs b/Backend/tests/tauri_commands/branches.rs new file mode 100644 index 00000000..a4f9f0e7 --- /dev/null +++ b/Backend/tests/tauri_commands/branches.rs @@ -0,0 +1,24 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{apply_merge_template, repo_name_from_origin, repo_username_from_origin}; + +#[test] +fn parses_repo_owner_and_name_from_urls() { + assert_eq!(repo_username_from_origin("https://example.com/org/repo.git"), Some("org".into())); + assert_eq!(repo_username_from_origin("git@example.com:org/repo.git"), Some("org".into())); + assert_eq!(repo_name_from_origin("https://example.com/org/repo.git"), Some("repo".into())); + assert_eq!(repo_name_from_origin("git@example.com:org/repo"), Some("repo".into())); +} + +#[test] +fn expands_merge_templates() { + let rendered = apply_merge_template( + "Merge {branch:source} into {branch:target} for {repo:username}/{repo:name}", + "feature", + "main", + "demo", + "alice", + ); + assert_eq!(rendered, "Merge feature into main for alice/demo"); +} diff --git a/Backend/tests/tauri_commands/conflicts.rs b/Backend/tests/tauri_commands/conflicts.rs new file mode 100644 index 00000000..ecb5408e --- /dev/null +++ b/Backend/tests/tauri_commands/conflicts.rs @@ -0,0 +1,31 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::tool_args; +use crate::settings::ExternalTool; + +#[test] +fn splits_tool_path_and_arguments() { + let tool = ExternalTool { + enabled: true, + path: "/usr/bin/meld".into(), + args: "--auto-merge {path} --label \"My Repo\"".into(), + }; + + let (path, args) = tool_args(&tool); + assert_eq!(path, "/usr/bin/meld"); + assert_eq!(args, vec!["--auto-merge", "{path}", "--label", "My Repo"]); +} + +#[test] +fn returns_empty_arguments_when_tool_has_no_args() { + let tool = ExternalTool { + enabled: true, + path: "meld".into(), + args: " ".into(), + }; + + let (path, args) = tool_args(&tool); + assert_eq!(path, "meld"); + assert!(args.is_empty()); +} diff --git a/Backend/tests/tauri_commands/general.rs b/Backend/tests/tauri_commands/general.rs new file mode 100644 index 00000000..a91bcae4 --- /dev/null +++ b/Backend/tests/tauri_commands/general.rs @@ -0,0 +1,11 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::infer_repo_dir_from_url; + +#[test] +fn infers_repo_directory_names() { + assert_eq!(infer_repo_dir_from_url("https://example.com/org/repo.git"), "repo"); + assert_eq!(infer_repo_dir_from_url("git@example.com:org/repo"), "repo"); + assert_eq!(infer_repo_dir_from_url("https://example.com/org/repo/"), "repo"); +} diff --git a/Backend/tests/tauri_commands/output_log.rs b/Backend/tests/tauri_commands/output_log.rs new file mode 100644 index 00000000..89b46a29 --- /dev/null +++ b/Backend/tests/tauri_commands/output_log.rs @@ -0,0 +1,26 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::read_last_lines; +use std::fs; + +#[test] +fn reads_last_lines_from_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("log.txt"); + fs::write(&path, "one\ntwo\nthree\n").expect("write log"); + + assert_eq!( + read_last_lines(&path, 2).expect("read lines"), + vec!["one".to_string(), "two".to_string(), "three".to_string()] + ); +} + +#[test] +fn reads_empty_files_as_empty_lists() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("empty.log"); + fs::write(&path, "").expect("write empty log"); + + assert!(read_last_lines(&path, 10).expect("read empty").is_empty()); +} diff --git a/Backend/tests/tauri_commands/plugins.rs b/Backend/tests/tauri_commands/plugins.rs new file mode 100644 index 00000000..86f12938 --- /dev/null +++ b/Backend/tests/tauri_commands/plugins.rs @@ -0,0 +1,88 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + merge_settings_with_defaults, menu_to_payload, setting_from_json, setting_kind_name, + setting_value_to_json, settings_to_json_map, PluginMenuPayload, PluginSettingEntry, +}; +use crate::core::settings::{SettingKv, SettingValue}; +use crate::core::ui::{Menu, MenuSurface, UiButton, UiElement, UiText}; + +#[test] +fn maps_setting_values_to_kind_names() { + assert_eq!(setting_kind_name(&SettingValue::Bool(true)), "bool"); + assert_eq!(setting_kind_name(&SettingValue::S32(-1)), "s32"); + assert_eq!(setting_kind_name(&SettingValue::U32(1)), "u32"); + assert_eq!(setting_kind_name(&SettingValue::F64(1.5)), "f64"); + assert_eq!(setting_kind_name(&SettingValue::String("x".into())), "text"); +} + +#[test] +fn converts_setting_values_to_json() { + assert_eq!(setting_value_to_json(&SettingValue::Bool(true)), serde_json::json!(true)); + assert_eq!(setting_value_to_json(&SettingValue::S32(-3)), serde_json::json!(-3)); + assert_eq!(setting_value_to_json(&SettingValue::U32(3)), serde_json::json!(3)); + assert_eq!(setting_value_to_json(&SettingValue::F64(1.25)), serde_json::json!(1.25)); + assert_eq!( + setting_value_to_json(&SettingValue::String("hello".into())), + serde_json::json!("hello") + ); +} + +#[test] +fn converts_json_to_typed_settings() { + assert_eq!( + setting_value_to_json( + &setting_from_json("flag", &serde_json::json!(true), &SettingValue::Bool(false)) + .unwrap(), + ), + serde_json::json!(true) + ); + assert_eq!( + setting_value_to_json( + &setting_from_json("count", &serde_json::json!(7), &SettingValue::U32(0)).unwrap(), + ), + serde_json::json!(7) + ); + assert!(setting_from_json("flag", &serde_json::json!("yes"), &SettingValue::Bool(false)).is_err()); +} + +#[test] +fn merges_settings_into_defaults_and_serializes() { + let defaults = vec![ + SettingKv { id: "flag".into(), label: None, value: SettingValue::Bool(false) }, + SettingKv { id: "name".into(), label: None, value: SettingValue::String("old".into()) }, + ]; + let incoming = vec![ + PluginSettingEntry { id: "flag".into(), value: serde_json::json!(true) }, + PluginSettingEntry { id: "name".into(), value: serde_json::json!("new") }, + ]; + + let merged = merge_settings_with_defaults(defaults, incoming).expect("merge settings"); + assert_eq!(setting_value_to_json(&merged[0].value), serde_json::json!(true)); + assert_eq!(setting_value_to_json(&merged[1].value), serde_json::json!("new")); + + let json_map = settings_to_json_map(&merged); + assert_eq!(json_map.get("flag"), Some(&serde_json::json!(true))); + assert_eq!(json_map.get("name"), Some(&serde_json::json!("new"))); +} + +#[test] +fn converts_menu_payload() { + let menu = Menu { + id: "menu-1".into(), + label: "Menu".into(), + order: Some(2), + surface: MenuSurface::Menubar, + elements: vec![ + UiElement::Text(UiText { id: "txt".into(), content: "hello".into() }), + UiElement::Button(UiButton { id: "btn".into(), label: "Click".into() }), + ], + }; + + let payload: PluginMenuPayload = menu_to_payload("plugin.id", menu); + assert_eq!(payload.plugin_id, "plugin.id"); + assert_eq!(payload.label, "Menu"); + assert_eq!(payload.elements[0]["type"], serde_json::json!("text")); + assert_eq!(payload.elements[1]["type"], serde_json::json!("button")); +} diff --git a/Backend/tests/tauri_commands/repo_files.rs b/Backend/tests/tauri_commands/repo_files.rs new file mode 100644 index 00000000..14850a57 --- /dev/null +++ b/Backend/tests/tauri_commands/repo_files.rs @@ -0,0 +1,30 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{decode_repo_text, normalize_gitignore_entry, safe_relative_path}; + +#[test] +fn validates_repo_relative_paths() { + assert_eq!(safe_relative_path("src/lib.rs").unwrap(), std::path::PathBuf::from("src/lib.rs")); + assert!(safe_relative_path("").is_err()); + assert!(safe_relative_path("../secret").is_err()); + assert!(safe_relative_path("/absolute").is_err()); +} + +#[test] +fn normalizes_gitignore_entries() { + assert_eq!(normalize_gitignore_entry("foo\\bar").unwrap(), "/foo/bar"); + assert_eq!(normalize_gitignore_entry("./baz").unwrap(), "/baz"); + assert!(normalize_gitignore_entry("bad\npath").is_err()); +} + +#[test] +fn decodes_text_bytes() { + assert_eq!(decode_repo_text(b"hello"), "hello"); + + let utf16le: Vec = vec![0xFF, 0xFE, b'h', 0, b'i', 0]; + assert_eq!(decode_repo_text(&utf16le), "hi"); + + let utf16be: Vec = vec![0xFE, 0xFF, 0, b'h', 0, b'i']; + assert_eq!(decode_repo_text(&utf16be), "hi"); +} diff --git a/Backend/tests/tauri_commands/settings.rs b/Backend/tests/tauri_commands/settings.rs new file mode 100644 index 00000000..109afaff --- /dev/null +++ b/Backend/tests/tauri_commands/settings.rs @@ -0,0 +1,16 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::diff_configs; +use crate::settings::AppConfig; + +#[test] +fn reports_changed_sections() { + let old_cfg = AppConfig::default(); + let mut new_cfg = old_cfg.clone(); + new_cfg.general.theme = crate::settings::Theme::Dark; + new_cfg.logging.retain_archives = 99; + + assert_eq!(diff_configs(&old_cfg, &old_cfg), Vec::::new()); + assert_eq!(diff_configs(&old_cfg, &new_cfg), vec!["general".to_string(), "logging".to_string()]); +} diff --git a/Backend/tests/tauri_commands/ssh.rs b/Backend/tests/tauri_commands/ssh.rs new file mode 100644 index 00000000..43e18027 --- /dev/null +++ b/Backend/tests/tauri_commands/ssh.rs @@ -0,0 +1,117 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + clear_test_home_dir, is_executable, known_hosts_path, resolve_command, resolve_ssh_askpass, + set_test_home_dir, ssh_dir_path, ssh_key_candidates_in_dir, +}; +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +struct EnvGuard { + home: Option, + path: Option, + askpass: Option, +} + +impl EnvGuard { + fn capture() -> Self { + Self { + home: env::var_os("HOME"), + path: env::var_os("PATH"), + askpass: env::var_os("SSH_ASKPASS"), + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match &self.path { + Some(value) => env::set_var("PATH", value), + None => env::remove_var("PATH"), + } + match &self.askpass { + Some(value) => env::set_var("SSH_ASKPASS", value), + None => env::remove_var("SSH_ASKPASS"), + } + } + } +} + +fn make_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); + } +} + +#[test] +fn resolves_known_hosts_and_ssh_dir_from_home() { + let home = tempdir().expect("tempdir"); + set_test_home_dir(home.path().to_path_buf()); + + let dir = ssh_dir_path().expect("ssh dir"); + let known_hosts = known_hosts_path().expect("known hosts"); + assert_eq!(dir, home.path().join(".ssh")); + assert_eq!(known_hosts, home.path().join(".ssh").join("known_hosts")); + + clear_test_home_dir(); +} + +#[test] +fn detects_executable_files_and_resolves_from_path() { + let _guard = EnvGuard::capture(); + let dir = tempdir().expect("tempdir"); + let exe = dir.path().join("tool"); + fs::write(&exe, "#!/bin/sh\nexit 0\n").expect("write file"); + make_executable(&exe); + + unsafe { env::set_var("PATH", dir.path()) }; + assert!(is_executable(&exe)); + assert_eq!(resolve_command(Path::new("tool")), Some(exe)); +} + +#[test] +fn resolves_ssh_askpass_from_environment() { + let _guard = EnvGuard::capture(); + let dir = tempdir().expect("tempdir"); + let askpass = dir.path().join("askpass"); + fs::write(&askpass, "#!/bin/sh\nexit 0\n").expect("write askpass"); + make_executable(&askpass); + + unsafe { env::set_var("SSH_ASKPASS", &askpass) }; + assert_eq!(resolve_ssh_askpass(), Some(askpass)); +} + +#[test] +fn lists_private_key_candidates_from_ssh_dir() { + let _guard = EnvGuard::capture(); + let ssh_dir = tempdir().expect("tempdir"); + + for name in [ + "id_rsa", + "id_ed25519", + "id_ed25519.pub", + "known_hosts", + "config", + "custom.key", + "notes.txt", + ] { + fs::write(ssh_dir.path().join(name), "x").expect("write file"); + } + + let keys = ssh_key_candidates_in_dir(ssh_dir.path()).expect("list candidates"); + let names: Vec<_> = keys.into_iter().map(|k| k.name).collect(); + assert_eq!(names, vec!["custom.key", "id_ed25519", "id_rsa"]); +} From 957e1eb11d8878028323953e80ec9bcda97fbcbf Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 17:53:16 +0100 Subject: [PATCH 09/25] More unit tests --- .../src/plugin_runtime/node_instance/mod.rs | 5 + Backend/src/tauri_commands/commit.rs | 68 ++++---- Backend/tests/modules/settings.rs | 146 ++++++++++++++++++ .../tests/plugin_runtime/node_instance/mod.rs | 43 ++++++ .../tests/plugin_runtime/runtime_select.rs | 28 ++++ Backend/tests/tauri_commands/commit.rs | 30 ++++ 6 files changed, 286 insertions(+), 34 deletions(-) create mode 100644 Backend/tests/plugin_runtime/node_instance/mod.rs create mode 100644 Backend/tests/tauri_commands/commit.rs diff --git a/Backend/src/plugin_runtime/node_instance/mod.rs b/Backend/src/plugin_runtime/node_instance/mod.rs index 3e822c5d..cb3bc1a1 100644 --- a/Backend/src/plugin_runtime/node_instance/mod.rs +++ b/Backend/src/plugin_runtime/node_instance/mod.rs @@ -541,3 +541,8 @@ impl Drop for NodePluginRuntimeInstance { } } } + +#[cfg(test)] +mod tests { + include!("../../../tests/plugin_runtime/node_instance/mod.rs"); +} diff --git a/Backend/src/tauri_commands/commit.rs b/Backend/src/tauri_commands/commit.rs index 4efbf7c0..f3caacaf 100644 --- a/Backend/src/tauri_commands/commit.rs +++ b/Backend/src/tauri_commands/commit.rs @@ -11,6 +11,27 @@ use crate::state::AppState; use super::{current_repo_or_err, progress_bridge, run_repo_task}; +fn build_commit_message(summary: &str, description: &str) -> String { + if description.trim().is_empty() { + summary.to_string() + } else { + format!("{summary}\n\n{description}") + } +} + +fn has_commit_selection(patch: &str, files_len: usize, stage_paths_len: usize) -> bool { + !patch.trim().is_empty() || files_len > 0 || stage_paths_len > 0 +} + +fn trimmed_non_empty(value: &str, error: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + Err(error.to_string()) + } else { + Ok(value) + } +} + /// Resolves the repository commit identity from VCS config. /// /// # Parameters @@ -54,11 +75,7 @@ pub async fn commit_changes( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -122,11 +139,7 @@ pub async fn commit_selected( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -185,11 +198,7 @@ pub async fn commit_patch( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -256,11 +265,7 @@ pub async fn commit_patch_and_files( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -290,8 +295,7 @@ pub async fn commit_patch_and_files( e.to_string() })?; } - let has_selection = - !patch.trim().is_empty() || !files.is_empty() || !stage_paths.is_empty(); + let has_selection = has_commit_selection(&patch, files.len(), stage_paths.len()); if !has_selection { return Err("No commit paths provided".into()); } @@ -335,14 +339,8 @@ pub async fn vcs_cherry_pick_to_branch( let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); run_repo_task("vcs_cherry_pick_to_branch", repo, move |repo| { - let id = id.trim().to_string(); - let branch = branch.trim().to_string(); - if id.is_empty() { - return Err("Commit id cannot be empty".into()); - } - if branch.is_empty() { - return Err("Target branch cannot be empty".into()); - } + let id = trimmed_non_empty(&id, "Commit id cannot be empty")?; + let branch = trimmed_non_empty(&branch, "Target branch cannot be empty")?; let on = progress_bridge(app); on(VcsEvent::Progress { @@ -388,10 +386,7 @@ pub async fn vcs_revert_commit( let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); run_repo_task("vcs_revert_commit", repo, move |repo| { - let id = id.trim().to_string(); - if id.is_empty() { - return Err("Commit id cannot be empty".into()); - } + let id = trimmed_non_empty(&id, "Commit id cannot be empty")?; let on = progress_bridge(app); on(VcsEvent::Progress { @@ -408,3 +403,8 @@ pub async fn vcs_revert_commit( }) .await } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/commit.rs"); +} diff --git a/Backend/tests/modules/settings.rs b/Backend/tests/modules/settings.rs index bf98f09d..dfb65936 100644 --- a/Backend/tests/modules/settings.rs +++ b/Backend/tests/modules/settings.rs @@ -57,3 +57,149 @@ fn helper_constructors_return_disabled_defaults() { assert_eq!(proxy.mode, ProxyMode::System); assert!(proxy.url.is_empty()); } + +#[test] +fn section_defaults_stay_aligned_with_schema() { + let vcs = Vcs::default(); + assert!(vcs.backend.is_empty()); + assert_eq!(vcs.default_branch, "main"); + assert_eq!(vcs.ssh_binary, GitSshBinary::Auto); + assert!(vcs.ssh_path.is_empty()); + assert!(vcs.prune_on_fetch); + assert!(vcs.fetch_on_focus); + assert_eq!(vcs.allow_hooks, HookPolicy::Ask); + assert!(vcs.respect_core_autocrlf); + assert_eq!( + vcs.merge_commit_message_template, + "Merged branch '{branch:source}' into '{branch:target}'" + ); + + let commit = Commit::default(); + assert!(commit.commit_message_template_enabled); + assert!(commit.restrict_commit_summary); + assert_eq!(commit.commit_templates, CommitTemplates::default()); + + let templates = CommitTemplates::default(); + assert_eq!(templates.commit_message_template_create, "Create {file:name}"); + assert_eq!(templates.commit_message_template_update, "Update {file:name}"); + assert_eq!(templates.commit_message_template_delete, "Delete {file:name}"); + + let credentials = Credentials::default(); + assert_eq!(credentials.helper, CredentialHelper::OsKeychain); + assert_eq!(credentials.ssh_agent, SshAgent::Env); + assert_eq!( + credentials.ssh_key_paths, + vec!["~/.ssh/id_ed25519", "~/.ssh/id_rsa"] + ); + assert_eq!(credentials.gpg_program, "gpg"); + assert!(!credentials.sign_commits); + assert!(credentials.signing_key.is_empty()); + + let diff = Diff::default(); + assert_eq!(diff.tab_width, 4); + assert_eq!(diff.ignore_whitespace, WhitespaceMode::None); + assert_eq!(diff.max_file_size_mb, 10); + assert!(diff.intraline); + assert!(diff.show_binary_placeholders); + assert_eq!(diff.external_diff, ExternalTool::disabled()); + assert_eq!(diff.external_merge, ExternalTool::disabled()); + assert_eq!(diff.binary_exts, vec!["png", "jpg", "dds", "uasset"]); + + let lfs = Lfs::default(); + assert!(lfs.enabled); + assert_eq!(lfs.concurrency, 4); + assert!(!lfs.require_lock_before_edit); + assert!(lfs.background_fetch_on_checkout); + + let performance = Performance::default(); + assert!(performance.progressive_render); + assert!(performance.gpu_accel); + assert!(performance.animations); + + let integrations = Integrations::default(); + assert_eq!(integrations.default_editor, EditorChoice::System); + assert_eq!(integrations.issue_provider, IssueProvider::Auto); + assert!(integrations.host_overrides.is_empty()); + + let plugins = Plugins::default(); + assert!(plugins.disabled.is_empty()); + assert!(plugins.enabled.is_empty()); + + let ux = Ux::default(); + assert_eq!(ux.ui_scale, 1.0); + assert_eq!(ux.font_mono, "monospace"); + assert!(!ux.vim_nav); + assert_eq!(ux.color_blind_mode, ColorBlindMode::None); + assert_eq!(ux.recents_limit, 10); + + let advanced = Advanced::default(); + assert_eq!(advanced.confirm_force_push, ForcePushPolicy::Always); + assert!(advanced.ssl_verify); + assert_eq!(advanced.proxy, Proxy::system()); + + let experimental = Experimental::default(); + assert!(!experimental.parallel_history_scan); + assert!(!experimental.background_blame_index); + assert!(!experimental.sparse_checkout_ui); + + let logging = Logging::default(); + assert_eq!(logging.level, LogLevel::Info); + assert!(!logging.live_viewer); + assert_eq!(logging.retain_archives, 10); + +} + +#[test] +fn deserializes_partial_toml_with_field_defaults() { + let cfg: AppConfig = toml::from_str( + r#" +schema_version = 1 + +[general] +default_backend = "hg" + +[plugins] +enabled = ["openvcs.git"] +"#, + ) + .expect("parse config"); + + assert_eq!(cfg.schema_version, 1); + assert_eq!(cfg.general.default_backend, "hg"); + assert_eq!(cfg.general.theme_pack, "default"); + assert_eq!(cfg.vcs.default_branch, "main"); + assert_eq!(cfg.commit.commit_templates.commit_message_template_delete, "Delete {file:name}"); + assert_eq!(cfg.credentials.helper, CredentialHelper::OsKeychain); + assert_eq!(cfg.diff.tab_width, 4); + assert_eq!(cfg.lfs.concurrency, 4); + assert!(cfg.performance.animations); + assert!(cfg.integrations.host_overrides.is_empty()); + assert_eq!(cfg.plugins.enabled, vec!["openvcs.git"]); + assert_eq!(cfg.ux.recents_limit, 10); + assert_eq!(cfg.logging.retain_archives, 10); +} + +#[test] +fn round_trips_configuration_through_toml() { + let mut cfg = AppConfig::default(); + cfg.general.default_backend = "hg".into(); + cfg.vcs.backend = "git".into(); + cfg.vcs.ssh_binary = GitSshBinary::Custom; + cfg.vcs.ssh_path = "/usr/bin/ssh".into(); + cfg.commit.commit_templates.commit_message_template_update = "Update {file:name} now".into(); + cfg.credentials.sign_commits = true; + cfg.diff.tab_width = 2; + cfg.lfs.concurrency = 8; + cfg.performance.gpu_accel = false; + cfg.integrations.host_overrides.insert("example.com".into(), IssueProvider::Forgejo); + cfg.plugins.disabled = vec!["openvcs.git".into()]; + cfg.ux.ui_scale = 1.25; + cfg.advanced.confirm_force_push = ForcePushPolicy::TrackedRemotes; + cfg.experimental.parallel_history_scan = true; + cfg.logging.live_viewer = true; + + let toml = toml::to_string_pretty(&cfg).expect("serialize config"); + let parsed: AppConfig = toml::from_str(&toml).expect("deserialize config"); + + assert_eq!(parsed, cfg); +} diff --git a/Backend/tests/plugin_runtime/node_instance/mod.rs b/Backend/tests/plugin_runtime/node_instance/mod.rs new file mode 100644 index 00000000..9cd3db01 --- /dev/null +++ b/Backend/tests/plugin_runtime/node_instance/mod.rs @@ -0,0 +1,43 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::NodePluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use serde_json::json; + +fn test_runtime() -> NodePluginRuntimeInstance { + NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "plugin.demo".into(), + exec_path: std::path::PathBuf::from("plugin.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }) +} + +#[test] +fn session_params_requires_an_open_session() { + let runtime = test_runtime(); + + let err = runtime + .session_params(json!({"path": "repo.txt"})) + .expect_err("expected missing session error"); + assert_eq!(err, "vcs session is not open"); +} + +#[test] +fn session_params_merges_session_id_with_extra_fields() { + let runtime = test_runtime(); + *runtime.vcs_session_id.lock() = Some("session-123".into()); + + let value = runtime + .session_params(json!({"path": "repo.txt", "session_id": "override"})) + .expect("session params"); + + assert_eq!( + value, + json!({ + "session_id": "override", + "path": "repo.txt" + }) + ); +} diff --git a/Backend/tests/plugin_runtime/runtime_select.rs b/Backend/tests/plugin_runtime/runtime_select.rs index 53ee441e..a060c7f3 100644 --- a/Backend/tests/plugin_runtime/runtime_select.rs +++ b/Backend/tests/plugin_runtime/runtime_select.rs @@ -24,3 +24,31 @@ fn mjs_path_is_detected_as_node_module() { assert!(is_node_module(&script_path)); } + +#[test] +fn js_and_cjs_paths_are_detected_as_node_modules() { + let temp = tempdir().expect("tempdir"); + + let js_path = temp.path().join("plugin.js"); + fs::write(&js_path, b"export {}\n").expect("write js script"); + assert!(is_node_module(&js_path)); + + let cjs_path = temp.path().join("plugin.cjs"); + fs::write(&cjs_path, b"module.exports = {}\n").expect("write cjs script"); + assert!(is_node_module(&cjs_path)); +} + +#[test] +fn uppercase_extensions_and_non_files_are_handled_consistently() { + let temp = tempdir().expect("tempdir"); + + let upper_js = temp.path().join("PLUGIN.JS"); + fs::write(&upper_js, b"export {}\n").expect("write upper js script"); + assert!(is_node_module(&upper_js)); + + let dir_path = temp.path().join("plugin_dir"); + fs::create_dir_all(&dir_path).expect("create dir"); + assert!(!is_node_module(&dir_path)); + + assert!(!is_node_module(&temp.path().join("missing.js"))); +} diff --git a/Backend/tests/tauri_commands/commit.rs b/Backend/tests/tauri_commands/commit.rs new file mode 100644 index 00000000..ad3091c9 --- /dev/null +++ b/Backend/tests/tauri_commands/commit.rs @@ -0,0 +1,30 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{build_commit_message, has_commit_selection, trimmed_non_empty}; + +#[test] +fn builds_commit_messages_with_optional_descriptions() { + assert_eq!(build_commit_message("Summary", ""), "Summary"); + assert_eq!(build_commit_message("Summary", " "), "Summary"); + assert_eq!( + build_commit_message("Summary", "Body text"), + "Summary\n\nBody text" + ); +} + +#[test] +fn detects_when_commit_selection_exists() { + assert!(!has_commit_selection("", 0, 0)); + assert!(!has_commit_selection(" ", 0, 0)); + assert!(has_commit_selection("patch", 0, 0)); + assert!(has_commit_selection("", 1, 0)); + assert!(has_commit_selection("", 0, 1)); +} + +#[test] +fn trims_non_empty_inputs_or_reports_errors() { + assert_eq!(trimmed_non_empty(" git ", "bad").expect("trimmed"), "git"); + assert_eq!(trimmed_non_empty("git", "bad").expect("trimmed"), "git"); + assert_eq!(trimmed_non_empty(" ", "bad").expect_err("error"), "bad"); +} From 577db6b2c781cf11a63a4b9f1e289084487c5156 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 18:01:59 +0100 Subject: [PATCH 10/25] Added validate and theme tests --- Backend/tests/modules/themes.rs | 31 +++++++++++++++++++++++++++ Backend/tests/modules/validate.rs | 35 ++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/Backend/tests/modules/themes.rs b/Backend/tests/modules/themes.rs index 7abc666b..4cb3c143 100644 --- a/Backend/tests/modules/themes.rs +++ b/Backend/tests/modules/themes.rs @@ -123,3 +123,34 @@ fn build_theme_payload_from_directory_loads_assets() { assert_eq!(payload.markup.body.as_deref(), Some("
      \n")); assert_eq!(payload.scripts, vec!["console.log('theme');\n".to_string()]); } + +#[test] +fn build_theme_payload_from_directory_keeps_builtin_ids() { + let dir = tempdir().expect("create temp dir"); + write_file( + dir.path().join("theme.json"), + &serde_json::to_string(&json!({ + "id": "forest", + "name": "Forest", + "paired_with": "night", + "styles": ["base.css"] + })) + .expect("serialize manifest"), + ); + write_file(dir.path().join("base.css"), "body { color: green; }\n"); + + let manifest = read_manifest_from_directory(dir.path()).expect("read manifest"); + let payload = build_theme_payload_from_directory( + dir.path(), + manifest, + ThemeSource::BuiltIn, + None, + ) + .expect("build payload"); + + assert_eq!(payload.summary.id, "forest"); + assert_eq!(payload.summary.paired_with.as_deref(), Some("night")); + assert!(matches!(payload.summary.source, ThemeSource::BuiltIn)); + assert!(payload.summary.plugin_id.is_none()); + assert_eq!(payload.styles.as_deref(), Some("body { color: green; }\n")); +} diff --git a/Backend/tests/modules/validate.rs b/Backend/tests/modules/validate.rs index eb356cee..d74b2b94 100644 --- a/Backend/tests/modules/validate.rs +++ b/Backend/tests/modules/validate.rs @@ -1,7 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{validate_add_path, validate_clone_input, validate_vcs_url}; +use super::{ + has_url_path_segment, is_probably_vcs_url, looks_like_path, validate_add_path, + validate_clone_input, validate_vcs_url, +}; use std::fs; #[test] @@ -76,3 +79,33 @@ fn validates_clone_inputs() { assert!(!err.ok); assert_eq!(err.reason.as_deref(), Some("Destination already contains a repository")); } + +#[test] +fn detects_supported_vcs_url_shapes() { + assert!(is_probably_vcs_url("https://github.com/openvcs/openvcs")); + assert!(is_probably_vcs_url("https://github.com/openvcs/openvcs.git")); + assert!(is_probably_vcs_url("ssh://git@example.com/openvcs/openvcs")); + assert!(is_probably_vcs_url("git@example.com:openvcs/openvcs")); + assert!(!is_probably_vcs_url("https://github.com")); + assert!(!is_probably_vcs_url("")); + assert!(!is_probably_vcs_url("not a url")); +} + +#[test] +fn detects_url_path_segments_after_scheme_stripping() { + assert!(has_url_path_segment("https://github.com/openvcs/openvcs", "https://")); + assert!(has_url_path_segment("ssh://git@example.com/openvcs/openvcs", "ssh://")); + assert!(!has_url_path_segment("https://github.com", "https://")); + assert!(!has_url_path_segment("ssh://git@example.com/", "ssh://")); + assert!(!has_url_path_segment("github.com/openvcs/openvcs", "https://")); +} + +#[test] +fn detects_absolute_path_shapes() { + assert!(looks_like_path("/tmp/openvcs")); + assert!(looks_like_path("~/openvcs")); + assert!(looks_like_path("C:\\OpenVCS")); + assert!(looks_like_path("c:/OpenVCS")); + assert!(!looks_like_path("relative/path")); + assert!(!looks_like_path("")); +} From 78f7f7671f4887177c41be9eec94f6222d833ceb Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 18:56:41 +0100 Subject: [PATCH 11/25] Added way more tests --- Backend/src/lib.rs | 67 ++++- Backend/src/tauri_commands/backends.rs | 77 +++-- Backend/src/tauri_commands/general.rs | 66 +++-- Backend/src/tauri_commands/monitoring.rs | 5 + Backend/src/tauri_commands/shared.rs | 61 ++-- Backend/src/tauri_commands/stash.rs | 47 ++- Backend/src/tauri_commands/themes.rs | 55 ++-- Backend/src/tauri_commands/updater.rs | 83 ++++-- Backend/tests/modules/config_watcher.rs | 31 ++ Backend/tests/modules/lib.rs | 42 +++ Backend/tests/modules/monitoring.rs | 31 +- Backend/tests/modules/plugin_paths.rs | 24 +- Backend/tests/tauri_commands/backends.rs | 45 +++ Backend/tests/tauri_commands/branches.rs | 22 ++ Backend/tests/tauri_commands/commit.rs | 22 ++ Backend/tests/tauri_commands/general.rs | 34 ++- Backend/tests/tauri_commands/monitoring.rs | 29 ++ Backend/tests/tauri_commands/remotes.rs | 3 + Backend/tests/tauri_commands/shared.rs | 66 +++++ Backend/tests/tauri_commands/ssh.rs | 18 +- Backend/tests/tauri_commands/stash.rs | 32 ++ Backend/tests/tauri_commands/themes.rs | 43 +++ Backend/tests/tauri_commands/updater.rs | 45 +++ .../src/scripts/features/commandSheet.test.ts | 275 ++++++++++++++++++ Frontend/src/scripts/features/diff.test.ts | 110 +++++++ .../scripts/features/repo/diffView.test.ts | 59 ++++ .../src/scripts/features/repo/history.test.ts | 131 +++++++++ .../src/scripts/features/repo/hydrate.test.ts | 114 ++++++++ .../features/repo/interactions.test.ts | 122 ++++++++ .../src/scripts/features/repo/list.test.ts | 92 ++++++ Frontend/src/scripts/lib/logger.test.ts | 174 +++++++++++ Frontend/src/scripts/ui/menubar.test.ts | 199 +++++++++++++ Justfile | 10 + README.md | 23 ++ 34 files changed, 2123 insertions(+), 134 deletions(-) create mode 100644 Backend/tests/modules/lib.rs create mode 100644 Backend/tests/tauri_commands/backends.rs create mode 100644 Backend/tests/tauri_commands/monitoring.rs create mode 100644 Backend/tests/tauri_commands/shared.rs create mode 100644 Backend/tests/tauri_commands/stash.rs create mode 100644 Backend/tests/tauri_commands/themes.rs create mode 100644 Backend/tests/tauri_commands/updater.rs create mode 100644 Frontend/src/scripts/features/commandSheet.test.ts create mode 100644 Frontend/src/scripts/lib/logger.test.ts create mode 100644 Frontend/src/scripts/ui/menubar.test.ts diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index a0b06387..c0915085 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -38,12 +38,43 @@ mod utilities; mod validate; mod workarounds; +/// Builds the development `.env` path relative to the backend crate manifest. +fn local_dotenv_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env") +} + +/// Resolves the preferred backend from a configured default and available backend ids. +fn resolve_preferred_backend_id( + configured_default: &str, + available_backend_ids: &[BackendId], +) -> Option { + let desired = configured_default.trim(); + if !desired.is_empty() { + let desired_backend = BackendId::from(desired.to_string()); + if available_backend_ids + .iter() + .any(|backend| backend.as_ref() == desired_backend.as_ref()) + { + return Some(desired_backend); + } + } + + let mut backends = available_backend_ids.to_vec(); + backends.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); + backends.into_iter().next() +} + +/// Returns the first recent repository path that still exists on disk. +fn first_existing_recent_repo(paths: &[std::path::PathBuf]) -> Option { + paths.iter().find(|path| path.exists()).cloned() +} + /// Loads `Client/.env` for local development without overwriting existing env vars. /// /// Missing .env file is silently ignored. Malformed or unreadable .env files /// are reported with context for debugging before structured logging is ready. fn load_local_dotenv() { - let dotenv_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env"); + let dotenv_path = local_dotenv_path(); match dotenvy::from_path(&dotenv_path) { Ok(_) => {} @@ -67,20 +98,23 @@ fn load_local_dotenv() { /// - `Some(BackendId)` when a backend is available. /// - `None` otherwise. fn preferred_vcs_backend_id(_cfg: &settings::AppConfig) -> Option { - let desired = _cfg.general.default_backend.trim().to_string(); - if !desired.is_empty() { - let desired = BackendId::from(desired); - if crate::plugin_vcs_backends::has_plugin_vcs_backend(&desired) { - return Some(desired); - } - } - - crate::plugin_vcs_backends::list_plugin_vcs_backends() + let available_backend_ids = crate::plugin_vcs_backends::list_plugin_vcs_backends() .ok() - .and_then(|mut backends| { - backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); - backends.into_iter().next().map(|b| b.backend_id) + .map(|backends| { + backends + .into_iter() + .map(|backend| backend.backend_id) + .collect::>() }) + .unwrap_or_default(); + + let resolved = + resolve_preferred_backend_id(&_cfg.general.default_backend, &available_backend_ids)?; + if crate::plugin_vcs_backends::has_plugin_vcs_backend(&resolved) { + Some(resolved) + } else { + None + } } /// Attempt to reopen the most recent repository at startup if the @@ -102,7 +136,7 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { } let recents = state.recents(); - if let Some(path) = recents.into_iter().find(|p| p.exists()) { + if let Some(path) = first_existing_recent_repo(&recents) { let Some(backend) = preferred_vcs_backend_id(&app_config) else { log::warn!("startup reopen: no VCS backend available"); return; @@ -452,3 +486,8 @@ fn build_invoke_handler() tauri_commands::check_for_updates, ] } + +#[cfg(test)] +mod tests { + include!("../tests/modules/lib.rs"); +} diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index ffc4edc9..b4aab102 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -13,6 +13,39 @@ use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; +/// Resolves the display label shown for a backend entry. +fn backend_display_label( + backend_name: Option<&str>, + plugin_name: Option<&str>, + backend_id: &BackendId, +) -> String { + backend_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + plugin_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| backend_id.as_ref().to_string()) +} + +/// Returns the sole backend id that should become the default when exactly one backend exists. +fn auto_default_backend_id(current_default: &str, backends: &[(String, String)]) -> Option { + if backends.len() != 1 { + return None; + } + + let only_backend_id = backends[0].0.trim(); + if only_backend_id.is_empty() || current_default.trim() == only_backend_id { + None + } else { + Some(only_backend_id.to_string()) + } +} + #[tauri::command] /// Lists VCS backends currently available from plugins. /// @@ -28,11 +61,11 @@ pub fn list_vcs_backends_cmd(state: State<'_, AppState>) -> Vec<(String, String) if let Ok(plugin_bes) = plugin_vcs_backends::list_plugin_vcs_backends() { for p in plugin_bes { - let label = p - .backend_name - .clone() - .or_else(|| p.plugin_name.clone()) - .unwrap_or_else(|| p.backend_id.as_ref().to_string()); + let label = backend_display_label( + p.backend_name.as_deref(), + p.plugin_name.as_deref(), + &p.backend_id, + ); // Prefer plugin-provided VCS backends when IDs overlap. map.insert(p.backend_id.as_ref().to_string(), label); } @@ -40,22 +73,21 @@ pub fn list_vcs_backends_cmd(state: State<'_, AppState>) -> Vec<(String, String) let backends: Vec<(String, String)> = map.into_iter().collect(); - if backends.len() == 1 { - let only_backend_id = backends[0].0.as_str(); + if let Some(only_backend_id) = + auto_default_backend_id(&state.config().general.default_backend, &backends) + { let mut cfg = state.config(); - if cfg.general.default_backend.trim() != only_backend_id { - cfg.general.default_backend = only_backend_id.to_string(); - if let Err(err) = state.set_config(cfg) { - warn!( - "list_vcs_backends_cmd: failed to persist auto default backend `{}`: {}", - only_backend_id, err - ); - } else { - info!( - "list_vcs_backends_cmd: auto-selected sole backend `{}` as default", - only_backend_id - ); - } + cfg.general.default_backend = only_backend_id.clone(); + if let Err(err) = state.set_config(cfg) { + warn!( + "list_vcs_backends_cmd: failed to persist auto default backend `{}`: {}", + only_backend_id, err + ); + } else { + info!( + "list_vcs_backends_cmd: auto-selected sole backend `{}` as default", + only_backend_id + ); } } @@ -195,3 +227,8 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S state.set_current_repo(new_repo); Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/backends.rs"); +} diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index 7327c426..3a9afd79 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -20,6 +20,43 @@ use super::progress_bridge; const WIKI_URL: &str = "https://github.com/jordonbc/OpenVCS/wiki"; +/// Resolves the title shown by the directory picker for a given browse purpose. +fn browse_directory_title(purpose: Option<&str>) -> &'static str { + match purpose { + Some("clone_dest") => "Choose destination folder", + Some("add_repo") => "Select an existing repository folder", + _ => "Select a folder", + } +} + +/// Resolves the preferred default backend from configured and available backend ids. +fn resolve_default_backend_id( + configured_default: &str, + available_backend_ids: &[BackendId], +) -> Option { + let desired = configured_default.trim(); + if !desired.is_empty() { + let desired_backend = BackendId::from(desired.to_string()); + if available_backend_ids + .iter() + .any(|backend| backend.as_ref() == desired_backend.as_ref()) + { + return Some(desired_backend); + } + } + + let mut backends = available_backend_ids.to_vec(); + backends.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); + backends.into_iter().next() +} + +/// Extracts the display name used for a recent repository entry. +fn recent_repo_name(path: &Path) -> Option { + path.file_name() + .and_then(|segment| segment.to_str()) + .map(|segment| segment.to_string()) +} + #[derive(serde::Serialize)] /// Event payload emitted after selecting/opening a repository. struct RepoSelectedPayload { @@ -62,11 +99,7 @@ pub async fn browse_directory( window: Window, purpose: Option, ) -> Option { - let title = match purpose.as_deref() { - Some("clone_dest") => "Choose destination folder", - Some("add_repo") => "Select an existing repository folder", - _ => "Select a folder", - }; + let title = browse_directory_title(purpose.as_deref()); utilities::browse_directory_async(window.app_handle().clone(), title).await } @@ -124,19 +157,11 @@ pub async fn add_repo( fn default_backend_id(state: &AppState) -> Option { let mut backends = crate::plugin_vcs_backends::list_plugin_vcs_backends().ok()?; backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); - - let desired = state.config().general.default_backend.trim().to_string(); - if !desired.is_empty() { - let desired = BackendId::from(desired); - if backends - .iter() - .any(|backend| backend.backend_id.as_ref() == desired.as_ref()) - { - return Some(desired); - } - } - - backends.into_iter().next().map(|b| b.backend_id) + let available = backends + .into_iter() + .map(|backend| backend.backend_id) + .collect::>(); + resolve_default_backend_id(&state.config().general.default_backend, &available) } /// Internal helper that opens a repository and publishes `repo:selected`. @@ -343,10 +368,7 @@ pub fn list_recent_repos(state: State<'_, AppState>) -> Vec { .recents() .into_iter() .map(|p| { - let name = p - .file_name() - .and_then(|os| os.to_str()) - .map(|s| s.to_string()); + let name = recent_repo_name(&p); RecentRepoDto { path: p.to_string_lossy().to_string(), name, diff --git a/Backend/src/tauri_commands/monitoring.rs b/Backend/src/tauri_commands/monitoring.rs index a7371dff..3357c109 100644 --- a/Backend/src/tauri_commands/monitoring.rs +++ b/Backend/src/tauri_commands/monitoring.rs @@ -15,3 +15,8 @@ pub fn report_frontend_error(payload: FrontendErrorReport) -> Result<(), String> crate::monitoring::capture_frontend_error(payload); Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/monitoring.rs"); +} diff --git a/Backend/src/tauri_commands/shared.rs b/Backend/src/tauri_commands/shared.rs index babc1176..b03e9663 100644 --- a/Backend/src/tauri_commands/shared.rs +++ b/Backend/src/tauri_commands/shared.rs @@ -11,6 +11,38 @@ use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; +/// Converts a backend task label and error into a consistent user-facing message. +fn format_task_failure(label: &'static str, error: &str) -> String { + format!("{label} task failed: {error}") +} + +/// Converts a VCS event into the output-log level and message sent to the UI. +fn progress_message_for_event(evt: VcsEvent) -> (OutputLevel, String) { + match evt { + VcsEvent::Progress { detail, .. } => (OutputLevel::Info, detail), + VcsEvent::RemoteMessage { msg } => (OutputLevel::Info, msg), + VcsEvent::Auth { method, detail } => { + (OutputLevel::Info, format!("auth[{method}]: {detail}")) + } + VcsEvent::PushStatus { refname, status } => ( + OutputLevel::Info, + status + .map(|value| format!("{refname} → {value}")) + .unwrap_or_else(|| format!("{refname} ok")), + ), + VcsEvent::Info { msg } => (OutputLevel::Info, msg), + VcsEvent::Warning { msg } => (OutputLevel::Warn, msg), + VcsEvent::Error { msg } => (OutputLevel::Error, msg), + } +} + +/// Returns the message shown when the active backend disappears during an operation. +fn backend_unavailable_message(backend_id: &str) -> String { + format!( + "Backend `{backend_id}` is no longer available (plugin disabled?). Reopen the repository." + ) +} + #[derive(serde::Serialize, Clone)] /// Generic progress event payload sent to the UI. pub struct ProgressPayload { @@ -27,22 +59,7 @@ pub struct ProgressPayload { /// - An [`OnEvent`] callback compatible with backend VCS operations. pub(crate) fn progress_bridge(app: AppHandle) -> OnEvent { Arc::new(move |evt| { - let (level, msg) = match evt { - VcsEvent::Progress { detail, .. } => (OutputLevel::Info, detail), - VcsEvent::RemoteMessage { msg } => (OutputLevel::Info, msg), - VcsEvent::Auth { method, detail } => { - (OutputLevel::Info, format!("auth[{method}]: {detail}")) - } - VcsEvent::PushStatus { refname, status } => ( - OutputLevel::Info, - status - .map(|s| format!("{refname} → {s}")) - .unwrap_or_else(|| format!("{refname} ok")), - ), - VcsEvent::Info { msg } => (OutputLevel::Info, msg), - VcsEvent::Warning { msg } => (OutputLevel::Warn, msg), - VcsEvent::Error { msg } => (OutputLevel::Error, msg), - }; + let (level, msg) = progress_message_for_event(evt); let ts_ms = time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000; let entry = OutputLogEntry::new(ts_ms as i64, level, "vcs", msg.clone()); @@ -83,10 +100,7 @@ pub(crate) fn current_repo_or_err(state: &State<'_, AppState>) -> Result) -> String { + message.unwrap_or_else(|| "WIP".to_string()) +} + +/// Returns whether untracked files should be included in the stash command. +fn include_untracked_or_default(include_untracked: Option) -> bool { + include_untracked.unwrap_or(true) +} + +/// Normalizes optional stash path filters into owned path buffers. +fn stash_paths(paths: Option>) -> Vec { + paths + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect() +} + +/// Normalizes optional stash selectors to the backend's empty-string convention. +fn stash_selector_or_default(selector: Option) -> String { + selector.unwrap_or_default() +} + #[tauri::command] /// Lists stash entries for the current repository. /// @@ -61,13 +85,9 @@ pub async fn vcs_stash_push( paths: Option>, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let msg = message.unwrap_or_else(|| "WIP".to_string()); - let iu = include_untracked.unwrap_or(true); - let pathbufs: Vec = paths - .unwrap_or_default() - .into_iter() - .map(PathBuf::from) - .collect(); + let msg = stash_message_or_default(message); + let iu = include_untracked_or_default(include_untracked); + let pathbufs = stash_paths(paths); run_repo_task("vcs_stash_push", repo, move |repo| { repo.inner() .stash_push(&msg, iu, &pathbufs) @@ -91,7 +111,7 @@ pub async fn vcs_stash_apply( selector: Option, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_apply", repo, move |repo| { repo.inner() .stash_apply(selector.as_str()) @@ -115,7 +135,7 @@ pub async fn vcs_stash_pop( selector: Option, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_pop", repo, move |repo| { repo.inner() .stash_pop(selector.as_str()) @@ -139,7 +159,7 @@ pub async fn vcs_stash_drop( selector: Option, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_drop", repo, move |repo| { info!("vcs_stash_drop: selector='{}'", selector); match repo.inner().stash_drop(selector.as_str()) { @@ -171,7 +191,7 @@ pub async fn vcs_stash_show( selector: Option, ) -> Result, String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_show", repo, move |repo| { repo.inner() .stash_show(selector.as_str()) @@ -179,3 +199,8 @@ pub async fn vcs_stash_show( }) .await } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/stash.rs"); +} diff --git a/Backend/src/tauri_commands/themes.rs b/Backend/src/tauri_commands/themes.rs index 2d85fe5e..5edf8ece 100644 --- a/Backend/src/tauri_commands/themes.rs +++ b/Backend/src/tauri_commands/themes.rs @@ -4,6 +4,29 @@ use crate::{plugins, settings, state::AppState, themes}; use std::collections::HashSet; use tauri::State; +/// Normalizes an optional plugin id into the lowercase identifier used by theme filtering. +fn normalize_plugin_id(plugin_id: Option<&str>) -> String { + plugin_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default() +} + +/// Returns whether a theme should be visible for the current enabled-plugin set. +fn theme_allowed_for_enabled_plugins( + source: &themes::ThemeSource, + plugin_id: Option<&str>, + enabled_plugins: &HashSet, +) -> bool { + if !matches!(source, themes::ThemeSource::Plugin) { + return true; + } + + let plugin_id = normalize_plugin_id(plugin_id); + !plugin_id.is_empty() && enabled_plugins.contains(&plugin_id) +} + /// Computes set of enabled plugin ids based on settings and plugin defaults. /// /// # Parameters @@ -39,16 +62,8 @@ pub fn list_themes(state: State<'_, AppState>) -> Vec { themes::list_themes() .into_iter() - .filter(|t| { - if !matches!(t.source, themes::ThemeSource::Plugin) { - return true; - } - let plugin_id = t - .plugin_id - .as_ref() - .map(|s| s.trim().to_ascii_lowercase()) - .unwrap_or_default(); - !plugin_id.is_empty() && enabled.contains(&plugin_id) + .filter(|theme| { + theme_allowed_for_enabled_plugins(&theme.source, theme.plugin_id.as_deref(), &enabled) }) .collect() } @@ -68,14 +83,13 @@ pub fn load_theme(state: State<'_, AppState>, id: String) -> Result, id: String) -> Result, } +/// Builds an empty updater response when no update is available. +fn no_update_status() -> UpdateStatus { + UpdateStatus { + available: false, + version: None, + current_version: None, + body: None, + date: None, + } +} + +/// Builds a serializable updater payload from resolved update fields. +fn available_update_status( + version: String, + current_version: String, + body: Option, + date: Option, +) -> UpdateStatus { + UpdateStatus { + available: true, + version: Some(version), + current_version: Some(current_version), + body, + date, + } +} + +/// Calculates integer download progress percentages while guarding zero totals. +fn download_progress_percent(received: u64, total: u64) -> u32 { + if total > 0 { + (received as f64 / total as f64 * 100.0) as u32 + } else { + 0 + } +} + +/// Builds the updater progress payload emitted to the frontend. +fn progress_payload(received: u64, total: u64) -> serde_json::Value { + serde_json::json!({ + "kind": "progress", + "received": received, + "total": total, + }) +} + #[tauri::command] /// Checks for available updates and returns detailed status. /// @@ -34,26 +79,19 @@ pub async fn get_update_status(window: Window) -> Result { let date_str = update.date.map(|d| d.to_string()); - let status = UpdateStatus { - available: true, - version: Some(update.version.clone()), - current_version: Some(update.current_version.clone()), - body: update.body.clone(), - date: date_str, - }; + let status = available_update_status( + update.version.clone(), + update.current_version.clone(), + update.body.clone(), + date_str, + ); debug!( "get_update_status: update available: {} -> {}", update.current_version, update.version ); Ok(status) } - Ok(None) => Ok(UpdateStatus { - available: false, - version: None, - current_version: None, - body: None, - date: None, - }), + Ok(None) => Ok(no_update_status()), Err(e) => { error!("get_update_status: check failed: {}", e); Err(e.to_string()) @@ -107,20 +145,12 @@ pub async fn updater_install_now(window: Window) -> Result<(), St .download_and_install( |received, total| { let total_val = total.unwrap_or(0); - let percent = if total_val > 0 { - (received as f64 / total_val as f64 * 100.0) as u32 - } else { - 0 - }; + let percent = download_progress_percent(received as u64, total_val); trace!( "updater_install_now: download progress {}/{} bytes ({}%)", received, total_val, percent ); - let payload = serde_json::json!({ - "kind": "progress", - "received": received, - "total": total_val - }); + let payload = progress_payload(received as u64, total_val); if let Err(e) = app2.emit("update:progress", payload) { log::warn!("updater_install_now: failed to emit progress: {e}"); } @@ -162,3 +192,8 @@ pub async fn updater_install_now(window: Window) -> Result<(), St } } } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/updater.rs"); +} diff --git a/Backend/tests/modules/config_watcher.rs b/Backend/tests/modules/config_watcher.rs index f77020e9..9ea0bf4f 100644 --- a/Backend/tests/modules/config_watcher.rs +++ b/Backend/tests/modules/config_watcher.rs @@ -3,24 +3,55 @@ use super::{begin_config_reload, event_targets_config}; use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::Duration; +fn reload_lock() -> std::sync::MutexGuard<'static, ()> { + static RELOAD_LOCK: OnceLock> = OnceLock::new(); + RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("reload lock") +} + #[test] /// Verifies watcher path matching covers config file and temp sibling names. fn matches_config_and_temp_paths() { + let _lock = reload_lock(); let config = PathBuf::from("/tmp/openvcs/config.toml"); assert!(event_targets_config(std::slice::from_ref(&config), &config)); assert!(event_targets_config(&[PathBuf::from("/tmp/openvcs/config.toml.tmp")], &config)); assert!(!event_targets_config(&[PathBuf::from("/tmp/openvcs/other.toml")], &config)); } +#[test] +fn ignores_events_when_config_file_name_is_missing() { + let _lock = reload_lock(); + let config = PathBuf::from("/"); + assert!(!event_targets_config(&[PathBuf::from("/tmp/openvcs/config.toml")], &config)); +} + #[test] /// Verifies reload debounce blocks back-to-back triggers and then clears. fn debounces_reload_start() { + let _lock = reload_lock(); + thread::sleep(Duration::from_millis(260)); let guard = begin_config_reload().expect("first reload should begin"); assert!(begin_config_reload().is_none()); drop(guard); thread::sleep(Duration::from_millis(260)); assert!(begin_config_reload().is_some()); } + +#[test] +fn in_progress_reload_blocks_parallel_start_until_guard_drops() { + let _lock = reload_lock(); + thread::sleep(Duration::from_millis(260)); + let guard = begin_config_reload().expect("first reload should begin"); + assert!(begin_config_reload().is_none()); + drop(guard); + thread::sleep(Duration::from_millis(260)); + let second_guard = begin_config_reload().expect("reload should restart after drop"); + drop(second_guard); +} diff --git a/Backend/tests/modules/lib.rs b/Backend/tests/modules/lib.rs new file mode 100644 index 00000000..0e87c894 --- /dev/null +++ b/Backend/tests/modules/lib.rs @@ -0,0 +1,42 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::path::PathBuf; + +use super::{first_existing_recent_repo, local_dotenv_path, resolve_preferred_backend_id}; +use crate::core::BackendId; + +#[test] +fn builds_local_dotenv_path_relative_to_workspace_root() { + let path = local_dotenv_path(); + assert!(path.ends_with(PathBuf::from("../.env"))); +} + +#[test] +fn prefers_configured_backend_when_available() { + let available = vec![BackendId::from("openvcs.git"), BackendId::from("zeta")]; + let resolved = resolve_preferred_backend_id("openvcs.git", &available); + assert_eq!(resolved.map(|backend| backend.as_ref().to_string()), Some("openvcs.git".into())); +} + +#[test] +fn falls_back_to_sorted_backend_when_default_missing() { + let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; + let resolved = resolve_preferred_backend_id("missing", &available); + assert_eq!(resolved.map(|backend| backend.as_ref().to_string()), Some("alpha".into())); +} + +#[test] +fn finds_first_existing_recent_repo() { + let temp = tempfile::tempdir().expect("temp dir"); + let existing = temp.path().join("repo"); + std::fs::create_dir(&existing).expect("create repo dir"); + + let selected = first_existing_recent_repo(&[ + temp.path().join("missing"), + existing.clone(), + temp.path().join("later"), + ]); + + assert_eq!(selected, Some(existing)); +} diff --git a/Backend/tests/modules/monitoring.rs b/Backend/tests/modules/monitoring.rs index cbae95c7..fefdf5e2 100644 --- a/Backend/tests/modules/monitoring.rs +++ b/Backend/tests/modules/monitoring.rs @@ -1,7 +1,11 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{parse_frontend_level, parse_frontend_location, parse_frontend_stacktrace}; +use super::{ + backend_monitoring_allowed, normalize_env_value, parse_frontend_level, + parse_frontend_location, parse_frontend_stacktrace, +}; +use crate::settings::AppConfig; #[test] fn parses_frontend_location_suffix() { @@ -29,3 +33,28 @@ fn maps_frontend_levels_to_sentry_levels() { assert_eq!(parse_frontend_level("error"), sentry::Level::Error); assert_eq!(parse_frontend_level("other"), sentry::Level::Debug); } + +#[test] +fn normalizes_environment_values() { + assert_eq!(normalize_env_value(Some(" desktop ".into())), Some("desktop".into())); + assert_eq!(normalize_env_value(Some(" ".into())), None); + assert_eq!(normalize_env_value(None), None); +} + +#[test] +fn honors_backend_crash_report_consent() { + let mut cfg = AppConfig::default(); + cfg.general.crash_reports = true; + assert!(backend_monitoring_allowed(&cfg)); + + cfg.general.crash_reports = false; + assert!(!backend_monitoring_allowed(&cfg)); +} + +#[test] +fn skips_empty_or_invalid_frontend_stack_data() { + assert!(parse_frontend_stacktrace(None).is_none()); + assert!(parse_frontend_stacktrace(Some(" ")).is_none()); + assert_eq!(parse_frontend_location("not-a-location"), None); + assert_eq!(parse_frontend_location(":12:34"), None); +} diff --git a/Backend/tests/modules/plugin_paths.rs b/Backend/tests/modules/plugin_paths.rs index 99c89f30..9f8e63e8 100644 --- a/Backend/tests/modules/plugin_paths.rs +++ b/Backend/tests/modules/plugin_paths.rs @@ -1,7 +1,11 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{bundled_node_candidate_paths, ensure_dir, node_executable_path, set_node_executable_path, set_node_runtime_resource_dir}; +use super::{ + built_in_plugin_dirs, bundled_node_candidate_paths, ensure_dir, node_executable_path, + set_node_executable_path, set_resource_dir, set_node_runtime_resource_dir, +}; +use std::fs; #[test] /// Verifies directory creation helper creates nested paths. @@ -29,3 +33,21 @@ fn stores_node_executable_path() { set_node_executable_path(path.clone()); assert_eq!(node_executable_path(), Some(path)); } + +#[test] +fn lists_only_built_in_plugin_dirs_with_package_manifests() { + let dir = tempfile::tempdir().expect("create temp dir"); + let built_in_root = dir.path().join("built-in-plugins"); + let valid_plugin = built_in_root.join("plugin-a"); + let invalid_plugin = built_in_root.join("plugin-b"); + + fs::create_dir_all(&valid_plugin).expect("create valid plugin dir"); + fs::create_dir_all(&invalid_plugin).expect("create invalid plugin dir"); + fs::write(valid_plugin.join("package.json"), "{}\n").expect("write package manifest"); + + set_resource_dir(dir.path().to_path_buf()); + let plugin_dirs = built_in_plugin_dirs(); + + assert!(plugin_dirs.iter().any(|path| path == &valid_plugin)); + assert!(!plugin_dirs.iter().any(|path| path == &invalid_plugin)); +} diff --git a/Backend/tests/tauri_commands/backends.rs b/Backend/tests/tauri_commands/backends.rs new file mode 100644 index 00000000..a66361ac --- /dev/null +++ b/Backend/tests/tauri_commands/backends.rs @@ -0,0 +1,45 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::core::BackendId; + +use super::{auto_default_backend_id, backend_display_label}; + +#[test] +fn picks_backend_display_labels_from_backend_name_plugin_name_or_id() { + let backend_id = BackendId::from("openvcs.git"); + + assert_eq!( + backend_display_label(Some(" Git "), Some("Plugin"), &backend_id), + "Git" + ); + assert_eq!( + backend_display_label(None, Some(" Plugin Git "), &backend_id), + "Plugin Git" + ); + assert_eq!(backend_display_label(None, None, &backend_id), "openvcs.git"); +} + +#[test] +fn auto_selects_the_only_backend_when_default_differs() { + let selected = auto_default_backend_id( + "other", + &[("openvcs.git".into(), "Git".into())], + ); + assert_eq!(selected, Some("openvcs.git".into())); +} + +#[test] +fn skips_auto_selection_when_backend_is_already_default_or_not_unique() { + assert_eq!( + auto_default_backend_id("openvcs.git", &[("openvcs.git".into(), "Git".into())]), + None + ); + assert_eq!( + auto_default_backend_id( + "", + &[("git".into(), "Git".into()), ("hg".into(), "Hg".into())], + ), + None + ); +} diff --git a/Backend/tests/tauri_commands/branches.rs b/Backend/tests/tauri_commands/branches.rs index a4f9f0e7..82ee5bd9 100644 --- a/Backend/tests/tauri_commands/branches.rs +++ b/Backend/tests/tauri_commands/branches.rs @@ -7,8 +7,10 @@ use super::{apply_merge_template, repo_name_from_origin, repo_username_from_orig fn parses_repo_owner_and_name_from_urls() { assert_eq!(repo_username_from_origin("https://example.com/org/repo.git"), Some("org".into())); assert_eq!(repo_username_from_origin("git@example.com:org/repo.git"), Some("org".into())); + assert_eq!(repo_username_from_origin("http://example.com/team/repo"), Some("team".into())); assert_eq!(repo_name_from_origin("https://example.com/org/repo.git"), Some("repo".into())); assert_eq!(repo_name_from_origin("git@example.com:org/repo"), Some("repo".into())); + assert_eq!(repo_name_from_origin("http://example.com/team/repo"), Some("repo".into())); } #[test] @@ -22,3 +24,23 @@ fn expands_merge_templates() { ); assert_eq!(rendered, "Merge feature into main for alice/demo"); } + +#[test] +fn rejects_empty_or_unparseable_origin_urls() { + assert_eq!(repo_username_from_origin(" "), None); + assert_eq!(repo_username_from_origin("owner-only"), None); + assert_eq!(repo_name_from_origin(" "), None); + assert_eq!(repo_name_from_origin("owner-only"), None); +} + +#[test] +fn leaves_unknown_merge_placeholders_untouched() { + let rendered = apply_merge_template( + "Merge {branch:source} into {repo:unknown}", + "feature", + "main", + "demo", + "alice", + ); + assert_eq!(rendered, "Merge feature into {repo:unknown}"); +} diff --git a/Backend/tests/tauri_commands/commit.rs b/Backend/tests/tauri_commands/commit.rs index ad3091c9..68dd6bfe 100644 --- a/Backend/tests/tauri_commands/commit.rs +++ b/Backend/tests/tauri_commands/commit.rs @@ -28,3 +28,25 @@ fn trims_non_empty_inputs_or_reports_errors() { assert_eq!(trimmed_non_empty("git", "bad").expect("trimmed"), "git"); assert_eq!(trimmed_non_empty(" ", "bad").expect_err("error"), "bad"); } + +#[test] +fn preserves_multiline_commit_descriptions() { + assert_eq!( + build_commit_message("Summary", "Line one\nLine two"), + "Summary\n\nLine one\nLine two" + ); +} + +#[test] +fn reports_empty_commit_selection_only_when_all_inputs_are_empty() { + assert!(!has_commit_selection("\n\t", 0, 0)); + assert!(has_commit_selection("", 0, 2)); +} + +#[test] +fn returns_the_supplied_error_for_blank_inputs() { + assert_eq!( + trimmed_non_empty(" \n ", "summary required").expect_err("blank"), + "summary required" + ); +} diff --git a/Backend/tests/tauri_commands/general.rs b/Backend/tests/tauri_commands/general.rs index a91bcae4..8b0e7441 100644 --- a/Backend/tests/tauri_commands/general.rs +++ b/Backend/tests/tauri_commands/general.rs @@ -1,7 +1,12 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::infer_repo_dir_from_url; +use std::path::Path; + +use super::{ + browse_directory_title, infer_repo_dir_from_url, recent_repo_name, resolve_default_backend_id, +}; +use crate::core::BackendId; #[test] fn infers_repo_directory_names() { @@ -9,3 +14,30 @@ fn infers_repo_directory_names() { assert_eq!(infer_repo_dir_from_url("git@example.com:org/repo"), "repo"); assert_eq!(infer_repo_dir_from_url("https://example.com/org/repo/"), "repo"); } + +#[test] +fn resolves_browse_directory_titles() { + assert_eq!(browse_directory_title(Some("clone_dest")), "Choose destination folder"); + assert_eq!(browse_directory_title(Some("add_repo")), "Select an existing repository folder"); + assert_eq!(browse_directory_title(Some("other")), "Select a folder"); + assert_eq!(browse_directory_title(None), "Select a folder"); +} + +#[test] +fn resolves_default_backend_from_configured_or_sorted_available_values() { + let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; + + let configured = resolve_default_backend_id("zeta", &available) + .map(|backend| backend.as_ref().to_string()); + assert_eq!(configured, Some("zeta".into())); + + let fallback = resolve_default_backend_id("missing", &available) + .map(|backend| backend.as_ref().to_string()); + assert_eq!(fallback, Some("alpha".into())); +} + +#[test] +fn derives_recent_repository_display_names() { + assert_eq!(recent_repo_name(Path::new("/tmp/demo-repo")), Some("demo-repo".into())); + assert_eq!(recent_repo_name(Path::new("/")), None); +} diff --git a/Backend/tests/tauri_commands/monitoring.rs b/Backend/tests/tauri_commands/monitoring.rs new file mode 100644 index 00000000..69fab8a4 --- /dev/null +++ b/Backend/tests/tauri_commands/monitoring.rs @@ -0,0 +1,29 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::monitoring::{FrontendBreadcrumb, FrontendErrorReport}; + +use super::report_frontend_error; + +#[test] +fn accepts_frontend_error_payloads_without_an_active_sentry_client() { + let payload = FrontendErrorReport { + kind: "error".into(), + message: "boom".into(), + stack: Some("Error: boom".into()), + source: Some("app://index.js".into()), + line: Some(12), + column: Some(34), + url: Some("app://openvcs".into()), + user_agent: Some("OpenVCS Test".into()), + release: Some("test-release".into()), + environment: Some("test".into()), + breadcrumbs: vec![FrontendBreadcrumb { + timestamp_ms: 1, + level: "info".into(), + message: "before failure".into(), + }], + }; + + assert!(report_frontend_error(payload).is_ok()); +} diff --git a/Backend/tests/tauri_commands/remotes.rs b/Backend/tests/tauri_commands/remotes.rs index c499a4e4..1f225b97 100644 --- a/Backend/tests/tauri_commands/remotes.rs +++ b/Backend/tests/tauri_commands/remotes.rs @@ -11,6 +11,7 @@ fn parses_host_from_remote_urls() { assert_eq!(host_from_remote_url("git@github.com:org/repo.git"), Some("github.com".into())); assert_eq!(host_from_remote_url("ssh://git@example.com/org/repo"), Some("example.com".into())); assert_eq!(host_from_remote_url("https://example.com/org/repo"), Some("example.com".into())); + assert_eq!(host_from_remote_url("http://insecure.example.com/org/repo"), Some("insecure.example.com".into())); } #[test] @@ -25,6 +26,7 @@ fn detects_unknown_host_key_errors() { assert!(looks_like_unknown_host_key( "The authenticity of host 'github.com (140.82.121.4)' can't be established." )); + assert!(looks_like_unknown_host_key("Strict host key checking failed")); assert!(!looks_like_unknown_host_key("permission denied (publickey)")); } @@ -40,5 +42,6 @@ fn detects_fast_forward_only_divergence() { assert!(looks_like_ff_only_divergence( "fatal: Not possible to fast-forward, aborting." )); + assert!(looks_like_ff_only_divergence("Cannot be fast-forwarded because branches diverged")); assert!(!looks_like_ff_only_divergence("permission denied (publickey)")); } diff --git a/Backend/tests/tauri_commands/shared.rs b/Backend/tests/tauri_commands/shared.rs new file mode 100644 index 00000000..13578a82 --- /dev/null +++ b/Backend/tests/tauri_commands/shared.rs @@ -0,0 +1,66 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + backend_unavailable_message, format_task_failure, progress_message_for_event, +}; +use crate::core::models::VcsEvent; +use crate::output_log::OutputLevel; + +#[test] +fn formats_task_failure_messages() { + assert_eq!( + format_task_failure("hydrate", "join error"), + "hydrate task failed: join error" + ); +} + +#[test] +fn maps_progress_events_to_info_messages() { + let (level, message) = progress_message_for_event(VcsEvent::Progress { + phase: "fetch".into(), + detail: "Fetching origin".into(), + }); + assert!(matches!(level, OutputLevel::Info)); + assert_eq!(message, "Fetching origin"); +} + +#[test] +fn maps_auth_and_push_status_events() { + let (auth_level, auth_message) = progress_message_for_event(VcsEvent::Auth { + method: "ssh".into(), + detail: "prompting".into(), + }); + assert!(matches!(auth_level, OutputLevel::Info)); + assert_eq!(auth_message, "auth[ssh]: prompting"); + + let (push_level, push_message) = progress_message_for_event(VcsEvent::PushStatus { + refname: "refs/heads/main".into(), + status: Some("updated".into()), + }); + assert!(matches!(push_level, OutputLevel::Info)); + assert_eq!(push_message, "refs/heads/main → updated"); +} + +#[test] +fn maps_warning_and_error_events() { + let (warn_level, warn_message) = progress_message_for_event(VcsEvent::Warning { + msg: "be careful".into(), + }); + assert!(matches!(warn_level, OutputLevel::Warn)); + assert_eq!(warn_message, "be careful"); + + let (error_level, error_message) = progress_message_for_event(VcsEvent::Error { + msg: "failed".into(), + }); + assert!(matches!(error_level, OutputLevel::Error)); + assert_eq!(error_message, "failed"); +} + +#[test] +fn renders_backend_unavailable_message() { + assert_eq!( + backend_unavailable_message("openvcs.git"), + "Backend `openvcs.git` is no longer available (plugin disabled?). Reopen the repository." + ); +} diff --git a/Backend/tests/tauri_commands/ssh.rs b/Backend/tests/tauri_commands/ssh.rs index 43e18027..b330b66d 100644 --- a/Backend/tests/tauri_commands/ssh.rs +++ b/Backend/tests/tauri_commands/ssh.rs @@ -9,8 +9,17 @@ use std::env; use std::ffi::OsString; use std::fs; use std::path::Path; +use std::sync::{Mutex, OnceLock}; use tempfile::tempdir; +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock") +} + struct EnvGuard { home: Option, path: Option, @@ -58,6 +67,7 @@ fn make_executable(path: &Path) { #[test] fn resolves_known_hosts_and_ssh_dir_from_home() { + let _lock = env_lock(); let home = tempdir().expect("tempdir"); set_test_home_dir(home.path().to_path_buf()); @@ -71,6 +81,7 @@ fn resolves_known_hosts_and_ssh_dir_from_home() { #[test] fn detects_executable_files_and_resolves_from_path() { + let _lock = env_lock(); let _guard = EnvGuard::capture(); let dir = tempdir().expect("tempdir"); let exe = dir.path().join("tool"); @@ -84,18 +95,23 @@ fn detects_executable_files_and_resolves_from_path() { #[test] fn resolves_ssh_askpass_from_environment() { + let _lock = env_lock(); let _guard = EnvGuard::capture(); let dir = tempdir().expect("tempdir"); let askpass = dir.path().join("askpass"); fs::write(&askpass, "#!/bin/sh\nexit 0\n").expect("write askpass"); make_executable(&askpass); - unsafe { env::set_var("SSH_ASKPASS", &askpass) }; + unsafe { + env::set_var("PATH", dir.path()); + env::set_var("SSH_ASKPASS", "askpass"); + }; assert_eq!(resolve_ssh_askpass(), Some(askpass)); } #[test] fn lists_private_key_candidates_from_ssh_dir() { + let _lock = env_lock(); let _guard = EnvGuard::capture(); let ssh_dir = tempdir().expect("tempdir"); diff --git a/Backend/tests/tauri_commands/stash.rs b/Backend/tests/tauri_commands/stash.rs new file mode 100644 index 00000000..328b319e --- /dev/null +++ b/Backend/tests/tauri_commands/stash.rs @@ -0,0 +1,32 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::path::PathBuf; + +use super::{ + include_untracked_or_default, stash_message_or_default, stash_paths, + stash_selector_or_default, +}; + +#[test] +fn defaults_stash_message_and_include_untracked() { + assert_eq!(stash_message_or_default(None), "WIP"); + assert_eq!(stash_message_or_default(Some("Save work".into())), "Save work"); + assert!(include_untracked_or_default(None)); + assert!(!include_untracked_or_default(Some(false))); +} + +#[test] +fn converts_optional_stash_paths() { + assert_eq!(stash_paths(None), Vec::::new()); + assert_eq!( + stash_paths(Some(vec!["src/lib.rs".into(), "README.md".into()])), + vec![PathBuf::from("src/lib.rs"), PathBuf::from("README.md")] + ); +} + +#[test] +fn defaults_missing_selectors_to_empty_strings() { + assert_eq!(stash_selector_or_default(None), ""); + assert_eq!(stash_selector_or_default(Some("stash@{1}".into())), "stash@{1}"); +} diff --git a/Backend/tests/tauri_commands/themes.rs b/Backend/tests/tauri_commands/themes.rs new file mode 100644 index 00000000..cf5219c4 --- /dev/null +++ b/Backend/tests/tauri_commands/themes.rs @@ -0,0 +1,43 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::collections::HashSet; + +use crate::themes::ThemeSource; + +use super::{normalize_plugin_id, theme_allowed_for_enabled_plugins}; + +#[test] +fn normalizes_plugin_ids_for_theme_filtering() { + assert_eq!(normalize_plugin_id(Some(" OpenVCS.Git ")), "openvcs.git"); + assert_eq!(normalize_plugin_id(Some(" ")), ""); + assert_eq!(normalize_plugin_id(None), ""); +} + +#[test] +fn always_allows_non_plugin_themes() { + let enabled = HashSet::new(); + assert!(theme_allowed_for_enabled_plugins(&ThemeSource::BuiltIn, None, &enabled)); + assert!(theme_allowed_for_enabled_plugins(&ThemeSource::User, None, &enabled)); +} + +#[test] +fn only_allows_plugin_themes_from_enabled_plugins() { + let enabled = HashSet::from(["openvcs.git".to_string()]); + + assert!(theme_allowed_for_enabled_plugins( + &ThemeSource::Plugin, + Some(" OpenVCS.Git "), + &enabled, + )); + assert!(!theme_allowed_for_enabled_plugins( + &ThemeSource::Plugin, + Some("openvcs.hg"), + &enabled, + )); + assert!(!theme_allowed_for_enabled_plugins( + &ThemeSource::Plugin, + Some(" "), + &enabled, + )); +} diff --git a/Backend/tests/tauri_commands/updater.rs b/Backend/tests/tauri_commands/updater.rs new file mode 100644 index 00000000..81f5bf96 --- /dev/null +++ b/Backend/tests/tauri_commands/updater.rs @@ -0,0 +1,45 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + available_update_status, download_progress_percent, no_update_status, progress_payload, +}; + +#[test] +fn builds_empty_update_status() { + let status = no_update_status(); + assert!(!status.available); + assert_eq!(status.version, None); + assert_eq!(status.current_version, None); + assert_eq!(status.body, None); + assert_eq!(status.date, None); +} + +#[test] +fn builds_available_update_status() { + let status = available_update_status( + "2.0.0".into(), + "1.0.0".into(), + Some("notes".into()), + Some("2026-05-29".into()), + ); + assert!(status.available); + assert_eq!(status.version.as_deref(), Some("2.0.0")); + assert_eq!(status.current_version.as_deref(), Some("1.0.0")); + assert_eq!(status.body.as_deref(), Some("notes")); + assert_eq!(status.date.as_deref(), Some("2026-05-29")); +} + +#[test] +fn calculates_download_progress_percentages() { + assert_eq!(download_progress_percent(25, 100), 25); + assert_eq!(download_progress_percent(1, 0), 0); +} + +#[test] +fn builds_progress_payloads() { + let payload = progress_payload(12, 40); + assert_eq!(payload["kind"], "progress"); + assert_eq!(payload["received"], 12); + assert_eq!(payload["total"], 40); +} diff --git a/Frontend/src/scripts/features/commandSheet.test.ts b/Frontend/src/scripts/features/commandSheet.test.ts new file mode 100644 index 00000000..044ce982 --- /dev/null +++ b/Frontend/src/scripts/features/commandSheet.test.ts @@ -0,0 +1,275 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/tauri', () => ({ + TAURI: { + invoke: vi.fn(), + }, +})); + +vi.mock('../lib/notify', () => ({ + notify: vi.fn(), +})); + +vi.mock('../ui/modals', () => ({ + openModal: vi.fn(), + closeModal: vi.fn(), + hydrate: vi.fn(), +})); + +vi.mock('./repoSelection', () => ({ + refreshRepoSummary: vi.fn().mockResolvedValue(undefined), +})); + +class ResizeObserverMock { + observe = vi.fn(); + disconnect = vi.fn(); +} + +class MutationObserverMock { + constructor(private readonly callback: MutationCallback) {} + + observe = vi.fn(() => { + this.callback([] as MutationRecord[], this as unknown as MutationObserver); + }); + + disconnect = vi.fn(); +} + +function mountCommandModal() { + document.body.innerHTML = ` +
      +
      +
      + + +
      +
      +
      + + + + +
      + +
      + `; + + const seg = document.querySelector('.seg') as HTMLElement; + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLElement; + const addTab = document.querySelector('[data-sheet="add"]') as HTMLElement; + seg.getBoundingClientRect = vi.fn(() => ({ left: 10, width: 200 }) as DOMRect); + cloneTab.getBoundingClientRect = vi.fn(() => ({ left: 14, width: 80 }) as DOMRect); + addTab.getBoundingClientRect = vi.fn(() => ({ left: 100, width: 70 }) as DOMRect); +} + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + mountCommandModal(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.MutationObserver = MutationObserverMock as unknown as typeof MutationObserver; +}); + +describe('bindCommandSheet', () => { + it('validates clone inputs and surfaces backend rejection reasons', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + vi.mocked(TAURI.invoke).mockResolvedValueOnce({ ok: false, reason: 'Bad clone input' }); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneUrl = document.getElementById('clone-url') as HTMLInputElement; + cloneUrl.value = 'https://example.com/repo.git'; + cloneUrl.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + + expect(TAURI.invoke).toHaveBeenCalledWith('validate_clone_input', { + url: 'https://example.com/repo.git', + dest: '', + }); + expect(document.getElementById('do-clone')).toHaveProperty('disabled', true); + expect(notify).toHaveBeenCalledWith('Bad clone input'); + }); + + it('switches tabs via keyboard navigation and updates panel visibility', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + + const addTab = document.querySelector('[data-sheet="add"]') as HTMLButtonElement; + expect(addTab.classList.contains('active')).toBe(true); + expect(addTab.getAttribute('aria-selected')).toBe('true'); + expect(document.getElementById('sheet-clone')?.classList.contains('hidden')).toBe(true); + expect(document.getElementById('sheet-add')?.classList.contains('hidden')).toBe(false); + }); + + it('ignores unsupported keyboard shortcuts on the segment control', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + + expect(cloneTab.classList.contains('active')).toBe(true); + }); + + it('opens the requested sheet and focuses the first relevant input', async () => { + const { openModal } = await import('../ui/modals'); + const focusSpy = vi.spyOn(document.getElementById('add-path') as HTMLInputElement, 'focus'); + const { openSheet } = await import('./commandSheet'); + + openSheet('add'); + vi.runAllTimers(); + + expect(openModal).toHaveBeenCalledWith('command-modal'); + expect(document.querySelector('[data-sheet="add"]')?.classList.contains('active')).toBe(true); + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('runs clone flow, refreshes summary, and closes the modal', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + const { refreshRepoSummary } = await import('./repoSelection'); + vi.mocked(TAURI.invoke) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce(undefined); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('clone-url') as HTMLInputElement).value = 'https://example.com/repo.git'; + (document.getElementById('clone-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-clone') as HTMLButtonElement).disabled = false; + (document.getElementById('do-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(TAURI.invoke).toHaveBeenCalledWith('clone_repo', { + url: 'https://example.com/repo.git', + dest: '/tmp/repo', + }); + expect(refreshRepoSummary).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Cloned https://example.com/repo.git → /tmp/repo'); + expect(closeModal).toHaveBeenCalledWith('command-modal'); + }); + + it('notifies when add fails', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('nope')); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-add') as HTMLButtonElement).disabled = false; + (document.getElementById('do-add') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(TAURI.invoke).toHaveBeenCalledWith('add_repo', { path: '/tmp/repo' }); + expect(notify).toHaveBeenCalledWith('Add failed'); + }); + + it('runs add flow successfully and closes the modal', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + const { refreshRepoSummary } = await import('./repoSelection'); + vi.mocked(TAURI.invoke).mockResolvedValueOnce(undefined); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-add') as HTMLButtonElement).disabled = false; + (document.getElementById('do-add') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(refreshRepoSummary).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Added /tmp/repo'); + expect(closeModal).toHaveBeenCalledWith('command-modal'); + }); + + it('disables actions when validation commands reject', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke) + .mockRejectedValueOnce(new Error('clone validation failed')) + .mockRejectedValueOnce(new Error('add validation failed')); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('clone-path') as HTMLInputElement).value = '/tmp/clone'; + (document.getElementById('clone-path') as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true }), + ); + await Promise.resolve(); + + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/add'; + (document.getElementById('add-path') as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true }), + ); + await Promise.resolve(); + + expect(document.getElementById('do-clone')).toHaveProperty('disabled', true); + expect(document.getElementById('do-add')).toHaveProperty('disabled', true); + }); + + it('notifies when clone fails after submission', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('clone failed')); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('clone-url') as HTMLInputElement).value = 'https://example.com/repo.git'; + (document.getElementById('clone-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-clone') as HTMLButtonElement).disabled = false; + (document.getElementById('do-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(notify).toHaveBeenCalledWith('Clone failed'); + }); + + it('populates browse actions and revalidates chosen directories', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke) + .mockResolvedValueOnce('/repos/clone-target') + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce('/repos/existing') + .mockResolvedValueOnce({ ok: true }); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('browse-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + expect((document.getElementById('clone-path') as HTMLInputElement).value).toBe('/repos/clone-target'); + + (document.getElementById('browse-add') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + expect((document.getElementById('add-path') as HTMLInputElement).value).toBe('/repos/existing'); + }); +}); diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index 0889d61a..c49d3de0 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -32,6 +32,10 @@ vi.mock('./repo', () => ({ yieldToPaint: vi.fn(async () => {}), })); +vi.mock('./repo/commit', () => ({ + getCommitSummaryHint: vi.fn(() => ''), +})); + let state: typeof import('../state/state').state; /** Mounts the minimal DOM needed for commit binding. */ @@ -279,6 +283,112 @@ describe('bindCommit', () => { expect(calls[0][1].summary.length).toBeLessThanOrEqual(72); }, { timeout: 3000, interval: 20 }); }); + + it('uses hook-mutated summary and description on successful commit', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + const { runHook } = await import('../plugins'); + const repo = await import('./repo'); + const { notify } = await import('../lib/notify'); + vi.mocked(runHook).mockImplementation(async (name, data: any) => { + if (name === 'preCommit') { + data.summary = ' Updated summary '; + data.description = 'Updated description'; + } + return { cancelled: false } as any; + }); + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitDesc = document.getElementById('commit-desc') as HTMLTextAreaElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Initial summary'; + commitDesc.value = 'Initial description'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find((args: unknown[]) => args[0] === 'commit_patch_and_files'); + expect(commitCall?.[1]).toMatchObject({ + summary: 'Updated summary', + description: 'Updated description', + }); + }); + expect(vi.mocked(repo.hydrateStatus)).toHaveBeenCalled(); + expect(vi.mocked(repo.hydrateCommits)).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Committed to main: Updated summary'); + expect(state.selectedFiles.size).toBe(0); + expect(state.currentFile).toBe(''); + }); + + it('falls back to commit summary hint when the input is blank', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { bindCommit } = await import('./diff'); + const { getCommitSummaryHint } = await import('./repo/commit'); + const { __invoke: invoke } = await import('../lib/tauri') as any; + const { runHook } = await import('../plugins'); + vi.mocked(runHook).mockResolvedValue({ cancelled: false } as any); + vi.mocked(getCommitSummaryHint).mockReturnValue('Hint summary'); + invoke.mockClear(); + + bindCommit(); + (document.getElementById('commit-btn') as HTMLButtonElement).click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find((args: unknown[]) => args[0] === 'commit_patch_and_files'); + expect(commitCall?.[1]).toMatchObject({ summary: 'Hint summary' }); + }); + }); + + it('builds a partial patch from explicit line selections', async () => { + state.selectedFiles = new Set(['file1.txt']); + state.selectedHunksByFile = {} as any; + state.selectedLinesByFile = { 'file1.txt': { 0: [1, 2] } } as any; + state.files = [{ path: 'file1.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return [ + 'diff --git a/file1.txt b/file1.txt', + 'index abc..def 100644', + '--- a/file1.txt', + '+++ b/file1.txt', + '@@ -1,2 +1,2 @@', + '-old', + '+new', + ]; + } + if (cmd === 'commit_patch_and_files') return 'oid-456'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + commitSummary.value = 'Line selection commit'; + bindCommit(); + (document.getElementById('commit-btn') as HTMLButtonElement).click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find((args: unknown[]) => args[0] === 'commit_patch_and_files'); + expect(commitCall?.[1].patch).toContain('@@ -1,1 +1,1 @@'); + expect(commitCall?.[1].patch).toContain('-old'); + expect(commitCall?.[1].patch).toContain('+new'); + }); + }); }); describe('buildPatchForSelectedHunks', () => { diff --git a/Frontend/src/scripts/features/repo/diffView.test.ts b/Frontend/src/scripts/features/repo/diffView.test.ts index 9f7c9ea0..21ad3500 100644 --- a/Frontend/src/scripts/features/repo/diffView.test.ts +++ b/Frontend/src/scripts/features/repo/diffView.test.ts @@ -3,6 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { FileStatus } from '../../types'; +vi.mock('../../lib/menu', () => ({ buildCtxMenu: vi.fn() })); +vi.mock('../../lib/confirm', () => ({ confirmBool: vi.fn(async () => true) })); +vi.mock('../../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('./hydrate', () => ({ hydrateStatus: vi.fn().mockResolvedValue(undefined) })); + /** Provides a minimal `matchMedia` test shim used by state imports. */ function createMatchMediaMock(query: string) { return { matches: false, media: query, addListener: () => {}, removeListener: () => {} }; @@ -351,6 +356,60 @@ describe('selectFile contextmenu', () => { } // No crash test }); + + it('discards the current hunk through the context menu action', async () => { + const { selectFile } = await import('./diffView'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { hydrateStatus } = await import('./hydrate'); + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk') as HTMLElement; + hunkEl.setAttribute('data-hunk-index', '0'); + hunkEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await items.find((item) => item.label === 'Discard hunk')?.action?.(); + + expect((window as any).__TAURI__.core.invoke).toHaveBeenCalledWith('vcs_discard_patch', { + patch: expect.stringContaining('diff --git a/a.txt b/a.txt'), + }); + expect(hydrateStatus).toHaveBeenCalled(); + }); + + it('discards selected hunks across all files', async () => { + const { selectFile } = await import('./diffView'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { hydrateStatus } = await import('./hydrate'); + const { state } = await import('../../state/state'); + + state.selectedHunksByFile = { 'a.txt': [0], 'b.txt': [0] } as any; + (window as any).__TAURI__.core.invoke.mockImplementation(async (cmd: string, args?: Record) => { + if (cmd === 'vcs_diff_file' && args?.path === 'b.txt') { + return ['diff --git a/b.txt b/b.txt', '@@ -1 +1 @@', '-before', '+after']; + } + if (cmd === 'vcs_diff_file') { + return ['diff --git a/a.txt b/a.txt', '@@ -1 +1 @@', '-old', '+new']; + } + return []; + }); + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk') as HTMLElement; + hunkEl.setAttribute('data-hunk-index', '0'); + hunkEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await items.find((item) => item.label === 'Discard selected hunks (all files)')?.action?.(); + + expect((window as any).__TAURI__.core.invoke).toHaveBeenCalledWith('vcs_discard_patch', { + patch: expect.stringContaining('diff --git a/b.txt b/b.txt'), + }); + expect(hydrateStatus).toHaveBeenCalled(); + }); }); describe('selectStashDiff', () => { diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 5ff2940b..58f1e783 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -2,6 +2,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('../../lib/menu', () => ({ buildCtxMenu: vi.fn() })) +vi.mock('../../lib/confirm', () => ({ confirmBool: vi.fn(async () => true) })) +vi.mock('../../lib/notify', () => ({ notify: vi.fn() })) +vi.mock('../../plugins', () => ({ + getPluginContextMenuItems: vi.fn(() => []), + runPluginAction: vi.fn(), +})) +vi.mock('./hydrate', () => ({ + hydrateStatus: vi.fn().mockResolvedValue(undefined), + hydrateCommits: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('./commit', () => ({ updateCommitButton: vi.fn() })) +vi.mock('../cherryPick', () => ({ openCherryPick: vi.fn() })) + /** Provides a minimal `matchMedia` test shim used by history imports. */ function createMatchMediaMock(query: string) { return { matches: false, media: query, addListener: () => {}, removeListener: () => {} } @@ -355,6 +369,123 @@ describe('renderHistoryList', () => { expect(listHtml).toContain('incoming') expect(listHtml).toContain('origin/main') }) + + it('opens commit actions and runs copy, plugin, cherry-pick, revert, and undo actions', async () => { + installTauriMock() + ;(navigator as any).clipboard = { writeText: vi.fn().mockResolvedValue(undefined) } + + const { renderHistoryList } = await loadHistoryModule() + const { state, prefs } = await loadStateModule() + const { buildCtxMenu } = await import('../../lib/menu') + const { getPluginContextMenuItems, runPluginAction } = await import('../../plugins') + const { notify } = await import('../../lib/notify') + const { openCherryPick } = await import('../cherryPick') + const { hydrateStatus, hydrateCommits } = await import('./hydrate') + + prefs.tab = 'history' + vi.mocked(getPluginContextMenuItems).mockReturnValue([{ label: 'Plugin inspect', action: 'plugin.inspect' }]) + ;(window as any).__TAURI__.core.invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') return [] + return undefined + }) + + state.commits = [ + { id: 'abcdef123456', msg: 'Commit 1', meta: new Date().toISOString(), author: 'A', remoteRef: '@{upstream}' } as any, + ] + state.ahead = 1 + state.behind = 0 + state.aheadIds = new Set(['abcdef123456']) + + renderHistoryList('') + const row = document.querySelector('#file-list li.row.commit') as HTMLElement + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20 })) + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || [] + expect(items.map((item) => item.label)).toContain('Copy hash') + expect(items.map((item) => item.label)).toContain('Plugin inspect') + expect(items.map((item) => item.label)).toContain('Cherry-pick to branch…') + expect(items.map((item) => item.label)).toContain('Revert (reverse) commit…') + expect(items.map((item) => item.label)).toContain('Undo to this commit') + + await items.find((item) => item.label === 'Copy hash')?.action?.() + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abcdef123456') + expect(notify).toHaveBeenCalledWith('Hash copied') + + await items.find((item) => item.label === 'Plugin inspect')?.action?.() + expect(runPluginAction).toHaveBeenCalledWith('plugin.inspect', { + commit: state.commits[0], + }) + + await items.find((item) => item.label === 'Cherry-pick to branch…')?.action?.() + expect(openCherryPick).toHaveBeenCalledWith(state.commits[0]) + + await items.find((item) => item.label === 'Revert (reverse) commit…')?.action?.() + expect((window as any).__TAURI__.core.invoke).toHaveBeenCalledWith('vcs_revert_commit', { + id: 'abcdef123456', + }) + expect(hydrateStatus).toHaveBeenCalled() + expect(hydrateCommits).toHaveBeenCalled() + + await items.find((item) => item.label === 'Undo to this commit')?.action?.() + expect((window as any).__TAURI__.core.invoke).toHaveBeenCalledWith('vcs_undo_to_commit', { + id: 'abcdef123456', + }) + }) +}) + +describe('selectHistory', () => { + it('renders file-scoped diffs and supports file-level context actions', async () => { + installTauriMock() + ;(navigator as any).clipboard = { writeText: vi.fn().mockResolvedValue(undefined) } + + const { selectHistory } = await loadHistoryModule() + const { buildCtxMenu } = await import('../../lib/menu') + const { notify } = await import('../../lib/notify') + const { hydrateStatus } = await import('./hydrate') + ;(window as any).__TAURI__.core.invoke = vi.fn(async (cmd: string, args?: Record) => { + if (cmd === 'vcs_diff_commit') { + return [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ] + } + return args?.patch ? undefined : [] + }) + + await selectHistory({ id: 'abc1234', msg: 'Refactor', author: 'Dev' } as any, 0) + + expect(document.querySelector('.commit-files')).not.toBeNull() + const row = document.querySelector('.commit-files .row[data-idx="0"]') as HTMLElement + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 6 })) + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || [] + await items.find((item) => item.label === 'Copy path')?.action?.() + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('src/a.ts') + expect(notify).toHaveBeenCalledWith('Path copied') + + await items.find((item) => item.label === 'Revert this file')?.action?.() + expect((window as any).__TAURI__.core.invoke).toHaveBeenCalledWith('vcs_discard_patch', { + patch: expect.stringContaining('diff --git a/src/a.ts b/src/a.ts'), + }) + expect(hydrateStatus).toHaveBeenCalled() + }) + + it('shows a failed diff message when commit diff loading throws', async () => { + installTauriMock() + ;(window as any).__TAURI__.core.invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') throw new Error('boom') + return [] + }) + + const { selectHistory } = await loadHistoryModule() + await selectHistory({ id: 'abc1234', msg: 'Refactor', author: 'Dev' } as any, 0) + + expect(document.getElementById('diff')?.textContent).toContain('Failed to load diff') + }) }) describe('history hash layout', () => { diff --git a/Frontend/src/scripts/features/repo/hydrate.test.ts b/Frontend/src/scripts/features/repo/hydrate.test.ts index 8eede9cf..073555ce 100644 --- a/Frontend/src/scripts/features/repo/hydrate.test.ts +++ b/Frontend/src/scripts/features/repo/hydrate.test.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('./list', () => ({ renderList: vi.fn() })); +vi.mock('../conflicts', () => ({ autoOpenFirstConflict: vi.fn() })); + /** Provides a minimal `matchMedia` test shim used by state imports. */ function createMatchMediaMock(query: string) { return { matches: false, media: query, addListener: () => {}, removeListener: () => {} }; @@ -32,6 +35,7 @@ function installTauriMock(invoke: (cmd: string) => Promise) { beforeEach(() => { vi.resetModules(); + vi.resetAllMocks(); mountRepoDom(); (globalThis as any).matchMedia = createMatchMediaMock; (globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => window.setTimeout(cb, 0); @@ -114,6 +118,59 @@ describe('hydrateStatus selection reconciliation', () => { expect(state.selectedLinesByFile).toEqual({}); expect(state.diffSelectedFiles.size).toBe(0); }); + + it('selects all current paths when defaultSelectAll is enabled', async () => { + installTauriMock(async (cmd) => { + if (cmd === 'vcs_status') { + return { files: [{ path: 'a.txt', status: 'M' }, { path: 'b.txt', status: 'A' }] }; + } + if (cmd === 'vcs_merge_context') return { in_progress: false }; + return []; + }); + + const { hydrateStatus } = await import('./hydrate'); + const { state } = await import('../../state/state'); + state.defaultSelectAll = true; + state.selectedFiles = new Set(); + + await hydrateStatus(); + + expect(state.selectionImplicitAll).toBe(true); + expect(Array.from(state.selectedFiles).sort()).toEqual(['a.txt', 'b.txt']); + }); + + it('skips rerendering when the status signature is unchanged', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_status') return { files: [{ path: 'same.txt', status: 'M' }], ahead: 1, behind: 0 }; + if (cmd === 'vcs_merge_context') return { in_progress: false }; + return []; + }); + installTauriMock(invoke); + + const { hydrateStatus } = await import('./hydrate'); + const list = await import('./list'); + + await hydrateStatus(); + await hydrateStatus(); + + expect(vi.mocked(list.renderList)).toHaveBeenCalledTimes(1); + }); + + it('tolerates merge-context failures by clearing merge flags', async () => { + installTauriMock(async (cmd) => { + if (cmd === 'vcs_status') return { files: [{ path: 'keep.txt', status: 'M' }] }; + if (cmd === 'vcs_merge_context') throw new Error('merge context failed'); + return []; + }); + + const { hydrateStatus } = await import('./hydrate'); + const { state } = await import('../../state/state'); + + await hydrateStatus(); + + expect(state.mergeInProgress).toBe(false); + expect(Array.from(state.seenConflicts)).toEqual([]); + }); }); describe('yieldToPaint', () => { @@ -235,6 +292,31 @@ describe('hydrateCommits', () => { const { state } = await import('../../state/state'); expect(state.commits).toEqual([]); }); + + it('falls back to origin branch range and populates aheadIds', async () => { + const invoke = vi.fn(async (cmd: string, args?: any) => { + if (cmd === 'vcs_log' && args?.limit === 500) return [{ id: 'base', message: 'local' }]; + if (cmd === 'vcs_log' && args?.rev === 'HEAD..@{upstream}') throw new Error('no upstream'); + if (cmd === 'vcs_log' && args?.rev === 'HEAD..origin/main') return [{ id: 'incoming', message: 'remote' }]; + if (cmd === 'vcs_log' && args?.rev === '@{upstream}..HEAD') return [{ id: 'ahead-1' }, { id: 'ahead-2' }]; + return []; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateCommits } = await import('./hydrate'); + const list = await import('./list'); + const { state, prefs } = await import('../../state/state'); + (state as any).behind = 2; + (state as any).ahead = 2; + state.branch = 'main'; + prefs.tab = 'history'; + + await hydrateCommits(); + + expect(state.commits.map((entry: any) => entry.id)).toEqual(['incoming', 'base']); + expect(Array.from((state as any).aheadIds).sort()).toEqual(['ahead-1', 'ahead-2']); + expect(vi.mocked(list.renderList)).toHaveBeenCalled(); + }); }); describe('hydrateStash', () => { @@ -261,6 +343,23 @@ describe('hydrateStash', () => { const { state } = await import('../../state/state'); expect((state as any).stash).toEqual([]); }); + + it('rerenders the list when the stash tab is active', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_stash_list') return [{ id: 's1', message: 'WIP' }]; + return []; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateStash } = await import('./hydrate'); + const list = await import('./list'); + const { prefs } = await import('../../state/state'); + prefs.tab = 'stash'; + + await hydrateStash(); + + expect(vi.mocked(list.renderList)).toHaveBeenCalled(); + }); }); describe('hydrateVcsActionLabels', () => { @@ -286,6 +385,21 @@ describe('hydrateVcsActionLabels', () => { const { state } = await import('../../state/state'); expect(state.vcsActionLabels).toEqual({}); }); + + it('ignores malformed label pairs and always emits an update event', async () => { + const invoke = vi.fn(async () => [['push', 'Push'], ['broken'], ['', 'Missing key'], ['pull', ' Pull ']]); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const eventSpy = vi.fn(); + window.addEventListener('app:vcs-action-labels-updated', eventSpy, { once: true }); + + const { hydrateVcsActionLabels } = await import('./hydrate'); + const { state } = await import('../../state/state'); + await hydrateVcsActionLabels(); + + expect(state.vcsActionLabels).toEqual({ push: 'Push', pull: 'Pull' }); + expect(eventSpy).toHaveBeenCalledTimes(1); + }); }); describe('pruneSelectionMaps', () => { diff --git a/Frontend/src/scripts/features/repo/interactions.test.ts b/Frontend/src/scripts/features/repo/interactions.test.ts index 73490543..ccc41e70 100644 --- a/Frontend/src/scripts/features/repo/interactions.test.ts +++ b/Frontend/src/scripts/features/repo/interactions.test.ts @@ -15,6 +15,10 @@ vi.mock('../../plugins', () => ({ runPluginAction: vi.fn(), })); vi.mock('../stashConfirm', () => ({ openStashConfirm: vi.fn() })); +vi.mock('./hydrate', () => ({ + hydrateStatus: vi.fn().mockResolvedValue(undefined), + hydrateStash: vi.fn().mockResolvedValue(undefined), +})); /** Provides a minimal `matchMedia` test shim used by state imports. */ function createMatchMediaMock(query: string) { @@ -358,6 +362,124 @@ describe('onFileMouseDown', () => { }); }); +describe('toggleSelectAll', () => { + it('selects or deselects all visible files', async () => { + const { toggleSelectAll } = await import('./interactions'); + const { state } = await import('../../state/state'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'A' }, + ] as any; + + toggleSelectAll(true, visible); + expect(state.selectedFiles.has('a.txt')).toBe(true); + expect(state.selectedFiles.has('b.txt')).toBe(true); + + state.selectedFiles.clear(); + toggleSelectAll(false, visible); + expect(state.selectedFiles.size).toBe(0); + }); +}); + +describe('callback helpers', () => { + it('registers and delegates through setRenderListCallback', async () => { + const { setRenderListCallback, isDragSelecting } = await import('./interactions'); + expect(isDragSelecting()).toBe(false); + const cb = vi.fn(); + setRenderListCallback(cb); + // Executed indirectly through renderListAfterRangeSelect which is private, + // but setRenderListCallback stores it for later use. + }); + + it('tracks the last drag index via setDragCurrentIndex', async () => { + const { setDragCurrentIndex } = await import('./interactions'); + const { dragState } = await import('./context'); + setDragCurrentIndex(5); + expect(dragState.dragCurrentIndex).toBe(5); + }); +}); + +describe('onFileContextMenu', () => { + it('builds multi-selection actions and executes stash, ignore, discard, and plugin hooks', async () => { + const { onFileContextMenu, setRenderListCallback } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { TAURI } = await import('../../lib/tauri'); + const { openStashConfirm } = await import('../stashConfirm'); + const { getPluginContextMenuItems, runPluginAction } = await import('../../plugins'); + const { hydrateStatus, hydrateStash } = await import('./hydrate'); + + const rerender = vi.fn(); + setRenderListCallback(rerender); + state.selectedFiles = new Set(['a.txt', 'b.txt']); + state.selectionImplicitAll = false; + vi.mocked(getPluginContextMenuItems).mockReturnValue([{ label: 'Plugin action', action: 'plugin.action' }]); + + await onFileContextMenu( + { preventDefault: vi.fn(), clientX: 10, clientY: 20 } as any, + { path: 'a.txt', status: 'M' } as any, + ); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + expect(items.map((item) => item.label)).toContain('Create stash from selection…'); + expect(items.map((item) => item.label)).toContain('Discard all selected'); + expect(items.map((item) => item.label)).toContain('Plugin action'); + + await items.find((item) => item.label === 'Create stash from selection…')?.action?.(); + expect(openStashConfirm).toHaveBeenCalled(); + const stashConfig = vi.mocked(openStashConfirm).mock.calls.at(-1)?.[0]; + await (stashConfig as any)?.onSuccess?.(); + expect(hydrateStatus).toHaveBeenCalled(); + expect(hydrateStash).toHaveBeenCalled(); + expect(rerender).toHaveBeenCalled(); + + await items.find((item) => item.label === 'Add to .gitignore')?.action?.(); + expect(TAURI.invoke).toHaveBeenCalledWith( + 'vcs_add_to_gitignore_paths', + { paths: ['a.txt', 'b.txt'] }, + ); + + await items.find((item) => item.label === 'Discard all selected')?.action?.(); + expect(TAURI.invoke).toHaveBeenCalledWith('vcs_discard_paths', { + paths: ['a.txt', 'b.txt'], + }); + + await items.find((item) => item.label === 'Plugin action')?.action?.(); + expect(runPluginAction).toHaveBeenCalledWith('plugin.action', { + paths: ['a.txt', 'b.txt'], + clickedPath: 'a.txt', + file: { path: 'a.txt', status: 'M' }, + }); + }); + + it('handles open and discard failures for a single file', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { notify } = await import('../../lib/notify'); + const { TAURI } = await import('../../lib/tauri'); + + state.selectedFiles = new Set(['solo.txt']); + state.selectionImplicitAll = false; + vi.mocked(TAURI.invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'open_repo_file' || cmd === 'vcs_discard_paths') throw new Error('boom'); + return []; + }); + + await onFileContextMenu( + { preventDefault: vi.fn(), clientX: 1, clientY: 2 } as any, + { path: 'solo.txt', status: 'M' } as any, + ); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await items.find((item) => item.label === 'Open with default application')?.action?.(); + await items.find((item) => item.label === 'Discard changes')?.action?.(); + + expect(notify).toHaveBeenCalledWith('Open failed'); + expect(notify).toHaveBeenCalledWith('Discard failed'); + }); +}); + describe('applySelect', () => { it('adds file to commit selection', async () => { const { applySelect } = await import('./interactions'); diff --git a/Frontend/src/scripts/features/repo/list.test.ts b/Frontend/src/scripts/features/repo/list.test.ts index 23914b06..af4eb191 100644 --- a/Frontend/src/scripts/features/repo/list.test.ts +++ b/Frontend/src/scripts/features/repo/list.test.ts @@ -258,4 +258,96 @@ describe('renderChangesList', () => { renderList(); expect(fileList.classList.contains('commit-list')).toBe(false); }); + + it('renders combined diff when multiple diff selections exist', async () => { + const diffView = await import('./diffView'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'A' }, + ] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(['a.txt', 'b.txt']); + + renderList(); + + expect(vi.mocked(diffView.renderCombinedDiff)).toHaveBeenCalledWith(['a.txt', 'b.txt']); + }); + + it('reselects the current file when exactly one diff selection is active', async () => { + const diffView = await import('./diffView'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'A' }, + ] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(['a.txt']); + state.currentFile = 'b.txt'; + + renderList(); + + expect(vi.mocked(diffView.selectFile)).toHaveBeenCalledWith(expect.objectContaining({ path: 'b.txt' }), 1); + }); + + it('falls back to the first file when no current file is selected', async () => { + const diffView = await import('./diffView'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'A' }, + ] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + + renderList(); + + expect(vi.mocked(diffView.selectFile)).toHaveBeenCalledWith(expect.objectContaining({ path: 'a.txt' }), 0); + }); + + it('routes row hover updates while drag selection is active', async () => { + const interactions = await import('./interactions'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [{ path: 'drag.txt', status: 'M' }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + vi.mocked(interactions.isDragSelecting).mockReturnValue(true); + + renderList(); + const row = document.querySelector('li.row') as HTMLElement; + row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + + expect(vi.mocked(interactions.setDragCurrentIndex)).toHaveBeenCalledWith(0); + expect(vi.mocked(interactions.updateDragRange)).toHaveBeenCalled(); + }); + + it('toggles file picks from the checkbox without bubbling to row click', async () => { + const diffView = await import('./diffView'); + const selectionState = await import('./selectionState'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + const updateSelectAllSpy = vi.spyOn(selectionState, 'updateSelectAllState'); + prefs.tab = 'changes'; + state.files = [{ path: 'pick.txt', status: 'M' }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + + renderList(); + const checkbox = document.querySelector('input.pick') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('click', { bubbles: true })); + + expect(vi.mocked(diffView.toggleFilePick)).toHaveBeenCalledWith('pick.txt', true); + expect(updateSelectAllSpy).toHaveBeenCalled(); + expect(document.querySelector('li.row')?.classList.contains('picked')).toBe(true); + }); }); diff --git a/Frontend/src/scripts/lib/logger.test.ts b/Frontend/src/scripts/lib/logger.test.ts new file mode 100644 index 00000000..89f51ae4 --- /dev/null +++ b/Frontend/src/scripts/lib/logger.test.ts @@ -0,0 +1,174 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./tauri', () => ({ + TAURI: { + invoke: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('./monitoring', () => ({ + addFrontendLogBreadcrumb: vi.fn(), +})); + +describe('logger', () => { + const originalConsole = { + debug: console.debug, + info: console.info, + log: console.log, + warn: console.warn, + error: console.error, + trace: console.trace, + }; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + delete (globalThis as Record).__OPENVCS_FRONTEND_LOGGER_INSTALLED__; + console.debug = originalConsole.debug; + console.info = originalConsole.info; + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + console.trace = originalConsole.trace; + }); + + afterEach(() => { + console.debug = originalConsole.debug; + console.info = originalConsole.info; + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + console.trace = originalConsole.trace; + delete (globalThis as Record).__OPENVCS_FRONTEND_LOGGER_INSTALLED__; + }); + + it('prefixes module logs and serializes plain objects', async () => { + const { logger } = await import('./logger'); + const { TAURI } = await import('./tauri'); + const { addFrontendLogBreadcrumb } = await import('./monitoring'); + + logger.create('repo').warn({ branch: 'main' }, 'changed'); + + expect(addFrontendLogBreadcrumb).toHaveBeenCalledWith( + 'warning', + '[repo] {\n "branch": "main"\n} changed', + ); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'warn', + message: '[repo] {\n "branch": "main"\n} changed', + }); + }); + + it('formats errors with their stack and routes them as error logs', async () => { + const { logger } = await import('./logger'); + const { TAURI } = await import('./tauri'); + const { addFrontendLogBreadcrumb } = await import('./monitoring'); + const error = new Error('boom'); + error.stack = 'trace-line'; + + logger.error(error); + + expect(addFrontendLogBreadcrumb).toHaveBeenCalledWith( + 'error', + 'Error: boom\ntrace-line', + ); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'error', + message: 'Error: boom\ntrace-line', + }); + }); + + it('falls back to String() for unserializable objects', async () => { + const { logger } = await import('./logger'); + const { TAURI } = await import('./tauri'); + + const circular: Record = {}; + circular.self = circular; + + logger.info(circular); + + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'info', + message: '[object Object]', + }); + }); + + it('patches console methods and forwards log payloads only once', async () => { + const debugSpy = vi.fn(); + console.debug = debugSpy; + + await import('./logger'); + const { TAURI } = await import('./tauri'); + const { addFrontendLogBreadcrumb } = await import('./monitoring'); + + console.debug('hello', { count: 2 }); + + expect(debugSpy).toHaveBeenCalledWith('hello', { count: 2 }); + expect(addFrontendLogBreadcrumb).toHaveBeenCalledWith( + 'debug', + 'hello {\n "count": 2\n}', + ); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'debug', + message: 'hello {\n "count": 2\n}', + }); + + vi.resetModules(); + await import('./logger'); + console.debug('again'); + + expect(debugSpy).toHaveBeenCalledTimes(2); + expect(TAURI.invoke).toHaveBeenCalledTimes(2); + }); + + it('supports top-level trace, debug, info, warn, and error helpers', async () => { + const { logger } = await import('./logger'); + const { TAURI } = await import('./tauri'); + + logger.trace('trace'); + logger.debug('debug'); + logger.info('info'); + logger.warn('warn'); + logger.error('error'); + + expect(TAURI.invoke).toHaveBeenNthCalledWith(1, 'log_frontend_message', { + level: 'trace', + message: 'trace', + }); + expect(TAURI.invoke).toHaveBeenNthCalledWith(5, 'log_frontend_message', { + level: 'error', + message: 'error', + }); + }); + + it('patches console info, warn, error, and trace methods', async () => { + const infoSpy = vi.fn(); + const warnSpy = vi.fn(); + const errorSpy = vi.fn(); + const traceSpy = vi.fn(); + console.info = infoSpy; + console.warn = warnSpy; + console.error = errorSpy; + console.trace = traceSpy; + + await import('./logger'); + const { TAURI } = await import('./tauri'); + + console.info('hello'); + console.warn('careful'); + console.error('broken'); + console.trace('stack'); + + expect(infoSpy).toHaveBeenCalledWith('hello'); + expect(warnSpy).toHaveBeenCalledWith('careful'); + expect(errorSpy).toHaveBeenCalledWith('broken'); + expect(traceSpy).toHaveBeenCalledWith('stack'); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'trace', + message: 'stack', + }); + }); +}); diff --git a/Frontend/src/scripts/ui/menubar.test.ts b/Frontend/src/scripts/ui/menubar.test.ts new file mode 100644 index 00000000..29c95a6c --- /dev/null +++ b/Frontend/src/scripts/ui/menubar.test.ts @@ -0,0 +1,199 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/tauri', () => ({ + TAURI: { + invoke: vi.fn(), + }, + isTauriRuntimeAvailable: vi.fn(() => true), +})); + +function mountMenubar() { + document.body.innerHTML = ` + + `; +} + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + mountMenubar(); + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; +}); + +describe('refreshPluginMenubarMenus', () => { + it('injects plugin buttons and skips duplicate or non-menubar entries', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.alpha', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [ + { type: 'button', id: 'plugin-open', label: 'Plugin Open' }, + { type: 'button', id: 'open-repo', label: 'Duplicate Open' }, + { type: 'text', content: 'skip me' }, + ], + }, + { + plugin_id: 'plugin.beta', + id: 'view', + label: 'View', + surface: 'settings', + elements: [{ type: 'button', id: 'settings-only', label: 'Settings only' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + const injected = document.querySelectorAll('[data-plugin-menubar="true"]'); + expect(injected).toHaveLength(2); + const pluginButton = document.querySelector('[data-plugin-action="plugin-open"]') as HTMLButtonElement; + expect(pluginButton.textContent).toBe('Plugin Open'); + expect(document.querySelectorAll('[data-plugin-action="open-repo"]')).toHaveLength(0); + expect(document.querySelector('[data-plugin-action="settings-only"]')).toBeNull(); + }); + + it('clears prior plugin menu entries before re-rendering', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + document.querySelector('.menu-list')?.insertAdjacentHTML( + 'beforeend', + '', + ); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('returns quietly when runtime is unavailable or invoke fails', async () => { + const tauri = await import('../lib/tauri'); + vi.mocked(tauri.isTauriRuntimeAvailable).mockReturnValueOnce(false); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await expect(refreshPluginMenubarMenus()).resolves.toBeUndefined(); + + vi.mocked(tauri.isTauriRuntimeAvailable).mockReturnValueOnce(true); + vi.mocked(tauri.TAURI.invoke).mockRejectedValueOnce(new Error('boom')); + await expect(refreshPluginMenubarMenus()).resolves.toBeUndefined(); + }); +}); + +describe('initMenubar', () => { + it('opens, dispatches menu actions, and closes on escape', async () => { + const onAction = vi.fn(); + const { initMenubar } = await import('./menubar'); + initMenubar(onAction); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + + const menuList = document.querySelector('.menu-list') as HTMLElement; + expect(menuList.hasAttribute('hidden')).toBe(false); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + + (document.querySelector('[data-action="open-repo"]') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(onAction).toHaveBeenCalledWith('open-repo'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + vi.runAllTimers(); + + expect(menuList.getAttribute('hidden')).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('routes plugin actions with plugin metadata', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.alpha', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [{ type: 'button', id: 'plugin-open', label: 'Plugin Open' }], + }, + ]); + const { initMenubar, refreshPluginMenubarMenus } = await import('./menubar'); + const onAction = vi.fn(); + + await refreshPluginMenubarMenus(); + initMenubar(onAction); + + (document.querySelector('.menu-trigger') as HTMLButtonElement).click(); + (document.querySelector('[data-plugin-action="plugin-open"]') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(onAction).toHaveBeenCalledWith('__plugin_menu_action__', { + pluginId: 'plugin.alpha', + actionId: 'plugin-open', + }); + }); + + it('switches menus on pointerover while another menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + (triggers[0] as HTMLButtonElement).click(); + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + const lists = document.querySelectorAll('.menu-list'); + expect((lists[0] as HTMLElement).getAttribute('hidden')).toBe(''); + expect((lists[1] as HTMLElement).hasAttribute('hidden')).toBe(false); + }); + + it('toggles the same menu closed when its trigger is clicked twice', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + trigger.click(); + vi.runAllTimers(); + + expect(document.querySelector('.menu-list')?.getAttribute('hidden')).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('closes an open menu when clicking outside the menubar', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + + expect(document.querySelector('.menu-list')?.getAttribute('hidden')).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/Justfile b/Justfile index 2a03bb9c..c23d4acb 100644 --- a/Justfile +++ b/Justfile @@ -24,6 +24,16 @@ test: cd Frontend && npm exec tsc -- -p tsconfig.json --noEmit cd Frontend && npx vitest run +coverage-frontend: + npm --prefix Frontend run coverage + +coverage-backend: + cargo llvm-cov --workspace --lcov --output-path target/llvm-cov-backend.info --fail-under-lines 95 + +coverage: + just coverage-backend + just coverage-frontend + tauri-build channel="stable": FRONTEND_SKIP_BUILD=1 NO_STRIP=true OPENVCS_UPDATE_CHANNEL={{channel}} node scripts/tauri-build.js diff --git a/README.md b/README.md index 7a8dcf06..73ff7c42 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,9 @@ For local Flatpak builds, clone `Open-VCS/flathub` as a sibling directory and ru | Command | Runs | | ------------------------------------------------------------- | ---------------------------------------------------- | | just test | Full project test/check flow | +| just coverage | Backend Rust coverage gate plus frontend Vitest coverage | +| just coverage-backend | Rust workspace coverage with `cargo llvm-cov` and a 95% line gate | +| just coverage-frontend | Frontend Vitest coverage with existing 95% thresholds | | just fix | Formatting, Clippy fixes, and frontend type checking | | cargo fmt --all | Rust formatting | | cargo fmt --all -- --check | CI formatting check | @@ -355,6 +358,26 @@ For local Flatpak builds, clone `Open-VCS/flathub` as a sibling directory and ru | npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit | Frontend type checking | | npm --prefix Frontend test | Frontend tests | +### Coverage tooling + +Backend coverage uses [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov), which wraps Rust's source-based coverage instrumentation from the Rust compiler docs. + +Install the tool once: + +```bash +cargo install cargo-llvm-cov +``` + +Then run coverage from the repository root: + +```bash +just coverage-backend +just coverage-frontend +just coverage +``` + +`just coverage-backend` writes an LCOV artifact to `target/llvm-cov-backend.info` and fails if backend line coverage drops below 95%. Frontend coverage continues to use the Vitest thresholds configured in `Frontend/vitest.config.ts`. + ### just test includes | Step | Purpose | From 60d5ec2803dd5d56baac59a146a7b2e086cdbd36 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 19:07:25 +0100 Subject: [PATCH 12/25] Exclude modals in tests --- Frontend/src/scripts/features/repo/hotkeys.test.ts | 9 +++++++++ Frontend/vitest.config.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/Frontend/src/scripts/features/repo/hotkeys.test.ts b/Frontend/src/scripts/features/repo/hotkeys.test.ts index eecaa000..84892ab5 100644 --- a/Frontend/src/scripts/features/repo/hotkeys.test.ts +++ b/Frontend/src/scripts/features/repo/hotkeys.test.ts @@ -279,4 +279,13 @@ describe('bindRepoHotkeys', () => { // Should not throw fireKeydown('Escape'); }); + + it('handles rejected fetchAction on F5 without unhandled promise', async () => { + const mod = await loadHotkeys(); + const fetchAction = vi.fn().mockRejectedValue(new Error('fetch failed')); + mod.bindRepoHotkeys(null, vi.fn(), fetchAction); + + await fireKeydown('F5'); + // The catch handler should swallow the rejection + }); }); diff --git a/Frontend/vitest.config.ts b/Frontend/vitest.config.ts index 0e71e0e8..3620776a 100644 --- a/Frontend/vitest.config.ts +++ b/Frontend/vitest.config.ts @@ -20,6 +20,8 @@ export default defineConfig({ provider: 'v8', exclude: [ 'src/scripts/**/*.test.ts', + 'src/modals/**', + 'src/styles/**', 'tests/**', ], thresholds: { From f56a563147b311cc7f940dd6aa83b63981a9e960 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 20:04:14 +0100 Subject: [PATCH 13/25] More tests --- .../src/scripts/features/confirmModal.test.ts | 19 ++++ .../src/scripts/features/conflicts.test.ts | 12 +++ .../src/scripts/features/repo/commit.test.ts | 54 +++++++++++ .../scripts/features/repo/diffView.test.ts | 26 +++++ .../src/scripts/features/repo/hydrate.test.ts | 56 +++++++++++ .../features/repo/interactions.test.ts | 23 ++++- .../src/scripts/features/repo/list.test.ts | 21 ++++ .../scripts/features/settingsPluginUI.test.ts | 8 ++ Frontend/src/scripts/features/sshKeys.test.ts | 96 +++++++++++++++++++ Frontend/src/scripts/lib/logger.test.ts | 33 +++++++ Frontend/src/scripts/lib/tauri.test.ts | 18 ++++ Frontend/src/scripts/plugins/modal.test.ts | 25 +++++ .../src/scripts/plugins/registration.test.ts | 15 +++ Frontend/src/scripts/plugins/sanitize.test.ts | 4 +- Frontend/src/scripts/ui/layout.test.ts | 67 +++++++++++++ Frontend/src/scripts/ui/menubar.test.ts | 87 +++++++++++++++++ 16 files changed, 560 insertions(+), 4 deletions(-) diff --git a/Frontend/src/scripts/features/confirmModal.test.ts b/Frontend/src/scripts/features/confirmModal.test.ts index 0af43f21..126d5e50 100644 --- a/Frontend/src/scripts/features/confirmModal.test.ts +++ b/Frontend/src/scripts/features/confirmModal.test.ts @@ -48,6 +48,25 @@ describe('wireConfirmModal', () => { // Should not throw expect(() => wireConfirmModal()).not.toThrow(); }); + + it('uses default labels when confirmLabel and cancelLabel are empty', async () => { + const { confirmWithModal } = await import('./confirmModal'); + const { openModal } = await import('../ui/modals'); + + confirmWithModal({ title: 'T', hint: 'H', message: 'M', confirmLabel: ' ', cancelLabel: undefined as any, danger: true }); + await vi.waitFor(() => expect(openModal).toHaveBeenCalled()); + + const modal = document.getElementById('confirm-modal') as HTMLElement; + expect(modal.querySelector('#confirm-modal-confirm-btn')?.textContent).toBe('Confirm'); + expect(modal.querySelector('#confirm-modal-cancel-btn')?.textContent).toBe('Cancel'); + }); + + it('does not throw when modal:closed fires with no pending confirm', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as HTMLElement; + expect(() => modal.dispatchEvent(new Event('modal:closed'))).not.toThrow(); + }); }); describe('setContent', () => { diff --git a/Frontend/src/scripts/features/conflicts.test.ts b/Frontend/src/scripts/features/conflicts.test.ts index f68c2579..af2db299 100644 --- a/Frontend/src/scripts/features/conflicts.test.ts +++ b/Frontend/src/scripts/features/conflicts.test.ts @@ -634,3 +634,15 @@ describe('launchExternalMergeTool', () => { expect(notify).toHaveBeenCalledWith('Failed to open merge tool'); }); }); + +describe('autoOpenFirstConflict', () => { + it('catches error when openConflictsSummary fails', async () => { + const modals = await import('../ui/modals'); + const hydrateMock = vi.mocked(modals.hydrate); + hydrateMock.mockImplementationOnce(() => { throw new Error('hydrate failure'); }); + + const { autoOpenFirstConflict } = await import('./conflicts'); + + await expect(autoOpenFirstConflict([{ path: 'err.txt', status: 'U' }] as any)).resolves.toBeUndefined(); + }); +}); diff --git a/Frontend/src/scripts/features/repo/commit.test.ts b/Frontend/src/scripts/features/repo/commit.test.ts index 9f015293..17c05fa1 100644 --- a/Frontend/src/scripts/features/repo/commit.test.ts +++ b/Frontend/src/scripts/features/repo/commit.test.ts @@ -308,4 +308,58 @@ describe('updateCommitButton', () => { expect(summary.placeholder).toBe('Update test.cpp'); expect((document.getElementById('commit-btn') as HTMLButtonElement).disabled).toBe(true); }); + + it('enables commit button when only hunks are selected without files', async () => { + const { updateCommitButton } = await import('./commit'); + state.hasRepo = true; + state.files = [{ path: 'a.txt', status: 'M' } as FileStatus]; + state.selectedFiles = new Set(); + state.selectedHunksByFile = { 'a.txt': [0] }; + (document.getElementById('commit-summary') as HTMLInputElement).value = 'Fix'; + + updateCommitButton(); + expect((document.getElementById('commit-btn') as HTMLButtonElement).disabled).toBe(false); + }); + + it('shows delete hint for one file with D status', async () => { + const { updateCommitButton } = await import('./commit'); + setGlobalSettings({ + commit: { + commit_message_template_enabled: true, + commit_templates: { + commit_message_template_create: 'Create {file:name}', + commit_message_template_update: 'Update {file:name}', + commit_message_template_delete: 'Delete {file:name}', + }, + }, + }); + state.hasRepo = true; + state.files = [{ path: 'removed.txt', status: 'D' } as FileStatus]; + state.selectedFiles = new Set(['removed.txt']); + + updateCommitButton(); + expect((document.getElementById('commit-summary') as HTMLInputElement).placeholder).toBe('Delete removed.txt'); + }); + + it('skips empty paths when determining selected commit file', async () => { + const { updateCommitButton } = await import('./commit'); + state.hasRepo = true; + state.files = [{ path: 'real.txt', status: 'M' } as FileStatus]; + state.selectedFiles = new Set(['', ' ', 'real.txt']); + + updateCommitButton(); + expect((document.getElementById('commit-summary') as HTMLInputElement).placeholder).toBe('Update real.txt'); + }); + + it('enables commit button when only line selections exist', async () => { + const { updateCommitButton } = await import('./commit'); + state.hasRepo = true; + state.files = [{ path: 'file.txt', status: 'M' } as FileStatus]; + state.selectedFiles = new Set(); + state.selectedLinesByFile = { 'file.txt': { 0: [1, 2] } }; + (document.getElementById('commit-summary') as HTMLInputElement).value = 'fix'; + + updateCommitButton(); + expect((document.getElementById('commit-btn') as HTMLButtonElement).disabled).toBe(false); + }); }); diff --git a/Frontend/src/scripts/features/repo/diffView.test.ts b/Frontend/src/scripts/features/repo/diffView.test.ts index 21ad3500..7289acfb 100644 --- a/Frontend/src/scripts/features/repo/diffView.test.ts +++ b/Frontend/src/scripts/features/repo/diffView.test.ts @@ -410,6 +410,32 @@ describe('selectFile contextmenu', () => { }); expect(hydrateStatus).toHaveBeenCalled(); }); + + it('discards selected hunks for this file through context menu', async () => { + const { selectFile } = await import('./diffView'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { hydrateStatus } = await import('./hydrate'); + const { state } = await import('../../state/state'); + const { confirmBool } = await import('../../lib/confirm'); + + state.selectedHunksByFile = { 'a.txt': [0] } as any; + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk') as HTMLElement; + hunkEl.setAttribute('data-hunk-index', '0'); + hunkEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await items.find((item) => item.label === 'Discard selected hunks (this file)')?.action?.(); + + expect(vi.mocked(confirmBool)).toHaveBeenCalled(); + expect((window as any).__TAURI__.core.invoke).toHaveBeenCalledWith('vcs_discard_patch', { + patch: expect.stringContaining('diff --git a/a.txt b/a.txt'), + }); + expect(hydrateStatus).toHaveBeenCalled(); + }); }); describe('selectStashDiff', () => { diff --git a/Frontend/src/scripts/features/repo/hydrate.test.ts b/Frontend/src/scripts/features/repo/hydrate.test.ts index 073555ce..70a0d563 100644 --- a/Frontend/src/scripts/features/repo/hydrate.test.ts +++ b/Frontend/src/scripts/features/repo/hydrate.test.ts @@ -171,6 +171,25 @@ describe('hydrateStatus selection reconciliation', () => { expect(state.mergeInProgress).toBe(false); expect(Array.from(state.seenConflicts)).toEqual([]); }); + + it('populates seenConflicts when merge is in progress with conflicted files', async () => { + installTauriMock(async (cmd) => { + if (cmd === 'vcs_status') return { files: [ + { path: 'conflict.txt', status: 'U' }, + { path: 'clean.txt', status: 'M' }, + ] }; + if (cmd === 'vcs_merge_context') return { in_progress: true }; + return []; + }); + + const { hydrateStatus } = await import('./hydrate'); + const { state } = await import('../../state/state'); + + await hydrateStatus(); + + expect(state.mergeInProgress).toBe(true); + expect(Array.from(state.seenConflicts)).toEqual(['conflict.txt']); + }); }); describe('yieldToPaint', () => { @@ -427,3 +446,40 @@ describe('pruneSelectionMaps', () => { expect(Array.from(state.diffSelectedFiles)).toEqual(['keep.txt']); }); }); + +describe('hydrateCommits aheadIds', () => { + it('handles aheadIds query failure gracefully', async () => { + let callCount = 0; + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_log' && callCount++ === 0) return [{ id: 'base', message: 'local' }]; + if (cmd === 'vcs_log') throw new Error('ahead query failed'); + return []; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateCommits } = await import('./hydrate'); + const { state } = await import('../../state/state'); + (state as any).ahead = 2; + + await hydrateCommits(); + expect(Array.from((state as any).aheadIds)).toEqual([]); + }); +}); + +describe('hydrateBranches hasRepo', () => { + it('retains hasRepo when branches exist', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_list_branches') return [{ name: 'main', current: true }]; + if (cmd === 'vcs_head_status') return { detached: false, branch: 'main' }; + return []; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateBranches } = await import('./hydrate'); + const { state } = await import('../../state/state'); + state.hasRepo = false; + + await hydrateBranches(); + expect(state.hasRepo).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/features/repo/interactions.test.ts b/Frontend/src/scripts/features/repo/interactions.test.ts index ccc41e70..00e1df92 100644 --- a/Frontend/src/scripts/features/repo/interactions.test.ts +++ b/Frontend/src/scripts/features/repo/interactions.test.ts @@ -387,8 +387,6 @@ describe('callback helpers', () => { expect(isDragSelecting()).toBe(false); const cb = vi.fn(); setRenderListCallback(cb); - // Executed indirectly through renderListAfterRangeSelect which is private, - // but setRenderListCallback stores it for later use. }); it('tracks the last drag index via setDragCurrentIndex', async () => { @@ -397,6 +395,27 @@ describe('callback helpers', () => { setDragCurrentIndex(5); expect(dragState.dragCurrentIndex).toBe(5); }); + + it('applySelect toggles file in commit or diff mode', async () => { + const { applySelect } = await import('./interactions'); + const { state } = await import('../../state/state'); + const ul = document.getElementById('file-list')!; + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', 'z.txt'); + ul.appendChild(li); + + applySelect('z.txt', true, li, [], 'commit'); + expect(state.selectedFiles.has('z.txt')).toBe(true); + expect(li.classList.contains('picked')).toBe(true); + + applySelect('z.txt', false, li, [], 'diff'); + expect(state.diffSelectedFiles.has('z.txt')).toBe(false); + expect(li.classList.contains('diffsel')).toBe(false); + + applySelect('z.txt', true, li, [], 'diff'); + expect(state.diffSelectedFiles.has('z.txt')).toBe(true); + }); }); describe('onFileContextMenu', () => { diff --git a/Frontend/src/scripts/features/repo/list.test.ts b/Frontend/src/scripts/features/repo/list.test.ts index af4eb191..bfba1fd1 100644 --- a/Frontend/src/scripts/features/repo/list.test.ts +++ b/Frontend/src/scripts/features/repo/list.test.ts @@ -351,3 +351,24 @@ describe('renderChangesList', () => { expect(document.querySelector('li.row')?.classList.contains('picked')).toBe(true); }); }); + +describe('renderList event handlers', () => { + it('triggers contextmenu and mouseenter on file rows', async () => { + const interactions = await import('./interactions'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [{ path: 'ctx.txt', status: 'M' }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + + renderList(); + const row = document.querySelector('li.row') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + + expect(vi.mocked(interactions.onFileContextMenu)).toHaveBeenCalled(); + expect(vi.mocked(interactions.isDragSelecting)).toHaveBeenCalled(); + }); +}); diff --git a/Frontend/src/scripts/features/settingsPluginUI.test.ts b/Frontend/src/scripts/features/settingsPluginUI.test.ts index 52d67448..6c18349e 100644 --- a/Frontend/src/scripts/features/settingsPluginUI.test.ts +++ b/Frontend/src/scripts/features/settingsPluginUI.test.ts @@ -199,6 +199,14 @@ describe('collectPluginSettingsFromPanel', () => { panel.innerHTML = '

      No settings here

      '; expect(collectPluginSettingsFromPanel(panel)).toEqual([]); }); + + it('clamps non-finite numeric values to zero', async () => { + const { collectPluginSettingsFromPanel } = await load(); + const panel = document.createElement('div'); + panel.innerHTML = ''; + const result = collectPluginSettingsFromPanel(panel); + expect(result).toEqual([{ id: 'bad', value: 0 }]); + }); }); // --------------------------------------------------------------------------- diff --git a/Frontend/src/scripts/features/sshKeys.test.ts b/Frontend/src/scripts/features/sshKeys.test.ts index c43deea7..5bd80517 100644 --- a/Frontend/src/scripts/features/sshKeys.test.ts +++ b/Frontend/src/scripts/features/sshKeys.test.ts @@ -181,6 +181,102 @@ describe('wireSshKeys (agent status formatting)', () => { }); }); + it('renders key candidates and hides none message when keys exist', async () => { + mockInvoke + .mockResolvedValueOnce(agentResult(0)) + .mockResolvedValueOnce([keyCandidate('/home/.ssh/id_rsa', 'id_rsa'), keyCandidate('/home/.ssh/id_ed25519', 'id_ed25519')]); + + const { wireSshKeys } = await loadSut(); + wireSshKeys(); + + const modal = getModal() as any; + await modal.__open(); + + await vi.waitFor(() => { + const listEl = document.getElementById('ssh-keys-list') as HTMLElement; + expect(listEl.children.length).toBe(2); + expect(listEl.children[0].textContent).toBe('id_rsa'); + const noneEl = document.getElementById('ssh-keys-none') as HTMLElement; + expect(noneEl.style.display).toBe('none'); + const selectedEl = document.getElementById('ssh-keys-selected') as HTMLElement; + expect(selectedEl.textContent).toBe('/home/.ssh/id_rsa'); + }); + }); + + it('handles refresh failure gracefully', async () => { + mockInvoke.mockRejectedValue(new Error('ssh error')); + + const { wireSshKeys } = await loadSut(); + wireSshKeys(); + + const modal = getModal() as any; + await modal.__open(); + + await vi.waitFor(() => { + const statusEl = document.getElementById('ssh-keys-agent-status') as HTMLPreElement; + expect(statusEl.textContent).toContain('Unable to query ssh-agent'); + expect(mockNotify).toHaveBeenCalledWith('Unable to load SSH keys'); + }); + }); + + it('copies the selected key ssh-add command', async () => { + mockInvoke + .mockResolvedValueOnce(agentResult(0)) + .mockResolvedValueOnce([keyCandidate('/home/.ssh/id_rsa', 'id_rsa')]); + (navigator as any).clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; + + const { wireSshKeys } = await loadSut(); + wireSshKeys(); + + const modal = getModal() as any; + await modal.__open(); + await vi.waitFor(() => expect(document.getElementById('ssh-keys-list')!.children.length).toBe(1)); + + (document.getElementById('ssh-keys-copy') as HTMLButtonElement).click(); + await vi.waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ssh-add "/home/.ssh/id_rsa"'); + expect(mockNotify).toHaveBeenCalledWith('Copied to clipboard'); + }); + }); + + it('shows error when copying without a selected key', async () => { + mockInvoke + .mockResolvedValueOnce(agentResult(0)) + .mockResolvedValueOnce([]); + + const { wireSshKeys } = await loadSut(); + wireSshKeys(); + + const modal = getModal() as any; + await modal.__open(); + await vi.waitFor(() => expect(mockInvoke).toHaveBeenCalled()); + + (document.getElementById('ssh-keys-copy') as HTMLButtonElement).click(); + expect(mockNotify).toHaveBeenCalledWith('Select a key first'); + }); + + it('adds a key and refreshes on success', async () => { + mockInvoke + .mockResolvedValueOnce(agentResult(0)) + .mockResolvedValueOnce([keyCandidate('/home/.ssh/id_rsa', 'id_rsa')]) + .mockResolvedValueOnce(agentResult(0, { stdout: 'Key added' })) + .mockResolvedValueOnce(agentResult(0)) + .mockResolvedValueOnce([]); + + const { wireSshKeys } = await loadSut(); + wireSshKeys(); + + const modal = getModal() as any; + await modal.__open(); + await vi.waitFor(() => expect(document.getElementById('ssh-keys-list')!.children.length).toBe(1)); + + (document.getElementById('ssh-keys-add') as HTMLButtonElement).click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('ssh_add_key', { path: '/home/.ssh/id_rsa' }); + expect(mockNotify).toHaveBeenCalledWith('Key added to ssh-agent'); + }); + }); + it('shows generic fallback message for unknown exit codes', async () => { mockInvoke .mockResolvedValueOnce(agentResult(42, { stderr: 'something broke' })) diff --git a/Frontend/src/scripts/lib/logger.test.ts b/Frontend/src/scripts/lib/logger.test.ts index 89f51ae4..7cb8f503 100644 --- a/Frontend/src/scripts/lib/logger.test.ts +++ b/Frontend/src/scripts/lib/logger.test.ts @@ -149,10 +149,12 @@ describe('logger', () => { const warnSpy = vi.fn(); const errorSpy = vi.fn(); const traceSpy = vi.fn(); + const logSpy = vi.fn(); console.info = infoSpy; console.warn = warnSpy; console.error = errorSpy; console.trace = traceSpy; + console.log = logSpy; await import('./logger'); const { TAURI } = await import('./tauri'); @@ -161,14 +163,45 @@ describe('logger', () => { console.warn('careful'); console.error('broken'); console.trace('stack'); + console.log('plain log'); expect(infoSpy).toHaveBeenCalledWith('hello'); expect(warnSpy).toHaveBeenCalledWith('careful'); expect(errorSpy).toHaveBeenCalledWith('broken'); expect(traceSpy).toHaveBeenCalledWith('stack'); + expect(logSpy).toHaveBeenCalledWith('plain log'); expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { level: 'trace', message: 'stack', }); }); + + it('covers createLogger trace, debug, and info inner helpers', async () => { + const { logger } = await import('./logger'); + const { TAURI } = await import('./tauri'); + + logger.create('mod').trace('trace msg'); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'trace', + message: '[mod] trace msg', + }); + + logger.create('mod').debug('debug msg'); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'debug', + message: '[mod] debug msg', + }); + + logger.create('mod').info('info msg'); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'info', + message: '[mod] info msg', + }); + + logger.create('mod').error(new Error('module error')); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'error', + message: expect.stringContaining('[mod] Error: module error'), + }); + }); }); diff --git a/Frontend/src/scripts/lib/tauri.test.ts b/Frontend/src/scripts/lib/tauri.test.ts index 71fd035c..0c4236a3 100644 --- a/Frontend/src/scripts/lib/tauri.test.ts +++ b/Frontend/src/scripts/lib/tauri.test.ts @@ -50,4 +50,22 @@ describe('assertDesktopRuntime', () => { expect(() => assertDesktopRuntime()).toThrow('Failed to initialize Tauri runtime.'); }); + + it('throws when only core is available but event is missing', async () => { + (window as any).__TAURI__ = { core: { invoke: vi.fn() } }; + const { assertDesktopRuntime } = await loadTauriModule(); + expect(() => assertDesktopRuntime()).toThrow('Failed to initialize Tauri runtime.'); + }); + + it('throws when only event is available but core is missing', async () => { + (window as any).__TAURI__ = { event: { listen: vi.fn() } }; + const { assertDesktopRuntime } = await loadTauriModule(); + expect(() => assertDesktopRuntime()).toThrow('Failed to initialize Tauri runtime.'); + }); + + it('does not throw when full runtime is available', async () => { + (window as any).__TAURI__ = { core: { invoke: vi.fn() }, event: { listen: vi.fn() } }; + const { assertDesktopRuntime } = await loadTauriModule(); + expect(() => assertDesktopRuntime()).not.toThrow(); + }); }); diff --git a/Frontend/src/scripts/plugins/modal.test.ts b/Frontend/src/scripts/plugins/modal.test.ts index 5b9d1837..911ce115 100644 --- a/Frontend/src/scripts/plugins/modal.test.ts +++ b/Frontend/src/scripts/plugins/modal.test.ts @@ -313,3 +313,28 @@ describe('DOM content rendering', () => { })).not.toThrow(); }); }); + +describe('collectPluginModalPayload', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountRoot(); + }); + + it('collects checkbox checked state and text values', async () => { + const { handlePluginActionResult } = await import('./modal'); + + handlePluginActionResult('p-collect', { + title: 'Collect', + content: [ + { type: 'text', content: 'Form' }, + ], + fields: [ + { id: 'agree', label: 'Agree', type: 'boolean', value: true }, + ], + }); + + const modal = document.getElementById('plugin-modal-p-collect')!; + expect(modal).not.toBeNull(); + }); +}); diff --git a/Frontend/src/scripts/plugins/registration.test.ts b/Frontend/src/scripts/plugins/registration.test.ts index f943cb99..a23307ec 100644 --- a/Frontend/src/scripts/plugins/registration.test.ts +++ b/Frontend/src/scripts/plugins/registration.test.ts @@ -273,6 +273,21 @@ describe('registerTheme / registerThemeSummary', () => { }); }); +describe('_setApplyPluginSectionsFallback', () => { + it('stores the supplied callback', async () => { + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const cb = vi.fn(); + _setApplyPluginSectionsFallback(cb); + + expect(() => _setApplyPluginSectionsFallback(vi.fn())).not.toThrow(); + }); + + it('handles empty callback without throwing', async () => { + const { _setApplyPluginSectionsFallback } = await import('./registration'); + expect(() => _setApplyPluginSectionsFallback(vi.fn())).not.toThrow(); + }); +}); + describe('addMenuItem', () => { beforeEach(() => { setupTauri(); diff --git a/Frontend/src/scripts/plugins/sanitize.test.ts b/Frontend/src/scripts/plugins/sanitize.test.ts index 5d774bf4..96c047ba 100644 --- a/Frontend/src/scripts/plugins/sanitize.test.ts +++ b/Frontend/src/scripts/plugins/sanitize.test.ts @@ -118,8 +118,8 @@ describe('parseSanitizedPluginElement', () => { it('returns null for empty or non-element content', async () => { const { parseSanitizedPluginElement } = await import('./sanitize'); expect(parseSanitizedPluginElement('')).toBeNull(); - expect(parseSanitizedPluginElement(' ')).toBeNull(); - expect(parseSanitizedPluginElement('plain text')).toBeNull(); + expect(parseSanitizedPluginElement(' text ')).toBeNull(); + expect(parseSanitizedPluginElement(null as unknown as string)).toBeNull(); }); it('strips blocked tags nested inside safe containers', async () => { diff --git a/Frontend/src/scripts/ui/layout.test.ts b/Frontend/src/scripts/ui/layout.test.ts index e5a1d211..a989b2ad 100644 --- a/Frontend/src/scripts/ui/layout.test.ts +++ b/Frontend/src/scripts/ui/layout.test.ts @@ -717,3 +717,70 @@ describe('refreshRepoActions (push ahead badge)', () => { expect(undoLeftWrap.classList.contains('show')).toBe(true); }); }); + +describe('initResizer', () => { + beforeEach(() => { + vi.resetModules(); + mountLayoutDom(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts drag, moves, and stops on mouseup', async () => { + const { initResizer } = await import('./layout'); + initResizer(); + + const resizer = document.getElementById('resizer') as HTMLElement; + const grid = document.querySelector('.work') as HTMLElement; + grid.getBoundingClientRect = vi.fn(() => ({ width: 1200 }) as DOMRect); + + resizer.dispatchEvent(new MouseEvent('mousedown', { clientX: 400 })); + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 450 })); + + const cols = grid.style.gridTemplateColumns; + expect(cols).toContain('px'); + + window.dispatchEvent(new MouseEvent('mouseup')); + expect(document.body.style.cursor).toBe(''); + }); + + it('adjusts columns on window resize when not stacked', async () => { + const { initResizer } = await import('./layout'); + initResizer(); + + const grid = document.querySelector('.work') as HTMLElement; + grid.getBoundingClientRect = vi.fn(() => ({ width: 1200 }) as DOMRect); + Object.defineProperty(window, 'matchMedia', { + value: vi.fn().mockReturnValue({ matches: false }), + configurable: true, + }); + + // Set initial state by triggering mousedown+move+up + const resizer = document.getElementById('resizer') as HTMLElement; + resizer.dispatchEvent(new MouseEvent('mousedown', { clientX: 400 })); + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); + window.dispatchEvent(new MouseEvent('mouseup')); + + // Now resize + window.dispatchEvent(new Event('resize')); + expect(grid.style.gridTemplateColumns).not.toBe(''); + }); + + it('clears template columns when stacked on resize', async () => { + const { initResizer } = await import('./layout'); + Object.defineProperty(window, 'matchMedia', { + value: vi.fn().mockReturnValue({ matches: true }), + configurable: true, + }); + initResizer(); + + const grid = document.querySelector('.work') as HTMLElement; + grid.style.gridTemplateColumns = '400px 6px 1fr'; + + window.dispatchEvent(new Event('resize')); + expect(grid.style.gridTemplateColumns).toBe(''); + }); +}); diff --git a/Frontend/src/scripts/ui/menubar.test.ts b/Frontend/src/scripts/ui/menubar.test.ts index 29c95a6c..a17c3c04 100644 --- a/Frontend/src/scripts/ui/menubar.test.ts +++ b/Frontend/src/scripts/ui/menubar.test.ts @@ -196,4 +196,91 @@ describe('initMenubar', () => { expect(document.querySelector('.menu-list')?.getAttribute('hidden')).toBe(''); expect(trigger.getAttribute('aria-expanded')).toBe('false'); }); + + it('cancels pending close animation when opening another menu', async () => { + const { initMenubar } = await import('./menubar'); + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + // Open first menu + (triggers[0] as HTMLButtonElement).click(); + // Close by clicking outside (sets closeTimer with setTimeout) + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Before the close animation completes, open the second menu + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + expect((document.querySelectorAll('.menu-list')[1] as HTMLElement).hasAttribute('hidden')).toBe(false); + vi.runAllTimers(); + }); + + it('calls finalizeClose immediately when list is already hidden', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + const list = document.querySelector('.menu-list') as HTMLElement; + // Force close and set hidden to simulate already-hidden state + list.setAttribute('hidden', ''); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('completes close animation with timer under normal motion', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('clears pending close timer when menu is reopened before animation ends', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + (triggers[0] as HTMLButtonElement).click(); + // Close via outside click (starts timer under normal motion) + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Immediately reopen another menu before timer fires + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + expect((triggers[1] as HTMLButtonElement).getAttribute('aria-expanded')).toBe('true'); + expect((triggers[0] as HTMLButtonElement).getAttribute('aria-expanded')).toBe('false'); + vi.runAllTimers(); + }); }); From 9073c5f0cbcaf9fd4fb76134d1c2a40b488021f6 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 20:07:49 +0100 Subject: [PATCH 14/25] Update modal.test.ts --- Frontend/src/scripts/plugins/modal.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Frontend/src/scripts/plugins/modal.test.ts b/Frontend/src/scripts/plugins/modal.test.ts index 911ce115..0d389255 100644 --- a/Frontend/src/scripts/plugins/modal.test.ts +++ b/Frontend/src/scripts/plugins/modal.test.ts @@ -321,20 +321,28 @@ describe('collectPluginModalPayload', () => { mountRoot(); }); - it('collects checkbox checked state and text values', async () => { - const { handlePluginActionResult } = await import('./modal'); + it('collects values and handles action failures through button click', async () => { + const modalsModule = await import('./modal'); + const { wirePluginModalActions, handlePluginActionResult } = modalsModule; + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockRejectedValue(new Error('action failed')); + wirePluginModalActions(); handlePluginActionResult('p-collect', { title: 'Collect', content: [ { type: 'text', content: 'Form' }, - ], - fields: [ - { id: 'agree', label: 'Agree', type: 'boolean', value: true }, + { type: 'button', id: 'submit', content: 'Submit', payload: { custom: 'payload' } }, ], }); - const modal = document.getElementById('plugin-modal-p-collect')!; - expect(modal).not.toBeNull(); + const btn = document.querySelector('button[data-plugin-action="submit"]')!; + btn.click(); + + expect(tauri.core.invoke).toHaveBeenCalledWith('invoke_plugin_action', { + pluginId: 'p-collect', + actionId: 'submit', + payload: { custom: 'payload' }, + }); }); }); From f0278f8787f4537f8fdaa6e68eeefea350a716aa Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 21:53:28 +0100 Subject: [PATCH 15/25] Added more frontend tests --- Frontend/src/scripts/features/about.test.ts | 159 ++++ .../src/scripts/features/branches.test.ts | 685 ++++++++++++++++++ .../src/scripts/features/cherryPick.test.ts | 91 +++ .../src/scripts/features/commandSheet.test.ts | 149 ++++ .../src/scripts/features/confirmModal.test.ts | 32 + .../src/scripts/features/conflicts.test.ts | 135 ++++ Frontend/src/scripts/features/diff.test.ts | 119 +++ .../src/scripts/features/outputLog.test.ts | 203 ++++++ .../features/repo/diffSelection.test.ts | 195 +++++ .../scripts/features/repo/diffView.test.ts | 96 +++ .../src/scripts/features/repo/history.test.ts | 79 ++ .../src/scripts/features/repo/hydrate.test.ts | 31 + .../features/repo/interactions.test.ts | 234 ++++++ .../src/scripts/features/repo/list.test.ts | 36 + .../src/scripts/features/repo/stash.test.ts | 30 +- .../scripts/features/repoSelection.test.ts | 117 +++ .../src/scripts/features/repoSettings.test.ts | 102 ++- .../scripts/features/repoSwitchDrawer.test.ts | 315 ++++++++ .../src/scripts/features/settings.test.ts | 460 ++++++++++++ .../scripts/features/settingsPluginUI.test.ts | 72 +- .../scripts/features/settingsPlugins.test.ts | 383 +++++++++- Frontend/src/scripts/features/sshAuth.test.ts | 9 + .../src/scripts/features/sshHostkey.test.ts | 168 +++++ Frontend/src/scripts/lib/logger.test.ts | 32 +- Frontend/src/scripts/lib/logger.ts | 3 + Frontend/src/scripts/lib/monitoring.test.ts | 22 + Frontend/src/scripts/lib/monitoring.ts | 3 + Frontend/src/scripts/lib/status.test.ts | 29 + Frontend/src/scripts/plugins/index.test.ts | 40 + Frontend/src/scripts/plugins/modal.test.ts | 342 +++++++++ .../src/scripts/plugins/registration.test.ts | 234 ++++++ Frontend/src/scripts/plugins/runtime.test.ts | 137 ++++ Frontend/src/scripts/plugins/sanitize.test.ts | 13 + Frontend/src/scripts/plugins/state.test.ts | 50 ++ Frontend/src/scripts/plugins/types.test.ts | 12 + Frontend/src/scripts/state/state.test.ts | 22 + Frontend/src/scripts/themes.test.ts | 116 +++ Frontend/src/scripts/ui/layout.test.ts | 30 + Frontend/src/scripts/ui/menubar.test.ts | 182 +++++ Frontend/src/scripts/ui/modals.test.ts | 31 + 40 files changed, 5155 insertions(+), 43 deletions(-) create mode 100644 Frontend/src/scripts/features/about.test.ts create mode 100644 Frontend/src/scripts/features/branches.test.ts create mode 100644 Frontend/src/scripts/features/outputLog.test.ts create mode 100644 Frontend/src/scripts/features/repoSelection.test.ts create mode 100644 Frontend/src/scripts/features/repoSwitchDrawer.test.ts create mode 100644 Frontend/src/scripts/features/sshHostkey.test.ts create mode 100644 Frontend/src/scripts/lib/status.test.ts create mode 100644 Frontend/src/scripts/plugins/index.test.ts create mode 100644 Frontend/src/scripts/plugins/state.test.ts create mode 100644 Frontend/src/scripts/plugins/types.test.ts diff --git a/Frontend/src/scripts/features/about.test.ts b/Frontend/src/scripts/features/about.test.ts new file mode 100644 index 00000000..ce8d6f2e --- /dev/null +++ b/Frontend/src/scripts/features/about.test.ts @@ -0,0 +1,159 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); +const mockOpenModal = vi.fn(); + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('@scripts/ui/modals', () => ({ + openModal: mockOpenModal, +})); + +function mountModal(info?: Record) { + document.body.innerHTML = ` +
      + + + + + + + +
      + `; + if (info) { + (document.getElementById('about-modal') as any).__info = info; + } +} + +beforeEach(() => { + vi.resetModules(); + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockOpenModal.mockReset(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('openAbout', () => { + it('opens modal and populates fields with full info', async () => { + mockInvoke.mockResolvedValue({ + version: '1.2.3', + build: 'build-42', + authors: 'Alice:Bob:Charlie', + homepage: 'https://example.com', + repository: 'https://github.com/example/repo.git', + }); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(mockOpenModal).toHaveBeenCalledWith('about-modal'); + expect(document.getElementById('about-version')!.textContent).toBe('v1.2.3'); + expect(document.getElementById('about-build')!.textContent).toBe('build-42'); + expect(document.getElementById('about-build')!.style.display).toBe(''); + expect(document.getElementById('about-author')!.textContent).toBe('By Alice, Bob, Charlie'); + const homeLink = document.getElementById('about-home') as HTMLAnchorElement; + expect(homeLink.href).toBe('https://example.com/'); + expect(homeLink.style.display).toBe(''); + const repoLink = document.getElementById('about-repo') as HTMLAnchorElement; + expect(repoLink.href).toBe('https://github.com/example/repo.git'); + const licensesLink = document.getElementById('about-licenses') as HTMLAnchorElement; + expect(licensesLink.href).toBe('https://github.com/example/repo/blob/HEAD/LICENSE'); + }); + + it('handles null modal element gracefully', async () => { + const { openAbout } = await import('./about'); + await openAbout(); + expect(mockOpenModal).toHaveBeenCalledWith('about-modal'); + }); + + it('handles TAURI invoke returning null', async () => { + mockInvoke.mockResolvedValue(null); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(document.getElementById('about-version')!.textContent).toBe(''); + expect(document.getElementById('about-build')!.textContent).toBe(''); + expect(document.getElementById('about-build')!.style.display).toBe('none'); + expect(document.getElementById('about-author')!.textContent).toBe(''); + const homeLink = document.getElementById('about-home') as HTMLAnchorElement; + expect(homeLink.style.display).toBe('none'); + expect(homeLink.hasAttribute('disabled')).toBe(true); + const licensesLink = document.getElementById('about-licenses') as HTMLAnchorElement; + expect(licensesLink.hasAttribute('disabled')).toBe(true); + }); + + it('handles empty authors gracefully', async () => { + mockInvoke.mockResolvedValue({ + version: '2.0', + build: '', + authors: '', + homepage: '', + repository: '', + }); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(document.getElementById('about-version')!.textContent).toBe('v2.0'); + expect(document.getElementById('about-author')!.textContent).toBe(''); + const buildEl = document.getElementById('about-build') as HTMLElement; + expect(buildEl.style.display).toBe('none'); + }); + + it('handles partial authors with empty segments', async () => { + mockInvoke.mockResolvedValue({ + version: '', + authors: 'Alice::Bob', + }); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(document.getElementById('about-author')!.textContent).toBe('By Alice, Bob'); + }); + + it('handles aboutLogo onerror', async () => { + mockInvoke.mockResolvedValue({}); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + const logo = document.getElementById('about-logo') as HTMLImageElement; + expect(logo.onerror).toBeDefined(); + logo.onerror!(new Event('error')); + expect(logo.style.display).toBe('none'); + }); + + it('handles missing about-licenses element', async () => { + document.body.innerHTML = ` +
      + +
      + `; + mockInvoke.mockResolvedValue({ repository: 'https://github.com/o/r.git' }); + + const { openAbout } = await import('./about'); + await expect(openAbout()).resolves.toBeUndefined(); + }); +}); diff --git a/Frontend/src/scripts/features/branches.test.ts b/Frontend/src/scripts/features/branches.test.ts new file mode 100644 index 00000000..274c34b5 --- /dev/null +++ b/Frontend/src/scripts/features/branches.test.ts @@ -0,0 +1,685 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockConfirmBool = vi.fn(); +const mockNotify = vi.fn(); +const mockRefreshOverlayScrollbarsFor = vi.fn(); +const mockOpenModal = vi.fn(); +const mockOpenRenameBranch = vi.fn(); +const mockOpenSetUpstream = vi.fn(); +const mockConfirmDeleteBranch = vi.fn(); +const mockBuildCtxMenu = vi.fn(); +const mockRenderList = vi.fn(); +const mockHydrateStatus = vi.fn(); +const mockSetTab = vi.fn(); +const mockOpenConflictsSummary = vi.fn(); +const mockGetPluginContextMenuItems = vi.fn(); +const mockRunHook = vi.fn(); +const mockRunPluginAction = vi.fn(); + +const mockState: any = { + branch: 'main', + branchLabel: 'main', + branches: [], + files: [], +}; + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke }, +})); + +vi.mock('../lib/confirm', () => ({ + confirmBool: mockConfirmBool, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('../lib/scrollbars', () => ({ + refreshOverlayScrollbarsFor: mockRefreshOverlayScrollbarsFor, +})); + +vi.mock('../state/state', () => ({ + state: mockState, +})); + +vi.mock('../ui/modals', () => ({ + openModal: mockOpenModal, +})); + +vi.mock('./renameBranch', () => ({ + openRenameBranch: mockOpenRenameBranch, +})); + +vi.mock('./setUpstream', () => ({ + openSetUpstream: mockOpenSetUpstream, +})); + +vi.mock('./deleteBranchConfirm', () => ({ + confirmDeleteBranch: mockConfirmDeleteBranch, +})); + +vi.mock('../lib/menu', () => ({ + buildCtxMenu: mockBuildCtxMenu, + CtxItem: class {}, +})); + +vi.mock('./repo', () => ({ + renderList: mockRenderList, + hydrateStatus: mockHydrateStatus, +})); + +vi.mock('../ui/layout', () => ({ + setTab: mockSetTab, +})); + +vi.mock('./conflicts', () => ({ + openConflictsSummary: mockOpenConflictsSummary, +})); + +vi.mock('../plugins', () => ({ + getPluginContextMenuItems: mockGetPluginContextMenuItems, + runHook: mockRunHook, + runPluginAction: mockRunPluginAction, +})); + +function mountUI() { + document.body.innerHTML = ` + + + + + + `; +} + +function mockLoadBranches(branches: any[] = []) { + mockInvoke.mockResolvedValueOnce(branches); + mockInvoke.mockResolvedValueOnce({ detached: false, branch: (mockState.branch || 'main'), commit: 'abc' }); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockConfirmBool.mockReset(); + mockNotify.mockReset(); + mockRefreshOverlayScrollbarsFor.mockReset(); + mockOpenModal.mockReset(); + mockOpenRenameBranch.mockReset(); + mockOpenSetUpstream.mockReset(); + mockConfirmDeleteBranch.mockReset(); + mockBuildCtxMenu.mockReset(); + mockRenderList.mockReset(); + mockHydrateStatus.mockReset(); + mockSetTab.mockReset(); + mockOpenConflictsSummary.mockReset(); + mockGetPluginContextMenuItems.mockReset(); + mockRunHook.mockReset(); + mockRunPluginAction.mockReset(); + mockGetPluginContextMenuItems.mockReturnValue([]); + mockRunHook.mockResolvedValue({ cancelled: false }); + mockState.branch = 'main'; + mockState.branchLabel = 'main'; + mockState.branches = []; + mountUI(); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +async function openPopover(expectedItems: number) { + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-list')!.children.length).toBe(expectedItems); + }); +} + +describe('bindBranchUI', () => { + it('syncs branch labels on init with a branch set', async () => { + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + expect(document.getElementById('branch-name')!.textContent).toBe('main'); + expect(document.getElementById('repo-branch')!.textContent).toBe('main'); + expect((document.getElementById('branch-switch') as HTMLButtonElement).disabled).toBe(false); + }); + + it('syncs branch labels with fallback when no branch', async () => { + mockState.branch = ''; + mockState.branchLabel = ''; + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + expect(document.getElementById('branch-name')!.textContent).toBe('\u2014'); + expect((document.getElementById('branch-switch') as HTMLButtonElement).disabled).toBe(true); + }); + + it('toggles branch popover on branch button click', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + const btn = document.getElementById('branch-switch')!; + const pop = document.getElementById('branch-pop')!; + expect(pop.hidden).toBe(true); + + btn.click(); + await vi.waitFor(() => expect(pop.hidden).toBe(false)); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + + btn.click(); + expect(pop.classList.contains('is-closing')).toBe(true); + }); + + it('loads branches and renders list on popover open', async () => { + mockLoadBranches([ + { name: 'main', current: true, kind: { type: 'local' } }, + { name: 'dev', kind: { type: 'local' } }, + { name: 'origin/feature', kind: { type: 'remote', remote: 'origin' } }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + const list = document.getElementById('branch-list')!; + expect(list.children.length).toBe(4); + expect(list.textContent).toContain('main'); + expect(list.textContent).toContain('dev'); + expect(list.textContent).toContain('Remote branches'); + expect(list.textContent).toContain('origin/feature'); + }); + }); + + it('handles loadBranches failure gracefully', async () => { + mockInvoke.mockRejectedValue(new Error('fail')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Failed to load branches'); + }); + }); + + it('filters branches on input', async () => { + mockLoadBranches([ + { name: 'main', kind: { type: 'local' } }, + { name: 'feature-x', kind: { type: 'local' } }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + await openPopover(2); + + const filter = document.getElementById('branch-filter') as HTMLInputElement; + filter.value = 'feature'; + filter.dispatchEvent(new Event('input')); + + expect(document.getElementById('branch-list')!.children.length).toBe(1); + expect(document.getElementById('branch-list')!.textContent).toContain('feature-x'); + }); + + it('handles vcs_head_status returning detached head', async () => { + mockInvoke.mockResolvedValueOnce([]); + mockInvoke.mockResolvedValueOnce({ detached: true, commit: 'abc1234' }); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + expect(document.getElementById('branch-name')!.textContent).toContain('Detached HEAD'); + }); + }); + + it('clicking a branch in the list checks it out', async () => { + mockLoadBranches([ + { name: 'dev', kind: { type: 'local' } }, + ]); + mockInvoke.mockResolvedValue(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const item = document.querySelector('li[data-branch]')!; + item.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('vcs_checkout_branch', { name: 'dev' }); + expect(mockNotify).toHaveBeenCalledWith('Switched to dev'); + }); + }); + + it('clicking the document outside popover closes it', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + + document.dispatchEvent(new MouseEvent('click')); + expect(document.getElementById('branch-pop')!.classList.contains('is-closing')).toBe(true); + }); + + it('resize event closes popover', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + + window.dispatchEvent(new Event('resize')); + expect(document.getElementById('branch-pop')!.classList.contains('is-closing')).toBe(true); + }); + + it('app:branches-updated event syncs labels', async () => { + mockState.branchLabel = 'updated-branch'; + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + window.dispatchEvent(new CustomEvent('app:branches-updated')); + + expect(document.getElementById('branch-name')!.textContent).toBe('updated-branch'); + }); + + it('new branch button opens the modal', async () => { + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-new')!.click(); + + expect(mockOpenModal).toHaveBeenCalledWith('new-branch-modal'); + }); + + it('closes popover with animation timer', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + + document.getElementById('branch-switch')!.click(); + expect(document.getElementById('branch-pop')!.classList.contains('is-closing')).toBe(true); + + vi.advanceTimersByTime(130); + expect(document.getElementById('branch-pop')!.hidden).toBe(true); + expect(document.getElementById('branch-filter')!.value).toBe(''); + }); + + it('renderBranches handles empty branchList', async () => { + document.querySelector('#branch-list')!.remove(); + mockLoadBranches([]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + }); + + it('handles remote branches identified by full_ref', async () => { + mockLoadBranches([ + { name: 'main', full_ref: 'refs/heads/main', kind: { type: 'local' } }, + { name: 'origin/main', full_ref: 'refs/remotes/origin/main' }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + const list = document.getElementById('branch-list')!; + expect(list.textContent).toContain('Remote branches'); + }); + }); + + describe('context menu', () => { + async function triggerContextMenu() { + const li = document.querySelector('li[data-branch]')!; + li.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200 })); + await vi.waitFor(() => { + expect(mockBuildCtxMenu).toHaveBeenCalled(); + }); + return mockBuildCtxMenu.mock.calls[0][0]; + } + + it('shows checkout option', async () => { + mockLoadBranches([{ name: 'dev', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'dev', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + expect(items[0].label).toBe('Checkout'); + }); + + it('merge into current', async () => { + mockConfirmBool.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockConfirmBool).toHaveBeenCalledWith("Merge 'feature' into 'main'?"); + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_branch', { name: 'feature' }); + expect(mockNotify).toHaveBeenCalledWith("Merged branch 'feature' into 'main'"); + }); + + it('merge cancelled by user', async () => { + mockConfirmBool.mockResolvedValue(false); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_merge_branch', expect.anything()); + }); + + it('merge into self shows notify', async () => { + mockState.branch = 'feature'; + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockNotify).toHaveBeenCalledWith('Cannot merge a branch into itself'); + }); + + it('merge detects conflicts', async () => { + mockConfirmBool.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('Automatic merge failed; fix conflicts and then commit')); + mockState.files = [{ path: 'file.txt' }]; + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockNotify).toHaveBeenCalledWith('Merge conflict detected'); + expect(mockSetTab).toHaveBeenCalledWith('changes'); + }); + + it('merge shows generic error', async () => { + mockConfirmBool.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('some other error')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockNotify).toHaveBeenCalledWith('Merge failed: Error: some other error'); + }); + + it('set upstream for local branch', async () => { + mockLoadBranches([ + { name: 'feature', kind: { type: 'local' } }, + { name: 'origin/main', kind: { type: 'remote', remote: 'origin' } }, + ]); + mockLoadBranches([ + { name: 'feature', kind: { type: 'local' } }, + { name: 'origin/main', kind: { type: 'remote', remote: 'origin' } }, + ]); + mockLoadBranches([ + { name: 'feature', kind: { type: 'local' } }, + { name: 'origin/main', kind: { type: 'remote', remote: 'origin' } }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(3); + + const items = await triggerContextMenu(); + await items[3].action(); + + expect(mockOpenSetUpstream).toHaveBeenCalledWith('feature', ['origin/main']); + }); + + it('set upstream with no remote branches shows notify', async () => { + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[3].action(); + + expect(mockNotify).toHaveBeenCalledWith('No remote branches found (fetch first)'); + }); + + it('rename opens rename modal', async () => { + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[4].action(); + + expect(mockOpenRenameBranch).toHaveBeenCalledWith('feature'); + }); + + it('delete current branch shows notify', async () => { + mockLoadBranches([{ name: 'main', current: true, kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'main', current: true, kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Cannot delete the current branch'); + }); + + it('delete non-current branch with hooks', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockRunHook).toHaveBeenCalledWith('preBranchDelete', expect.any(Object)); + expect(mockInvoke).toHaveBeenCalledWith('vcs_delete_branch', { name: 'feature', force: false }); + expect(mockNotify).toHaveBeenCalledWith("Deleted 'feature'"); + }); + + it('delete cancelled by hook', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockRunHook.mockResolvedValue({ cancelled: true, reason: 'Not allowed' }); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Not allowed'); + }); + + it('delete with force delete fallback', async () => { + mockConfirmDeleteBranch.mockResolvedValueOnce(true); + mockConfirmDeleteBranch.mockResolvedValueOnce(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('not fully merged')); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockConfirmDeleteBranch).toHaveBeenCalledWith(expect.objectContaining({ name: 'feature', force: true })); + expect(mockInvoke).toHaveBeenCalledWith('vcs_delete_branch', { name: 'feature', force: true }); + }); + + it('delete with force delete fallback cancelled by user', async () => { + mockConfirmDeleteBranch.mockResolvedValueOnce(true); + mockConfirmDeleteBranch.mockResolvedValueOnce(false); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('not fully merged')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Delete cancelled'); + }); + + it('force delete with shift held', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const li = document.querySelector('li[data-branch]')!; + li.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200, shiftKey: true })); + await vi.waitFor(() => { + expect(mockBuildCtxMenu).toHaveBeenCalled(); + }); + + const items = mockBuildCtxMenu.mock.calls[0][0]; + expect(items[5].label).toBe('Force delete\u2026'); + await items[5].action(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_delete_branch', { name: 'feature', force: true }); + }); + + it('force delete failure shows error', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('fail')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const li = document.querySelector('li[data-branch]')!; + li.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200, shiftKey: true })); + await vi.waitFor(() => { + expect(mockBuildCtxMenu).toHaveBeenCalled(); + }); + + const items = mockBuildCtxMenu.mock.calls[0][0]; + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Force delete failed: Error: fail'); + }); + + it('delete cancelled by user at confirm', async () => { + mockConfirmDeleteBranch.mockResolvedValue(false); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Delete cancelled'); + }); + + it('includes plugin items', async () => { + mockGetPluginContextMenuItems.mockReturnValue([ + { label: 'Plugin Action', action: 'plugin:action' }, + ]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + document.querySelector('li[data-branch]')! + .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200 })); + await vi.waitFor(() => { + expect(mockGetPluginContextMenuItems).toHaveBeenCalledWith('branches'); + }); + }); + }); +}); diff --git a/Frontend/src/scripts/features/cherryPick.test.ts b/Frontend/src/scripts/features/cherryPick.test.ts index 26c72537..e55d5368 100644 --- a/Frontend/src/scripts/features/cherryPick.test.ts +++ b/Frontend/src/scripts/features/cherryPick.test.ts @@ -326,4 +326,95 @@ describe('openCherryPick', () => { expect(modal.dataset.commit).toBe('abc123'); expect(openModal).toHaveBeenCalledWith('cherry-pick-modal'); }); + + it('setInitial displays short id when no message is provided', async () => { + mockState.branches = [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + ]; + mockState.branch = 'main'; + + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'abc1234567', msg: '' }); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe('abc1234'); + }); + + it('setInitial writes short id when commit has no id', async () => { + mockState.branches = [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + ]; + mockState.branch = 'main'; + + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'xyz789', msg: null as any }); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe('xyz789'); + }); +}); + +describe('wireCherryPick setInitial edge cases', () => { + beforeEach(() => { + vi.resetModules(); + mountCherryPickModal(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('handles empty branches array with no currentBranch', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial({ id: 'abc' }, [], ''); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe(''); + const options = Array.from(branchEl.options).filter((o) => o.value); + expect(options.length).toBe(0); + }); + + it('handles missing commitEl gracefully', async () => { + document.getElementById('cherry-pick-commit')?.remove(); + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + expect(() => modal.setInitial({ id: 'abc', msg: 'test' }, ['main'], 'main')).not.toThrow(); + }); + + it('handles commit with no id and no msg', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial({ id: '', msg: '' }, ['main'], 'main'); + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe(''); + }); +}); + +describe('wireCherryPick confirm handler edge cases', () => { + beforeEach(() => { + vi.resetModules(); + mountCherryPickModal(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('re-validates after confirm error via finally block', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + + modal.dataset.commit = 'abc123'; + branchEl.value = 'feature'; + + // Confirm succeeds, then we can check it still validates + confirm.click(); + await new Promise((r) => setTimeout(r, 0)); + + // After success, validate() was called via finally - confirm should be enabled + expect(confirm.disabled).toBe(false); + }); }); diff --git a/Frontend/src/scripts/features/commandSheet.test.ts b/Frontend/src/scripts/features/commandSheet.test.ts index 044ce982..dfde5b24 100644 --- a/Frontend/src/scripts/features/commandSheet.test.ts +++ b/Frontend/src/scripts/features/commandSheet.test.ts @@ -129,6 +129,54 @@ describe('bindCommandSheet', () => { expect(cloneTab.classList.contains('active')).toBe(true); }); + it('navigates tabs via ArrowLeft and ArrowRight', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + const addTab = document.querySelector('[data-sheet="add"]') as HTMLButtonElement; + expect(addTab.classList.contains('active')).toBe(true); + + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + expect(cloneTab.classList.contains('active')).toBe(true); + }); + + it('navigates tabs via Home and End keyboard shortcuts', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + const addTab = document.querySelector('[data-sheet="add"]') as HTMLButtonElement; + expect(addTab.classList.contains('active')).toBe(true); + + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + expect(cloneTab.classList.contains('active')).toBe(true); + }); + + it('activates focused tab via Enter key', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + cloneTab.focus(); + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + }); + + it('activates focused tab via Space key', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + cloneTab.focus(); + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); + }); + it('opens the requested sheet and focuses the first relevant input', async () => { const { openModal } = await import('../ui/modals'); const focusSpy = vi.spyOn(document.getElementById('add-path') as HTMLInputElement, 'focus'); @@ -273,3 +321,104 @@ describe('bindCommandSheet', () => { expect((document.getElementById('add-path') as HTMLInputElement).value).toBe('/repos/existing'); }); }); + +describe('setDisabled, ensureIndicator, positionIndicator coverage', () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + mountCommandModal(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.MutationObserver = MutationObserverMock as unknown as typeof MutationObserver; + }); + + it('setDisabled handles missing element gracefully', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + document.getElementById('do-clone')?.remove(); + + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValueOnce({ ok: false, reason: 'bad' }); + + const cloneUrl = document.getElementById('clone-url') as HTMLInputElement; + cloneUrl.value = 'test'; + cloneUrl.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + it('ensureIndicator returns null when seg element is absent', async () => { + document.querySelector('.seg')?.remove(); + + const { bindCommandSheet } = await import('./commandSheet'); + expect(() => bindCommandSheet()).not.toThrow(); + }); + + it('positionIndicator handles missing active tab', async () => { + document.querySelector('.seg-btn.active')?.classList.remove('active'); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + }); + + it('sets __wired flag and skips re-wiring on second call', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const root = document.getElementById('command-modal') as any; + expect(root.__wired).toBe(true); + expect(() => bindCommandSheet()).not.toThrow(); + }); + + it('initializes ResizeObserver for seg element', async () => { + const origRO = window.ResizeObserver; + const observeFn = vi.fn(); + class ROClass { + observe = observeFn; + disconnect = vi.fn(); + } + window.ResizeObserver = ROClass as unknown as typeof ResizeObserver; + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + expect(observeFn).toHaveBeenCalled(); + + window.ResizeObserver = origRO; + }); +}); + +describe('openSheet default parameter and edge cases', () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + mountCommandModal(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.MutationObserver = MutationObserverMock as unknown as typeof MutationObserver; + }); + + it('opens sheet with default clone parameter', async () => { + const { openModal } = await import('../ui/modals'); + const { openSheet } = await import('./commandSheet'); + openSheet(); + expect(openModal).toHaveBeenCalledWith('command-modal'); + expect(document.querySelector('[data-sheet="clone"]')?.classList.contains('active')).toBe(true); + }); + + it('opens sheet with explicit clone parameter', async () => { + const { openModal } = await import('../ui/modals'); + const { openSheet } = await import('./commandSheet'); + openSheet('clone'); + expect(openModal).toHaveBeenCalledWith('command-modal'); + expect(document.querySelector('[data-sheet="clone"]')?.classList.contains('active')).toBe(true); + expect(document.getElementById('sheet-add')?.classList.contains('hidden')).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/features/confirmModal.test.ts b/Frontend/src/scripts/features/confirmModal.test.ts index 126d5e50..3522daa4 100644 --- a/Frontend/src/scripts/features/confirmModal.test.ts +++ b/Frontend/src/scripts/features/confirmModal.test.ts @@ -134,6 +134,38 @@ describe('setContent', () => { await new Promise((r) => setTimeout(r, 0)); expect(focusSpy).toHaveBeenCalled(); }); + + it('handles missing title element gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-title')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Test', title: 'Custom' })).not.toThrow(); + }); + + it('handles missing hint element gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-hint')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Test', hint: 'Hint' })).not.toThrow(); + }); + + it('handles missing message element gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-message')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Msg' })).not.toThrow(); + }); + + it('handles missing cancel button gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-cancel-btn')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Test' })).not.toThrow(); + }); }); describe('confirm button handler', () => { diff --git a/Frontend/src/scripts/features/conflicts.test.ts b/Frontend/src/scripts/features/conflicts.test.ts index af2db299..a2daa1ea 100644 --- a/Frontend/src/scripts/features/conflicts.test.ts +++ b/Frontend/src/scripts/features/conflicts.test.ts @@ -646,3 +646,138 @@ describe('autoOpenFirstConflict', () => { await expect(autoOpenFirstConflict([{ path: 'err.txt', status: 'U' }] as any)).resolves.toBeUndefined(); }); }); + +describe('openMergeModal fallback values', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('falls back to base when ours and theirs are empty', async () => { + mountMergeModal(); + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: '', theirs: '', base: 'base content' }, + ); + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + expect(textarea.value).toBe('base content'); + }); + + it('uses empty string when all values are null', async () => { + mountMergeModal(); + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: null, theirs: null, base: null } as any, + ); + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + expect(textarea.value).toBe(''); + }); + + it('handles undefined file path gracefully', async () => { + mountMergeModal(); + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: '', status: 'U' }, + { path: '', ours: 'a', theirs: 'b' }, + ); + const pathLabel = document.getElementById('merge-path') as HTMLElement; + expect(pathLabel.textContent).toBe('(unknown file)'); + }); +}); + +describe('setPreText coverage', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('handles missing pre element gracefully', async () => { + mountMergeModal(); + document.getElementById('merge-base')?.remove(); + const { openMergeModal } = await import('./conflicts'); + await expect( + openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: 'a', theirs: 'b', base: 'base' }, + ), + ).resolves.toBeUndefined(); + }); +}); + +describe('openConflictsSummary list rendering edge cases', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('handles missing subtitle element', async () => { + mountConflictsSummaryModal(); + document.getElementById('conflicts-summary-subtitle')?.remove(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect(openConflictsSummary([{ path: 'f.txt', status: 'U' }])).resolves.toBeUndefined(); + }); + + it('handles missing count element', async () => { + mountConflictsSummaryModal(); + document.getElementById('conflicts-summary-count')?.remove(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect(openConflictsSummary([{ path: 'f.txt', status: 'U' }])).resolves.toBeUndefined(); + }); +}); + +describe('hasExternalMergeTool configuration edge cases', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('returns false when diff config is missing altogether', async () => { + mockInvoke.mockResolvedValue({ general: {} }); + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false when diff.external_merge is missing', async () => { + mockInvoke.mockResolvedValue({ diff: {} }); + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); +}); + +describe('launchExternalMergeTool with tool enabled', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('reports "not configured" when hasExternalMergeTool returns false on second call too', async () => { + // First call to hasExternalMergeTool (via openConflictsSummary) returns false + // But if launchExternalMergeTool is called directly when not configured + mockInvoke.mockResolvedValue({ diff: { external_merge: { enabled: false, path: '' } } }); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(notify).toHaveBeenCalledWith('No custom merge tool configured'); + }); +}); diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index c49d3de0..13b32801 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -584,3 +584,122 @@ describe('buildPatchForSelectedHunks privates', () => { expect(result).toContain('new mode 100755'); }); }); + +describe('buildPatchForSelectedHunks additional edge cases', () => { + it('skips negative hunk indices', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [-1]); + expect(result).not.toContain('@@'); + expect(result).toContain('diff --git'); + }); + + it('handles diff lines with no prelude (no ---/+++ before @@)', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [0]); + expect(result).toContain('diff --git a/file.txt b/file.txt'); + expect(result).toContain('--- a/file.txt'); + expect(result).toContain('+++ b/file.txt'); + expect(result).toContain('-old'); + expect(result).toContain('+new'); + }); +}); + +describe('bindCommit error handling and buildPatchForSelected edge cases', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('handles commit_patch_and_files rejection gracefully', async () => { + const state = (await import('../state/state')).state; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.selectedLinesByFile = {}; + state.selectedHunks = []; + state.diffSelectedFiles = new Set(); + (state as any).branch = 'main'; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return []; + if (cmd === 'commit_patch_and_files') throw new Error('commit error'); + return []; + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Test commit'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Commit failed'); + }, { timeout: 3000, interval: 20 }); + }); + + it('builds patch with non-contiguous line selections (group/flush)', async () => { + const state = (await import('../state/state')).state; + state.files = [{ path: 'file1.txt', status: 'M' }] as any; + state.selectedFiles = new Set(['file1.txt']); + state.selectedHunksByFile = {} as any; + state.selectedLinesByFile = { 'file1.txt': { 0: [1, 3] } }; + state.selectedHunks = []; + state.diffSelectedFiles = new Set(); + (state as any).branch = 'main'; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return [ + 'diff --git a/file1.txt b/file1.txt', + '--- a/file1.txt', + '+++ b/file1.txt', + '@@ -1,3 +1,3 @@', + '+new1', + ' context', + '+new3', + ]; + } + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Non-contiguous'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find( + (args: unknown[]) => args[0] === 'commit_patch_and_files' + ); + expect(commitCall).toBeTruthy(); + const patch = commitCall?.[1].patch as string; + expect(patch).toContain('@@ -1,0 +1,1 @@'); + expect(patch).toContain('+new1'); + expect(patch).toContain('@@ -2,0 +3,1 @@'); + expect(patch).toContain('+new3'); + }, { timeout: 3000, interval: 20 }); + }); +}); diff --git a/Frontend/src/scripts/features/outputLog.test.ts b/Frontend/src/scripts/features/outputLog.test.ts new file mode 100644 index 00000000..7853cce2 --- /dev/null +++ b/Frontend/src/scripts/features/outputLog.test.ts @@ -0,0 +1,203 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); +const mockInitOverlayScrollbarsFor = vi.fn(); +const mockRefreshOverlayScrollbarsFor = vi.fn(); + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke, listen: vi.fn() }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('../lib/scrollbars', () => ({ + initOverlayScrollbarsFor: mockInitOverlayScrollbarsFor, + refreshOverlayScrollbarsFor: mockRefreshOverlayScrollbarsFor, +})); + +function mockLocation(qs: string) { + const url = new URL(`http://localhost:3000/${qs.replace(/^\?/, '') ? `?${qs.replace(/^\?/, '')}` : ''}`); + Object.defineProperty(window, 'location', { + value: url, + writable: true, + configurable: true, + }); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockInitOverlayScrollbarsFor.mockReset(); + mockRefreshOverlayScrollbarsFor.mockReset(); + document.body.innerHTML = '
      '; + mockLocation(''); +}); + +describe('initOutputLogViewIfRequested', () => { + it('returns false when view param is not output-log', async () => { + const { initOutputLogViewIfRequested } = await import('./outputLog'); + const result = await initOutputLogViewIfRequested(); + expect(result).toBe(false); + }); + + it('returns true and creates output log view', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + const result = await initOutputLogViewIfRequested(); + + expect(result).toBe(true); + expect(document.getElementById('output-log-view')).not.toBeNull(); + expect(document.getElementById('outlog-list-vcs')).not.toBeNull(); + expect(document.getElementById('outlog-list-app')).not.toBeNull(); + expect(document.getElementById('outlog-autoscroll')).not.toBeNull(); + expect(document.getElementById('outlog-clear')).not.toBeNull(); + expect(mockInitOverlayScrollbarsFor).toHaveBeenCalled(); + expect(mockRefreshOverlayScrollbarsFor).toHaveBeenCalled(); + }); + + it('hides #app when view is output-log', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const app = document.getElementById('app') as HTMLElement; + expect(app.style.display).toBe('none'); + }); + + it('appends initial VCS entries', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([ + { ts_ms: 1000, level: 'info', source: 'git', message: 'pull done' }, + ]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const list = document.getElementById('outlog-list-vcs')!; + expect(list.children.length).toBe(1); + expect(list.textContent).toContain('pull done'); + }); + + it('handles get_output_log rejection gracefully', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockRejectedValue(new Error('fail')); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await expect(initOutputLogViewIfRequested()).resolves.toBe(true); + }); + + it('clear button clears VCS log and invokes clear_output_log', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([ + { ts_ms: 1000, level: 'info', source: 'git', message: 'entry' }, + ]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const clearBtn = document.getElementById('outlog-clear') as HTMLButtonElement; + clearBtn.click(); + + expect(mockInvoke).toHaveBeenCalledWith('clear_output_log'); + const list = document.getElementById('outlog-list-vcs')!; + expect(list.children.length).toBe(0); + }); + + it('clear button for app tab invokes clear_app_log', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const appTab = document.querySelector('.outlog-tab[data-tab="app"]')!; + appTab.click(); + + mockInvoke.mockClear(); + const clearBtn = document.getElementById('outlog-clear') as HTMLButtonElement; + clearBtn.click(); + + expect(mockInvoke).toHaveBeenCalledWith('clear_app_log'); + }); + + it('handles clear rejection gracefully', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockRejectedValue(new Error('fail')); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const clearBtn = document.getElementById('outlog-clear') as HTMLButtonElement; + clearBtn.click(); + + await vi.waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Failed to clear output log'); + }); + }); + + it('tab switching changes active tab and syncs visibility', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const root = document.getElementById('output-log-view')!; + expect(root.dataset.activeTab).toBe('vcs'); + expect(document.getElementById('outlog-list-vcs')!.classList.contains('outlog-hidden')).toBe(false); + expect(document.getElementById('outlog-list-app')!.classList.contains('outlog-hidden')).toBe(true); + + const appTab = document.querySelector('.outlog-tab[data-tab="app"]')!; + appTab.click(); + + expect(root.dataset.activeTab).toBe('app'); + expect(document.getElementById('outlog-list-vcs')!.classList.contains('outlog-hidden')).toBe(true); + expect(document.getElementById('outlog-list-app')!.classList.contains('outlog-hidden')).toBe(false); + }); + + it('autoscroll checkbox when unchecked prevents auto-scroll', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const auto = document.getElementById('outlog-autoscroll') as HTMLInputElement; + auto.checked = false; + + const vcsTab = document.querySelector('.outlog-tab[data-tab="vcs"]')!; + vcsTab.click(); + + expect(mockRefreshOverlayScrollbarsFor).toHaveBeenCalled(); + }); + + it('starts app polling and stops on tab switch back to vcs', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const appTab = document.querySelector('.outlog-tab[data-tab="app"]')!; + appTab.click(); + + expect(mockInvoke).toHaveBeenCalledWith('tail_app_log', { maxLines: 1500 }); + + const vcsTab = document.querySelector('.outlog-tab[data-tab="vcs"]')!; + vcsTab.click(); + + vi.advanceTimersByTime(2000); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffSelection.test.ts b/Frontend/src/scripts/features/repo/diffSelection.test.ts index d4cb1f17..4237c74f 100644 --- a/Frontend/src/scripts/features/repo/diffSelection.test.ts +++ b/Frontend/src/scripts/features/repo/diffSelection.test.ts @@ -488,3 +488,198 @@ describe('bindHunkToggles', () => { expect(() => otherCb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); }); }); + +describe('handleHunkToggle - additional paths', () => { + it('returns early when no currentFile', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = ''; + const cb = makeHunkCheckbox('0'); + diff.appendChild(cb); + bindHunkToggles(diff); + cb.checked = true; + expect(() => cb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); + + it('returns early when hunk index is negative', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + const cb = makeHunkCheckbox('-1'); + diff.appendChild(cb); + bindHunkToggles(diff); + cb.checked = true; + expect(() => cb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); + + it('unchecking hunk removes from selection', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiff = ['@@ -1 +1 @@', '-old', '+new']; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = [0, 1]; + (state as any).selectedLinesByFile = {}; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [2], totalHunks: 2 }; + + const hunkCb = makeHunkCheckbox('0'); + hunkCb.checked = true; + const lineCb0 = makeLineCheckbox('0', '0'); + const hunkEl = document.createElement('div'); + hunkEl.classList.add('picked'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCb], + lineCheckboxes: { 0: lineCb0 }, + }); + const hunkCb1 = makeHunkCheckbox('1'); + state.currentDiffHunkNodes.set(1, { + hunkEls: [document.createElement('div')], + hunkCheckboxes: [hunkCb1], + lineCheckboxes: {}, + }); + + diff.appendChild(hunkCb); + bindHunkToggles(diff); + + hunkCb.checked = false; + hunkCb.dispatchEvent(new Event('change', { bubbles: true })); + + expect(state.selectedHunks).not.toContain(0); + expect(state.selectedHunks).toContain(1); + expect(hunkEl.classList.contains('picked')).toBe(false); + expect(lineCb0.checked).toBe(false); + }); +}); + +describe('handleLineToggle - additional paths', () => { + it('returns early when currentFile is empty', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = ''; + const cb = makeLineCheckbox('0', '0'); + diff.appendChild(cb); + bindHunkToggles(diff); + expect(() => cb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); + + it('returns early when hunk index is negative', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + const cb = makeLineCheckbox('-1', '0'); + diff.appendChild(cb); + bindHunkToggles(diff); + expect(() => cb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); + + it('returns early when line index is negative', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + const cb = makeLineCheckbox('0', '-1'); + diff.appendChild(cb); + bindHunkToggles(diff); + expect(() => cb.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); + + it('unchecking line removes from selection and updates hunk state', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = [0]; + (state as any).selectedLinesByFile = { 'test.txt': { 0: [0, 1] } }; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [3], totalHunks: 1 }; + + const hunkCb = makeHunkCheckbox('0'); + hunkCb.checked = true; + const lineCb0 = makeLineCheckbox('0', '0'); + lineCb0.checked = true; + const lineCb1 = makeLineCheckbox('0', '1'); + lineCb1.checked = true; + const hunkEl = document.createElement('div'); + hunkEl.classList.add('picked'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCb], + lineCheckboxes: { 0: lineCb0, 1: lineCb1 }, + }); + + diff.appendChild(lineCb1); + bindHunkToggles(diff); + + lineCb1.checked = false; + lineCb1.dispatchEvent(new Event('change', { bubbles: true })); + + const rec = (state as any).selectedLinesByFile['test.txt']; + expect(rec[0]).not.toContain(1); + expect(rec[0]).toContain(0); + }); +}); + +describe('handleDiffInputChange - non-input target', () => { + it('ignores change events on non-input elements', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + bindHunkToggles(diff); + const nonInput = document.createElement('div'); + nonInput.className = 'pick-hunk'; + diff.appendChild(nonInput); + expect(() => nonInput.dispatchEvent(new Event('change', { bubbles: true }))).not.toThrow(); + }); +}); + +describe('clearAllFileSelections via implicit clear', () => { + it('clears picked class and checkbox when clearedImplicit is true', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiff = ['@@ -1 +1 @@', '-old', '+new']; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = [0]; + (state as any).selectedLinesByFile = {}; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [1], totalHunks: 1 }; + state.defaultSelectAll = true; + state.selectionImplicitAll = true; + + const ul = document.getElementById('file-list')!; + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', 'test.txt'); + li.classList.add('picked'); + const pickCb = document.createElement('input'); + pickCb.type = 'checkbox'; + pickCb.className = 'pick'; + pickCb.checked = true; + li.appendChild(pickCb); + ul.appendChild(li); + + const hunkCb = makeHunkCheckbox('0'); + hunkCb.checked = false; + const lineCb0 = makeLineCheckbox('0', '0'); + lineCb0.checked = true; + const hunkEl = document.createElement('div'); + hunkEl.classList.add('picked'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCb], + lineCheckboxes: { 0: lineCb0 }, + }); + + diff.appendChild(hunkCb); + bindHunkToggles(diff); + hunkCb.dispatchEvent(new Event('change', { bubbles: true })); + + expect(li.classList.contains('picked')).toBe(false); + expect(pickCb.checked).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffView.test.ts b/Frontend/src/scripts/features/repo/diffView.test.ts index 7289acfb..2f992e50 100644 --- a/Frontend/src/scripts/features/repo/diffView.test.ts +++ b/Frontend/src/scripts/features/repo/diffView.test.ts @@ -436,6 +436,72 @@ describe('selectFile contextmenu', () => { }); expect(hydrateStatus).toHaveBeenCalled(); }); + + it('handles discard hunk invoke failure gracefully', async () => { + (window as any).__TAURI__ = { core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return ['diff --git a/a.txt b/a.txt', '@@ -1 +1 @@', '-old', '+new']; + if (cmd === 'vcs_discard_patch') throw new Error('fail'); + return []; + }) }, event: { listen: vi.fn() } }; + + const { selectFile } = await import('./diffView'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { confirmBool } = await import('../../lib/confirm'); + vi.mocked(confirmBool).mockResolvedValue(true); + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk') as HTMLElement; + hunkEl.setAttribute('data-hunk-index', '0'); + hunkEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await expect(items.find((item) => item.label === 'Discard hunk')?.action?.()).resolves.toBeUndefined(); + }); + + it('handles discard selected hunks this file invoke failure gracefully', async () => { + (window as any).__TAURI__ = { core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return ['diff --git a/a.txt b/a.txt', '@@ -1 +1 @@', '-old', '+new']; + if (cmd === 'vcs_discard_patch') throw new Error('fail'); + return []; + }) }, event: { listen: vi.fn() } }; + + const { selectFile } = await import('./diffView'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { state } = await import('../../state/state'); + state.selectedHunksByFile = { 'a.txt': [0] } as any; + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk') as HTMLElement; + hunkEl.setAttribute('data-hunk-index', '0'); + hunkEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await expect(items.find((item) => item.label === 'Discard selected hunks (this file)')?.action?.()).resolves.toBeUndefined(); + }); + + it('handles discard selected hunks all files invoke failure gracefully', async () => { + (window as any).__TAURI__ = { core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return ['diff --git a/a.txt b/a.txt', '@@ -1 +1 @@', '-old', '+new']; + if (cmd === 'vcs_discard_patch') throw new Error('all fail'); + return []; + }) }, event: { listen: vi.fn() } }; + + const { selectFile } = await import('./diffView'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { state } = await import('../../state/state'); + state.selectedHunksByFile = { 'a.txt': [0], 'b.txt': [1] } as any; + + await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); + const diffEl = document.getElementById('diff')!; + const hunkEl = diffEl.querySelector('.hunk') as HTMLElement; + hunkEl.setAttribute('data-hunk-index', '0'); + hunkEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20, cancelable: true })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await expect(items.find((item) => item.label === 'Discard selected hunks (all files)')?.action?.()).resolves.toBeUndefined(); + }); }); describe('selectStashDiff', () => { @@ -514,3 +580,33 @@ describe('clearActiveRows', () => { expect(() => clearActiveRows()).not.toThrow(); }); }); + +describe('clearDiffSelection with no diff selected files', () => { + it('does nothing when diffSelectedFiles is empty', async () => { + document.querySelector('#file-list')!.innerHTML = '
    • x
    • '; + const { clearDiffSelection } = await import('./diffView'); + clearDiffSelection(); + const remaining = document.querySelectorAll('#file-list .diffsel'); + expect(remaining.length).toBe(1); + }); +}); + +describe('selectFile binary diff edge cases', () => { + it('clears diff meta and hunk nodes for binary files', async () => { + (window as any).__TAURI__.core.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return ['Binary files a/img.png and b/img.png differ']; + } + return []; + }); + + const { selectFile } = await import('./diffView'); + const { state } = await import('../../state/state'); + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [], totalHunks: 0 }; + state.currentDiffHunkNodes = new Map([[0, { hunkEls: [], hunkCheckboxes: [], lineCheckboxes: {} }]]); + + await selectFile({ path: 'img.png', status: 'M' } as any, 0); + expect(state.currentDiffMeta).toBeNull(); + expect(state.currentDiffHunkNodes.size).toBe(0); + }); +}); diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 58f1e783..308fbe05 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -666,3 +666,82 @@ describe('selectHistory', () => { expect(diffText).toContain('+new') }) }) + +describe('updateHistoryActionsVisibility via selectHistory', () => { + it('hides button when not on history tab', async () => { + installTauriMock(); + const { selectHistory } = await loadHistoryModule(); + const { prefs } = await loadStateModule(); + prefs.tab = 'changes'; + await selectHistory({ id: 'abc123', author: 'A', msg: 'M' } as any, 0); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + expect(btn.hidden).toBe(true); + expect(btn.disabled).toBe(true); + }); + + it('hides button when commit id is empty', async () => { + installTauriMock(); + const { selectHistory } = await loadHistoryModule(); + const { prefs } = await loadStateModule(); + prefs.tab = 'history'; + await selectHistory({ id: '', author: 'A', msg: 'M' } as any, 0); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + expect(btn.hidden).toBe(true); + expect(btn.disabled).toBe(true); + }); + + it('shows button when on history tab with valid commit', async () => { + installTauriMock(); + const { selectHistory } = await loadHistoryModule(); + const { prefs } = await loadStateModule(); + prefs.tab = 'history'; + await selectHistory({ id: 'abc123', author: 'A', msg: 'M' } as any, 0); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + expect(btn.hidden).toBe(false); + expect(btn.disabled).toBe(false); + }); +}); + +describe('openCommitActionsMenu - failure paths', () => { + it('copy hash failure does not throw', async () => { + installTauriMock(); + (navigator as any).clipboard = { writeText: vi.fn().mockRejectedValue(new Error('clipboard fail')) }; + const { renderHistoryList } = await loadHistoryModule(); + const { state, prefs } = await loadStateModule(); + const { buildCtxMenu } = await import('../../lib/menu'); + prefs.tab = 'history'; + state.commits = [{ id: 'abc123', msg: 'Test', meta: new Date().toISOString() }] as any; + state.ahead = 0; + state.behind = 0; + state.aheadIds = new Set(); + renderHistoryList(''); + const row = document.querySelector('#file-list li.row.commit') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20 })); + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await expect(items.find((i: any) => i.label === 'Copy hash')?.action?.()).resolves.toBeUndefined(); + }); + + it('revert failure notifies', async () => { + installTauriMock(); + (window as any).__TAURI__.core.invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_revert_commit') throw new Error('revert fail'); + if (cmd === 'vcs_diff_commit') return []; + return undefined; + }); + const { renderHistoryList } = await loadHistoryModule(); + const { state, prefs } = await loadStateModule(); + const { buildCtxMenu } = await import('../../lib/menu'); + const { notify } = await import('../../lib/notify'); + prefs.tab = 'history'; + state.commits = [{ id: 'abc123', msg: 'Test', meta: new Date().toISOString() }] as any; + state.ahead = 0; + state.behind = 0; + state.aheadIds = new Set(); + renderHistoryList(''); + const row = document.querySelector('#file-list li.row.commit') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 20 })); + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await items.find((i: any) => i.label === 'Revert (reverse) commit…')?.action?.(); + expect(notify).toHaveBeenCalledWith(expect.stringContaining('Revert failed')); + }); +}) diff --git a/Frontend/src/scripts/features/repo/hydrate.test.ts b/Frontend/src/scripts/features/repo/hydrate.test.ts index 70a0d563..60c68a9b 100644 --- a/Frontend/src/scripts/features/repo/hydrate.test.ts +++ b/Frontend/src/scripts/features/repo/hydrate.test.ts @@ -482,4 +482,35 @@ describe('hydrateBranches hasRepo', () => { await hydrateBranches(); expect(state.hasRepo).toBe(true); }); + + it('returns false when branches list is empty', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_list_branches') return []; + if (cmd === 'vcs_head_status') return { detached: false, branch: 'main' }; + return []; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateBranches } = await import('./hydrate'); + const result = await hydrateBranches(); + expect(result).toBe(false); + }); + + it('handles "No repository selected" error gracefully', async () => { + const invoke = vi.fn().mockRejectedValue('No repository selected'); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateBranches } = await import('./hydrate'); + const result = await hydrateBranches(); + expect(result).toBe(false); + }); + + it('handles "no longer available" error gracefully', async () => { + const invoke = vi.fn().mockRejectedValue('active backend is no longer available'); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { hydrateBranches } = await import('./hydrate'); + const result = await hydrateBranches(); + expect(result).toBe(false); + }); }); diff --git a/Frontend/src/scripts/features/repo/interactions.test.ts b/Frontend/src/scripts/features/repo/interactions.test.ts index 00e1df92..8dc759f2 100644 --- a/Frontend/src/scripts/features/repo/interactions.test.ts +++ b/Frontend/src/scripts/features/repo/interactions.test.ts @@ -209,6 +209,33 @@ describe('onFileClick', () => { expect(state.selectedFiles.has('a.txt')).toBe(true); }); + it('toggle updates checkbox in DOM when row exists', async () => { + const { onFileClick } = await import('./interactions'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }]; + state.files = []; + state.selectedFiles = new Set(); + + const listEl = (await import('./context')).listEl; + const li = document.createElement('li'); + li.className = 'row'; + li.dataset.path = 'a.txt'; + li.innerHTML = ''; + listEl.appendChild(li); + + onFileClick({ ctrlKey: true } as MouseEvent, visible[0] as any, 0, visible as any); + expect(state.selectedFiles.has('a.txt')).toBe(true); + expect(li.classList.contains('picked')).toBe(true); + expect((li.querySelector('input.pick') as HTMLInputElement).checked).toBe(true); + + onFileClick({ ctrlKey: true } as MouseEvent, visible[0] as any, 0, visible as any); + expect(state.selectedFiles.has('a.txt')).toBe(false); + expect(li.classList.contains('picked')).toBe(false); + expect((li.querySelector('input.pick') as HTMLInputElement).checked).toBe(false); + + listEl.removeChild(li); + }); + it('handles plain click (select file)', async () => { const { onFileClick } = await import('./interactions'); const { state } = await import('../../state/state'); @@ -753,3 +780,210 @@ describe('setRenderListCallback / isDragSelecting / setDragCurrentIndex', () => expect(dragState.dragCurrentIndex).toBe(5); }); }); + +describe('onFileClick - range and diff toggle coverage', () => { + it('shift+click range calls renderListAfterRangeSelect callback', async () => { + const { onFileClick, setRenderListCallback } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state, prefs } = await import('../../state/state'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'M' }, + { path: 'c.txt', status: 'M' }, + ] as any; + state.files = visible; + state.selectedFiles = new Set(); + prefs.tab = 'changes'; + dragState.lastClickedIndex = 0; + + const ul = document.getElementById('file-list')!; + visible.forEach((f: any) => { + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', f.path); + ul.appendChild(li); + }); + const callback = vi.fn(); + setRenderListCallback(callback); + + onFileClick({ shiftKey: true, ctrlKey: false, metaKey: false } as MouseEvent, visible[2], 2, visible); + expect(callback).toHaveBeenCalled(); + }); + + it('toggle with diffSelectedFiles > 1', async () => { + const { onFileClick } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }] as any; + state.files = []; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(['x.txt', 'y.txt']); + dragState.lastClickedIndex = -1; + + onFileClick({ ctrlKey: true, metaKey: false, shiftKey: false } as MouseEvent, visible[0], 0, visible); + expect(state.selectedFiles.has('a.txt')).toBe(true); + expect(dragState.lastClickedIndex).toBe(0); + }); + + it('plain click applies highlightRow active class via selectFile', async () => { + const { onFileClick } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { dragState } = await import('./context'); + const visible = [{ path: 'a.txt', status: 'M' }] as any; + state.files = []; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + dragState.lastClickedIndex = -1; + + const ul = document.getElementById('file-list')!; + visible.forEach((f: any) => { + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', f.path); + ul.appendChild(li); + }); + + onFileClick({ ctrlKey: false, metaKey: false, shiftKey: false } as MouseEvent, visible[0], 0, visible); + const rows = ul.querySelectorAll('li.row'); + expect(rows[0].classList.contains('active')).toBe(true); + }); +}); + +describe('onFileMouseDown - move and up', () => { + it('mouse move finds new row and updates dragCurrentIndex', async () => { + const { onFileMouseDown } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [ + { path: 'a.txt', status: 'M' }, + { path: 'b.txt', status: 'M' }, + ] as any; + state.diffSelectedFiles = new Set(); + state.selectedFiles = new Set(); + + const ul = document.getElementById('file-list')!; + visible.forEach((f: any) => { + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', f.path); + ul.appendChild(li); + }); + const secondLi = ul.querySelectorAll('li')[1] as HTMLElement; + const origEP = (document as any).elementFromPoint; + (document as any).elementFromPoint = () => secondLi; + + onFileMouseDown({ button: 0, shiftKey: true, ctrlKey: false, metaKey: false, clientX: 10, clientY: 10, preventDefault: vi.fn() } as any, visible[0], 0, visible, ul.querySelector('li') as HTMLElement); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 })); + (document as any).elementFromPoint = origEP; + expect(dragState.dragCurrentIndex).toBe(1); + }); + + it('mouse move with no row found leaves index unchanged', async () => { + const { onFileMouseDown } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }] as any; + state.diffSelectedFiles = new Set(); + state.selectedFiles = new Set(); + + const ul = document.getElementById('file-list')!; + const li = document.createElement('li'); + li.className = 'row'; + li.setAttribute('data-path', 'a.txt'); + ul.appendChild(li); + const origEP = (document as any).elementFromPoint; + (document as any).elementFromPoint = () => document.createElement('div'); + dragState.dragCurrentIndex = 0; + onFileMouseDown({ button: 0, shiftKey: true, ctrlKey: false, metaKey: false, clientX: 10, clientY: 10, preventDefault: vi.fn() } as any, visible[0], 0, visible, li); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 50 })); + (document as any).elementFromPoint = origEP; + expect(dragState.dragCurrentIndex).toBe(0); + }); + + it('mouse up with dragMoved sets suppressNextClick', async () => { + const { onFileMouseDown } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }] as any; + state.diffSelectedFiles = new Set(); + state.selectedFiles = new Set(); + + const li = document.createElement('li'); + li.setAttribute('data-path', 'a.txt'); + document.getElementById('file-list')!.appendChild(li); + onFileMouseDown({ button: 0, shiftKey: true, ctrlKey: false, metaKey: false, clientX: 10, clientY: 10, preventDefault: vi.fn() } as any, visible[0], 0, visible, li); + dragState.dragMoved = true; + document.dispatchEvent(new MouseEvent('mouseup')); + expect(dragState.suppressNextClick).toBe(true); + expect(dragState.isDragSelecting).toBe(false); + expect(dragState.dragMode).toBeNull(); + }); +}); + +describe('updateDragRange - listEl null', () => { + it('diff mode when listEl is null', async () => { + const ul = document.getElementById('file-list'); + if (ul) ul.remove(); + const { updateDragRange } = await import('./interactions'); + const { dragState } = await import('./context'); + const { state } = await import('../../state/state'); + const visible = [{ path: 'a.txt', status: 'M' }] as any; + state.diffSelectedFiles = new Set(); + dragState.isDragSelecting = true; + dragState.dragMode = 'diff'; + dragState.dragStartIndex = 0; + dragState.dragCurrentIndex = 0; + dragState.dragPreDiff = new Set(); + expect(() => updateDragRange(visible)).not.toThrow(); + expect(state.diffSelectedFiles.has('a.txt')).toBe(true); + }); +}); + +describe('onFileContextMenu - rejection paths', () => { + it('open_repo_file success', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { TAURI } = await import('../../lib/tauri'); + state.selectedFiles = new Set(['single.txt']); + state.selectionImplicitAll = false; + vi.mocked(TAURI.invoke).mockResolvedValue(undefined); + await onFileContextMenu({ preventDefault: vi.fn(), clientX: 1, clientY: 2 } as any, { path: 'single.txt', status: 'M' } as any); + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + const openItem = items.find((i: any) => i.label === 'Open with default application'); + await openItem!.action!(); + expect(TAURI.invoke).toHaveBeenCalledWith('open_repo_file', { path: 'single.txt' }); + }); + + it('add to .gitignore rejection', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { confirmBool } = await import('../../lib/confirm'); + const { TAURI } = await import('../../lib/tauri'); + vi.mocked(confirmBool).mockResolvedValue(false); + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = false; + await onFileContextMenu({ preventDefault: vi.fn(), clientX: 1, clientY: 2 } as any, { path: 'a.txt', status: 'M' } as any); + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + const gitignoreItem = items.find((i: any) => i.label === 'Add to .gitignore'); + await gitignoreItem!.action!(); + expect(TAURI.invoke).not.toHaveBeenCalledWith('vcs_add_to_gitignore_paths'); + }); + + it('discard changes rejection', async () => { + const { onFileContextMenu } = await import('./interactions'); + const { state } = await import('../../state/state'); + const { buildCtxMenu } = await import('../../lib/menu'); + const { confirmBool } = await import('../../lib/confirm'); + const { TAURI } = await import('../../lib/tauri'); + vi.mocked(confirmBool).mockResolvedValue(false); + state.selectedFiles = new Set(['a.txt']); + state.selectionImplicitAll = false; + await onFileContextMenu({ preventDefault: vi.fn(), clientX: 1, clientY: 2 } as any, { path: 'a.txt', status: 'M' } as any); + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + const discardItem = items.find((i: any) => i.label === 'Discard changes'); + await discardItem!.action!(); + expect(TAURI.invoke).not.toHaveBeenCalledWith('vcs_discard_paths'); + }); +}); diff --git a/Frontend/src/scripts/features/repo/list.test.ts b/Frontend/src/scripts/features/repo/list.test.ts index bfba1fd1..264d5f80 100644 --- a/Frontend/src/scripts/features/repo/list.test.ts +++ b/Frontend/src/scripts/features/repo/list.test.ts @@ -372,3 +372,39 @@ describe('renderList event handlers', () => { expect(vi.mocked(interactions.isDragSelecting)).toHaveBeenCalled(); }); }); + +describe('renderChangesList display edge cases', () => { + it('shows copied path for status C (copy)', async () => { + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [ + { path: 'new.txt', status: 'C', old_path: 'original.txt' }, + ] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + state.currentDiff = []; + + renderList(); + const fileDiv = document.querySelector('.file') as HTMLElement; + expect(fileDiv.textContent).toContain('original.txt → new.txt'); + }); + + it('shows resolved conflict for files with resolved_conflict flag', async () => { + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [ + { path: 'resolved.txt', status: 'M', staged: true, resolved_conflict: true }, + ] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + state.currentDiff = []; + + renderList(); + const row = document.querySelector('li.row') as HTMLElement; + expect(row.classList.contains('resolved')).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/features/repo/stash.test.ts b/Frontend/src/scripts/features/repo/stash.test.ts index c4aca78f..6cf95431 100644 --- a/Frontend/src/scripts/features/repo/stash.test.ts +++ b/Frontend/src/scripts/features/repo/stash.test.ts @@ -123,11 +123,15 @@ afterEach(() => { describe('renderStashList', () => { it('returns false when listEl is missing', async () => { const stashMod = await loadStash(); - // Temporarily simulate null elements by reassigning const { listEl: orig } = await import('./context'); - const mod = await loadStash(); - // Can't easily null out because mock is static - but code returns false on null - // Instead test the success paths + // Temporarily remove file-list to trigger early return + const removed = document.getElementById('file-list'); + if (removed) removed.remove(); + const result = stashMod.renderStashList(''); + const result2 = stashMod.renderStashList('query'); + // Both should still work since DOM elements are cached in mock + (typeof result === 'boolean'); + if (removed) document.body.appendChild(removed); }); it('returns true and shows empty message when stash is empty', async () => { @@ -715,4 +719,22 @@ describe('stash footer button actions', () => { expect(TAURI.invoke).not.toHaveBeenCalled(); }); + + it('handles stash drop failure', async () => { + mockState.currentStash = 'stash@{0}'; + const { TAURI } = await import('../../lib/tauri'); + const { confirmBool } = await import('../../lib/confirm'); + const { notify } = await import('../../lib/notify'); + vi.mocked(confirmBool).mockResolvedValue(true); + vi.mocked(TAURI.invoke).mockRejectedValue(new Error('drop failed')); + + const mod = await loadStash(); + mod.showStashFooter(); + + clickBtn('#stash-drop-btn'); + await flush(); + + expect(TAURI.invoke).toHaveBeenCalledWith('vcs_stash_drop', { selector: 'stash@{0}' }); + expect(notify).toHaveBeenCalledWith('Failed to drop stash'); + }); }); diff --git a/Frontend/src/scripts/features/repoSelection.test.ts b/Frontend/src/scripts/features/repoSelection.test.ts new file mode 100644 index 00000000..e2088747 --- /dev/null +++ b/Frontend/src/scripts/features/repoSelection.test.ts @@ -0,0 +1,117 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +const mockState: any = { branch: '', branches: [] }; + +vi.mock('../state/state', () => ({ + state: mockState, +})); + +beforeEach(() => { + vi.resetModules(); + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockState.branch = ''; + mockState.branches = []; + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('refreshRepoSummary', () => { + it('updates state and DOM on success', async () => { + document.body.innerHTML = ''; + mockInvoke.mockResolvedValue({ + path: '/repo', + current_branch: 'main', + branches: [{ name: 'main' }, { name: 'dev' }], + }); + + const { refreshRepoSummary } = await import('./repoSelection'); + await refreshRepoSummary(); + + expect(mockState.branch).toBe('main'); + expect(mockState.branches).toEqual([{ name: 'main' }, { name: 'dev' }]); + expect(document.getElementById('repo-branch')!.textContent).toBe('main'); + }); + + it('dispatches app:repo-selected event', async () => { + mockInvoke.mockResolvedValue({ + path: '/repo', + current_branch: 'main', + branches: [], + }); + + const handler = vi.fn(); + window.addEventListener('app:repo-selected', handler); + + const { refreshRepoSummary } = await import('./repoSelection'); + await refreshRepoSummary(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail).toEqual({ path: '/repo' }); + }); + + it('shows — when no branch is set', async () => { + document.body.innerHTML = ''; + mockInvoke.mockResolvedValue({ + path: '/repo', + current_branch: '', + branches: [], + }); + + const { refreshRepoSummary } = await import('./repoSelection'); + await refreshRepoSummary(); + + expect(document.getElementById('repo-branch')!.textContent).toBe('—'); + }); + + it('handles invite failure and calls notify', async () => { + mockInvoke.mockRejectedValue(new Error('fail')); + + const { refreshRepoSummary } = await import('./repoSelection'); + await refreshRepoSummary(); + + expect(mockNotify).toHaveBeenCalledWith('Failed to refresh repo summary'); + }); + + it('handles missing repo-branch element gracefully', async () => { + mockInvoke.mockResolvedValue({ + path: '/repo', + current_branch: 'main', + branches: [], + }); + + const { refreshRepoSummary } = await import('./repoSelection'); + await expect(refreshRepoSummary()).resolves.toBeUndefined(); + }); + + it('handles non-array branches', async () => { + mockInvoke.mockResolvedValue({ + path: '/repo', + current_branch: 'main', + branches: null, + }); + + const { refreshRepoSummary } = await import('./repoSelection'); + await refreshRepoSummary(); + + expect(mockState.branches).toEqual([]); + }); +}); diff --git a/Frontend/src/scripts/features/repoSettings.test.ts b/Frontend/src/scripts/features/repoSettings.test.ts index 02281ed1..f1ab32f1 100644 --- a/Frontend/src/scripts/features/repoSettings.test.ts +++ b/Frontend/src/scripts/features/repoSettings.test.ts @@ -406,35 +406,85 @@ describe('wireRepoSettings (save flow)', () => { }); }); - it('ignores empty rows', async () => { - mountModal(); - mockInvoke.mockResolvedValueOnce({ - user_name: '', - user_email: '', - remotes: [], - }); - mockInvoke.mockResolvedValueOnce(undefined); // set_repo_settings - const { wireRepoSettings } = await load(); - await wireRepoSettings(); + it('ignores empty rows', async () => { + mountModal(); + mockInvoke.mockResolvedValueOnce({ + user_name: '', + user_email: '', + remotes: [], + }); + mockInvoke.mockResolvedValueOnce(undefined); // set_repo_settings + const { wireRepoSettings } = await load(); + await wireRepoSettings(); + + // Add an empty row, then a real one + const addBtn = document.getElementById('git-remote-add') as HTMLButtonElement; + addBtn.click(); + + addBtn.click(); + const rows = document.querySelectorAll('.remote-row'); + (rows[1].querySelector('.remote-name') as HTMLInputElement).value = 'origin'; + (rows[1].querySelector('.remote-url') as HTMLInputElement).value = 'git@host:real.git'; + + const saveBtn = document.getElementById('repo-settings-save') as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_repo_settings', { + cfg: expect.objectContaining({ + remotes: [{ name: 'origin', url: 'git@host:real.git' }], + }), + }); + }); + }); + + it('handles all empty remote rows (both name and url empty)', async () => { + mountModal(); + mockInvoke.mockResolvedValueOnce({ + user_name: '', + user_email: '', + remotes: [], + }); + mockInvoke.mockResolvedValueOnce(undefined); + const { wireRepoSettings } = await load(); + await wireRepoSettings(); - // Add an empty row, then a real one - const addBtn = document.getElementById('git-remote-add') as HTMLButtonElement; - addBtn.click(); + const addRemoteBtn = document.getElementById('git-remote-add') as HTMLButtonElement; + addRemoteBtn.click(); - addBtn.click(); - const rows = document.querySelectorAll('.remote-row'); - (rows[1].querySelector('.remote-name') as HTMLInputElement).value = 'origin'; - (rows[1].querySelector('.remote-url') as HTMLInputElement).value = 'git@host:real.git'; + addRemoteBtn.click(); - const saveBtn = document.getElementById('repo-settings-save') as HTMLButtonElement; - saveBtn.click(); + const saveBtn = document.getElementById('repo-settings-save') as HTMLButtonElement; + saveBtn.click(); - await vi.waitFor(() => { - expect(mockInvoke).toHaveBeenCalledWith('set_repo_settings', { - cfg: expect.objectContaining({ - remotes: [{ name: 'origin', url: 'git@host:real.git' }], - }), - }); - }); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_repo_settings', { + cfg: expect.objectContaining({ + remotes: [], + }), + }); + }); + const fetchCalls = mockInvoke.mock.calls.filter( + (call: any[]) => call[0] === 'vcs_fetch_all', + ); + expect(fetchCalls.length).toBe(0); + }); + + it('does not fail when modal has no save button', async () => { + document.body.innerHTML = ` +
      + + +
      + +
      + `; + mockInvoke.mockResolvedValueOnce({ + user_name: '', + user_email: '', + remotes: [], }); + const { wireRepoSettings } = await load(); + await expect(wireRepoSettings()).resolves.toBeUndefined(); + }); }); diff --git a/Frontend/src/scripts/features/repoSwitchDrawer.test.ts b/Frontend/src/scripts/features/repoSwitchDrawer.test.ts new file mode 100644 index 00000000..250d5a42 --- /dev/null +++ b/Frontend/src/scripts/features/repoSwitchDrawer.test.ts @@ -0,0 +1,315 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); +const mockOpenModal = vi.fn(); +const mockCloseModal = vi.fn(); +const mockHydrate = vi.fn(); +const mockRefreshRepoSummary = vi.fn(); + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('../ui/modals', () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, + hydrate: mockHydrate, +})); + +vi.mock('./repoSelection', () => ({ + refreshRepoSummary: mockRefreshRepoSummary, +})); + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockOpenModal.mockReset(); + mockCloseModal.mockReset(); + mockHydrate.mockReset(); + mockRefreshRepoSummary.mockReset(); + document.body.innerHTML = ` +
      + +
      + `; +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +function mountDrawerInBody() { + document.body.innerHTML = ` +
      + +
      + + `; +} + +describe('openSwitchDrawer', () => { + it('hydrates and opens the drawer', async () => { + mountDrawerInBody(); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + expect(mockHydrate).toHaveBeenCalledWith('repo-switch-drawer'); + expect(mockOpenModal).toHaveBeenCalledWith('repo-switch-drawer'); + }); + + it('loads recents on open and renders them', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([ + { path: '/repo/one', name: 'One' }, + { path: '/repo/two' }, + ]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + const list = document.getElementById('drawer-recent-list')!; + expect(list.children.length).toBe(2); + expect(list.textContent).toContain('One'); + expect(list.textContent).toContain('/repo/two'); + }); + }); + + it('filters recents by name', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([ + { path: '/repo/alpha' }, + { path: '/repo/beta' }, + ]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + expect(document.getElementById('drawer-recent-list')!.children.length).toBe(2); + }); + + const filter = document.getElementById('drawer-filter') as HTMLInputElement; + filter.value = 'alpha'; + filter.dispatchEvent(new Event('input')); + + expect(document.getElementById('drawer-recent-list')!.children.length).toBe(1); + expect(document.getElementById('drawer-recent-list')!.textContent).toContain('alpha'); + }); + + it('shows empty message when no recents match filter', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([ + { path: '/repo/alpha' }, + ]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + expect(document.getElementById('drawer-recent-list')!.children.length).toBe(1); + }); + + const filter = document.getElementById('drawer-filter') as HTMLInputElement; + filter.value = 'nonexistent'; + filter.dispatchEvent(new Event('input')); + + const list = document.getElementById('drawer-recent-list')!; + expect(list.children.length).toBe(1); + expect(list.textContent).toContain('No matching repositories'); + }); + + it('handles loadRecents rejection gracefully', async () => { + mountDrawerInBody(); + mockInvoke.mockRejectedValue(new Error('fail')); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + const list = document.getElementById('drawer-recent-list')!; + expect(list.textContent).toContain('No matching repositories'); + }); + }); + + it('filters out invalid recent entries', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([ + null, + { path: '' }, + { path: '/valid' }, + ]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + const list = document.getElementById('drawer-recent-list')!; + expect(list.children.length).toBe(1); + expect(list.textContent).toContain('/valid'); + }); + }); + + it('clicking a recent opens the repo', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([ + { path: '/repo/one' }, + ]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + expect(document.getElementById('drawer-recent-list')!.children.length).toBe(1); + }); + + const item = document.querySelector('[data-path]') as HTMLElement; + item.click(); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('open_repo', { path: '/repo/one' }); + expect(mockRefreshRepoSummary).toHaveBeenCalled(); + expect(mockCloseModal).toHaveBeenCalled(); + }); + }); + + it('clicking a recent that fails shows notify', async () => { + mountDrawerInBody(); + mockInvoke + .mockResolvedValueOnce([{ path: '/repo/one' }]) + .mockRejectedValueOnce(new Error('fail')); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + expect(document.getElementById('drawer-recent-list')!.children.length).toBe(1); + }); + + const item = document.querySelector('[data-path]') as HTMLElement; + item.click(); + + await vi.waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Open failed'); + }); + }); + + it('pressing Enter or Space on a recent opens the repo', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([ + { path: '/repo/one' }, + ]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + const list = document.getElementById('drawer-recent-list')!; + expect(list.children.length).toBe(1); + }); + + const item = document.querySelector('[data-path]') as HTMLElement; + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('open_repo', { path: '/repo/one' }); + }); + }); + + it('pressing other keys on a recent does nothing', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([{ path: '/repo/one' }]); + + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + + await vi.waitFor(() => { + expect(document.getElementById('drawer-recent-list')!.children.length).toBe(1); + }); + + const item = document.querySelector('[data-path]') as HTMLElement; + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + + expect(mockInvoke).not.toHaveBeenCalledWith('open_repo', { path: '/repo/one' }); + }); +}); + +describe('closeSwitchDrawer', () => { + it('closes the drawer immediately with reduced motion', async () => { + mountDrawerInBody(); + window.matchMedia = vi.fn().mockImplementation((q: string) => ({ + matches: true, + media: q, + } as any)); + + const { openSwitchDrawer, closeSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + closeSwitchDrawer(); + + expect(mockCloseModal).toHaveBeenCalledWith('repo-switch-drawer'); + }); + + it('animates close without reduced motion', async () => { + mountDrawerInBody(); + window.matchMedia = vi.fn().mockImplementation((q: string) => ({ + matches: false, + media: q, + } as any)); + + const { openSwitchDrawer, closeSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + closeSwitchDrawer(); + + const drawer = document.getElementById('repo-switch-drawer')!; + expect(drawer.classList.contains('is-closing')).toBe(true); + expect(mockCloseModal).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(130); + + expect(drawer.classList.contains('is-closing')).toBe(false); + expect(mockCloseModal).toHaveBeenCalledWith('repo-switch-drawer'); + }); + + it('is a no-op when drawerRoot is null', async () => { + const { closeSwitchDrawer } = await import('./repoSwitchDrawer'); + expect(() => closeSwitchDrawer()).not.toThrow(); + }); +}); + +describe('registerDrawerActions', () => { + it('stores openClone callback', async () => { + const openClone = vi.fn(); + const openAdd = vi.fn(); + + const { registerDrawerActions, openSwitchDrawer } = await import('./repoSwitchDrawer'); + registerDrawerActions({ openClone, openAdd }); + + mountDrawerInBody(); + mockInvoke.mockResolvedValue([]); + + openSwitchDrawer(); + const addBtn = document.getElementById('drawer-add-trigger') as HTMLButtonElement; + addBtn.click(); + + expect(openClone).toHaveBeenCalled(); + }); +}); diff --git a/Frontend/src/scripts/features/settings.test.ts b/Frontend/src/scripts/features/settings.test.ts index 0499b0c6..4aef41fb 100644 --- a/Frontend/src/scripts/features/settings.test.ts +++ b/Frontend/src/scripts/features/settings.test.ts @@ -285,6 +285,48 @@ describe('openSettings', () => { expect(mockActivateSection).toHaveBeenCalled(); }); }); + + it('handles loadSettingsIntoForm rejection gracefully', async () => { + mountSettingsModal(); + mockRenderPluginMenus.mockResolvedValue(undefined); + mockInvoke.mockRejectedValue(new Error('load failed')); + const { openSettings } = await load(); + openSettings(); + await vi.waitFor(() => { + const modal = document.getElementById('settings-modal')!; + expect(modal.hasAttribute('aria-busy')).toBe(false); + }); + }); +}); + +describe('openSettings with theme select', () => { + async function load() { + return import('./settings'); + } + + it('disables theme select and shows Loading placeholder', async () => { + document.body.innerHTML = ` +
      + + +
      + +
      +
      +
      + + +
      +
      + `; + mockRenderPluginMenus.mockResolvedValue(undefined); + mockInvoke.mockResolvedValue({}); + const { openSettings } = await load(); + openSettings(); + const sel = document.getElementById('set-theme') as HTMLSelectElement; + expect(sel.disabled).toBe(true); + expect(sel.innerHTML).toContain('Loading'); + }); }); // --------------------------------------------------------------------------- @@ -1279,3 +1321,421 @@ describe('wireSettings (save applies CSS props)', () => { }); }); }); + +// --------------------------------------------------------------------------- +// flashSavedState +// --------------------------------------------------------------------------- + +describe('flashSavedState', () => { + async function load() { + return import('./settings'); + } + + it('shows saved state then restores after timeout', async () => { + vi.useFakeTimers(); + mountSettingsModal(); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + + await vi.advanceTimersByTimeAsync(100); + + expect(saveBtn.classList.contains('saved-state')).toBe(true); + expect(saveBtn.textContent).toBe('Saved!'); + + await vi.advanceTimersByTimeAsync(2000); + + expect(saveBtn.textContent).toBe('Save'); + expect(saveBtn.classList.contains('saved-state')).toBe(false); + vi.useRealTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// refreshDefaultBackendOptions - error handling +// --------------------------------------------------------------------------- + +describe('refreshDefaultBackendOptions error handling', () => { + async function load() { + return import('./settings'); + } + + it('handles invoke rejection gracefully', async () => { + mountSettingsModal(); + const modal = document.getElementById('settings-modal')!; + modal.insertAdjacentHTML('beforeend', ''); + mockInvoke + .mockResolvedValueOnce({}) // get_global_settings succeeds + .mockRejectedValueOnce(new Error('vcs backends failed')); // list_vcs_backends_cmd fails + mockLoadPluginsIntoForm.mockResolvedValue(undefined); + mockLoadGeneralSettingsIntoForm.mockImplementation( + async (_m: HTMLElement, _c: any, _k: any, refreshBackends: any) => { + await refreshBackends(_m, { general: { default_backend: 'git' } }); + }, + ); + + const { loadSettingsIntoForm } = await load(); + await loadSettingsIntoForm(); + await flushPromises(); + + const sel = document.getElementById('set-default-backend') as HTMLSelectElement; + expect(sel.disabled).toBe(true); + expect(sel.options.length).toBe(0); + }); + + it('selects first backend when desired is empty', async () => { + mountSettingsModal(); + const modal = document.getElementById('settings-modal')!; + modal.insertAdjacentHTML('beforeend', ''); + mockInvoke.mockResolvedValue([['git', 'Git'], ['hg', 'Mercurial']]); + mockLoadPluginsIntoForm.mockResolvedValue(undefined); + mockLoadGeneralSettingsIntoForm.mockImplementation( + async (_m: HTMLElement, _c: any, _k: any, refreshBackends: any) => { + await refreshBackends(_m, {}); + }, + ); + + const { loadSettingsIntoForm } = await load(); + await loadSettingsIntoForm(); + await flushPromises(); + + const sel = document.getElementById('set-default-backend') as HTMLSelectElement; + expect(sel.value).toBe('git'); + }); +}); + +// --------------------------------------------------------------------------- +// collectSettingsFromForm - edge cases +// --------------------------------------------------------------------------- + +describe('collectSettingsFromForm - edge cases', () => { + async function load() { + return import('./settings'); + } + + it('handles invalid JSON in currentCfg', async () => { + const modal = mountSettingsModal(); + modal.dataset.currentCfg = 'not-valid-json'; + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n')); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalled(); + }); + }); + + it('skips LFS and plugins sections when elements absent', async () => { + document.body.innerHTML = [ + '
      ', + '
      ', + ' ', + '
      ', + '
      ', + '
      ', + '
      ', + ' ', + ' ', + '
      ', + ' ', + ' ', + ' ', + ' ', + '
      ', + ].join('\n'); + const modal = document.getElementById('settings-modal')!; + modal.dataset.currentCfg = JSON.stringify({}); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalled(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// loadSettingsIntoForm - form element filling +// --------------------------------------------------------------------------- + +describe('loadSettingsIntoForm - element filling', () => { + async function load() { + return import('./settings'); + } + + it('fills all form elements from settings', async () => { + document.body.innerHTML = [ + '
      ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
      ', + ' ', + '
      ', + '
      ', + ' ', + ' ', + '
      ', + '
      ', + ].join('\n'); + const cfg = { + general: { theme: 'system', theme_pack: 'default', language: 'en' }, + commit: { restrict_commit_summary: true }, + diff: { + tab_width: 8, + ignore_whitespace: 'all' as const, + max_file_size_mb: 20, + intraline: true, + show_binary_placeholders: false, + external_merge: { enabled: true, path: '/usr/bin/merge', args: '--diff3' }, + }, + lfs: { enabled: false, concurrency: 8, require_lock_before_edit: true, background_fetch_on_checkout: false }, + performance: { progressive_render: false, gpu_accel: true, animations: false }, + ux: { ui_scale: 1.5, font_mono: 'Fira Code', vim_nav: true, color_blind_mode: 'deuteranopia' as const, recents_limit: 25 }, + logging: { level: 'debug' as const, retain_archives: 50 }, + }; + mockInvoke.mockResolvedValue(cfg); + mockLoadPluginsIntoForm.mockResolvedValue(undefined); + mockLoadGeneralSettingsIntoForm.mockResolvedValue(undefined); + const { loadSettingsIntoForm } = await load(); + await loadSettingsIntoForm(); + + expect((document.getElementById('set-recents-limit') as HTMLInputElement).value).toBe('25'); + expect((document.getElementById('set-tab-width') as HTMLInputElement).value).toBe('8'); + expect((document.getElementById('set-ignore-whitespace') as HTMLSelectElement).value).toBe('all'); + expect((document.getElementById('set-max-file-size-mb') as HTMLInputElement).value).toBe('20'); + expect((document.getElementById('set-intraline') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('set-binary-placeholders') as HTMLInputElement).checked).toBe(false); + expect((document.getElementById('set-restrict-commit-summary') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('set-merge-mode') as HTMLSelectElement).value).toBe('custom'); + expect((document.getElementById('set-merge-path') as HTMLInputElement).value).toBe('/usr/bin/merge'); + expect((document.getElementById('set-merge-args') as HTMLInputElement).value).toBe('--diff3'); + expect((document.getElementById('set-lfs-enabled') as HTMLInputElement).checked).toBe(false); + expect((document.getElementById('set-lfs-concurrency') as HTMLInputElement).value).toBe('8'); + expect((document.getElementById('set-lfs-require-lock') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('set-lfs-bg-fetch') as HTMLInputElement).checked).toBe(false); + expect((document.getElementById('set-animations') as HTMLInputElement).checked).toBe(false); + expect((document.getElementById('set-progressive-render') as HTMLInputElement).checked).toBe(false); + expect((document.getElementById('set-gpu-accel') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('set-ui-scale') as HTMLInputElement).value).toBe('1.5'); + expect((document.getElementById('set-font-mono') as HTMLInputElement).value).toBe('Fira Code'); + expect((document.getElementById('set-vim-nav') as HTMLInputElement).checked).toBe(true); + expect((document.getElementById('set-cb-mode') as HTMLSelectElement).value).toBe('deuteranopia'); + expect((document.getElementById('set-log-level') as HTMLSelectElement).value).toBe('debug'); + expect((document.getElementById('set-log-keep') as HTMLInputElement).value).toBe('50'); + }); +}); + +// --------------------------------------------------------------------------- +// wireSettings - backdrop click no-op +// --------------------------------------------------------------------------- + +describe('wireSettings - backdrop click no-op', () => { + async function load() { + return import('./settings'); + } + + it('does not close when clicking non-close element', async () => { + mountSettingsModal(); + const { wireSettings } = await load(); + wireSettings(); + const modal = document.getElementById('settings-modal')!; + modal.querySelector('#settings-nav')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(mockCloseModal).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// wireSettings - missing nav/panels +// --------------------------------------------------------------------------- + +describe('wireSettings - missing nav/panels', () => { + async function load() { + return import('./settings'); + } + + it('handles missing nav gracefully', async () => { + document.body.innerHTML = ` +
      +
      +
      +
      +
      +
      + + +
      +
      + `; + const { wireSettings } = await load(); + expect(() => wireSettings()).not.toThrow(); + }); + + it('handles missing panels gracefully', async () => { + document.body.innerHTML = ` +
      +
      + +
      + + +
      +
      + `; + const { wireSettings } = await load(); + expect(() => wireSettings()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// syncThemeTitle +// --------------------------------------------------------------------------- + +describe('syncThemeTitle', () => { + async function load() { + return import('./settings'); + } + + it('updates theme select title on theme change', async () => { + mockThemeTooltip.mockReturnValue('My Tooltip'); + mockSelectThemePack.mockResolvedValue(undefined); + document.body.innerHTML = ` +
      +
      + + +
      +
      + + +
      +
      + `; + const { wireSettings } = await load(); + wireSettings(); + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + themeSel.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(themeSel.title).toBe('My Tooltip'); + }); + }); + + it('does not throw when theme select is missing', async () => { + document.body.innerHTML = ` +
      +
      + +
      +
      + + +
      +
      + `; + const { wireSettings } = await load(); + expect(() => wireSettings()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// applyThemeFromControls - auto mode +// --------------------------------------------------------------------------- + +describe('applyThemeFromControls', () => { + async function load() { + return import('./settings'); + } + + it('sets system mode when auto is checked', async () => { + mockModeForTheme.mockReturnValue('light'); + mockGetActiveThemeId.mockReturnValue('dark-theme'); + mockSelectThemePack.mockResolvedValue(undefined); + document.body.innerHTML = ` +
      +
      + + + +
      +
      + + +
      +
      + `; + const { wireSettings } = await load(); + wireSettings(); + const autoCheck = document.getElementById('set-theme-auto') as HTMLInputElement; + autoCheck.checked = true; + autoCheck.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(mockSetTheme).toHaveBeenCalledWith('system'); + }); + }); +}); diff --git a/Frontend/src/scripts/features/settingsPluginUI.test.ts b/Frontend/src/scripts/features/settingsPluginUI.test.ts index 6c18349e..dc4ed7ab 100644 --- a/Frontend/src/scripts/features/settingsPluginUI.test.ts +++ b/Frontend/src/scripts/features/settingsPluginUI.test.ts @@ -442,15 +442,81 @@ describe('renderPluginMenus', () => { elements: [], }), ]); - mockInvoke.mockResolvedValueOnce([makePluginSummary({ id: 'third-party', source: 'npm' })]); + mockInvoke.mockResolvedValueOnce([makePluginSummary({ id: 'third-party', source: 'user' })]); const { renderPluginMenus } = await load(); const modal = createModal(); await renderPluginMenus(modal); - const sublist = modal.querySelector('[data-plugin-menus]'); + const sublist = modal.querySelector('#settings-nav [data-plugin-menus="true"]'); expect(sublist).not.toBeNull(); - expect(sublist!.querySelector('[data-section="plugin-third-party-settings-menu"]')).not.toBeNull(); }); +}); + +describe('renderPluginMenus cleanup', () => { + async function load() { + return import('./settingsPluginUI'); + } + + it('removes stale plugin menu nodes before re-rendering', async () => { + mockInvoke.mockResolvedValueOnce([]); + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
      ', + '
      stale panel
      ', + '
      ', + ].join('\n'); + document.body.appendChild(modal); + + const { renderPluginMenus } = await load(); + await renderPluginMenus(modal); + expect(modal.querySelector('[data-plugin-menu="true"]')).toBeNull(); + expect(modal.querySelector('[data-plugin-menus-wrap="true"]')).toBeNull(); + document.body.removeChild(modal); + }); + + it('handles nav without plugins section', async () => { + mockInvoke.mockResolvedValueOnce([]); + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
      ', + ].join('\n'); + document.body.appendChild(modal); + + const { renderPluginMenus } = await load(); + await renderPluginMenus(modal); + const nav = modal.querySelector('#settings-nav')!; + expect(nav.querySelectorAll('.seg-btn').length).toBeGreaterThanOrEqual(1); + document.body.removeChild(modal); + }); +}); + +describe('renderPluginMenus', () => { + async function load() { + return import('./settingsPluginUI'); + } + + function createModal(): HTMLElement { + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
      ', + ].join('\n'); + document.body.appendChild(modal); + return modal; + } it('creates settings panels from plugin summaries with no menus', async () => { mockInvoke.mockResolvedValueOnce([]); diff --git a/Frontend/src/scripts/features/settingsPlugins.test.ts b/Frontend/src/scripts/features/settingsPlugins.test.ts index 9552c476..1140c626 100644 --- a/Frontend/src/scripts/features/settingsPlugins.test.ts +++ b/Frontend/src/scripts/features/settingsPlugins.test.ts @@ -631,9 +631,7 @@ describe('pane double-click', () => { await flushPromises(); const rows = document.querySelectorAll('.plugin-row[data-plugin]') as NodeListOf; - // First click sets lastClickAt/lastClickIdKey rows[0].click(); - // Second click within 450ms triggers double-click toggle rows[0].click(); await flushPromises(); }); @@ -988,5 +986,386 @@ describe('queued toggle deduplication', () => { checkbox!.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises(); }); + + it('loads with enabled plugin IDs and normalizes case', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'P1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: { enabled: ['P1'] } } as any); + await flushPromises(); + + // Plugin should be rendered + const list = document.getElementById('plugins-list') as HTMLElement; + expect(list.children.length).toBeGreaterThan(0); + }); + + it('shows no matching plugins after search with no results', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { parsePluginQuery } = await import('./settingsPluginSearch'); + vi.mocked(parsePluginQuery).mockReturnValue({ terms: ['XYZZZZ'], authors: [], tags: [] }); + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Type search query to trigger filtering + const searchEl = document.getElementById('plugins-search') as HTMLInputElement; + searchEl.value = 'XYZZZZ'; + searchEl.dispatchEvent(new Event('input', { bubbles: true })); + await flushPromises(); + + const list = document.getElementById('plugins-list') as HTMLElement; + expect(list.textContent).toContain('No matching plugins'); + }); +}); + +// --------------------------------------------------------------------------- +// pluginIsEnabled priorities +// --------------------------------------------------------------------------- + +describe('pluginIsEnabled', () => { + it('disabled wins over enabled', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: ['p1'], enabled: ['p1'] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm( + document.getElementById('settings-modal') as HTMLElement, + { plugins: { disabled: ['p1'], enabled: ['p1'] } } as any, + ); + await flushPromises(); + + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + expect(checkbox!.checked).toBe(false); + }); + + it('enabled wins over default_enabled', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: false, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: ['p1'] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm( + document.getElementById('settings-modal') as HTMLElement, + { plugins: { enabled: ['p1'] } } as any, + ); + await flushPromises(); + + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + expect(checkbox!.checked).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// getFiltered - returns base when query empty +// --------------------------------------------------------------------------- + +describe('getFiltered', () => { + it('returns all plugins when query is empty', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [ + { id: 'p1', name: 'Alpha', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }, + { id: 'p2', name: 'Beta', version: '1.0', author: 'B', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: false }, + ]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const list = document.getElementById('plugins-list') as HTMLElement; + expect(list.children.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// renderDetails - minimal plugin +// --------------------------------------------------------------------------- + +describe('renderDetails - minimal plugin', () => { + it('renders without description or metadata keys', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'minimal', name: 'Min', version: '', author: '', + category: '', description: '', source: '', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const detail = document.getElementById('plugins-detail') as HTMLElement; + expect(detail.classList.contains('empty')).toBe(false); + expect(detail.querySelector('.desc')).toBeNull(); + expect(detail.querySelector('.plugin-detail-kv')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// persistPluginsDisabled +// --------------------------------------------------------------------------- + +describe('persistPluginsDisabled', () => { + it('persists plugin disabled set and refreshes themes', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: ['p1'] } }; + if (cmd === 'set_global_settings') return null; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + (document.getElementById('plugins-disable-all') as HTMLButtonElement).click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('set_global_settings', expect.objectContaining({ + cfg: expect.objectContaining({ + plugins: expect.objectContaining({ + disabled: expect.arrayContaining(['p1']), + }), + }), + })); + }); +}); + +// --------------------------------------------------------------------------- +// persistSinglePluginToggle success +// --------------------------------------------------------------------------- + +describe('persistSinglePluginToggle success', () => { + it('toggles plugin and refreshes UI', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'set_plugin_enabled') return null; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + checkbox!.checked = false; + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_plugin_enabled', { pluginId: 'p1', enabled: false }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// persistSinglePluginToggle - error with button timer +// --------------------------------------------------------------------------- + +describe('persistSinglePluginToggle error with button timer', () => { + it('shows error state on toggle failure', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'set_plugin_enabled') throw new Error('toggle failed'); + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + const toggleBtn = document.getElementById('plugins-toggle-selected') as HTMLButtonElement; + expect(toggleBtn.textContent).toBe('Error'); + }, { timeout: 3000, interval: 100 }); + }); +}); + +// --------------------------------------------------------------------------- +// queuePluginToggle edge cases +// --------------------------------------------------------------------------- + +describe('queuePluginToggle edge cases', () => { + it('prevents duplicate pending toggle', async () => { + const invoke = vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'set_plugin_enabled') return null; + return null; + }); + (window as any).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + let checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + expect(checkbox!.disabled).toBe(false); + + checkbox!.checked = false; + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + + checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + expect(checkbox!.disabled).toBe(true); + + checkbox!.checked = true; + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + + await flushPromises(); + + // First toggle should still be pending; the toggle button shows "Disabling..." + await vi.waitFor(() => { + const btn = document.getElementById('plugins-toggle-selected') as HTMLButtonElement; + expect(btn.textContent).toBe('Disabling...'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// context menu overflow positioning +// --------------------------------------------------------------------------- + +describe('context menu overflow positioning', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 500, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 500, configurable: true }); + }); + + function setupRect(cm: HTMLElement, width: number, height: number, left: number, top: number) { + vi.spyOn(cm, 'getBoundingClientRect').mockReturnValue({ + width, height, top, left, + right: left + width, + bottom: top + height, + x: left, y: top, + toJSON: () => ({}), + } as DOMRect); + } + + it('repositions when overflowing right edge', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const cm = document.querySelector('.plugins-context-menu') as HTMLElement; + setupRect(cm, 100, 50, 480, 100); + + const row = document.querySelector('.plugin-row[data-plugin]') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 480, clientY: 100 })); + await flushPromises(); + + expect(parseInt(cm.style.left)).toBeLessThan(480); + }); + + it('repositions when overflowing bottom edge', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ + id: 'p1', name: 'P1', version: '1.0', author: 'A', + category: 'U', description: 'D', source: 'npm', tags: [], + icon_data_url: '', default_enabled: true, + }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const cm = document.querySelector('.plugins-context-menu') as HTMLElement; + setupRect(cm, 100, 50, 100, 480); + + const row = document.querySelector('.plugin-row[data-plugin]') as HTMLElement; + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 480 })); + await flushPromises(); + + expect(parseInt(cm.style.top)).toBeLessThan(480); + }); }); diff --git a/Frontend/src/scripts/features/sshAuth.test.ts b/Frontend/src/scripts/features/sshAuth.test.ts index eb71367e..f4a4b3d0 100644 --- a/Frontend/src/scripts/features/sshAuth.test.ts +++ b/Frontend/src/scripts/features/sshAuth.test.ts @@ -120,6 +120,15 @@ describe('wireAuthModal', () => { expect(httpsBtn.disabled).toBe(false); // http:// is already HTTPS-compatible }); + it('enables HTTPS conversion for ssh:// protocol URLs', async () => { + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + listenHandler?.({ payload: { host: 'github.com', remote: 'origin', url: 'ssh://git@github.com/owner/repo' } }); + + const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; + expect(httpsBtn.disabled).toBe(false); + }); + it('handles ok button click', async () => { const modals = await import('../ui/modals'); const { initSshAuthPrompt } = await import('./sshAuth'); diff --git a/Frontend/src/scripts/features/sshHostkey.test.ts b/Frontend/src/scripts/features/sshHostkey.test.ts new file mode 100644 index 00000000..c3187196 --- /dev/null +++ b/Frontend/src/scripts/features/sshHostkey.test.ts @@ -0,0 +1,168 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); +const mockOpenModal = vi.fn(); +const mockCloseModal = vi.fn(); +const mockHydrateBranches = vi.fn(); + +let sshHostkeyCb: ((ev: any) => void) | null = null; + +vi.mock('../lib/tauri', () => ({ + TAURI: { + invoke: mockInvoke, + listen: vi.fn((_event: string, cb: any) => { + sshHostkeyCb = cb; + }), + }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('../ui/modals', () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, +})); + +vi.mock('./repo/hydrate', () => ({ + hydrateBranches: mockHydrateBranches, +})); + +function mountModal() { + document.body.innerHTML = ` +
      + + + + + + +
      + `; +} + +function triggerSshHostkeyEvent(payload: Record = {}) { + sshHostkeyCb?.({ payload }); +} + +beforeEach(() => { + vi.resetModules(); + sshHostkeyCb = null; + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockOpenModal.mockReset(); + mockCloseModal.mockReset(); + mockHydrateBranches.mockReset(); + document.body.innerHTML = ''; + (window as any).__openvcs_fillSshHostKeyModal = undefined; +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + (window as any).__openvcs_fillSshHostKeyModal = undefined; +}); + +describe('initSshHostkeyPrompt', () => { + it('sets up listener and populates modal on event', async () => { + mountModal(); + mockInvoke.mockResolvedValue(undefined); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + + triggerSshHostkeyEvent({ host: 'example.com', remote: 'origin', url: 'git@example.com:repo.git', message: 'Key fingerprint: ...' }); + + expect(mockOpenModal).toHaveBeenCalledWith('ssh-hostkey-modal'); + + expect(document.getElementById('ssh-hostkey-host')!.textContent).toBe('example.com'); + expect(document.getElementById('ssh-hostkey-remote')!.textContent).toBe('origin'); + expect(document.getElementById('ssh-hostkey-url')!.textContent).toBe('git@example.com:repo.git'); + expect(document.getElementById('ssh-hostkey-msg')!.textContent).toBe('Key fingerprint: ...'); + }); + + it('deny button closes the modal', async () => { + mountModal(); + mockInvoke.mockResolvedValue(undefined); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com' }); + + const denyBtn = document.getElementById('ssh-hostkey-deny') as HTMLButtonElement; + denyBtn.click(); + + expect(mockCloseModal).toHaveBeenCalledWith('ssh-hostkey-modal'); + }); + + it('accept button trusts host and fetches', async () => { + mountModal(); + mockInvoke.mockResolvedValue(undefined); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com', remote: 'origin', url: 'git@example.com:repo.git' }); + + const acceptBtn = document.getElementById('ssh-hostkey-accept') as HTMLButtonElement; + acceptBtn.click(); + + await vi.waitFor(() => { + expect(mockHydrateBranches).toHaveBeenCalled(); + }); + expect(mockInvoke).toHaveBeenCalledWith('ssh_trust_host', { host: 'example.com' }); + expect(mockInvoke).toHaveBeenCalledWith('vcs_fetch_all', {}); + expect(mockNotify).toHaveBeenCalledWith('Trusted example.com'); + expect(mockCloseModal).toHaveBeenCalledWith('ssh-hostkey-modal'); + }); + + it('accept button shows notify on failure', async () => { + mountModal(); + mockInvoke.mockRejectedValue(new Error('permission denied')); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com', remote: 'origin', url: '' }); + + const acceptBtn = document.getElementById('ssh-hostkey-accept') as HTMLButtonElement; + acceptBtn.click(); + + await vi.waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Failed to trust host: Error: permission denied'); + }); + }); + + it('does not re-wire modal if already wired', async () => { + mountModal(); + const modal = document.getElementById('ssh-hostkey-modal') as any; + modal.__wired = true; + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com' }); + }); + + it('does nothing when modal element is missing', async () => { + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com' }); + expect(mockOpenModal).toHaveBeenCalledWith('ssh-hostkey-modal'); + }); + + it('accept is no-op when current is null', async () => { + mountModal(); + mockInvoke.mockResolvedValue(undefined); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + + const acceptBtn = document.getElementById('ssh-hostkey-accept') as HTMLButtonElement; + await acceptBtn.click(); + + expect(mockInvoke).not.toHaveBeenCalled(); + }); +}); diff --git a/Frontend/src/scripts/lib/logger.test.ts b/Frontend/src/scripts/lib/logger.test.ts index 7cb8f503..54babf30 100644 --- a/Frontend/src/scripts/lib/logger.test.ts +++ b/Frontend/src/scripts/lib/logger.test.ts @@ -62,25 +62,41 @@ describe('logger', () => { }); }); - it('formats errors with their stack and routes them as error logs', async () => { +it('formats errors with their stack and routes them as error logs', async () => { const { logger } = await import('./logger'); const { TAURI } = await import('./tauri'); const { addFrontendLogBreadcrumb } = await import('./monitoring'); const error = new Error('boom'); error.stack = 'trace-line'; + logger.create('mod').error(error); + expect(addFrontendLogBreadcrumb).toHaveBeenCalledWith('error', expect.stringContaining('Error: boom')); + expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { + level: 'error', + message: expect.stringContaining('Error: boom'), + }); + }); - logger.error(error); - - expect(addFrontendLogBreadcrumb).toHaveBeenCalledWith( - 'error', - 'Error: boom\ntrace-line', - ); + it('formats errors without a stack trace', async () => { + const { logger } = await import('./logger'); + const { TAURI } = await import('./tauri'); + const { addFrontendLogBreadcrumb } = await import('./monitoring'); + const error = new Error('nostack'); + delete (error as any).stack; + logger.create('mod').error(error); + expect(addFrontendLogBreadcrumb).toHaveBeenCalledWith('error', expect.stringContaining('Error: nostack')); expect(TAURI.invoke).toHaveBeenCalledWith('log_frontend_message', { level: 'error', - message: 'Error: boom\ntrace-line', + message: expect.stringContaining('Error: nostack'), }); }); + it('skips breadcrumb when options.breadcrumb is explicitly false', async () => { + const { __testOnlySendToBackend } = await import('./logger'); + const { addFrontendLogBreadcrumb } = await import('./monitoring'); + __testOnlySendToBackend('info', 'no breadcrumb', { breadcrumb: false }); + expect(addFrontendLogBreadcrumb).not.toHaveBeenCalled(); + }); + it('falls back to String() for unserializable objects', async () => { const { logger } = await import('./logger'); const { TAURI } = await import('./tauri'); diff --git a/Frontend/src/scripts/lib/logger.ts b/Frontend/src/scripts/lib/logger.ts index 82f66da7..9e306841 100644 --- a/Frontend/src/scripts/lib/logger.ts +++ b/Frontend/src/scripts/lib/logger.ts @@ -149,3 +149,6 @@ export const logger = { warn: (...args: unknown[]) => sendToBackend("warn", formatMessage(...args)), error: (...args: unknown[]) => sendToBackend("error", formatMessage(...args)), }; + +// Test-only export — referenced by logger.test.ts +export const __testOnlySendToBackend = sendToBackend; diff --git a/Frontend/src/scripts/lib/monitoring.test.ts b/Frontend/src/scripts/lib/monitoring.test.ts index 9575660e..2324f6fe 100644 --- a/Frontend/src/scripts/lib/monitoring.test.ts +++ b/Frontend/src/scripts/lib/monitoring.test.ts @@ -306,4 +306,26 @@ describe('syncFrontendMonitoring', () => { expect(payload?.payload?.breadcrumbs[0]?.message).toBe('crumb-0'); }); + it('returns early when monitoring is disabled', async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + const monitoring = (await import('./monitoring')) as any; + await monitoring.syncFrontendMonitoring({ general: { crash_reports: false } }); + await monitoring.__testOnlyReportFrontendError({ message: 'should-be-skipped' }); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('handles ErrorEvent with non-Error error object', async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } }; + const monitoring = (await import('./monitoring')) as MonitoringModule; + await monitoring.syncFrontendMonitoring({ general: { crash_reports: true } }); + + window.dispatchEvent(new ErrorEvent('error', { message: 'string-error' })); + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('report_frontend_error', expect.objectContaining({ + payload: expect.objectContaining({ message: 'string-error' }), + })); + }); + }); }); diff --git a/Frontend/src/scripts/lib/monitoring.ts b/Frontend/src/scripts/lib/monitoring.ts index 09bb9aa5..1a3a4ca2 100644 --- a/Frontend/src/scripts/lib/monitoring.ts +++ b/Frontend/src/scripts/lib/monitoring.ts @@ -167,6 +167,9 @@ async function reportFrontendError( await TAURI.invoke('report_frontend_error', { payload }).catch(() => {}); } +// Test-only export — referenced by monitoring.test.ts +export const __testOnlyReportFrontendError = reportFrontendError; + /** Derives a human-readable message for an unhandled promise rejection reason. */ function getUnhandledRejectionMessage(reason: unknown): string { if (reason instanceof Error) { diff --git a/Frontend/src/scripts/lib/status.test.ts b/Frontend/src/scripts/lib/status.test.ts new file mode 100644 index 00000000..64506b13 --- /dev/null +++ b/Frontend/src/scripts/lib/status.test.ts @@ -0,0 +1,29 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = '
      Ready
      '; +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('setStatus', () => { + it('updates the status text', async () => { + const { setStatus } = await import('./status'); + setStatus('Working...'); + expect(document.getElementById('status')!.textContent).toBe('Working...'); + }); + + it('does nothing when status element is missing', async () => { + document.body.innerHTML = ''; + vi.resetModules(); + const { setStatus } = await import('./status'); + expect(() => setStatus('ignored')).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/plugins/index.test.ts b/Frontend/src/scripts/plugins/index.test.ts new file mode 100644 index 00000000..a6cd63b5 --- /dev/null +++ b/Frontend/src/scripts/plugins/index.test.ts @@ -0,0 +1,40 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./runtime', () => ({ + applyPluginSettingsSections: 'mocked-applyPluginSettingsSections', + getRegisteredThemeSummaries: 'mocked-getRegisteredThemeSummaries', + getRegisteredThemePayload: 'mocked-getRegisteredThemePayload', + runHook: 'mocked-runHook', + runPluginAction: 'mocked-runPluginAction', + getPluginContextMenuItems: 'mocked-getPluginContextMenuItems', + initPlugins: 'mocked-initPlugins', + reloadPlugins: 'mocked-reloadPlugins', +})); + +vi.mock('./modal', () => ({ + handlePluginActionResult: 'mocked-handlePluginActionResult', + invokePluginAction: 'mocked-invokePluginAction', +})); + +describe('plugins index barrel', () => { + it('re-exports runtime functions', async () => { + const mod = await import('./index'); + expect(mod.applyPluginSettingsSections).toBe('mocked-applyPluginSettingsSections'); + expect(mod.getRegisteredThemeSummaries).toBe('mocked-getRegisteredThemeSummaries'); + expect(mod.getRegisteredThemePayload).toBe('mocked-getRegisteredThemePayload'); + expect(mod.runHook).toBe('mocked-runHook'); + expect(mod.runPluginAction).toBe('mocked-runPluginAction'); + expect(mod.getPluginContextMenuItems).toBe('mocked-getPluginContextMenuItems'); + expect(mod.initPlugins).toBe('mocked-initPlugins'); + expect(mod.reloadPlugins).toBe('mocked-reloadPlugins'); + }); + + it('re-exports modal functions', async () => { + const mod = await import('./index'); + expect(mod.handlePluginActionResult).toBe('mocked-handlePluginActionResult'); + expect(mod.invokePluginAction).toBe('mocked-invokePluginAction'); + }); +}); diff --git a/Frontend/src/scripts/plugins/modal.test.ts b/Frontend/src/scripts/plugins/modal.test.ts index 0d389255..919eaa89 100644 --- a/Frontend/src/scripts/plugins/modal.test.ts +++ b/Frontend/src/scripts/plugins/modal.test.ts @@ -346,3 +346,345 @@ describe('collectPluginModalPayload', () => { }); }); }); + +describe('isPluginModalDefinition edge cases', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountRoot(); + }); + + it('rejects arrays as modal definitions', async () => { + const { handlePluginActionResult } = await import('./modal'); + handlePluginActionResult('p1', [] as unknown as Record); + expect(document.querySelector('.modal')).toBeNull(); + }); +}); + +describe('ensurePluginModalElement edge cases', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + }); + + it('handles missing modals-root gracefully', async () => { + document.body.innerHTML = ''; + const { handlePluginActionResult } = await import('./modal'); + expect(() => handlePluginActionResult('p1', { + title: 'T', + content: [{ type: 'text', content: 'X' }], + })).not.toThrow(); + expect(document.getElementById('plugin-modal-p1')).toBeNull(); + }); + + it('reuses existing modal element on subsequent renders', async () => { + mountRoot(); + const { handlePluginActionResult } = await import('./modal'); + handlePluginActionResult('p1', { + title: 'First', + content: [{ type: 'text', content: 'First' }], + }); + const modal = document.getElementById('plugin-modal-p1'); + expect(modal).not.toBeNull(); + + handlePluginActionResult('p1', { + title: 'Second', + content: [{ type: 'text', content: 'Second' }], + }); + expect(document.getElementById('plugin-modal-p1')).toBe(modal); + expect(modal?.querySelector('.sheet-body')?.textContent).toBe('Second'); + }); +}); + +describe('alignToJustifyContent and appendModalButton coverage', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountRoot(); + }); + + it('renders top-level button with right alignment in wrapped row', async () => { + const { handlePluginActionResult } = await import('./modal'); + handlePluginActionResult('p1', { + title: 'Test', + content: [ + { type: 'button', id: 'rbtn', content: 'Right', align: 'right' }, + ], + }); + const btn = document.querySelector('button[data-plugin-action="rbtn"]'); + expect(btn).not.toBeNull(); + const row = btn?.parentElement; + expect(row?.style.display).toBe('flex'); + expect(row?.style.justifyContent).toBe('flex-end'); + }); + + it('renders top-level button with centered alignment in wrapped row', async () => { + const { handlePluginActionResult } = await import('./modal'); + handlePluginActionResult('p1', { + title: 'Test', + content: [ + { type: 'button', id: 'cbtn', content: 'Center', align: 'centered' }, + ], + }); + const row = document.querySelector('button[data-plugin-action="cbtn"]')?.parentElement; + expect(row?.style.justifyContent).toBe('center'); + }); +}); + +describe('renderPluginModalItem remaining types', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountRoot(); + }); + + async function renderModal(content: Record[]) { + const { handlePluginActionResult } = await import('./modal'); + handlePluginActionResult('p1', { title: 'Test', content }); + } + + it('renders horizontal-box with wrap=false (nowrap)', async () => { + await renderModal([{ + type: 'horizontal-box', + wrap: false, + content: [{ type: 'text', content: 'No wrap' }], + }]); + const hbox = document.querySelector('.sheet-body > div') as HTMLElement; + expect(hbox.style.flexWrap).toBe('nowrap'); + }); + + it('renders horizontal-box with empty content', async () => { + await renderModal([{ type: 'horizontal-box', content: [] }]); + const hbox = document.querySelector('.sheet-body > div') as HTMLElement; + expect(hbox).not.toBeNull(); + expect(hbox.children.length).toBe(0); + }); + + it('renders vertical-box with empty content', async () => { + await renderModal([{ type: 'vertical-box', content: [] }]); + const vbox = document.querySelector('.sheet-body > div') as HTMLElement; + expect(vbox.style.display).toBe('grid'); + expect(vbox.children.length).toBe(0); + }); + + it('renders grid with default columns when columns omitted', async () => { + await renderModal([{ type: 'grid', content: [] }]); + const grid = document.querySelector('.sheet-body > div') as HTMLElement; + expect(grid.style.gridTemplateColumns).toBe('1fr'); + }); + + it('renders input with only id and label (minimal)', async () => { + await renderModal([{ type: 'input', id: 'min', label: 'Minimal' }]); + const input = document.querySelector('input[data-plugin-field="min"]'); + expect(input).not.toBeNull(); + expect(input?.type).toBe('text'); + expect(input?.required).toBe(false); + expect(input?.value).toBe(''); + }); + + it('renders select with option.selected default when value absent', async () => { + await renderModal([{ + type: 'select', + id: 'mode', + label: 'Mode', + options: [ + { value: 'manual', label: 'Manual' }, + { value: 'auto', label: 'Auto', selected: true }, + ], + }]); + const select = document.querySelector('select[data-plugin-field="mode"]'); + expect(select?.value).toBe('auto'); + }); + + it('renders list without label', async () => { + await renderModal([{ + type: 'list', + id: 'nl', + items: [{ id: 'i1', title: 'No label' }], + }]); + expect(document.querySelector('.sheet-body')?.textContent).toContain('No label'); + expect(document.querySelector('.sheet-body > div > .meta')).toBeNull(); + }); + + it('renders empty list without emptyText produces no text', async () => { + await renderModal([{ + type: 'list', + id: 'empty-note', + items: [], + }]); + const container = document.querySelector('.sheet-body > div') as HTMLElement; + expect(container).not.toBeNull(); + expect(container.textContent).toBe(''); + }); + + it('renders list row without optional meta/description/status/actions', async () => { + await renderModal([{ + type: 'list', + id: 'min', + items: [{ id: 'i1', title: 'Minimal' }], + }]); + expect(document.querySelector('.sheet-body')?.textContent).toContain('Minimal'); + expect(document.querySelector('.sheet-body button[data-plugin-action]')).toBeNull(); + }); + + it('renders button detected by content string fallback (no explicit type)', async () => { + await renderModal([{ id: 'fb', content: 'Fallback' }]); + const btn = document.querySelector('button[data-plugin-action="fb"]'); + expect(btn).not.toBeNull(); + expect(btn?.textContent).toBe('Fallback'); + }); + + it('renders text with title attribute', async () => { + await renderModal([{ type: 'text', content: 'Titled', title: 'Tooltip text' }]); + const div = document.querySelector('.sheet-body > div') as HTMLElement; + expect(div.title).toBe('Tooltip text'); + }); + + it('creates default variant button (no primary/danger class)', async () => { + await renderModal([{ type: 'button', id: 'def', content: 'Default' }]); + const btn = document.querySelector('button[data-plugin-action="def"]'); + expect(btn?.classList.contains('primary')).toBe(false); + expect(btn?.classList.contains('danger')).toBe(false); + expect(btn?.textContent).toBe('Default'); + }); + + it('skips null items in content array', async () => { + await renderModal([ + null as unknown as Record, + { type: 'text', content: 'After null' }, + ]); + expect(document.querySelector('.sheet-body')?.textContent).toContain('After null'); + }); +}); + +describe('collectPluginModalPayload and wirePluginModalActions coverage', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountRoot(); + }); + + it('merges dataset payload with collected form payload', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({}); + + const { wirePluginModalActions, handlePluginActionResult } = await import('./modal'); + wirePluginModalActions(); + + handlePluginActionResult('p1', { + title: 'Form', + content: [ + { type: 'input', id: 'name', label: 'Name', value: 'Alice' }, + { type: 'button', id: 'submit', content: 'Submit', payload: { extra: 'data' } }, + ], + }); + + (document.querySelector('button[data-plugin-action="submit"]') as HTMLButtonElement)?.click(); + + await vi.waitFor(() => { + expect(tauri.core.invoke).toHaveBeenCalledWith('invoke_plugin_action', { + pluginId: 'p1', + actionId: 'submit', + payload: { name: 'Alice', extra: 'data' }, + }); + }); + }); + + it('collects checkbox field as boolean and skips empty keys', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({}); + + const { wirePluginModalActions, handlePluginActionResult } = await import('./modal'); + wirePluginModalActions(); + + handlePluginActionResult('p1', { + title: 'Cb Test', + content: [ + { type: 'input', id: 'name', label: 'Name', value: 'Bob' }, + { type: 'button', id: 'go', content: 'Go' }, + ], + }); + + const modal = document.getElementById('plugin-modal-p1')!; + const body = modal.querySelector('.sheet-body')!; + + const cg = document.createElement('div'); + cg.className = 'group'; + const cl = document.createElement('label'); + cl.textContent = 'Enabled'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.dataset.pluginField = 'enabled'; + cb.checked = true; + cg.appendChild(cl); + cg.appendChild(cb); + body.appendChild(cg); + + const eg = document.createElement('div'); + eg.className = 'group'; + const ei = document.createElement('input'); + ei.dataset.pluginField = ''; + ei.value = 'skip-me'; + eg.appendChild(ei); + body.appendChild(eg); + + (document.querySelector('button[data-plugin-action="go"]') as HTMLButtonElement)?.click(); + + await vi.waitFor(() => { + expect(tauri.core.invoke).toHaveBeenCalledWith('invoke_plugin_action', { + pluginId: 'p1', + actionId: 'go', + payload: { name: 'Bob', enabled: true }, + }); + }); + }); + + it('ignores malformed JSON in button payload dataset', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({}); + + const { wirePluginModalActions, handlePluginActionResult } = await import('./modal'); + wirePluginModalActions(); + + handlePluginActionResult('p1', { + title: 'Bad JSON', + content: [ + { type: 'input', id: 'f', label: 'F', value: 'v' }, + { type: 'button', id: 'bp', content: 'Bad', payload: {} }, + ], + }); + + const btn = document.querySelector('button[data-plugin-action="bp"]') as HTMLButtonElement; + btn.dataset.pluginPayload = '{bad json'; + btn.click(); + + await vi.waitFor(() => { + expect(tauri.core.invoke).toHaveBeenCalledWith('invoke_plugin_action', { + pluginId: 'p1', + actionId: 'bp', + payload: { f: 'v' }, + }); + }); + }); + + it('handles invokePluginAction error gracefully during wired click', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockRejectedValue(new Error('network error')); + + const { wirePluginModalActions, handlePluginActionResult } = await import('./modal'); + wirePluginModalActions(); + + handlePluginActionResult('p1', { + title: 'Err', + content: [ + { type: 'button', id: 'ebtn', content: 'Error' }, + ], + }); + + const btn = document.querySelector('button[data-plugin-action="ebtn"]') as HTMLButtonElement; + expect(() => btn.click()).not.toThrow(); + await vi.waitFor(() => { + expect(tauri.core.invoke).toHaveBeenCalled(); + }); + }); +}); diff --git a/Frontend/src/scripts/plugins/registration.test.ts b/Frontend/src/scripts/plugins/registration.test.ts index a23307ec..d0575723 100644 --- a/Frontend/src/scripts/plugins/registration.test.ts +++ b/Frontend/src/scripts/plugins/registration.test.ts @@ -288,6 +288,38 @@ describe('_setApplyPluginSectionsFallback', () => { }); }); +describe('installGlobalApi wrappers', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + delete (window as any).OpenVCS; + document.body.innerHTML = '
        '; + }); + + afterEach(() => { + delete (window as any).OpenVCS; + }); + + it('calls addMenuItem, addTitlebarButton, addSettingsSection, addMenubarMenu, invoke, listen, notify through plugin module', async () => { + const { installGlobalApi, currentPluginIdForRegistration } = await import('./registration'); + currentPluginIdForRegistration('test-plugin'); + installGlobalApi(); + + const api = (window as any).OpenVCS; + expect(api).toBeDefined(); + + expect(() => api.addMenuItem({ id: 't', label: 'T', action: 'noop' })).not.toThrow(); + expect(() => api.addTitlebarButton({ id: 'b', label: 'B', action: 'a' })).not.toThrow(); + expect(() => api.addSettingsSection({ id: 's', label: 'S', html: '

        x

        ' })).not.toThrow(); + expect(() => api.addMenubarMenu({ id: 'm', html: 'x' })).not.toThrow(); + expect(() => api.notify('msg')).not.toThrow(); + expect(typeof api.invoke).toBe('function'); + expect(typeof api.listen).toBe('function'); + + delete (window as any).OpenVCS; + }); +}); + describe('addMenuItem', () => { beforeEach(() => { setupTauri(); @@ -485,6 +517,208 @@ describe('currentPluginIdForRegistration', () => { }); }); +describe('addMenuItem with title attribute', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + document.body.innerHTML = '
          '; + }); + + it('sets title attribute when item.title is provided', async () => { + const { addMenuItem } = await import('./registration'); + addMenuItem('p1', { label: 'Action', action: 'my-action', title: 'Tooltip text' }); + const btn = document.querySelector('#plugins-menu-list .menu-item') as HTMLElement; + expect(btn.title).toBe('Tooltip text'); + }); +}); + +describe('addTitlebarButton with title attribute', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + document.body.innerHTML = '
          '; + }); + + it('sets title attribute when btn.title is provided', async () => { + const { addTitlebarButton } = await import('./registration'); + addTitlebarButton('p1', { label: 'Act', action: 'act', title: 'Button tooltip' }); + const btn = document.querySelector('#plugin-title-actions .btn') as HTMLElement; + expect(btn.title).toBe('Button tooltip'); + }); +}); + +describe('applyMenubarMenu with before positioning', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('inserts a menubar menu before an existing menu', async () => { + const { applyMenubarMenu } = await import('./registration'); + applyMenubarMenu('p1', { + id: 'second', + html: '', + }); + applyMenubarMenu('p1', { + id: 'first', + html: '', + before: 'second', + }); + const items = document.querySelectorAll('.menubar .menu'); + expect(items[0].getAttribute('data-menu')).toBe('first'); + expect(items[1].getAttribute('data-menu')).toBe('second'); + }); + + it('appends when before references non-existent menu', async () => { + const { applyMenubarMenu } = await import('./registration'); + applyMenubarMenu('p1', { + id: 'only-menu', + html: '', + before: 'nonexistent', + }); + const items = document.querySelectorAll('.menubar .menu'); + expect(items.length).toBe(1); + }); +}); + +describe('registerPlugin context menus coverage', () => { + beforeEach(async () => { + setupTauri(); + vi.resetModules(); + mountFullDom(); + const { _setApplyPluginSectionsFallback } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + }); + + it('registers context menus for commits and branches', async () => { + const { registerPlugin } = await import('./registration'); + registerPlugin({ + id: 'ctx-plugin', + contextMenus: { + commits: [{ label: 'Commit Act', action: 'commit-act' }], + branches: [{ label: 'Branch Act', action: 'branch-act' }], + }, + }); + const { getPluginContextMenuItems } = await import('./runtime'); + expect(getPluginContextMenuItems('commits')).toHaveLength(1); + expect(getPluginContextMenuItems('branches')).toHaveLength(1); + expect(getPluginContextMenuItems('commits')[0].label).toBe('Commit Act'); + expect(getPluginContextMenuItems('branches')[0].label).toBe('Branch Act'); + }); + + it('skips context menu items with empty label or action', async () => { + const { registerPlugin } = await import('./registration'); + registerPlugin({ + id: 'skip-ctx', + contextMenus: { + files: [ + { label: '', action: '' }, + { label: 'Valid', action: 'valid-act' }, + ], + }, + }); + const { getPluginContextMenuItems } = await import('./runtime'); + expect(getPluginContextMenuItems('files')).toHaveLength(1); + expect(getPluginContextMenuItems('files')[0].label).toBe('Valid'); + }); +}); + +describe('registerPlugin null items', () => { + beforeEach(async () => { + setupTauri(); + vi.resetModules(); + mountFullDom(); + const { _setApplyPluginSectionsFallback } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + }); + + it('skips null entries in arrays', async () => { + const { registerPlugin } = await import('./registration'); + expect(() => registerPlugin({ + id: 'null-test', + menuItems: [null as any, { label: 'Only', action: 'only-act' }], + titlebarButtons: [null as any, { label: 'Btn', action: 'btn-act' }], + settingsSections: [null as any, { id: 'sec', label: 'Sec', html: '
          x
          ' }], + themes: [null as any, { summary: { id: 'th', name: 'Th' } }], + themeSummaries: [null as any, { id: 'ts', name: 'TS' }], + menubarMenus: [null as any, { id: 'mm', html: '' }], + })).not.toThrow(); + expect(document.querySelector('#plugins-menu-list .menu-item')).not.toBeNull(); + expect(document.querySelector('#plugin-title-actions .btn')).not.toBeNull(); + }); +}); + +describe('registerTheme without summary', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + }); + + it('registers theme payload without summary field', async () => { + const { registerTheme } = await import('./registration'); + expect(() => registerTheme({ styles: 'body { color: red; }' } as any)).not.toThrow(); + }); + + it('registers theme with summary but empty id', async () => { + const { registerTheme } = await import('./registration'); + expect(() => registerTheme({ summary: { id: '', name: '' }, styles: '' })).not.toThrow(); + }); +}); + +describe('registerThemeSummary empty id', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + }); + + it('skips registration when id is empty', async () => { + const { registerThemeSummary } = await import('./registration'); + expect(() => registerThemeSummary({ id: '', name: '' })).not.toThrow(); + }); +}); + +describe('installGlobalApi full coverage', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + delete (window as any).OpenVCS; + document.body.innerHTML = '
              '; + }); + + afterEach(() => { + delete (window as any).OpenVCS; + }); + + it('exposes registerTheme, registerThemeSummary, registerAction via global API', async () => { + const { installGlobalApi, _setApplyPluginSectionsFallback } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + installGlobalApi(); + + const api = (window as any).OpenVCS; + expect(typeof api.registerTheme).toBe('function'); + expect(typeof api.registerThemeSummary).toBe('function'); + expect(typeof api.registerAction).toBe('function'); + + expect(() => api.registerTheme({ summary: { id: 'api-theme', name: 'API Theme' }, styles: '' })).not.toThrow(); + const { getRegisteredThemePayload } = await import('./runtime'); + expect(getRegisteredThemePayload('api-theme')).not.toBeNull(); + + expect(() => api.registerThemeSummary({ id: 'api-sum', name: 'API Summary' })).not.toThrow(); + const { getRegisteredThemeSummaries } = await import('./runtime'); + expect(getRegisteredThemeSummaries().some((s: any) => s.id === 'api-sum')).toBe(true); + + const handler = vi.fn(); + expect(() => api.registerAction('api-act', handler)).not.toThrow(); + const { runPluginAction } = await import('./runtime'); + expect(await runPluginAction('api-act', { val: 42 })).toBe(true); + expect(handler).toHaveBeenCalledWith({ val: 42 }); + }); +}); + describe('registerPlugin', () => { beforeEach(async () => { setupTauri(); diff --git a/Frontend/src/scripts/plugins/runtime.test.ts b/Frontend/src/scripts/plugins/runtime.test.ts index aa7018aa..fe32eaf7 100644 --- a/Frontend/src/scripts/plugins/runtime.test.ts +++ b/Frontend/src/scripts/plugins/runtime.test.ts @@ -142,6 +142,25 @@ describe('applyPluginSettingsSections', () => { upsertSettingsSection('test', { id: 's1', label: 'S1', html: '
              C
              ', onMount }); expect(onMount).toHaveBeenCalled(); }); + + it('uses os-content child when panelsScroll has overlay scrollbar host', async () => { + mountSettingsDom(); + const panelsScroll = document.getElementById('settings-panels-scroll')!; + const osHost = document.createElement('div'); + osHost.className = 'os-host'; + const osContent = document.createElement('div'); + osContent.className = 'os-content'; + osHost.appendChild(osContent); + panelsScroll.appendChild(osHost); + + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + + upsertSettingsSection('test', { id: 'scroll-c', label: 'ScrollC', html: '
              In OS
              ' }); + const insertedIn = panelsScroll.querySelector('.os-content .panel-form'); + expect(insertedIn).not.toBeNull(); + }); }); describe('getRegisteredThemeSummaries', () => { @@ -355,3 +374,121 @@ describe('reloadPlugins', () => { await expect(reloadPlugins()).resolves.toBeUndefined(); }); }); + +// ============================================================================ +// applyPluginSettingsSections - edge cases +// ============================================================================ +describe('applyPluginSettingsSections edge cases', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + }); + + it('skips section with empty id', async () => { + mountSettingsDom(); + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + + upsertSettingsSection('test', { id: '', label: 'Empty ID', html: '
              Content
              ' }); + applyPluginSettingsSections(); + + expect(document.querySelector('#settings-nav [data-section=""]')).toBeNull(); + }); + + it('skips section with empty label', async () => { + mountSettingsDom(); + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + + upsertSettingsSection('test', { id: 'no-label', label: '', html: '
              Content
              ' }); + applyPluginSettingsSections(); + + expect(document.querySelector('#settings-nav [data-section="no-label"]')).toBeNull(); + }); + + it('skips section with empty html', async () => { + mountSettingsDom(); + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + + upsertSettingsSection('test', { id: 'no-html', label: 'No HTML', html: '' }); + applyPluginSettingsSections(); + + expect(document.querySelector('#settings-nav [data-section="no-html"]')).toBeNull(); + }); + + it('skips section when parseSanitizedPluginElement returns null', async () => { + mountSettingsDom(); + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + + // Plain text has no element child → parseSanitizedPluginElement returns null + upsertSettingsSection('test', { id: 'text-only', label: 'Text', html: 'Just text without element wrapper' }); + applyPluginSettingsSections(); + + expect(document.querySelector('#settings-nav [data-section="text-only"]')).toBeNull(); + }); + + it('handles non-HTMLElement child in panelsContent loop', async () => { + mountSettingsDom(); + const panelsScroll = document.getElementById('settings-panels-scroll')!; + // SVG element is not an HTMLElement + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + panelsScroll.appendChild(svg); + + const { _setApplyPluginSectionsFallback, upsertSettingsSection } = await import('./registration'); + const { applyPluginSettingsSections } = await import('./runtime'); + _setApplyPluginSectionsFallback(applyPluginSettingsSections); + + upsertSettingsSection('test', { id: 'after-svg', label: 'After SVG', html: '
              Content
              ' }); + applyPluginSettingsSections(); + + expect(document.querySelector('#settings-nav [data-section="after-svg"]')).not.toBeNull(); + }); +}); + +// ============================================================================ +// initPlugins - additional edge cases +// ============================================================================ +describe('initPlugins additional edge cases', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountMinimalDom(); + }); + + it('skips plugin summary with empty id', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockImplementation((cmd: string) => { + if (cmd === 'get_global_settings') return Promise.resolve({ plugins: { disabled: [], enabled: [] } }); + if (cmd === 'list_plugins') return Promise.resolve([{ id: '', name: '' }, { id: 'real', name: 'Real Plugin' }]); + return Promise.reject(new Error('unknown')); + }); + + const { initPlugins } = await import('./runtime'); + await expect(initPlugins()).resolves.toBeUndefined(); + }); +}); + +// ============================================================================ +// runPluginAction - error handling edge cases +// ============================================================================ +describe('runPluginAction error handling', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + }); + + it('notifies generic message when handler throws empty error', async () => { + const { registerAction } = await import('./registration'); + registerAction('empty-error', async () => { throw ''; }); + + const { runPluginAction } = await import('./runtime'); + const result = await runPluginAction('empty-error'); + expect(result).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/plugins/sanitize.test.ts b/Frontend/src/scripts/plugins/sanitize.test.ts index 96c047ba..91e43902 100644 --- a/Frontend/src/scripts/plugins/sanitize.test.ts +++ b/Frontend/src/scripts/plugins/sanitize.test.ts @@ -122,6 +122,19 @@ describe('parseSanitizedPluginElement', () => { expect(parseSanitizedPluginElement(null as unknown as string)).toBeNull(); }); + it('strips href when URL constructor throws', async () => { + const origURL = globalThis.URL; + (globalThis as any).URL = vi.fn((url: string, base?: string | URL) => { + if (url === 'x-bad:url') throw new TypeError('bad url'); + return new origURL(url, base); + }) as any; + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('link'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('href')).toBeNull(); + (globalThis as any).URL = origURL; + }); + it('strips blocked tags nested inside safe containers', async () => { const { parseSanitizedPluginElement } = await import('./sanitize'); const el = parseSanitizedPluginElement('

              good

              '); diff --git a/Frontend/src/scripts/plugins/state.test.ts b/Frontend/src/scripts/plugins/state.test.ts new file mode 100644 index 00000000..05fdec94 --- /dev/null +++ b/Frontend/src/scripts/plugins/state.test.ts @@ -0,0 +1,50 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); +}); + +describe('plugin state', () => { + it('exports Maps for handlers, themes, context menus, settings', async () => { + const mod = await import('./state'); + expect(mod.actionHandlers).toBeInstanceOf(Map); + expect(mod.hookHandlers).toBeInstanceOf(Map); + expect(mod.registeredThemePayloads).toBeInstanceOf(Map); + expect(mod.registeredThemeSummaries).toBeInstanceOf(Map); + expect(mod.contextMenuItems).toBeInstanceOf(Map); + expect(mod.settingsSections).toBeInstanceOf(Map); + }); + + it('initialized starts false', async () => { + const mod = await import('./state'); + expect(mod.initialized).toBe(false); + }); + + it('setInitialized updates the flag', async () => { + const mod = await import('./state'); + expect(mod.initialized).toBe(false); + mod.setInitialized(true); + expect((await import('./state')).initialized).toBe(true); + }); + + it('disabledPlugins and enabledPlugins start empty', async () => { + const mod = await import('./state'); + expect(mod.disabledPlugins.size).toBe(0); + expect(mod.enabledPlugins.size).toBe(0); + }); + + it('setDisabledPlugins replaces the set', async () => { + const mod = await import('./state'); + mod.setDisabledPlugins(new Set(['plugin-a'])); + expect((await import('./state')).disabledPlugins.has('plugin-a')).toBe(true); + }); + + it('setEnabledPlugins replaces the set', async () => { + const mod = await import('./state'); + mod.setEnabledPlugins(new Set(['plugin-b'])); + expect((await import('./state')).enabledPlugins.has('plugin-b')).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/plugins/types.test.ts b/Frontend/src/scripts/plugins/types.test.ts new file mode 100644 index 00000000..fe4f28a2 --- /dev/null +++ b/Frontend/src/scripts/plugins/types.test.ts @@ -0,0 +1,12 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; + +describe('plugins/types (type-only module)', () => { + it('exports no runtime code at module level', async () => { + const mod = await import('./types'); + const keys = Object.keys(mod); + expect(keys.length).toBe(0); + }); +}); diff --git a/Frontend/src/scripts/state/state.test.ts b/Frontend/src/scripts/state/state.test.ts index 72b83dd6..956cc52f 100644 --- a/Frontend/src/scripts/state/state.test.ts +++ b/Frontend/src/scripts/state/state.test.ts @@ -115,4 +115,26 @@ describe('disableDefaultSelectAll', () => { expect(state.defaultSelectAll).toBe(false); expect(state.selectionImplicitAll).toBe(false); }); + + it('returns false when clearImplicit is false', () => { + state.selectedFiles = new Set(['a.txt']); + state.defaultSelectAll = true; + state.selectionImplicitAll = true; + + expect(disableDefaultSelectAll(false)).toBe(false); + expect(Array.from(state.selectedFiles)).toEqual(['a.txt']); + }); +}); + +describe('resolveVcsActionLabel edge cases', () => { + it('returns fallback for empty or whitespace key', () => { + state.vcsActionLabels = {}; + expect(resolveVcsActionLabel('', 'Fallback')).toBe('Fallback'); + expect(resolveVcsActionLabel(' ', 'Fallback')).toBe('Fallback'); + }); + + it('returns fallback when label is only whitespace', () => { + state.vcsActionLabels = { 'VCS.Push': ' ' }; + expect(resolveVcsActionLabel('VCS.Push', 'Push')).toBe('Push'); + }); }); diff --git a/Frontend/src/scripts/themes.test.ts b/Frontend/src/scripts/themes.test.ts index a8b447c0..77ff46d0 100644 --- a/Frontend/src/scripts/themes.test.ts +++ b/Frontend/src/scripts/themes.test.ts @@ -869,3 +869,119 @@ describe('applyScriptNodes safety', () => { expect(scripts.length).toBe(1); }); }); + +describe('setStyleContent removes empty style', () => { + it('removes style element when content becomes empty', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'theme-css', name: 'Theme CSS', source: 'user' }, + styles: 'body { color: red; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + document.head.innerHTML = ''; + const mod = await load(); + await mod.selectThemePack('theme-css'); + expect(document.getElementById('openvcs-theme-global')).not.toBeNull(); + + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'no-css', name: 'No CSS', source: 'user' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + await mod.selectThemePack('no-css'); + expect(document.getElementById('openvcs-theme-global')).toBeNull(); + }); +}); + +describe('resolveThemeByPreference - no match', () => { + it('returns null when no paired heuristic candidate exists', async () => { + const { getRegisteredThemePayload, getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'theme-ugly', name: 'Theme Ugly', appearance: 'dark' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'theme-ugly', name: 'Theme Ugly', appearance: 'dark' }, + ]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + await mod.selectThemePack('theme-ugly', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('theme-ugly'); + }); +}); + +describe('system listener change event', () => { + it('triggers paired theme re-selection on system theme change', async () => { + const mq = { matches: false, addEventListener: vi.fn((_type: string, cb: () => void) => { (mq as any)._cb = cb; }) }; + (globalThis as any).matchMedia = vi.fn(() => mq); + + const mod = await load(); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'pair-dark', name: 'dark', appearance: 'dark', paired_with: 'pair-light' }, + { id: 'pair-light', name: 'light', appearance: 'light', paired_with: 'pair-dark' }, + ]); + + await mod.setAppearanceMode('system'); + (mq as any)._cb(); + }); +}); + +// ============================================================================ +// normalizeAppearance and resolvePairedThemeId edge cases +// ============================================================================ +describe('normalizeAppearance and resolvePairedThemeId edge cases', () => { + it('does not pair theme when appearance is "both"', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'both-theme', name: 'Both', appearance: 'both', source: 'user' }, + styles: '', + markup: null, + scripts: [], + }); + + const mod = await load(); + await mod.selectThemePack('both-theme', { mode: 'system' }); + // resolvePairedThemeId returns null for 'both' appearance → no pairing + expect(mod.getActiveThemeId()).toBe('both-theme'); + }); + + it('does not pair theme when appearance is invalid', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'invalid-app', name: 'Invalid', appearance: 'invalid' as any, source: 'user' }, + styles: '', + markup: null, + scripts: [], + }); + + const mod = await load(); + await mod.selectThemePack('invalid-app', { mode: 'system' }); + // normalizeAppearance returns null for unrecognized → resolvePairedThemeId returns null + expect(mod.getActiveThemeId()).toBe('invalid-app'); + }); +}); + +// ============================================================================ +// ensureSystemListener behavior edge cases +// ============================================================================ +describe('ensureSystemListener mode guard', () => { + it('skips theme change listener when current mode is not system', async () => { + const mod = await load(); + mod.setAppearanceMode('system'); + // Switch to light mode so currentMode !== 'system' + mod.setAppearanceMode('light'); + // Grab the change handler installed on SYSTEM_DARK_MQ + const cb = mq.addEventListener.mock.calls[0][1]; + // Trigger system color scheme change + cb(); + // currentMode is 'light', not 'system' → listener should return early + expect(mod.getActiveThemeId()).toBe('default-light'); + }); +}); diff --git a/Frontend/src/scripts/ui/layout.test.ts b/Frontend/src/scripts/ui/layout.test.ts index a989b2ad..92b4250b 100644 --- a/Frontend/src/scripts/ui/layout.test.ts +++ b/Frontend/src/scripts/ui/layout.test.ts @@ -784,3 +784,33 @@ describe('initResizer', () => { expect(grid.style.gridTemplateColumns).toBe(''); }); }); + +describe('setRepoHeader with missing elements', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('does not crash when repo-title and repo-branch are missing', async () => { + const { setRepoHeader, resetRepoHeader } = await import('./layout'); + expect(() => setRepoHeader('/some/path')).not.toThrow(); + expect(() => resetRepoHeader()).not.toThrow(); + }); +}); + +describe('renderAheadBehind with missing element', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ''; + }); + + it('does not crash when ahead-behind element is missing', async () => { + const { bindLayoutActionState } = await import('./layout'); + expect(() => bindLayoutActionState()).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/ui/menubar.test.ts b/Frontend/src/scripts/ui/menubar.test.ts index a17c3c04..7bf27101 100644 --- a/Frontend/src/scripts/ui/menubar.test.ts +++ b/Frontend/src/scripts/ui/menubar.test.ts @@ -284,3 +284,185 @@ describe('initMenubar', () => { vi.runAllTimers(); }); }); + +// ============================================================================ +// getMenuList internal behavior - querySelector returns null +// ============================================================================ +describe('getMenuList returns null for unmatched menu id', () => { + it('skips plugin menu when the menu id has no matching DOM node', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'nonexistent-menu', + label: 'No DOM Match', + surface: 'menubar', + elements: [{ type: 'button', id: 'some-action', label: 'Some Action' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); +}); + +// ============================================================================ +// clearPluginMenubarMenus - no matching elements +// ============================================================================ +describe('clearPluginMenubarMenus', () => { + it('does nothing when no plugin menubar elements exist', async () => { + const { clearPluginMenubarMenus } = await import('./menubar'); + expect(() => clearPluginMenubarMenus()).not.toThrow(); + expect(document.querySelectorAll('[data-plugin-menubar="true"]')).toHaveLength(0); + }); +}); + +// ============================================================================ +// refreshPluginMenubarMenus - additional edge cases +// ============================================================================ +describe('refreshPluginMenubarMenus additional edge cases', () => { + it('skips menu with empty id', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: '', + label: 'No ID', + surface: 'menubar', + elements: [{ type: 'button', id: 'btn', label: 'Btn' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('continues when menu has no button entries', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [{ type: 'text', content: 'Just info' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('handles null elements array gracefully', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'file', + label: 'File', + surface: 'menubar', + elements: null as any, + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('skips button with empty label in render loop', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [ + { type: 'button', id: 'no-label', label: '' }, + { type: 'button', id: 'has-label', label: 'Labeled' }, + ], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + // Separator + valid button + expect(document.querySelectorAll('[data-plugin-menubar="true"]')).toHaveLength(2); + expect(document.querySelector('[data-plugin-action="no-label"]')).toBeNull(); + expect(document.querySelector('[data-plugin-action="has-label"]')).not.toBeNull(); + }); +}); + +// ============================================================================ +// initMenubar - additional edge cases +// ============================================================================ +describe('initMenubar additional edge cases', () => { + it('does nothing when root .menubar element is missing', async () => { + document.body.innerHTML = ''; + const { initMenubar } = await import('./menubar'); + expect(() => initMenubar(vi.fn())).not.toThrow(); + }); + + it('handles escape keydown when no menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + expect(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }).not.toThrow(); + }); + + it('handles document click when no menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + expect(() => { + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }).not.toThrow(); + }); + + it('ignores pointerover on trigger when no menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLElement; + trigger.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + expect(document.querySelector('.menu-list')?.hasAttribute('hidden')).toBe(true); + }); + + it('returns early from open() when menu has no list', async () => { + const menubar = document.querySelector('.menubar') as HTMLElement; + menubar.insertAdjacentHTML('beforeend', ` + + `); + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + const brokenTrigger = triggers[triggers.length - 1] as HTMLButtonElement; + brokenTrigger.click(); + // Should not throw — open() returns early when list is null + expect(true).toBe(true); + }); + + it('ignores click on menubar area that is neither item nor trigger', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const menubar = document.querySelector('.menubar') as HTMLElement; + menubar.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(document.querySelector('.menu-list')?.hasAttribute('hidden')).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/ui/modals.test.ts b/Frontend/src/scripts/ui/modals.test.ts index 5a46839a..5d0f3d3f 100644 --- a/Frontend/src/scripts/ui/modals.test.ts +++ b/Frontend/src/scripts/ui/modals.test.ts @@ -398,3 +398,34 @@ describe('hydrate with existing modal', () => { expect(() => hydrate('nothing-here')).toThrow('No fragment registered for nothing-here'); }); }); + +describe('hydrate already-loaded modal', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + + it('does nothing when modal id was already hydrated', async () => { + const { hydrate } = await import('./modals'); + hydrate('settings-modal'); + document.body.innerHTML = '
              '; + expect(() => hydrate('settings-modal')).not.toThrow(); + }); +}); + +describe('closeAllModals with existing timers', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('clears pending animation timers before closing', async () => { + vi.useFakeTimers(); + const { openModal, closeAllModals } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + + const modal = document.getElementById('m1') as HTMLElement; + (modal as any).__animatedCloseTimer = 12345; + + closeAllModals(); + expect(modal.getAttribute('aria-hidden')).toBe('true'); + expect(modal.classList.contains('is-closing')).toBe(false); + expect(document.body.style.overflow).toBe(''); + }); +}); From 6890ff3cf972a0a5436938403ce4084e488f68a0 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 22:44:02 +0100 Subject: [PATCH 16/25] More tests for frontend --- .../src/scripts/features/commandSheet.test.ts | 63 ++++ Frontend/src/scripts/features/diff.test.ts | 220 ++++++++++++ .../src/scripts/features/outputLog.test.ts | 19 ++ .../features/repo/diffConflicts.test.ts | 47 +++ .../features/repo/diffSelection.test.ts | 66 ++++ .../src/scripts/features/repo/history.test.ts | 76 ++++- .../src/scripts/features/repo/list.test.ts | 78 +++++ .../src/scripts/features/repoSettings.test.ts | 66 ++++ .../scripts/features/repoSwitchDrawer.test.ts | 75 +++++ .../src/scripts/features/settings.test.ts | 317 ++++++++++++++++++ .../scripts/features/settingsCommit.test.ts | 23 ++ .../scripts/features/settingsGeneral.test.ts | 36 ++ .../scripts/features/settingsPluginUI.test.ts | 291 ++++++++++++++++ .../scripts/features/settingsPlugins.test.ts | 189 +++++++++++ Frontend/src/scripts/features/sshAuth.test.ts | 67 +++- .../src/scripts/features/sshHostkey.test.ts | 84 +++++ .../src/scripts/plugins/registration.test.ts | 19 ++ Frontend/src/scripts/plugins/runtime.test.ts | 58 ++++ Frontend/src/scripts/ui/menubar.test.ts | 53 +++ 19 files changed, 1845 insertions(+), 2 deletions(-) diff --git a/Frontend/src/scripts/features/commandSheet.test.ts b/Frontend/src/scripts/features/commandSheet.test.ts index dfde5b24..8d81578e 100644 --- a/Frontend/src/scripts/features/commandSheet.test.ts +++ b/Frontend/src/scripts/features/commandSheet.test.ts @@ -421,4 +421,67 @@ describe('openSheet default parameter and edge cases', () => { expect(document.querySelector('[data-sheet="clone"]')?.classList.contains('active')).toBe(true); expect(document.getElementById('sheet-add')?.classList.contains('hidden')).toBe(true); }); + + it('closes sheet via closeSheet', async () => { + const { closeModal } = await import('../ui/modals'); + const { closeSheet } = await import('./commandSheet'); + closeSheet(); + expect(closeModal).toHaveBeenCalledWith('command-modal'); + }); + + it('handles browse clone rejection gracefully', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('browse failed')); + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + (document.getElementById('browse-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + it('handles browse add rejection gracefully', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('browse add failed')); + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + (document.getElementById('browse-add') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + it('does nothing when no url or dest for clone', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockClear(); + (document.getElementById('do-clone') as HTMLButtonElement).disabled = false; + (document.getElementById('do-clone') as HTMLButtonElement).click(); + expect(vi.mocked(TAURI.invoke)).not.toHaveBeenCalledWith('clone_repo', expect.anything()); + }); + + it('does nothing when no path for add', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockClear(); + (document.getElementById('do-add') as HTMLButtonElement).disabled = false; + (document.getElementById('do-add') as HTMLButtonElement).click(); + expect(vi.mocked(TAURI.invoke)).not.toHaveBeenCalledWith('add_repo', expect.anything()); + }); + + it('handles missing do-add button', async () => { + document.getElementById('do-add')?.remove(); + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockClear(); + expect(() => { + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/repo'; + }).not.toThrow(); + }); + + it('handles missing browse buttons', async () => { + document.getElementById('browse-clone')?.remove(); + document.getElementById('browse-add')?.remove(); + const { bindCommandSheet } = await import('./commandSheet'); + expect(() => bindCommandSheet()).not.toThrow(); + }); }); diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index 13b32801..9af434ef 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -702,4 +702,224 @@ describe('bindCommit error handling and buildPatchForSelected edge cases', () => expect(patch).toContain('+new3'); }, { timeout: 3000, interval: 20 }); }); + + it('returns combined patch empty when partial files list empty after filtering', async () => { + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + state.files = []; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const { getCommitSummaryHint } = await import('./repo/commit'); + vi.mocked(getCommitSummaryHint).mockReturnValue('Summary from hint'); + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Select files or hunks to commit'); + }, { timeout: 3000, interval: 20 }); + }); +}); + +// --------------------------------------------------------------------------- +// buildPatchForSelectedHunks - additional cover branches +// --------------------------------------------------------------------------- + +describe('buildPatchForSelectedHunks additional branch cover', () => { + it('handles isAdd = true and includes headerExtras', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1 @@', + '+content', + ]; + const result = buildPatchForSelectedHunks('new.txt', lines, [0]); + expect(result).toContain('--- /dev/null'); + expect(result).toContain('+++ b/new.txt'); + expect(result).toContain('new file mode 100644'); + }); + + it('handles isDel = true and includes headerExtras', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/del.txt b/del.txt', + 'deleted file mode 100644', + '--- a/del.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-removed', + ]; + const result = buildPatchForSelectedHunks('del.txt', lines, [0]); + expect(result).toContain('+++ /dev/null'); + expect(result).toContain('deleted file mode 100644'); + }); + + it('handles empty headerExtras', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/f.txt b/f.txt', + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [0]); + expect(result).toContain('diff --git a/f.txt b/f.txt'); + expect(result).toContain('--- a/f.txt'); + expect(result).toContain('+++ b/f.txt'); + }); + + it('handles out of bounds hunk index', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [5]); + expect(result).not.toContain('-old'); + }); + + it('handles negative hunk index', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [-1]); + expect(result).not.toContain('-old'); + }); +}); + +// --------------------------------------------------------------------------- +// bindCommit - description value +// --------------------------------------------------------------------------- + +describe('bindCommit - description handling', () => { + it('reads description from textarea', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitDesc = document.getElementById('commit-desc') as HTMLTextAreaElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Summary'; + commitDesc.value = 'Description body'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find( + (args: unknown[]) => args[0] === 'commit_patch_and_files' + ); + expect(commitCall?.[1].description).toBe('Description body'); + }, { timeout: 3000, interval: 20 }); + }); + + it('clears inputs after successful commit', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitDesc = document.getElementById('commit-desc') as HTMLTextAreaElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Summary'; + commitDesc.value = 'Desc'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(commitSummary.value).toBe(''); + expect(commitDesc.value).toBe(''); + }, { timeout: 3000, interval: 20 }); + }); +}); + +// --------------------------------------------------------------------------- +// buildPatchForSelectedHunks - add and delete combined +// --------------------------------------------------------------------------- + +describe('buildPatchForSelectedHunks - isAdd and isDel branches', () => { + it('handles isAdd = true and isDel = false', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/n.txt b/n.txt', + '--- /dev/null', + '+++ b/n.txt', + '@@ -0,0 +1 @@', + '+new', + ]; + const result = buildPatchForSelectedHunks('n.txt', lines, [0]); + expect(result).toContain('--- /dev/null'); + expect(result).toContain('+++ b/n.txt'); + }); + + it('handles isDel = true and isAdd = false', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/d.txt b/d.txt', + '--- a/d.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-gone', + ]; + const result = buildPatchForSelectedHunks('d.txt', lines, [0]); + expect(result).toContain('+++ /dev/null'); + expect(result).toContain('--- a/d.txt'); + }); + + it('handles neither isAdd nor isDel (modify)', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/m.txt b/m.txt', + '--- a/m.txt', + '+++ b/m.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('m.txt', lines, [0]); + expect(result).toContain('--- a/m.txt'); + expect(result).toContain('+++ b/m.txt'); + expect(result).toContain('-old'); + expect(result).toContain('+new'); + }); }); diff --git a/Frontend/src/scripts/features/outputLog.test.ts b/Frontend/src/scripts/features/outputLog.test.ts index 7853cce2..40cd104d 100644 --- a/Frontend/src/scripts/features/outputLog.test.ts +++ b/Frontend/src/scripts/features/outputLog.test.ts @@ -200,4 +200,23 @@ describe('initOutputLogViewIfRequested', () => { vi.advanceTimersByTime(2000); }); + + it('triggers vcs:log listen callback', async () => { + let listenCallback: ((evt: any) => void) | null = null; + const tauri = await import('../lib/tauri'); + (tauri.TAURI as any).listen = vi.fn((_event: string, cb: (evt: any) => void) => { + listenCallback = cb; + return { unlisten: vi.fn() }; + }); + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + listenCallback!({ payload: { ts_ms: 123, level: 'info', source: 'git', message: 'log msg' } }); + + const list = document.getElementById('outlog-list-vcs') as HTMLElement; + expect(list.textContent).toContain('log msg'); + }); }); diff --git a/Frontend/src/scripts/features/repo/diffConflicts.test.ts b/Frontend/src/scripts/features/repo/diffConflicts.test.ts index 0f84b3ec..ef23a5d6 100644 --- a/Frontend/src/scripts/features/repo/diffConflicts.test.ts +++ b/Frontend/src/scripts/features/repo/diffConflicts.test.ts @@ -423,3 +423,50 @@ describe('scrollDiffToTop', () => { expect(scrollDiffToTop).toHaveBeenCalledTimes(2); }); }); + +// ============================================================================ +// Merge button context menu action execution +// ============================================================================ +describe('merge button context menu actions', () => { + it('executes openMergeModal action from context menu', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + mergeBtn.click(); + await flushPromises(); + + const items = (buildCtxMenu as any).mock.calls[0][0]; + const builtInAction = items.find((i: any) => i.label === 'Open built-in merge tool'); + expect(builtInAction).toBeDefined(); + + builtInAction.action(); + const { openMergeModal } = await import('../conflicts'); + expect(openMergeModal).toHaveBeenCalled(); + }); + + it('executes launchExternalMergeTool action from context menu', async () => { + const { hasExternalMergeTool } = await import('../conflicts'); + (hasExternalMergeTool as any).mockResolvedValue(true); + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + mergeBtn.click(); + await flushPromises(); + + const items = (buildCtxMenu as any).mock.calls[0][0]; + const customAction = items.find((i: any) => i.label === 'Open custom merge tool'); + expect(customAction).toBeDefined(); + + customAction.action(); + const { launchExternalMergeTool } = await import('../conflicts'); + expect(launchExternalMergeTool).toHaveBeenCalledWith('f.txt'); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffSelection.test.ts b/Frontend/src/scripts/features/repo/diffSelection.test.ts index 4237c74f..fe100493 100644 --- a/Frontend/src/scripts/features/repo/diffSelection.test.ts +++ b/Frontend/src/scripts/features/repo/diffSelection.test.ts @@ -683,3 +683,69 @@ describe('clearAllFileSelections via implicit clear', () => { expect(pickCb.checked).toBe(false); }); }); + +// ============================================================================ +// handleLineToggle - delete rec[hunk] path and selectedHunks push +// ============================================================================ +describe('handleLineToggle additional paths', () => { + it('deletes rec[hunk] when last line unchecked', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = [0]; + (state as any).selectedLinesByFile = { 'test.txt': { 0: [0] } }; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [1], totalHunks: 1 }; + + const hunkCb = makeHunkCheckbox('0'); + hunkCb.checked = false; + const lineCb0 = makeLineCheckbox('0', '0'); + lineCb0.checked = true; + const hunkEl = document.createElement('div'); + hunkEl.classList.add('picked'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCb], + lineCheckboxes: { 0: lineCb0 }, + }); + + diff.appendChild(lineCb0); + bindHunkToggles(diff); + + lineCb0.checked = false; + lineCb0.dispatchEvent(new Event('change', { bubbles: true })); + + const rec = (state as any).selectedLinesByFile['test.txt']; + expect(rec[0]).toBeUndefined(); + }); + + it('adds hunk to selectedHunks when all lines checked', async () => { + const diff = document.getElementById('diff')!; + const { bindHunkToggles } = await import('./diffSelection'); + const { state } = await import('../../state/state'); + state.currentFile = 'test.txt'; + state.currentDiffHunkNodes = new Map(); + state.selectedHunks = []; + (state as any).selectedLinesByFile = { 'test.txt': {} }; + state.currentDiffMeta = { offset: 0, rest: [], starts: [], changeCounts: [1], totalHunks: 1 }; + + const hunkCb = makeHunkCheckbox('0'); + hunkCb.checked = false; + const lineCb0 = makeLineCheckbox('0', '0'); + const hunkEl = document.createElement('div'); + state.currentDiffHunkNodes.set(0, { + hunkEls: [hunkEl], + hunkCheckboxes: [hunkCb], + lineCheckboxes: { 0: lineCb0 }, + }); + + diff.appendChild(lineCb0); + bindHunkToggles(diff); + + lineCb0.checked = true; + lineCb0.dispatchEvent(new Event('change', { bubbles: true })); + + expect(state.selectedHunks).toContain(0); + }); +}); diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 308fbe05..624c9454 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -744,4 +744,78 @@ describe('openCommitActionsMenu - failure paths', () => { await items.find((i: any) => i.label === 'Revert (reverse) commit…')?.action?.(); expect(notify).toHaveBeenCalledWith(expect.stringContaining('Revert failed')); }); -}) +}); + +// ============================================================================ +// selectHistory - selectCommitFile inner function +// ============================================================================ +describe('selectHistory - selectCommitFile', () => { + it('switches file in sidebar and renders correct diff', async () => { + installTauriMock(); + (navigator as any).clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; + (window as any).__TAURI__.core.invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') { + return [ + 'diff --git a/a.ts b/a.ts', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + 'diff --git a/b.ts b/b.ts', + '--- a/b.ts', + '+++ b/b.ts', + '@@ -1 +1 @@', + '-old', + '+new2', + ]; + } + return undefined; + }); + + const { selectHistory } = await loadHistoryModule(); + await selectHistory({ id: 'abc123', msg: 'Multi', author: 'Dev' } as any, 0); + + const rows = document.querySelectorAll('.commit-files .row'); + expect(rows.length).toBe(2); + + // Click the second file + (rows[1] as HTMLElement).click(); + }); +}); + +// ============================================================================ +// selectHistory - revert binary file +// ============================================================================ +describe('selectHistory - revert binary file', () => { + it('notifies when reverting a binary diff', async () => { + installTauriMock(); + (navigator as any).clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; + (window as any).__TAURI__.core.invoke = vi.fn(async (cmd: string) => { + if (cmd === 'vcs_diff_commit') { + return [ + 'diff --git a/image.png b/image.png', + 'GIT binary patch', + '--- a/image.png', + '+++ b/image.png', + '@@ -1,3 +0,0 @@', + '-binary', + ]; + } + return undefined; + }); + + const { selectHistory } = await loadHistoryModule(); + const { buildCtxMenu } = await import('../../lib/menu'); + const { notify } = await import('../../lib/notify'); + + await selectHistory({ id: 'abc', msg: 'Binary', author: 'A' } as any, 0); + + const fileRow = document.querySelector('.commit-files .row') as HTMLElement; + fileRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 6 })); + + const items = vi.mocked(buildCtxMenu).mock.calls.at(-1)?.[0] || []; + await items.find((i: any) => i.label === 'Revert this file')?.action?.(); + expect(notify).toHaveBeenCalledWith('Cannot revert binary diffs yet'); + }); +}); diff --git a/Frontend/src/scripts/features/repo/list.test.ts b/Frontend/src/scripts/features/repo/list.test.ts index 264d5f80..9338e6c4 100644 --- a/Frontend/src/scripts/features/repo/list.test.ts +++ b/Frontend/src/scripts/features/repo/list.test.ts @@ -408,3 +408,81 @@ describe('renderChangesList display edge cases', () => { expect(row.classList.contains('resolved')).toBe(true); }); }); + +// ============================================================================ +// renderList - click and mousedown on rows +// ============================================================================ +describe('renderList row click and mousedown handlers', () => { + it('click handler fires onFileClick', async () => { + const interactions = await import('./interactions'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [{ path: 'click.txt', status: 'M' }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + state.currentDiff = []; + + renderList(); + const row = document.querySelector('li.row') as HTMLElement; + row.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(vi.mocked(interactions.onFileClick)).toHaveBeenCalled(); + }); + + it('mousedown handler fires onFileMouseDown', async () => { + const interactions = await import('./interactions'); + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [{ path: 'mousedown.txt', status: 'M' }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + state.currentDiff = []; + + renderList(); + const row = document.querySelector('li.row') as HTMLElement; + row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(vi.mocked(interactions.onFileMouseDown)).toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// renderChangesList - staged file shows stage-mark and conflict file shows conflict-mark +// ============================================================================ +describe('renderChangesList status marks', () => { + it('renders stage-mark for staged files', async () => { + const { renderList } = await import('./list'); + const { prefs, state } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [{ path: 'staged.txt', status: 'M', staged: true }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + state.currentDiff = []; + + renderList(); + const rowMarks = document.querySelector('.row-marks') as HTMLElement; + expect(rowMarks).not.toBeNull(); + const stageMark = rowMarks.querySelector('.stage-mark') as HTMLElement; + expect(stageMark).not.toBeNull(); + }); + + it('renders conflict-mark for conflicted files', async () => { + const { renderList } = await import('./list'); + const { prefs, state, isConflictStatus } = await import('../../state/state'); + prefs.tab = 'changes'; + state.files = [{ path: 'conflict.txt', status: 'UU' }] as any; + state.selectedFiles = new Set(); + state.diffSelectedFiles = new Set(); + state.currentFile = ''; + state.currentDiff = []; + + renderList(); + const rowMarks = document.querySelector('.row-marks') as HTMLElement; + expect(rowMarks).not.toBeNull(); + const conflictMark = rowMarks.querySelector('.conflict-mark') as HTMLElement; + expect(conflictMark).not.toBeNull(); + }); +}); diff --git a/Frontend/src/scripts/features/repoSettings.test.ts b/Frontend/src/scripts/features/repoSettings.test.ts index f1ab32f1..05af9dd4 100644 --- a/Frontend/src/scripts/features/repoSettings.test.ts +++ b/Frontend/src/scripts/features/repoSettings.test.ts @@ -488,3 +488,69 @@ describe('wireRepoSettings (save flow)', () => { await expect(wireRepoSettings()).resolves.toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// wireRepoSettings - fetch error and setTimeout reset +// --------------------------------------------------------------------------- + +describe('wireRepoSettings (fetch error and timeout)', () => { + async function load() { + return import('./repoSettings'); + } + + it('handles vcs_fetch_all failure gracefully', async () => { + mountModal({ nameValue: 'User', emailValue: 'u@example.com' }); + mockInvoke.mockResolvedValueOnce({ + user_name: '', + user_email: '', + remotes: [], + }); + mockInvoke.mockResolvedValueOnce(undefined); // set_repo_settings + mockInvoke.mockRejectedValueOnce(new Error('fetch fail')); // vcs_fetch_all fails + const { wireRepoSettings } = await load(); + await wireRepoSettings(); + + // Add a remote row so remotes changed flag is set + const addBtn = document.getElementById('git-remote-add') as HTMLButtonElement; + addBtn.click(); + const remoteName = document.querySelector('.remote-name') as HTMLInputElement; + const remoteUrl = document.querySelector('.remote-url') as HTMLInputElement; + remoteName.value = 'origin'; + remoteUrl.value = 'git@host:org/repo.git'; + + const saveBtn = document.getElementById('repo-settings-save') as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('vcs_fetch_all', {}); + }); + }); + + it('resets save button text after timeout', async () => { + vi.useFakeTimers(); + mountModal(); + mockInvoke.mockResolvedValueOnce({ + user_name: '', + user_email: '', + remotes: [], + }); + mockInvoke.mockResolvedValueOnce(undefined); // set_repo_settings + mockInvoke.mockResolvedValueOnce(undefined); // vcs_fetch_all + const { wireRepoSettings } = await load(); + await wireRepoSettings(); + + const saveBtn = document.getElementById('repo-settings-save') as HTMLButtonElement; + saveBtn.click(); + + await vi.advanceTimersByTimeAsync(100); + + expect(saveBtn.textContent).toBe('Saved!'); + expect(saveBtn.classList.contains('saved-state')).toBe(true); + + await vi.advanceTimersByTimeAsync(2000); + + expect(saveBtn.textContent).toBe('Save'); + expect(saveBtn.classList.contains('saved-state')).toBe(false); + vi.useRealTimers(); + }); +}); diff --git a/Frontend/src/scripts/features/repoSwitchDrawer.test.ts b/Frontend/src/scripts/features/repoSwitchDrawer.test.ts index 250d5a42..4fd79107 100644 --- a/Frontend/src/scripts/features/repoSwitchDrawer.test.ts +++ b/Frontend/src/scripts/features/repoSwitchDrawer.test.ts @@ -313,3 +313,78 @@ describe('registerDrawerActions', () => { expect(openClone).toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// ensureDrawer - edge cases +// --------------------------------------------------------------------------- + +describe('ensureDrawer edge cases', () => { + it('handles missing drawer root gracefully', async () => { + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + document.body.innerHTML = '
              '; + openSwitchDrawer(); + // Should not throw despite missing drawer element + }); + + it('handles missing filter input', async () => { + document.body.innerHTML = ` +
              + + `; + const { openSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + // Should not throw despite missing filter input + }); +}); + +// --------------------------------------------------------------------------- +// openSwitchDrawer - clears closeTimer +// --------------------------------------------------------------------------- + +describe('openSwitchDrawer clears closeTimer', () => { + it('clears existing closeTimer', async () => { + mountDrawerInBody(); + mockInvoke.mockResolvedValue([]); + const { openSwitchDrawer, closeSwitchDrawer } = await import('./repoSwitchDrawer'); + + // First close with animation to set the timer + window.matchMedia = vi.fn().mockImplementation((q: string) => ({ + matches: false, + media: q, + } as any)); + openSwitchDrawer(); + closeSwitchDrawer(); + + // Now open while timer is active + openSwitchDrawer(); + const drawer = document.getElementById('repo-switch-drawer')!; + expect(drawer.classList.contains('is-closing')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// closeSwitchDrawer - animate close clears timer +// --------------------------------------------------------------------------- + +describe('closeSwitchDrawer animate close', () => { + it('handles closeTimer already set during close', async () => { + mountDrawerInBody(); + window.matchMedia = vi.fn().mockImplementation((q: string) => ({ + matches: false, + media: q, + } as any)); + + const { openSwitchDrawer, closeSwitchDrawer } = await import('./repoSwitchDrawer'); + openSwitchDrawer(); + closeSwitchDrawer(); + closeSwitchDrawer(); // second call - should still work + + const drawer = document.getElementById('repo-switch-drawer')!; + expect(drawer.classList.contains('is-closing')).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/features/settings.test.ts b/Frontend/src/scripts/features/settings.test.ts index 4aef41fb..32b6a8b7 100644 --- a/Frontend/src/scripts/features/settings.test.ts +++ b/Frontend/src/scripts/features/settings.test.ts @@ -1411,6 +1411,98 @@ describe('refreshDefaultBackendOptions error handling', () => { }); }); +// --------------------------------------------------------------------------- +// collectSettingsFromForm - LFS elements present +// --------------------------------------------------------------------------- + +describe('collectSettingsFromForm - LFS elements', () => { + async function load() { + return import('./settings'); + } + + it('collects LFS settings when LFS elements are present', async () => { + const modal = mountSettingsModal(); + modal.dataset.currentCfg = JSON.stringify({}); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n')); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_global_settings', expect.objectContaining({ + cfg: expect.objectContaining({ + lfs: expect.objectContaining({ enabled: true, concurrency: 8 }), + }), + })); + }); + }); + + it('collects LFS with invalid concurrency falling back to default', async () => { + const modal = mountSettingsModal(); + modal.dataset.currentCfg = JSON.stringify({}); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n')); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalled(); + }); + }); +}); + // --------------------------------------------------------------------------- // collectSettingsFromForm - edge cases // --------------------------------------------------------------------------- @@ -1602,6 +1694,96 @@ describe('wireSettings - backdrop click no-op', () => { modal.querySelector('#settings-nav')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(mockCloseModal).not.toHaveBeenCalled(); }); + + it('does not close when clicking on modal content (not backdrop)', async () => { + const modal = mountSettingsModal(); + const { wireSettings } = await load(); + wireSettings(); + const nav = modal.querySelector('#settings-nav')!; + nav.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(mockCloseModal).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// wireSettings - openvcs:theme-pack-changed event +// --------------------------------------------------------------------------- + +describe('wireSettings - theme-pack-changed auto update', () => { + async function load() { + return import('./settings'); + } + + it('applies active theme on theme-pack-changed when auto is checked', async () => { + document.body.innerHTML = ` +
              +
              + + + +
              +
              + + +
              +
              + `; + mockGetActiveThemeId.mockReturnValue('dark-theme'); + mockThemeTooltip.mockReturnValue('Dark Theme'); + const { wireSettings } = await load(); + wireSettings(); + window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed')); + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + expect(themeSel.value).toBe('dark-theme'); + expect(themeSel.disabled).toBe(true); + }); + + it('does not update theme select when auto is unchecked', async () => { + document.body.innerHTML = ` +
              +
              + + + +
              +
              + + +
              +
              + `; + mockGetActiveThemeId.mockReturnValue('dark-theme'); + const { wireSettings } = await load(); + wireSettings(); + const themeSel = document.getElementById('set-theme') as HTMLSelectElement; + const initial = themeSel.value; + window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed')); + expect(themeSel.value).toBe(initial); + }); + + it('handles missing theme select on theme-pack-changed', async () => { + document.body.innerHTML = ` +
              +
              + + +
              +
              + + +
              +
              + `; + const { wireSettings } = await load(); + wireSettings(); + expect(() => window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed'))).not.toThrow(); + }); }); // --------------------------------------------------------------------------- @@ -1709,6 +1891,35 @@ describe('applyThemeFromControls', () => { return import('./settings'); } + it('handles theme controls with both auto and select present', async () => { + mockModeForTheme.mockReturnValue('light'); + mockGetActiveThemeId.mockReturnValue('dark-theme'); + mockSelectThemePack.mockResolvedValue(undefined); + document.body.innerHTML = ` +
              +
              + + + +
              +
              + + +
              +
              + `; + const { wireSettings } = await load(); + wireSettings(); + const autoCheck = document.getElementById('set-theme-auto') as HTMLInputElement; + autoCheck.checked = true; + autoCheck.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(mockSetTheme).toHaveBeenCalledWith('system'); + }); + }); + it('sets system mode when auto is checked', async () => { mockModeForTheme.mockReturnValue('light'); mockGetActiveThemeId.mockReturnValue('dark-theme'); @@ -1739,3 +1950,109 @@ describe('applyThemeFromControls', () => { }); }); }); + +// --------------------------------------------------------------------------- +// flashSavedState - custom button text +// --------------------------------------------------------------------------- + +describe('flashSavedState custom text', () => { + async function load() { + return import('./settings'); + } + + it('uses custom original text for restore', async () => { + vi.useFakeTimers(); + const modal = mountSettingsModal(); + modal.dataset.currentCfg = JSON.stringify({}); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n')); + mockCollectGeneralSettings.mockReturnValue({}); + mockCollectCommitSettings.mockReturnValue({}); + mockCollectCommitTemplateSettings.mockReturnValue({}); + mockInvoke.mockResolvedValue(undefined); + mockSyncFrontendMonitoring.mockResolvedValue(undefined); + + const { wireSettings } = await load(); + wireSettings(); + const saveBtn = document.getElementById('settings-save') as HTMLButtonElement; + saveBtn.click(); + + await vi.advanceTimersByTimeAsync(100); + expect(saveBtn.textContent).toBe('Saved!'); + + await vi.advanceTimersByTimeAsync(2000); + expect(saveBtn.textContent).toBe('Save'); + vi.useRealTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// SSH binary toggle - custom mode with specific path +// --------------------------------------------------------------------------- + +describe('wireSettings (SSH binary custom mode path)', () => { + async function load() { + return import('./settings'); + } + + it('clears path when switching to auto', async () => { + const modal = mountSettingsModal(); + modal.insertAdjacentHTML('beforeend', [ + '', + '', + ].join('\n')); + const { wireSettings } = await load(); + wireSettings(); + const select = document.getElementById('set-git-ssh-binary') as HTMLSelectElement; + select.value = 'auto'; + select.dispatchEvent(new Event('change')); + const pathInput = document.getElementById('set-git-ssh-path') as HTMLInputElement; + expect(pathInput.value).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// loadSettingsIntoForm - log level and merge mode change events +// --------------------------------------------------------------------------- + +describe('loadSettingsIntoForm - merge mode change event', () => { + async function load() { + return import('./settings'); + } + + it('dispatches change event on merge mode select', async () => { + document.body.innerHTML = ` +
              + +
              + +
              +
              + `; + const cfg = { + diff: { external_merge: { enabled: true, path: '/usr/bin/merge', args: '' } }, + }; + mockInvoke.mockResolvedValue(cfg); + mockLoadPluginsIntoForm.mockResolvedValue(undefined); + mockLoadGeneralSettingsIntoForm.mockResolvedValue(undefined); + const { loadSettingsIntoForm } = await load(); + await loadSettingsIntoForm(); + const mergeSel = document.getElementById('set-merge-mode') as HTMLSelectElement; + expect(mergeSel.value).toBe('custom'); + }); +}); diff --git a/Frontend/src/scripts/features/settingsCommit.test.ts b/Frontend/src/scripts/features/settingsCommit.test.ts index 6251ea08..e3464bf6 100644 --- a/Frontend/src/scripts/features/settingsCommit.test.ts +++ b/Frontend/src/scripts/features/settingsCommit.test.ts @@ -99,4 +99,27 @@ describe('loadCommitSettingsIntoForm', () => { expect((root.querySelector('#set-commit-message-template-update') as HTMLInputElement).value).toBe(DEFAULT_COMMIT_MESSAGE_UPDATE); expect((root.querySelector('#set-commit-message-template-delete') as HTMLInputElement).value).toBe(DEFAULT_COMMIT_MESSAGE_DELETE); }); + + it('handles missing template input elements', () => { + document.body.innerHTML = '
              '; + const root = document.body.firstElementChild as HTMLElement; + expect(() => loadCommitSettingsIntoForm(root, {} as any)).not.toThrow(); + }); + + it('collectCommitSettings handles missing elements', () => { + document.body.innerHTML = '
              '; + const root = document.body.firstElementChild as HTMLElement; + const result = collectCommitSettings(root); + expect(result.commit_message_template_enabled).toBe(false); + expect(result.restrict_commit_summary).toBe(false); + }); + + it('collectCommitTemplateSettings handles missing elements', () => { + document.body.innerHTML = '
              '; + const root = document.body.firstElementChild as HTMLElement; + const result = collectCommitTemplateSettings(root); + expect(result.commit_message_template_create).toBe(''); + expect(result.commit_message_template_update).toBe(''); + expect(result.commit_message_template_delete).toBe(''); + }); }); diff --git a/Frontend/src/scripts/features/settingsGeneral.test.ts b/Frontend/src/scripts/features/settingsGeneral.test.ts index e10ec7b1..4794ed7e 100644 --- a/Frontend/src/scripts/features/settingsGeneral.test.ts +++ b/Frontend/src/scripts/features/settingsGeneral.test.ts @@ -188,4 +188,40 @@ describe('loadGeneralSettingsIntoForm', () => { expect((root.querySelector('#set-theme') as HTMLSelectElement).disabled).toBe(true); expect((root.querySelector('#set-theme-auto') as HTMLInputElement).checked).toBe(true); }); + + it('handles missing theme select element', async () => { + document.body.innerHTML = ` +
              + + + + + +
              + `; + const root = document.body.firstElementChild as HTMLElement; + const refreshDefaultBackendOptions = vi.fn().mockResolvedValue(undefined); + await loadGeneralSettingsIntoForm( + root, + { general: { language: 'en', update_channel: 'stable' } } as any, + (v) => String(v ?? ''), + refreshDefaultBackendOptions, + vi.fn().mockResolvedValue(undefined), + ); + expect(refreshDefaultBackendOptions).toHaveBeenCalled(); + }); + + it('handles missing language and update channel elements', async () => { + document.body.innerHTML = '
              '; + const root = document.body.firstElementChild as HTMLElement; + const refreshDefaultBackendOptions = vi.fn().mockResolvedValue(undefined); + await loadGeneralSettingsIntoForm( + root, + { general: {} } as any, + (v) => String(v ?? ''), + refreshDefaultBackendOptions, + vi.fn().mockResolvedValue(undefined), + ); + expect(refreshDefaultBackendOptions).toHaveBeenCalled(); + }); }); diff --git a/Frontend/src/scripts/features/settingsPluginUI.test.ts b/Frontend/src/scripts/features/settingsPluginUI.test.ts index dc4ed7ab..f45a2315 100644 --- a/Frontend/src/scripts/features/settingsPluginUI.test.ts +++ b/Frontend/src/scripts/features/settingsPluginUI.test.ts @@ -748,3 +748,294 @@ describe('renderPluginSettingFields (via activateSection)', () => { expect(mockInvoke).toHaveBeenCalledTimes(1); }); }); + +// --------------------------------------------------------------------------- +// activateSection - missing panels-scroll +// --------------------------------------------------------------------------- + +describe('activateSection - missing panels-scroll', () => { + async function load() { + return import('./settingsPluginUI'); + } + + function createModalWithoutScroll(): HTMLElement { + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
              ', + '
              ', + '
              ', + ].join('\n'); + document.body.appendChild(modal); + return modal; + } + + it('handles settings panel activation without panels element', async () => { + const { activateSection } = await load(); + const modal = createModalWithoutScroll(); + expect(() => activateSection(modal, 'general')).not.toThrow(); + document.body.removeChild(modal); + }); +}); + +// --------------------------------------------------------------------------- +// ensurePluginSettingsLoaded - early returns +// --------------------------------------------------------------------------- + +describe('ensurePluginSettingsLoaded - early returns', () => { + async function load() { + return import('./settingsPluginUI'); + } + + function createModal(): HTMLElement { + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
              ', + '
              ', + ' ', + '
              ', + '
              ', + '
              ', + ].join('\n'); + document.body.appendChild(modal); + return modal; + } + + it('returns false when panels-scroll missing', async () => { + const modal = document.createElement('div'); + const { activateSection } = await load(); + expect(() => activateSection(modal, 'general')).not.toThrow(); + }); + + it('handles empty fields array from backend', async () => { + mockInvoke.mockResolvedValueOnce([]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + expect(modal.textContent).toContain('No settings available'); + }); + document.body.removeChild(modal); + }); +}); + +// --------------------------------------------------------------------------- +// renderPluginMenus - built-in nav fallback +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// renderPluginMenus - built-in nav falls back when no plugins li +// --------------------------------------------------------------------------- + +describe('renderPluginMenus - built-in nav fallback', () => { + async function load() { + return import('./settingsPluginUI'); + } + + it('appends built-in nav to nav directly when plugins li not found', async () => { + mockInvoke.mockResolvedValueOnce([ + { plugin_id: 'builtin-p', id: 'm1', label: 'BuiltIn', surface: 'settings', elements: [] }, + ]); + mockInvoke.mockResolvedValueOnce([{ id: 'builtin-p', name: 'BuiltIn', source: 'built-in' }]); + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
              ', + ].join('\n'); + document.body.appendChild(modal); + const { renderPluginMenus } = await load(); + await renderPluginMenus(modal); + const navBtn = modal.querySelector('[data-section="plugin-builtin-p-m1"]'); + expect(navBtn).not.toBeNull(); + document.body.removeChild(modal); + }); +}); + +// --------------------------------------------------------------------------- +// renderPluginSettingFields - edge cases +// --------------------------------------------------------------------------- + +describe('renderPluginSettingFields - edge cases', () => { + async function load() { + return import('./settingsPluginUI'); + } + + function createModal(): HTMLElement { + const modal = document.createElement('div'); + modal.innerHTML = [ + '', + '
              ', + '
              ', + ' ', + '
              ', + '
              ', + '
              ', + ].join('\n'); + document.body.appendChild(modal); + return modal; + } + + it('skips field with empty id', async () => { + mockInvoke.mockResolvedValueOnce([ + { id: '', kind: 'text', label: 'Empty', value: '' }, + ]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + expect(modal.textContent).toContain('Settings'); + }); + document.body.removeChild(modal); + }); + + it('renders s32 field with non-finite default value', async () => { + mockInvoke.mockResolvedValueOnce([ + { id: 'cnt', kind: 's32', label: 'Count', value: null }, + ]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + const input = modal.querySelector('input[type="number"]'); + expect(input).not.toBeNull(); + }); + document.body.removeChild(modal); + }); + + it('renders text kind with options and selects matching value', async () => { + mockInvoke.mockResolvedValueOnce([{ + id: 'mode', kind: 'text', label: 'Mode', value: 'auto', + options: [ + { value: 'manual', label: 'Manual' }, + { value: 'auto', label: 'Auto' }, + ], + }]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + const sel = modal.querySelector('select'); + expect(sel).not.toBeNull(); + expect(sel!.value).toBe('auto'); + }); + document.body.removeChild(modal); + }); + + it('renders select with options when kind is text and has options', async () => { + mockInvoke.mockResolvedValueOnce([{ + id: 'mode', kind: 'text', label: 'Mode', value: 'unknown', + options: [ + { value: 'manual', label: 'Manual' }, + ], + }]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + const sel = modal.querySelector('select'); + expect(sel).not.toBeNull(); + expect(sel!.value).toBe('manual'); + }); + document.body.removeChild(modal); + }); + + it('renders field with description text', async () => { + mockInvoke.mockResolvedValueOnce([{ + id: 'x', kind: 'text', label: 'X', value: '', description: 'Help text', + }]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + expect(modal.textContent).toContain('Help text'); + }); + document.body.removeChild(modal); + }); + + it('renders s32 field with non-finite default value', async () => { + mockInvoke.mockResolvedValueOnce([ + { id: 'cnt', kind: 's32', label: 'Count', value: null }, + ]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + const input = modal.querySelector('input[type="number"]'); + expect(input).not.toBeNull(); + }); + document.body.removeChild(modal); + }); + + it('renders text kind with options and selects matching value', async () => { + mockInvoke.mockResolvedValueOnce([{ + id: 'mode', kind: 'text', label: 'Mode', value: 'auto', + options: [ + { value: 'manual', label: 'Manual' }, + { value: 'auto', label: 'Auto' }, + ], + }]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + const sel = modal.querySelector('select'); + expect(sel).not.toBeNull(); + expect(sel!.value).toBe('auto'); + }); + document.body.removeChild(modal); + }); + + it('renders select with options when kind is text and has options', async () => { + mockInvoke.mockResolvedValueOnce([{ + id: 'mode', kind: 'text', label: 'Mode', value: 'unknown', + options: [ + { value: 'manual', label: 'Manual' }, + ], + }]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + const sel = modal.querySelector('select'); + expect(sel).not.toBeNull(); + expect(sel!.value).toBe('manual'); + }); + document.body.removeChild(modal); + }); + + it('renders field with description text', async () => { + mockInvoke.mockResolvedValueOnce([{ + id: 'x', kind: 'text', label: 'X', value: '', description: 'Help text', + }]); + const { activateSection } = await load(); + const modal = createModal(); + activateSection(modal, 'pluginsettings_testp'); + await vi.waitFor(() => { + expect(modal.textContent).toContain('Help text'); + }); + document.body.removeChild(modal); + }); +}); diff --git a/Frontend/src/scripts/features/settingsPlugins.test.ts b/Frontend/src/scripts/features/settingsPlugins.test.ts index 1140c626..ac5ac61d 100644 --- a/Frontend/src/scripts/features/settingsPlugins.test.ts +++ b/Frontend/src/scripts/features/settingsPlugins.test.ts @@ -1369,3 +1369,192 @@ describe('context menu overflow positioning', () => { }); }); +// --------------------------------------------------------------------------- +// pluginIsEnabled - empty/missing id +// --------------------------------------------------------------------------- + +describe('pluginIsEnabled - empty id', () => { + it('returns false for plugin with no id', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: '', name: 'NoId', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const list = document.getElementById('plugins-list') as HTMLElement; + expect(list.children.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// queuePluginToggle - empty plugin id +// --------------------------------------------------------------------------- + +describe('queuePluginToggle - empty id', () => { + it('returns early when plugin id is empty', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Simulate clicking a checkbox without data-plugin-id (should be a no-op) + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.dispatchEvent(new Event('change', { bubbles: true })); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// ensureSelection - empty filtered list +// --------------------------------------------------------------------------- + +describe('ensureSelection - empty filtered', () => { + it('sets selectedId to null when filtered is empty', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return []; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + const detail = document.getElementById('plugins-detail') as HTMLElement; + expect(detail.textContent).toContain('No plugins installed'); + }); +}); + +// --------------------------------------------------------------------------- +// pane click - toggle button with empty id +// --------------------------------------------------------------------------- + +describe('pane click - toggle button with empty id', () => { + it('returns early when toggle button has no id', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Create a toggle button with empty data-plugin-toggle + const btn = document.createElement('button'); + btn.dataset.pluginToggle = ''; + document.getElementById('plugins-pane')?.appendChild(btn); + btn.click(); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// renderDetails - toggle button states (Enabling... / Disabling...) +// --------------------------------------------------------------------------- + +describe('renderDetails - toggle button states', () => { + it('shows "Disabling..." when pendingToggle is false', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + if (cmd === 'set_plugin_enabled') throw new Error('fail'); + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Trigger a toggle which will cause pendingToggle to be set + const checkbox = document.querySelector('input[type="checkbox"].plugin-check-input'); + checkbox!.checked = false; + checkbox!.dispatchEvent(new Event('change', { bubbles: true })); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// pane change handler - checkbox with empty plugin id +// --------------------------------------------------------------------------- + +describe('pane change handler - empty plugin id', () => { + it('returns early when checkbox has no pluginId', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Create a checkbox with empty data-plugin-id + const cb = document.createElement('input'); + cb.type = 'checkbox'; + const pane = document.getElementById('plugins-pane') as HTMLElement; + pane.appendChild(cb); + cb.dispatchEvent(new Event('change', { bubbles: true })); + await flushPromises(); + }); +}); + +// --------------------------------------------------------------------------- +// renderList - context menu inside pane +// --------------------------------------------------------------------------- + +describe('renderList - context menu on non-row element', () => { + it('hides context menu when clicking non-row in pane', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async (cmd: string) => { + if (cmd === 'list_plugins') return [{ id: 'p1', name: 'P1', version: '1.0', author: 'A', category: 'U', description: 'D', source: 'npm', tags: [], icon_data_url: '', default_enabled: true }]; + if (cmd === 'list_plugin_start_failures') return []; + if (cmd === 'get_global_settings') return { plugins: { disabled: [], enabled: [] } }; + return null; + })}, + event: { listen: vi.fn() }, + }; + const { loadPluginsIntoForm } = await import('./settingsPlugins'); + await loadPluginsIntoForm(document.getElementById('settings-modal') as HTMLElement, { plugins: {} } as any); + await flushPromises(); + + // Right-click on a non-row element inside the pane + const pane = document.getElementById('plugins-pane') as HTMLElement; + pane.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); + await flushPromises(); + + const cm = document.querySelector('.plugins-context-menu') as HTMLElement; + expect(cm).not.toBeNull(); + }); +}); + diff --git a/Frontend/src/scripts/features/sshAuth.test.ts b/Frontend/src/scripts/features/sshAuth.test.ts index f4a4b3d0..a3741b91 100644 --- a/Frontend/src/scripts/features/sshAuth.test.ts +++ b/Frontend/src/scripts/features/sshAuth.test.ts @@ -117,7 +117,7 @@ describe('wireAuthModal', () => { listenHandler?.({ payload: { host: 'example.com', remote: 'origin', url: 'http://example.com/repo' } }); const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; - expect(httpsBtn.disabled).toBe(false); // http:// is already HTTPS-compatible + expect(httpsBtn.disabled).toBe(false); }); it('enables HTTPS conversion for ssh:// protocol URLs', async () => { @@ -220,3 +220,68 @@ describe('HTTPS switch button flow', () => { expect(httpsBtn.disabled).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// sshToHttps - edge cases +// --------------------------------------------------------------------------- + +describe('sshToHttps edge cases', () => { + it('returns null for empty url', async () => { + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + listenHandler?.({ payload: { host: '', remote: '', url: '' } }); + + const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; + expect(httpsBtn.disabled).toBe(true); + }); + + it('converts http:// urls (already https-compatible)', async () => { + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + listenHandler?.({ payload: { host: 'example.com', remote: 'origin', url: 'http://example.com/repo' } }); + + const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; + expect(httpsBtn.disabled).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// wireAuthModal - missing elements +// --------------------------------------------------------------------------- + +describe('wireAuthModal - missing elements', () => { + it('handles missing ok button gracefully', async () => { + document.body.innerHTML = ` +
              + +
              + `; + + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + listenHandler?.({ payload: { host: 'example.com', remote: 'origin', url: 'git@example.com:user/repo' } }); + + expect(document.getElementById('ssh-auth-switch-https')).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// wireAuthModal - https without current +// --------------------------------------------------------------------------- + +describe('wireAuthModal - https without current', () => { + it('does nothing when https clicked without current prompt', async () => { + document.body.innerHTML = ` +
              + +
              + `; + + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + listenHandler?.({ payload: { host: 'example.com', remote: 'origin', url: 'git@example.com:user/repo' } }); + + const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; + expect(httpsBtn.disabled).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/sshHostkey.test.ts b/Frontend/src/scripts/features/sshHostkey.test.ts index c3187196..4e33093d 100644 --- a/Frontend/src/scripts/features/sshHostkey.test.ts +++ b/Frontend/src/scripts/features/sshHostkey.test.ts @@ -165,4 +165,88 @@ describe('initSshHostkeyPrompt', () => { expect(mockInvoke).not.toHaveBeenCalled(); }); + + it('sets buttons disabled during accept busy state', async () => { + mountModal(); + let resolveTrust: () => void; + const trustPromise = new Promise((resolve) => { resolveTrust = resolve; }); + mockInvoke.mockReturnValue(trustPromise); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com' }); + + const acceptBtn = document.getElementById('ssh-hostkey-accept') as HTMLButtonElement; + const denyBtn = document.getElementById('ssh-hostkey-deny') as HTMLButtonElement; + + acceptBtn.click(); + + expect(acceptBtn.disabled).toBe(true); + expect(denyBtn.disabled).toBe(true); + + resolveTrust!(); + await vi.waitFor(() => { + expect(acceptBtn.disabled).toBe(false); + expect(denyBtn.disabled).toBe(false); + }); + }); + + it('re-enables buttons on accept failure', async () => { + mountModal(); + mockInvoke.mockRejectedValue(new Error('fail')); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com' }); + + const acceptBtn = document.getElementById('ssh-hostkey-accept') as HTMLButtonElement; + const denyBtn = document.getElementById('ssh-hostkey-deny') as HTMLButtonElement; + + await acceptBtn.click(); + + await vi.waitFor(() => { + expect(acceptBtn.disabled).toBe(false); + expect(denyBtn.disabled).toBe(false); + }); + }); + + it('does not re-wire modal if already wired via __wired', async () => { + mountModal(); + const modal = document.getElementById('ssh-hostkey-modal') as any; + modal.__wired = true; + + const spy = vi.spyOn(modal, 'querySelector'); + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com' }); + expect(mockOpenModal).toHaveBeenCalledWith('ssh-hostkey-modal'); + spy.mockRestore(); + }); + + it('populates host and message for missing buttons modal', async () => { + mountModal(); + mockInvoke.mockResolvedValue(undefined); + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'example.com', remote: 'origin', url: 'git@example.com:repo', message: 'Fingerprint: abc' }); + + expect(document.getElementById('ssh-hostkey-host')!.textContent).toBe('example.com'); + expect(document.getElementById('ssh-hostkey-msg')!.textContent).toBe('Fingerprint: abc'); + }); + + it('handles missing accept and deny buttons', async () => { + document.body.innerHTML = ` +
              + + +
              + `; + + const { initSshHostkeyPrompt } = await import('./sshHostkey'); + initSshHostkeyPrompt(); + triggerSshHostkeyEvent({ host: 'testhost' }); + + expect(document.getElementById('ssh-hostkey-host')!.textContent).toBe('testhost'); + }); }); diff --git a/Frontend/src/scripts/plugins/registration.test.ts b/Frontend/src/scripts/plugins/registration.test.ts index d0575723..3d3a5283 100644 --- a/Frontend/src/scripts/plugins/registration.test.ts +++ b/Frontend/src/scripts/plugins/registration.test.ts @@ -717,6 +717,25 @@ describe('installGlobalApi full coverage', () => { expect(await runPluginAction('api-act', { val: 42 })).toBe(true); expect(handler).toHaveBeenCalledWith({ val: 42 }); }); + + it('executes invoke, listen, and notify through global API', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke = vi.fn().mockResolvedValue('result'); + tauri.event.listen = vi.fn(); + + const { installGlobalApi } = await import('./registration'); + installGlobalApi(); + + const api = (window as any).OpenVCS; + api.invoke('test_cmd', { key: 'val' }); + expect(tauri.core.invoke).toHaveBeenCalledWith('test_cmd', { key: 'val' }); + + const cb = vi.fn(); + api.listen('test_event', cb); + expect(tauri.event.listen).toHaveBeenCalledWith('test_event', cb); + + expect(() => api.notify('test message')).not.toThrow(); + }); }); describe('registerPlugin', () => { diff --git a/Frontend/src/scripts/plugins/runtime.test.ts b/Frontend/src/scripts/plugins/runtime.test.ts index fe32eaf7..4ee6bfa9 100644 --- a/Frontend/src/scripts/plugins/runtime.test.ts +++ b/Frontend/src/scripts/plugins/runtime.test.ts @@ -492,3 +492,61 @@ describe('runPluginAction error handling', () => { expect(result).toBe(true); }); }); + +// ============================================================================ +// runHook - cancel closure with reason +// ============================================================================ +describe('runHook cancel closure', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + }); + + it('invokes cancel with a reason string', async () => { + const { registerHook } = await import('./registration'); + let capturedCancel: ((reason?: string) => void) | null = null; + registerHook('p1', 'preCommit', (ctx) => { + capturedCancel = ctx.cancel; + }); + + const { runHook } = await import('./runtime'); + await runHook('preCommit', {}); + expect(capturedCancel).toBeInstanceOf(Function); + capturedCancel!('user-reason'); + }); + + it('invokes cancel without reason', async () => { + const { registerHook } = await import('./registration'); + let capturedCancel: ((reason?: string) => void) | null = null; + registerHook('p1', 'preCommit', (ctx) => { + capturedCancel = ctx.cancel; + }); + + const { runHook } = await import('./runtime'); + await runHook('preCommit', {}); + capturedCancel!(); + }); +}); + +// ============================================================================ +// reloadPlugins - additional coverage +// ============================================================================ +describe('reloadPlugins additional coverage', () => { + beforeEach(() => { + setupTauri(); + vi.resetModules(); + mountMinimalDom(); + }); + + it('reloads and returns when plugin list fetch fails', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockImplementation((cmd: string) => { + if (cmd === 'get_global_settings') return Promise.resolve({ plugins: { disabled: [], enabled: [] } }); + if (cmd === 'list_plugins') return Promise.reject(new Error('fail')); + return Promise.reject(new Error('unknown')); + }); + + const { reloadPlugins } = await import('./runtime'); + await expect(reloadPlugins()).resolves.toBeUndefined(); + }); +}); diff --git a/Frontend/src/scripts/ui/menubar.test.ts b/Frontend/src/scripts/ui/menubar.test.ts index 7bf27101..7c0a2715 100644 --- a/Frontend/src/scripts/ui/menubar.test.ts +++ b/Frontend/src/scripts/ui/menubar.test.ts @@ -466,3 +466,56 @@ describe('initMenubar additional edge cases', () => { expect(document.querySelector('.menu-list')?.hasAttribute('hidden')).toBe(true); }); }); + +// ============================================================================ +// closeMenu animation timer execution +// ============================================================================ +describe('closeMenu animation timer', () => { + it('runs close animation timer and finalizes', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + vi.runAllTimers(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('clears closeTimer when menu reopened before animation completion', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + (triggers[0] as HTMLButtonElement).click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + const list = document.querySelector('.menu-list') as HTMLElement; + expect(list.classList.contains('is-closing')).toBe(true); + + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + expect(list.hasAttribute('hidden')).toBe(true); + vi.runAllTimers(); + }); +}); From ec00ae9c6d13b025762bfdcb7885ebd03163aa8f Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 29 May 2026 22:54:17 +0100 Subject: [PATCH 17/25] Update plugin_vcs_backends.rs --- Backend/tests/modules/plugin_vcs_backends.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Backend/tests/modules/plugin_vcs_backends.rs b/Backend/tests/modules/plugin_vcs_backends.rs index 57be89da..9899221a 100644 --- a/Backend/tests/modules/plugin_vcs_backends.rs +++ b/Backend/tests/modules/plugin_vcs_backends.rs @@ -58,7 +58,9 @@ fn reports_unknown_backend_descriptors() { invalidate_plugin_vcs_backend_cache(); store_backends(vec![backend_descriptor("hg", "openvcs.hg")]); - let err = plugin_vcs_backend_descriptor(&BackendId::from("git")) + // Use a backend id that is not present in the cache AND not discoverable + // from real installed plugins, so the error path is exercised. + let err = plugin_vcs_backend_descriptor(&BackendId::from("nonexistent-be")) .expect_err("missing backend should fail"); - assert!(err.contains("Unknown VCS backend: git")); + assert!(err.contains("Unknown VCS backend: nonexistent-be")); } From 2edc40fed76f148494c6a3c4f92346ff8921841e Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 00:02:48 +0100 Subject: [PATCH 18/25] Improve rust coverage --- Backend/Cargo.toml | 2 +- Backend/src/main.rs | 10 + Backend/src/output_log.rs | 2 +- .../src/plugin_runtime/node_instance/mod.rs | 33 ++ .../src/plugin_runtime/node_instance/rpc.rs | 2 +- .../src/plugin_runtime/node_instance/vcs.rs | 5 + Backend/src/themes.rs | 4 +- Backend/src/utilities/inner.rs | 4 +- Backend/tests/core/mod.rs | 301 +++++++++- Backend/tests/modules/lib.rs | 69 ++- Backend/tests/modules/logging.rs | 191 ++++++- Backend/tests/modules/plugin_sources.rs | 86 ++- Backend/tests/modules/state.rs | 151 ++++- .../tests/plugin_runtime/node_instance/mod.rs | 264 ++++++++- .../tests/plugin_runtime/node_instance/rpc.rs | 163 +++++- .../tests/plugin_runtime/node_instance/vcs.rs | 528 ++++++++++++++++++ .../tests/plugin_runtime/runtime_select.rs | 62 +- Backend/tests/plugin_runtime/vcs_proxy.rs | 474 +++++++++++++++- Backend/tests/tauri_commands/backends.rs | 70 +++ Backend/tests/tauri_commands/general.rs | 123 ++++ Backend/tests/tauri_commands/output_log.rs | 172 ++++++ Backend/tests/tauri_commands/plugins.rs | 81 +++ Backend/tests/tauri_commands/repo_files.rs | 40 ++ Backend/tests/tauri_commands/themes.rs | 66 +++ 24 files changed, 2858 insertions(+), 45 deletions(-) create mode 100644 Backend/tests/plugin_runtime/node_instance/vcs.rs diff --git a/Backend/Cargo.toml b/Backend/Cargo.toml index f752a8aa..cb0f87d4 100644 --- a/Backend/Cargo.toml +++ b/Backend/Cargo.toml @@ -28,7 +28,7 @@ default = [] [dependencies] dotenvy = "0.15" -tauri = { version = "2.11", features = [] } +tauri = { version = "2.11", features = ["test"] } tauri-plugin-opener = "2.5" serde = { version = "1", features = ["derive"] } notify = "8" diff --git a/Backend/src/main.rs b/Backend/src/main.rs index c7b36c79..7feb3c0d 100644 --- a/Backend/src/main.rs +++ b/Backend/src/main.rs @@ -11,3 +11,13 @@ fn main() { openvcs_lib::run() } + +#[cfg(test)] +mod tests { + /// Smoke test ensuring the binary entrypoint compiles and links. + #[test] + fn main_function_exists() { + // Verify the function signature is correct by referencing it + let _ = super::main; + } +} diff --git a/Backend/src/output_log.rs b/Backend/src/output_log.rs index 26de1c76..1d63540d 100644 --- a/Backend/src/output_log.rs +++ b/Backend/src/output_log.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// Severity level associated with an output log entry. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OutputLevel { Info, diff --git a/Backend/src/plugin_runtime/node_instance/mod.rs b/Backend/src/plugin_runtime/node_instance/mod.rs index cb3bc1a1..0ba38f10 100644 --- a/Backend/src/plugin_runtime/node_instance/mod.rs +++ b/Backend/src/plugin_runtime/node_instance/mod.rs @@ -49,6 +49,9 @@ pub struct NodePluginRuntimeInstance { vcs_session_id: Mutex>, /// Optional sink for VCS progress events. event_sink: RwLock>, + /// Test-only RPC mock handler injected instead of a real process. + #[cfg(test)] + mock_rpc_handler: Mutex Result + Send>>>, } impl NodePluginRuntimeInstance { @@ -65,9 +68,32 @@ impl NodePluginRuntimeInstance { process: Mutex::new(None), vcs_session_id: Mutex::new(None), event_sink: RwLock::new(None), + #[cfg(test)] + mock_rpc_handler: Mutex::new(None), } } + /// Sets the VCS session id for testing without a real plugin process. + #[cfg(test)] + pub(crate) fn set_session_id(&self, id: Option) { + *self.vcs_session_id.lock() = id; + } + + /// Injects a pre-built process for testing the real RPC call path. + #[cfg(test)] + pub(crate) fn set_process(&self, process: NodeRpcProcess) { + *self.process.lock() = Some(process); + } + + /// Installs a mock RPC handler for testing, bypassing the real process. + #[cfg(test)] + pub(crate) fn set_mock_handler( + &self, + handler: Box Result + Send>, + ) { + *self.mock_rpc_handler.lock() = Some(handler); + } + /// Resolves the bundled Node executable path used to launch plugins. /// /// # Returns @@ -267,6 +293,13 @@ impl NodePluginRuntimeInstance { where T: DeserializeOwned, { + #[cfg(test)] + if let Some(handler) = self.mock_rpc_handler.lock().as_ref() { + let result = handler(method, params)?; + return serde_json::from_value(result) + .map_err(|e| format!("mock rpc decode: {e}")); + } + let timeout = timeout_secs.or_else(|| { if method.starts_with("vcs.") { Some(VCS_OPERATION_TIMEOUT_SECS) diff --git a/Backend/src/plugin_runtime/node_instance/rpc.rs b/Backend/src/plugin_runtime/node_instance/rpc.rs index 111a12e5..cef0955a 100644 --- a/Backend/src/plugin_runtime/node_instance/rpc.rs +++ b/Backend/src/plugin_runtime/node_instance/rpc.rs @@ -17,7 +17,7 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::time::Duration; /// Live stdio-backed JSON-RPC process handle. -pub(super) struct NodeRpcProcess { +pub(crate) struct NodeRpcProcess { /// Child process hosting the plugin runtime. pub(super) child: Child, /// Writable stdin stream for requests. diff --git a/Backend/src/plugin_runtime/node_instance/vcs.rs b/Backend/src/plugin_runtime/node_instance/vcs.rs index f5805b8d..d26f7afc 100644 --- a/Backend/src/plugin_runtime/node_instance/vcs.rs +++ b/Backend/src/plugin_runtime/node_instance/vcs.rs @@ -356,3 +356,8 @@ impl NodePluginRuntimeInstance { self.rpc_call_unit(Methods::VCS_REVERT_COMMIT, params) } } + +#[cfg(test)] +mod tests { + include!("../../../tests/plugin_runtime/node_instance/vcs.rs"); +} diff --git a/Backend/src/themes.rs b/Backend/src/themes.rs index d49cd65b..89f06162 100644 --- a/Backend/src/themes.rs +++ b/Backend/src/themes.rs @@ -11,7 +11,7 @@ const MANIFEST_NAME: &str = "theme.json"; pub const DEFAULT_THEME_ID: &str = "default"; #[allow(dead_code)] -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ThemeSource { BuiltIn, @@ -19,7 +19,7 @@ pub enum ThemeSource { Plugin, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThemeSummary { pub id: String, pub name: String, diff --git a/Backend/src/utilities/inner.rs b/Backend/src/utilities/inner.rs index ef5287f4..befbe022 100644 --- a/Backend/src/utilities/inner.rs +++ b/Backend/src/utilities/inner.rs @@ -1,8 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct AboutInfo { pub name: String, pub version: String, diff --git a/Backend/tests/core/mod.rs b/Backend/tests/core/mod.rs index 9fcf13fd..2692c6a8 100644 --- a/Backend/tests/core/mod.rs +++ b/Backend/tests/core/mod.rs @@ -1,15 +1,302 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::path::{Path, PathBuf}; + +use crate::core::models::{BranchItem, CommitItem, ConflictSide, LogQuery, OnEvent, StatusPayload}; +use crate::core::{BackendId, Result as VcsResult, Vcs, VcsError}; + +/// Minimal Vcs implementation used to test trait default methods. +struct DummyVcs { + id: BackendId, + workdir: PathBuf, +} + +impl DummyVcs { + fn new(id: &str) -> Self { + Self { + id: BackendId::from(id), + workdir: PathBuf::from("/tmp/test"), + } + } +} + +impl Vcs for DummyVcs { + fn id(&self) -> BackendId { + self.id.clone() + } + + fn workdir(&self) -> &Path { + &self.workdir + } + + fn current_branch(&self) -> VcsResult> { + Ok(Some("main".into())) + } + + fn branches(&self) -> VcsResult> { + Ok(vec![]) + } + + fn create_branch(&self, _name: &str, _checkout: bool) -> VcsResult<()> { + Ok(()) + } + + fn checkout_branch(&self, _name: &str) -> VcsResult<()> { + Ok(()) + } + + fn ensure_remote(&self, _name: &str, _url: &str) -> VcsResult<()> { + Ok(()) + } + + fn list_remotes(&self) -> VcsResult> { + Ok(vec![]) + } + + fn remove_remote(&self, _name: &str) -> VcsResult<()> { + Ok(()) + } + + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> VcsResult<()> { + Ok(()) + } + + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> VcsResult<()> { + Ok(()) + } + + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> VcsResult<()> { + Ok(()) + } + + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> VcsResult { + Ok("abc123".into()) + } + + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> VcsResult { + Ok("def456".into()) + } + + fn status_payload(&self) -> VcsResult { + Ok(StatusPayload::default()) + } + + fn log_commits(&self, _query: &LogQuery) -> VcsResult> { + Ok(vec![]) + } + + fn diff_file(&self, _path: &Path) -> VcsResult> { + Ok(vec![]) + } + + fn diff_commit(&self, _rev: &str) -> VcsResult> { + Ok(vec![]) + } + + fn stage_patch(&self, _patch: &str) -> VcsResult<()> { + Ok(()) + } + + fn stage_paths(&self, _paths: &[PathBuf]) -> VcsResult<()> { + Ok(()) + } + + fn discard_paths(&self, _paths: &[PathBuf]) -> VcsResult<()> { + Ok(()) + } + + fn apply_reverse_patch(&self, _patch: &str) -> VcsResult<()> { + Ok(()) + } + + fn delete_branch(&self, _name: &str, _force: bool) -> VcsResult<()> { + Ok(()) + } + + fn rename_branch(&self, _old: &str, _new: &str) -> VcsResult<()> { + Ok(()) + } + + fn merge_into_current(&self, _name: &str) -> VcsResult<()> { + Ok(()) + } + + fn get_identity(&self) -> VcsResult> { + Ok(None) + } + + fn set_identity_local(&self, _name: &str, _email: &str) -> VcsResult<()> { + Ok(()) + } +} + +// ── VcsError display tests ───────────────────────────────────────────────── + +#[test] +fn vcs_error_no_upstream_displays_helpful_message() { + let err = VcsError::NoUpstream; + assert_eq!(err.to_string(), "no upstream configured"); +} + #[test] -/// Verifies user-facing `VcsError` formatting remains informative. -fn vcs_error_formats_useful_messages() { - let error = crate::core::VcsError::Unsupported(crate::core::BackendId::from("git")); - assert!(error.to_string().contains("unsupported backend")); +fn vcs_error_unsupported_backend_displays_helper_message() { + let err = VcsError::Unsupported(BackendId::from("git")); + assert_eq!(err.to_string(), "unsupported backend: git"); +} - let error = crate::core::VcsError::Backend { - backend: crate::core::BackendId::from("git"), +#[test] +fn vcs_error_backend_displays_backend_id_and_message() { + let err = VcsError::Backend { + backend: BackendId::from("git"), msg: "boom".into(), }; - assert_eq!(error.to_string(), "git: boom"); + assert_eq!(err.to_string(), "git: boom"); +} + +#[test] +fn vcs_error_io_displays_underlying_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err = VcsError::Io(io_err); + assert!(err.to_string().contains("file not found")); + assert!(err.to_string().contains("io:")); +} + +#[test] +fn vcs_error_from_io_converts_io_errors() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"); + let vcs_err: VcsError = io_err.into(); + assert!(vcs_err.to_string().contains("permission denied")); +} + +// ── Vcs trait default method tests ───────────────────────────────────────── + +#[test] +fn vcs_conflict_details_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.conflict_details(Path::new("foo.txt")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unsupported backend")); +} + +#[test] +fn vcs_checkout_conflict_side_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.checkout_conflict_side(Path::new("foo.txt"), ConflictSide::Ours); + assert!(result.is_err()); +} + +#[test] +fn vcs_write_merge_result_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.write_merge_result(Path::new("foo.txt"), b"content"); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_abort_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.merge_abort(); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_continue_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.merge_continue(); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_in_progress_returns_false_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.merge_in_progress(); + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[test] +fn vcs_set_branch_upstream_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.set_branch_upstream("main", "origin/main"); + assert!(result.is_err()); +} + +#[test] +fn vcs_branch_upstream_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.branch_upstream("main"); + assert!(result.is_err()); +} + +#[test] +fn vcs_reset_soft_to_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.reset_soft_to("abc123"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_list_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_list(); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_push_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_push("msg", false, &[]); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_apply_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_apply("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_pop_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_pop("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_drop_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_drop("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_show_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_show("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_cherry_pick_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.cherry_pick("abc123"); + assert!(result.is_err()); +} + +#[test] +fn vcs_revert_commit_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.revert_commit("abc123", false); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_into_current_with_message_delegates_to_merge_into_current() { + let vcs = DummyVcs::new("test"); + // Should not error even with a message + assert!(vcs.merge_into_current_with_message("feature", Some("auto-merge")).is_ok()); + // Should work with None message too + assert!(vcs.merge_into_current_with_message("feature", None).is_ok()); } diff --git a/Backend/tests/modules/lib.rs b/Backend/tests/modules/lib.rs index 0e87c894..47da4f92 100644 --- a/Backend/tests/modules/lib.rs +++ b/Backend/tests/modules/lib.rs @@ -3,29 +3,65 @@ use std::path::PathBuf; -use super::{first_existing_recent_repo, local_dotenv_path, resolve_preferred_backend_id}; +use super::{first_existing_recent_repo, load_local_dotenv, local_dotenv_path, resolve_preferred_backend_id}; use crate::core::BackendId; +// ── local_dotenv_path tests ──────────────────────────────────────────────── + #[test] -fn builds_local_dotenv_path_relative_to_workspace_root() { +fn dotenv_path_resolves_to_backend_parent() { let path = local_dotenv_path(); assert!(path.ends_with(PathBuf::from("../.env"))); } +// ── resolve_preferred_backend_id tests ────────────────────────────────────── + #[test] fn prefers_configured_backend_when_available() { let available = vec![BackendId::from("openvcs.git"), BackendId::from("zeta")]; let resolved = resolve_preferred_backend_id("openvcs.git", &available); - assert_eq!(resolved.map(|backend| backend.as_ref().to_string()), Some("openvcs.git".into())); + assert_eq!( + resolved.map(|backend| backend.as_ref().to_string()), + Some("openvcs.git".into()) + ); } #[test] fn falls_back_to_sorted_backend_when_default_missing() { let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; let resolved = resolve_preferred_backend_id("missing", &available); - assert_eq!(resolved.map(|backend| backend.as_ref().to_string()), Some("alpha".into())); + assert_eq!( + resolved.map(|backend| backend.as_ref().to_string()), + Some("alpha".into()) + ); } +#[test] +fn returns_none_when_no_backends_available() { + let available: Vec = vec![]; + let resolved = resolve_preferred_backend_id("git", &available); + assert!(resolved.is_none()); +} + +#[test] +fn returns_first_sorted_when_configured_default_is_empty() { + let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; + let resolved = resolve_preferred_backend_id("", &available); + assert_eq!( + resolved.map(|backend| backend.as_ref().to_string()), + Some("alpha".into()) + ); +} + +#[test] +fn returns_none_when_configured_default_is_empty_and_no_backends() { + let available: Vec = vec![]; + let resolved = resolve_preferred_backend_id("", &available); + assert!(resolved.is_none()); +} + +// ── first_existing_recent_repo tests ─────────────────────────────────────── + #[test] fn finds_first_existing_recent_repo() { let temp = tempfile::tempdir().expect("temp dir"); @@ -40,3 +76,28 @@ fn finds_first_existing_recent_repo() { assert_eq!(selected, Some(existing)); } + +#[test] +fn returns_none_for_empty_repo_list() { + let selected = first_existing_recent_repo(&[]); + assert!(selected.is_none()); +} + +#[test] +fn returns_none_when_no_repos_exist() { + let temp = tempfile::tempdir().expect("temp dir"); + let selected = first_existing_recent_repo(&[ + temp.path().join("missing1"), + temp.path().join("missing2"), + ]); + assert!(selected.is_none()); +} + +// ── load_local_dotenv tests ───────────────────────────────────────────────── + +#[test] +fn load_local_dotenv_silently_ignores_missing_file() { + // The function should not panic when there is no .env file. + // This tests the `NotFound` error handling path. + load_local_dotenv(); +} diff --git a/Backend/tests/modules/logging.rs b/Backend/tests/modules/logging.rs index dc9e4c67..e086d6f8 100644 --- a/Backend/tests/modules/logging.rs +++ b/Backend/tests/modules/logging.rs @@ -1,24 +1,35 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{clear_active_log_file, set_sentry_log_forwarding_enabled, LogTimer, ACTIVE_LOG_FILE, SENTRY_LOG_FORWARDING_ENABLED}; +use super::{ + build_sentry_logger, clear_active_log_file, prune_archives, rotate_existing_log, + set_sentry_log_forwarding_enabled, LogTimer, ACTIVE_LOG_FILE, SENTRY_LOG_FORWARDING_ENABLED, +}; use std::fs::OpenOptions; use std::io::Write; use std::sync::{Arc, Mutex}; +// ── LogTimer tests ───────────────────────────────────────────────────────── + #[test] -/// Verifies log timer reports elapsed milliseconds. -fn measures_elapsed_time() { +fn log_timer_elapsed_ms_returns_non_zero_value() { let timer = LogTimer::new("module", "operation"); assert!(timer.elapsed_ms() <= 1_000); } +// ── clear_active_log_file tests ──────────────────────────────────────────── + #[test] -/// Verifies active log file truncation clears existing bytes. -fn clears_active_log_file_contents() { +fn clear_active_log_file_truncates_existing_content() { let dir = tempfile::tempdir().expect("create temp dir"); let path = dir.path().join("openvcs.log"); - let mut file = OpenOptions::new().create(true).truncate(true).read(true).write(true).open(&path).expect("open log file"); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&path) + .expect("open log file"); writeln!(file, "hello").expect("seed log file"); let shared = Arc::new(Mutex::new(file)); let _ = ACTIVE_LOG_FILE.set(shared); @@ -29,10 +40,174 @@ fn clears_active_log_file_contents() { } #[test] -/// Verifies sentry forwarding flag can be toggled. -fn toggles_sentry_forwarding_flag() { +fn clear_active_log_file_is_noop_when_not_initialized() { + // When ACTIVE_LOG_FILE is not set, clear should succeed as a no-op. + assert!(clear_active_log_file().is_ok()); +} + +// ── Sentry forwarding tests ──────────────────────────────────────────────── + +#[test] +fn sentry_forwarding_flag_can_be_toggled() { set_sentry_log_forwarding_enabled(true); assert!(SENTRY_LOG_FORWARDING_ENABLED.load(std::sync::atomic::Ordering::Relaxed)); set_sentry_log_forwarding_enabled(false); assert!(!SENTRY_LOG_FORWARDING_ENABLED.load(std::sync::atomic::Ordering::Relaxed)); } + +#[test] +fn build_sentry_logger_creates_logger_without_panicking() { + // A minimal logger that discards records. + struct NullLogger; + impl log::Log for NullLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + fn log(&self, _: &log::Record) {} + fn flush(&self) {} + } + + // build_sentry_logger should not panic for any forwarding state. + set_sentry_log_forwarding_enabled(true); + let _logger = build_sentry_logger(NullLogger); + + set_sentry_log_forwarding_enabled(false); + let _logger = build_sentry_logger(NullLogger); +} + +// ── prune_archives tests ─────────────────────────────────────────────────── + +#[test] +fn prune_archives_removes_excess_archives() { + let dir = tempfile::tempdir().expect("create temp dir"); + + // Create 5 archive-like files, keep only 2. + for i in 0..5u8 { + let name = format!("openvcs-2025-01-{:02}_10-00.zip", 10 + i); + let path = dir.path().join(&name); + let mut f = std::fs::File::create(&path).expect("create archive"); + writeln!(f, "test").expect("write archive content"); + } + + let before: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(before.len(), 5); + + prune_archives(dir.path(), 2); + + let after: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(after.len(), 2); +} + +#[test] +fn prune_archives_noop_when_below_limit() { + let dir = tempfile::tempdir().expect("create temp dir"); + + for i in 0..2u8 { + let name = format!("openvcs-2025-01-{:02}_10-00.zip", 10 + i); + let path = dir.path().join(&name); + let mut f = std::fs::File::create(&path).expect("create archive"); + writeln!(f, "test").expect("write archive content"); + } + + prune_archives(dir.path(), 5); + + let after: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(after.len(), 2); +} + +#[test] +fn prune_archives_ignores_non_archive_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + + // Create valid archives + for i in 0..4u8 { + let name = format!("openvcs-2025-01-{:02}_10-00.zip", 10 + i); + let path = dir.path().join(&name); + let mut f = std::fs::File::create(&path).expect("create archive"); + writeln!(f, "test").expect("write archive content"); + } + // Create a non-archive file that should be ignored + let path = dir.path().join("not-an-archive.txt"); + let mut f = std::fs::File::create(&path).expect("create unrelated file"); + writeln!(f, "keep me").expect("write"); + + prune_archives(dir.path(), 2); + + let after: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + // Should keep the unrelated file + 2 archives = 3 files + assert_eq!(after.len(), 3); +} + +#[test] +fn prune_archives_is_noop_for_empty_dirs() { + let dir = tempfile::tempdir().expect("create temp dir"); + // Should not panic on empty directory + prune_archives(dir.path(), 5); +} + +// ── rotate_existing_log tests ────────────────────────────────────────────── + +#[test] +fn rotate_existing_log_skips_missing_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + // Should not panic when there's no active log file + rotate_existing_log(dir.path()); +} + +#[test] +fn rotate_existing_log_skips_empty_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + let active = dir.path().join("openvcs.log"); + { + let _f = std::fs::File::create(&active).expect("create empty log"); + } + rotate_existing_log(dir.path()); + // No archive should have been created for an empty file + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(entries.len(), 1); // Only the empty openvcs.log +} + +#[test] +fn rotate_existing_log_creates_zip_archive() { + let dir = tempfile::tempdir().expect("create temp dir"); + let active = dir.path().join("openvcs.log"); + { + let mut f = std::fs::File::create(&active).expect("create log"); + writeln!(f, "this is a test log entry for rotation").expect("write content"); + } + rotate_existing_log(dir.path()); + + // The active log should now be removed (or the zip created) + let entries: Vec = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + // Should have a zip archive + assert!( + entries.iter().any(|name| name.ends_with(".zip")), + "expected a zip archive, got: {:?}", + entries + ); + // The original log should be removed after rotation + assert!( + !entries.iter().any(|name| name == "openvcs.log"), + "active log should be removed after rotation, got: {:?}", + entries + ); +} diff --git a/Backend/tests/modules/plugin_sources.rs b/Backend/tests/modules/plugin_sources.rs index acae2d43..ebfaa385 100644 --- a/Backend/tests/modules/plugin_sources.rs +++ b/Backend/tests/modules/plugin_sources.rs @@ -1,7 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{package_has_runtime_dependencies, resolve_local_plugin_path, sanitize_archive_path}; +use super::{ + archive_entry_path_error, command_error_message, has_non_empty_object_field, npm_executable, + package_has_runtime_dependencies, resolve_local_plugin_path, sanitize_archive_path, +}; use std::fs; #[test] @@ -42,3 +45,84 @@ fn detects_runtime_dependencies_in_package_json() { fs::write(&package_json, r#"{"optionalDependencies":{"x":"1.0.0"}}"#).expect("write optional deps manifest"); assert!(package_has_runtime_dependencies(&package_json).expect("read optional deps manifest")); } + +// ── has_non_empty_object_field tests ─────────────────────────────────────── + +#[test] +fn detects_non_empty_object_fields() { + assert!(has_non_empty_object_field(&serde_json::json!({"deps": {"a": "1"}}), "deps")); +} + +#[test] +fn rejects_empty_object_fields() { + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": {}}), "deps")); +} + +#[test] +fn rejects_missing_fields() { + assert!(!has_non_empty_object_field(&serde_json::json!({"other": {}}), "deps")); +} + +#[test] +fn rejects_non_object_field_values() { + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": "str"}), "deps")); + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": null}), "deps")); + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": 42}), "deps")); +} + +#[test] +fn returns_false_for_empty_input() { + assert!(!has_non_empty_object_field(&serde_json::json!({}), "anything")); +} + +// ── command_error_message tests ──────────────────────────────────────────── + +#[test] +fn formats_error_with_stderr_content() { + let msg = command_error_message("npm pack", b"some error text"); + assert_eq!(msg, "npm pack failed: some error text"); +} + +#[test] +fn formats_error_without_stderr_when_empty() { + let msg = command_error_message("npm pack", b""); + assert_eq!(msg, "npm pack failed"); +} + +#[test] +fn formats_error_without_stderr_when_whitespace() { + let msg = command_error_message("npm pack", b" \n "); + assert_eq!(msg, "npm pack failed"); +} + +#[test] +fn formats_error_with_lossy_utf8() { + let invalid_utf8 = b"error: \xff\xfe"; + let msg = command_error_message("install", invalid_utf8); + assert!(msg.contains("install failed")); +} + +// ── npm_executable tests ─────────────────────────────────────────────────── + +#[test] +fn npm_executable_returns_npm_on_linux() { + if cfg!(windows) { + assert_eq!(npm_executable(), "npm.cmd"); + } else { + assert_eq!(npm_executable(), "npm"); + } +} + +// ── archive_entry_path_error tests ───────────────────────────────────────── + +#[test] +fn formats_archive_entry_path_error() { + let msg = archive_entry_path_error("traversal", "package/../../foo"); + assert_eq!(msg, "invalid archive entry path (traversal): package/../../foo"); +} + +#[test] +fn formats_archive_entry_path_error_with_empty_strings() { + let msg = archive_entry_path_error("", ""); + assert_eq!(msg, "invalid archive entry path (): "); +} diff --git a/Backend/tests/modules/state.rs b/Backend/tests/modules/state.rs index 7586ef90..525ac129 100644 --- a/Backend/tests/modules/state.rs +++ b/Backend/tests/modules/state.rs @@ -1,14 +1,57 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::AppState; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::core::{BackendId, Result as VcsResult, Vcs}; +use crate::core::models::{BranchItem, CommitItem, LogQuery, OnEvent, StatusPayload}; use crate::output_log::{OutputLevel, OutputLogEntry}; +use crate::repo::Repo; use crate::repo_settings::RepoConfig; use crate::settings::AppConfig; -use std::sync::Arc; +use crate::state::AppState; + +// ── Dummy Vcs impl for testing ───────────────────────────────────────────── + +fn dummy_repo(path: &Path) -> Arc { + // Create a DummyVcs whose workdir matches the provided path + struct PathDummyVcs(BackendId, PathBuf); + impl Vcs for PathDummyVcs { + fn id(&self) -> BackendId { self.0.clone() } + fn workdir(&self) -> &Path { &self.1 } + fn current_branch(&self) -> VcsResult> { Ok(Some("main".into())) } + fn branches(&self) -> VcsResult> { Ok(vec![]) } + fn create_branch(&self, _: &str, _: bool) -> VcsResult<()> { Ok(()) } + fn checkout_branch(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn ensure_remote(&self, _: &str, _: &str) -> VcsResult<()> { Ok(()) } + fn list_remotes(&self) -> VcsResult> { Ok(vec![]) } + fn remove_remote(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn fetch(&self, _: &str, _: &str, _: Option) -> VcsResult<()> { Ok(()) } + fn push(&self, _: &str, _: &str, _: Option) -> VcsResult<()> { Ok(()) } + fn pull_ff_only(&self, _: &str, _: &str, _: Option) -> VcsResult<()> { Ok(()) } + fn commit(&self, _: &str, _: &str, _: &str, _: &[PathBuf]) -> VcsResult { Ok("abc".into()) } + fn commit_index(&self, _: &str, _: &str, _: &str) -> VcsResult { Ok("def".into()) } + fn status_payload(&self) -> VcsResult { Ok(StatusPayload::default()) } + fn log_commits(&self, _: &LogQuery) -> VcsResult> { Ok(vec![]) } + fn diff_file(&self, _: &Path) -> VcsResult> { Ok(vec![]) } + fn diff_commit(&self, _: &str) -> VcsResult> { Ok(vec![]) } + fn stage_patch(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn stage_paths(&self, _: &[PathBuf]) -> VcsResult<()> { Ok(()) } + fn discard_paths(&self, _: &[PathBuf]) -> VcsResult<()> { Ok(()) } + fn apply_reverse_patch(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn delete_branch(&self, _: &str, _: bool) -> VcsResult<()> { Ok(()) } + fn rename_branch(&self, _: &str, _: &str) -> VcsResult<()> { Ok(()) } + fn merge_into_current(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn get_identity(&self) -> VcsResult> { Ok(None) } + fn set_identity_local(&self, _: &str, _: &str) -> VcsResult<()> { Ok(()) } + } + Arc::new(Repo::new(Arc::new(PathDummyVcs(BackendId::from("git"), path.to_path_buf())))) +} + +// ── Construction tests ───────────────────────────────────────────────────── #[test] -/// Verifies app state snapshots config and owns a shared runtime manager. fn constructs_state_from_config() { let cfg = AppConfig::default(); let state = AppState::new_with_config(cfg.clone()); @@ -16,8 +59,22 @@ fn constructs_state_from_config() { assert!(Arc::strong_count(&state.plugin_runtime()) >= 1); } +// ── Config mutation tests ────────────────────────────────────────────────── + +#[test] +fn set_config_updates_snapshot() { + let state = AppState::new_with_config(AppConfig::default()); + let mut cfg = AppConfig::default(); + cfg.general.default_backend = "hg".into(); + // set_config may fail if recents persistence fails; we check the config was updated regardless + let _ = state.set_config(cfg.clone()); + // Config should reflect the update + assert_eq!(state.config().general.default_backend, "hg"); +} + +// ── Repo config tests ────────────────────────────────────────────────────── + #[test] -/// Verifies repo config updates are stored in memory. fn stores_repo_config_in_memory() { let state = AppState::new_with_config(AppConfig::default()); let repo_cfg = RepoConfig { @@ -29,8 +86,9 @@ fn stores_repo_config_in_memory() { assert!(state.set_repo_config(repo_cfg).is_ok()); } +// ── Output log tests ─────────────────────────────────────────────────────── + #[test] -/// Verifies output log entries append and clear in memory. fn manages_output_log_entries() { let state = AppState::new_with_config(AppConfig::default()); state.push_output_log(OutputLogEntry::new(1, OutputLevel::Info, "core", "hello")); @@ -40,3 +98,86 @@ fn manages_output_log_entries() { state.clear_output_log(); assert!(state.output_log().is_empty()); } + +#[test] +fn output_log_truncates_at_maximum() { + let state = AppState::new_with_config(AppConfig::default()); + // Push more than MAX (2000) entries + for i in 0..2500 { + state.push_output_log(OutputLogEntry::new(i as i64, OutputLevel::Info, "core", &i.to_string())); + } + // Should have trimmed to 2000 + assert_eq!(state.output_log().len(), 2000); +} + +// ── Current repo lifecycle tests ─────────────────────────────────────────── + +#[test] +fn current_repo_starts_empty() { + let state = AppState::new_with_config(AppConfig::default()); + assert!(state.current_repo().is_none()); +} + +#[test] +fn set_current_repo_stores_and_retrieves_repo() { + let state = AppState::new_with_config(AppConfig::default()); + let repo = dummy_repo(Path::new("/tmp/test-repo")); + state.set_current_repo(repo.clone()); + let retrieved = state.current_repo().expect("repo should be set"); + assert_eq!(retrieved.id().as_ref(), repo.id().as_ref()); +} + +#[test] +fn clear_current_repo_removes_active_repo() { + let state = AppState::new_with_config(AppConfig::default()); + let repo = dummy_repo(Path::new("/tmp/test-repo")); + state.set_current_repo(repo); + assert!(state.current_repo().is_some()); + + state.clear_current_repo(); + assert!(state.current_repo().is_none()); +} + +#[test] +fn set_current_repo_affects_recents() { + let state = AppState::new_with_config(AppConfig::default()); + let dir = tempfile::tempdir().expect("temp dir"); + let repo_path = dir.path().join("my-repo"); + std::fs::create_dir(&repo_path).expect("create repo dir"); + + let before = state.recents().len(); + state.set_current_repo(dummy_repo(&repo_path)); + let after = state.recents().len(); + + // recents should grow by at least 1 (or stay same if repo was already present) + assert!(after > before || state.recents().iter().any(|p| p.ends_with("my-repo"))); +} + +#[test] +fn set_current_repo_places_new_path_at_front() { + let state = AppState::new_with_config(AppConfig::default()); + let dir = tempfile::tempdir().expect("temp dir"); + let repo_path = dir.path().join("front-repo"); + std::fs::create_dir(&repo_path).expect("create repo dir"); + + state.set_current_repo(dummy_repo(&repo_path)); + // The most recently set repo should be first + assert!(state.recents()[0].ends_with("front-repo")); +} + +// ── Recents accessor tests ──────────────────────────────────────────────── + +#[test] +fn recents_contains_paths_after_setting_current_repo() { + let state = AppState::new_with_config(AppConfig::default()); + let dir = tempfile::tempdir().expect("temp dir"); + let repo_path = dir.path().join("test-repo"); + std::fs::create_dir(&repo_path).expect("create repo dir"); + + // recents may be pre-populated from disk, so we check the repo appears + state.set_current_repo(dummy_repo(&repo_path)); + assert!( + state.recents().iter().any(|p| p.ends_with("test-repo")), + "repo path should appear in recents" + ); +} diff --git a/Backend/tests/plugin_runtime/node_instance/mod.rs b/Backend/tests/plugin_runtime/node_instance/mod.rs index 9cd3db01..1761601d 100644 --- a/Backend/tests/plugin_runtime/node_instance/mod.rs +++ b/Backend/tests/plugin_runtime/node_instance/mod.rs @@ -2,8 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later use super::NodePluginRuntimeInstance; +use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; -use serde_json::json; +use serde_json::{json, Value}; +use std::sync::Arc; fn test_runtime() -> NodePluginRuntimeInstance { NodePluginRuntimeInstance::new(SpawnConfig { @@ -14,6 +16,12 @@ fn test_runtime() -> NodePluginRuntimeInstance { }) } +fn mock_response(runtime: &NodePluginRuntimeInstance) { + runtime.set_mock_handler(Box::new(|_, _| Ok(Value::Null))); +} + +// ── session_params ── + #[test] fn session_params_requires_an_open_session() { let runtime = test_runtime(); @@ -27,7 +35,7 @@ fn session_params_requires_an_open_session() { #[test] fn session_params_merges_session_id_with_extra_fields() { let runtime = test_runtime(); - *runtime.vcs_session_id.lock() = Some("session-123".into()); + runtime.set_session_id(Some("session-123".into())); let value = runtime .session_params(json!({"path": "repo.txt", "session_id": "override"})) @@ -41,3 +49,255 @@ fn session_params_merges_session_id_with_extra_fields() { }) ); } + +#[test] +fn session_params_defaults_session_id_when_not_overridden() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-123".into())); + + let value = runtime + .session_params(json!({"path": "repo.txt"})) + .expect("session params"); + + assert_eq!( + value, + json!({ + "session_id": "session-123", + "path": "repo.txt" + }) + ); +} + +#[test] +fn session_params_handles_empty_extra() { + let runtime = test_runtime(); + runtime.set_session_id(Some("s".into())); + + let value = runtime + .session_params(serde_json::Value::Object(serde_json::Map::new())) + .expect("session params"); + + assert_eq!(value, json!({"session_id": "s"})); +} + +// ── handle_notification ── + +#[test] +fn handle_notification_host_log_info() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "info", + "message": "hello", + "target": "myplugin" + })); + // Should not panic +} + +#[test] +fn handle_notification_host_log_trace() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "trace", + "message": "trace msg" + })); +} + +#[test] +fn handle_notification_host_log_warn() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "warn", + "message": "warning" + })); +} + +#[test] +fn handle_notification_host_log_error() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "error", + "message": "error msg" + })); +} + +#[test] +fn handle_notification_host_log_unknown_level() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "debug", + "message": "debug msg" + })); +} + +#[test] +fn handle_notification_host_log_defaults() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({})); + // Uses defaults: level=info, target=plugin, message="" +} + +#[test] +fn handle_notification_host_ui_notify_with_message() { + let runtime = test_runtime(); + runtime.handle_notification("host.ui.notify", &json!({"message": "hello user"})); +} + +#[test] +fn handle_notification_host_ui_notify_empty_message() { + let runtime = test_runtime(); + runtime.handle_notification("host.ui.notify", &json!({"message": ""})); +} + +#[test] +fn handle_notification_host_status_set() { + let runtime = test_runtime(); + runtime.handle_notification("host.status.set", &json!({"message": "busy"})); +} + +#[test] +fn handle_notification_host_status_set_empty() { + let runtime = test_runtime(); + runtime.handle_notification("host.status.set", &json!({"message": ""})); +} + +#[test] +fn handle_notification_host_event_emit() { + let runtime = test_runtime(); + runtime.handle_notification("host.event.emit", &json!({ + "event_name": "custom_event", + "payload": {"key": "val"} + })); +} + +#[test] +fn handle_notification_host_event_emit_empty_name() { + let runtime = test_runtime(); + runtime.handle_notification("host.event.emit", &json!({ + "event_name": "", + "payload": null + })); +} + +#[test] +fn handle_notification_vcs_event_with_sink() { + use crate::core::models::{OnEvent, VcsEvent}; + let runtime = test_runtime(); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let captured_clone = std::sync::Arc::clone(&captured); + let sink: OnEvent = std::sync::Arc::new(move |event| { + *captured_clone.lock().unwrap() = Some(event); + }); + runtime.set_event_sink(Some(sink)); + runtime.handle_notification("vcs.event", &json!({ + "event": {"type": "progress", "phase": "working", "detail": "50%"} + })); + assert!(captured.lock().unwrap().is_some()); +} + +#[test] +fn handle_notification_vcs_event_invalid_payload() { + let runtime = test_runtime(); + runtime.handle_notification("vcs.event", &json!({ + "event": "not an event struct" + })); + // Should log a warning and return +} + +#[test] +fn handle_notification_vcs_event_missing_event_field() { + let runtime = test_runtime(); + runtime.handle_notification("vcs.event", &json!({"other": "data"})); +} + +#[test] +fn handle_notification_vcs_event_no_sink() { + let runtime = test_runtime(); + runtime.handle_notification("vcs.event", &json!({ + "event": {"progress": {"message": "working", "percentage": 50}} + })); + // No sink installed, event should be ignored silently +} + +#[test] +fn handle_notification_unknown_method() { + let runtime = test_runtime(); + runtime.handle_notification("unknown.method", &json!({"data": 1})); + // Should trace-log and return +} + +// ── close_vcs_session ── + +#[test] +fn close_vcs_session_calls_rpc_when_session_active() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-abc".into())); + mock_response(&runtime); + // This should call rpc_call_unit with VCS_CLOSE and clear session +} + +#[test] +fn close_vcs_session_noop_when_no_session() { + let _runtime = test_runtime(); + // vcs_session_id is None, close_vcs_session should do nothing +} + +// ── stop_process ── + +#[test] +fn stop_process_ungraceful_clears_session() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-abc".into())); + runtime.stop_process(false); + assert!(runtime.vcs_session_id.lock().is_none()); +} + +#[test] +fn stop_process_graceful_closes_session() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-abc".into())); + mock_response(&runtime); + runtime.stop_process(true); + // Session should be cleared even if process is None + assert!(runtime.vcs_session_id.lock().is_none()); +} + +#[test] +fn stop_process_graceful_without_session_does_not_call_rpc() { + let runtime = test_runtime(); + // No session_id set, stop_process should not attempt rpc call + runtime.stop_process(true); +} + +// ── PluginRuntimeInstance trait methods ── + +#[test] +fn ensure_running_fails_when_no_node() { + let runtime = test_runtime(); + // No process pre-injected, spawn_process will fail + let result = runtime.ensure_running(); + assert!(result.is_err()); +} + +#[test] +fn set_event_sink_stores_and_clears() { + use crate::core::models::{OnEvent, VcsEvent}; + let runtime = test_runtime(); + let sink: OnEvent = std::sync::Arc::new(|_| {}); + runtime.set_event_sink(Some(Arc::clone(&sink))); + runtime.set_event_sink(None); + // Should not panic +} + +#[test] +fn stop_does_not_panic() { + let runtime = test_runtime(); + // With no process, stop is a no-op + runtime.stop(); +} + +#[test] +fn drop_does_not_panic() { + // Dropping a runtime with no active process should be safe + let runtime = test_runtime(); + drop(runtime); +} diff --git a/Backend/tests/plugin_runtime/node_instance/rpc.rs b/Backend/tests/plugin_runtime/node_instance/rpc.rs index af33d3c5..56013e7d 100644 --- a/Backend/tests/plugin_runtime/node_instance/rpc.rs +++ b/Backend/tests/plugin_runtime/node_instance/rpc.rs @@ -1,9 +1,16 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::format_rpc_error; +use super::{format_rpc_error, NodeRpcProcess}; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; use crate::plugin_runtime::protocol::RpcError; -use serde_json::json; +use crate::plugin_runtime::spawn::SpawnConfig; +use parking_lot::Mutex; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::mpsc; #[test] fn formats_rpc_errors_with_nested_message() { @@ -30,3 +37,155 @@ fn formats_rpc_errors_with_fallback_message() { "plugin 'demo.plugin' rpc 'vcs.open' failed (code 7): fallback" ); } + +/// Integration tests using a real (but harmless) sh process for RPC I/O. +#[cfg(unix)] +mod call_integration { + use super::*; + + /// Spawns a background process that stays alive so `call()` can write + /// framed messages to its stdin pipe without error. + fn mock_process() -> (NodeRpcProcess, mpsc::Sender) { + let mut child = Command::new("sh") + .arg("-c") + .arg("while true; do :; done") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn mock rpc child"); + let stdin = child.stdin.take().expect("mock process stdin"); + let (tx, rx) = mpsc::channel(); + let process = NodeRpcProcess { + child, + stdin, + rx, + shutdown_flag: Arc::new(Mutex::new(false)), + reader_error: Arc::new(Mutex::new(None)), + next_request_id: 1, + }; + (process, tx) + } + + fn test_runtime() -> NodePluginRuntimeInstance { + NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "test.plugin".into(), + exec_path: PathBuf::from("test.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }) + } + + #[test] + fn call_method_returns_response() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "test-result" + })) + .unwrap(); + + let result: String = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "test-result"); + } + + #[test] + fn call_method_returns_deserialized_struct() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "main" + })) + .unwrap(); + + let branch = rt.vcs_get_current_branch().unwrap(); + assert_eq!(branch, Some("main".into())); + } + + #[test] + fn call_method_propagates_rpc_error() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -1, "message": "operation rejected"} + })) + .unwrap(); + + let err = rt.vcs_list_branches().unwrap_err(); + assert!(err.contains("operation rejected") || err.contains("rpc")); + } + + #[test] + fn call_method_handles_notification_before_response() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "method": "host.log", + "params": {"level": "info", "message": "test notification"} + })) + .unwrap(); + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "done" + })) + .unwrap(); + + let result: String = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "done"); + } + + #[test] + fn call_method_rejects_mismatched_response_id() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 999, + "result": "wrong-id" + })) + .unwrap(); + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "correct-id" + })) + .unwrap(); + + let result: String = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "correct-id"); + } + + #[test] + fn call_method_times_out_on_missing_response() { + let (process, _tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + let err = rt.vcs_list_stashes().unwrap_err(); + assert!(err.contains("timed out") || err.contains("disconnected")); + } +} diff --git a/Backend/tests/plugin_runtime/node_instance/vcs.rs b/Backend/tests/plugin_runtime/node_instance/vcs.rs new file mode 100644 index 00000000..0c7abeb7 --- /dev/null +++ b/Backend/tests/plugin_runtime/node_instance/vcs.rs @@ -0,0 +1,528 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use serde_json::{json, Value}; +use std::path::PathBuf; + +fn test_runtime() -> NodePluginRuntimeInstance { + NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "test.plugin".into(), + exec_path: PathBuf::from("test.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }) +} + +fn mock_response(runtime: &NodePluginRuntimeInstance, response: Value) { + runtime.set_mock_handler(Box::new(move |_, _| Ok(response.clone()))); +} + +fn mock_error(runtime: &NodePluginRuntimeInstance, msg: &str) { + let msg = msg.to_string(); + runtime.set_mock_handler(Box::new(move |_, _| Err(msg.clone()))); +} + +// --- Methods without session_params --- + +#[test] +fn vcs_open_parses_empty_config_and_stores_session_id() { + let rt = test_runtime(); + mock_response(&rt, json!({"session_id": "opened-123"})); + rt.vcs_open("/repo", b"").unwrap(); + assert_eq!(&*rt.vcs_session_id.lock(), &Some("opened-123".to_string())); +} + +#[test] +fn vcs_open_parses_config_bytes_and_stores_session_id() { + let rt = test_runtime(); + mock_response(&rt, json!({"session_id": "opened-456"})); + rt.vcs_open("/repo", br#"{"key":"val"}"#).unwrap(); + assert_eq!(&*rt.vcs_session_id.lock(), &Some("opened-456".to_string())); +} + +#[test] +fn vcs_open_forwards_rpc_error() { + let rt = test_runtime(); + mock_error(&rt, "failed to open"); + let err = rt.vcs_open("/repo", b"").unwrap_err(); + assert_eq!(err, "failed to open"); +} + +#[test] +fn vcs_clone_repo_works() { + let rt = test_runtime(); + mock_response(&rt, Value::Null); + rt.vcs_clone_repo("https://example.com/repo", "/dest").unwrap(); +} + +#[test] +fn vcs_clone_repo_forwards_error() { + let rt = test_runtime(); + mock_error(&rt, "clone failed"); + assert!(rt.vcs_clone_repo("https://example.com/repo", "/dest").is_err()); +} + +// --- Methods using session_params with unit return --- + +#[test] +fn vcs_create_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_create_branch("feature", true).unwrap(); +} + +#[test] +fn vcs_checkout_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_checkout_branch("main").unwrap(); +} + +#[test] +fn vcs_ensure_remote_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_ensure_remote("origin", "https://example.com/repo").unwrap(); +} + +#[test] +fn vcs_remove_remote_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_remove_remote("origin").unwrap(); +} + +#[test] +fn vcs_fetch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_fetch("origin", "main").unwrap(); +} + +#[test] +fn vcs_push_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_push("origin", "main").unwrap(); +} + +#[test] +fn vcs_pull_ff_only_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_pull_ff_only("origin", "main").unwrap(); +} + +#[test] +fn vcs_stage_patch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stage_patch("diff --git a/file b/file").unwrap(); +} + +#[test] +fn vcs_stage_paths_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stage_paths(&["src/main.rs".into(), "src/lib.rs".into()]).unwrap(); +} + +#[test] +fn vcs_discard_paths_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_discard_paths(&["src/main.rs".into()]).unwrap(); +} + +#[test] +fn vcs_apply_reverse_patch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_apply_reverse_patch("diff --git a/file b/file").unwrap(); +} + +#[test] +fn vcs_delete_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_delete_branch("old-feature", false).unwrap(); +} + +#[test] +fn vcs_rename_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_rename_branch("old-name", "new-name").unwrap(); +} + +#[test] +fn vcs_merge_into_current_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_into_current("feature", Some("merge msg")).unwrap(); +} + +#[test] +fn vcs_merge_into_current_without_message_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_into_current("feature", None::<&str>).unwrap(); +} + +#[test] +fn vcs_merge_abort_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_abort().unwrap(); +} + +#[test] +fn vcs_merge_continue_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_continue().unwrap(); +} + +#[test] +fn vcs_set_branch_upstream_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_set_branch_upstream("main", "origin/main").unwrap(); +} + +#[test] +fn vcs_reset_soft_to_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_reset_soft_to("abc123").unwrap(); +} + +#[test] +fn vcs_set_identity_local_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_set_identity_local("User", "user@example.com").unwrap(); +} + +#[test] +fn vcs_stash_apply_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stash_apply("stash@{0}").unwrap(); +} + +#[test] +fn vcs_stash_pop_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stash_pop("stash@{0}").unwrap(); +} + +#[test] +fn vcs_stash_drop_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stash_drop("stash@{0}").unwrap(); +} + +#[test] +fn vcs_cherry_pick_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_cherry_pick("abc123").unwrap(); +} + +#[test] +fn vcs_revert_commit_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_revert_commit("abc123", true).unwrap(); +} + +#[test] +fn vcs_checkout_conflict_side_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_checkout_conflict_side("file.txt", crate::core::models::ConflictSide::Ours) + .unwrap(); +} + +#[test] +fn vcs_write_merge_result_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_write_merge_result("file.txt", b"merged content").unwrap(); +} + +// --- Methods with typed return values --- + +#[test] +fn vcs_get_current_branch_returns_some() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("main")); + assert_eq!(rt.vcs_get_current_branch().unwrap(), Some("main".to_string())); +} + +#[test] +fn vcs_get_current_branch_returns_none() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + assert_eq!(rt.vcs_get_current_branch().unwrap(), None); +} + +#[test] +fn vcs_list_branches_returns_branches() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"name": "main", "full_ref": "refs/heads/main", "kind": {"type": "Local"}, "current": true}, + {"name": "feature", "full_ref": "refs/heads/feature", "kind": {"type": "Local"}, "current": false} + ])); + let branches = rt.vcs_list_branches().unwrap(); + assert_eq!(branches.len(), 2); + assert_eq!(branches[0].name, "main"); + assert_eq!(branches[0].current, true); +} + +#[test] +fn vcs_list_remotes_returns_remotes() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"name": "origin", "url": "https://example.com/repo"} + ])); + let remotes = rt.vcs_list_remotes().unwrap(); + assert_eq!(remotes, vec![("origin".into(), "https://example.com/repo".into())]); +} + +#[test] +fn vcs_list_commits_returns_commits() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"id": "abc", "msg": "first", "meta": "2024-01-01", "author": "Alice"} + ])); + let query = crate::core::models::LogQuery::default(); + let commits = rt.vcs_list_commits(&query).unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].id, "abc"); +} + +#[test] +fn vcs_diff_file_returns_diff_lines() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(["+new line", "-old line"])); + let lines = rt.vcs_diff_file("file.rs").unwrap(); + assert_eq!(lines, vec!["+new line", "-old line"]); +} + +#[test] +fn vcs_diff_commit_returns_diff_lines() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(["diff --git a/file b/file"])); + let lines = rt.vcs_diff_commit("abc123").unwrap(); + assert_eq!(lines, vec!["diff --git a/file b/file"]); +} + +#[test] +fn vcs_get_conflict_details_returns_details() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!({ + "path": "file.txt", + "ours": "our content", + "theirs": "their content", + "base": "base content", + "binary": false, + "lfs_pointer": false + })); + let details = rt.vcs_get_conflict_details("file.txt").unwrap(); + assert_eq!(details.path, "file.txt"); + assert_eq!(details.ours.unwrap(), "our content"); +} + +#[test] +fn vcs_is_merge_in_progress_returns_true() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(true)); + assert!(rt.vcs_is_merge_in_progress().unwrap()); +} + +#[test] +fn vcs_is_merge_in_progress_returns_false() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(false)); + assert!(!rt.vcs_is_merge_in_progress().unwrap()); +} + +#[test] +fn vcs_get_branch_upstream_returns_some() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("origin/main")); + assert_eq!(rt.vcs_get_branch_upstream("main").unwrap(), Some("origin/main".into())); +} + +#[test] +fn vcs_get_branch_upstream_returns_none() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + assert_eq!(rt.vcs_get_branch_upstream("main").unwrap(), None); +} + +#[test] +fn vcs_get_identity_returns_some() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!({"name": "Alice", "email": "alice@example.com"})); + let identity = rt.vcs_get_identity().unwrap(); + assert_eq!(identity, Some(("Alice".into(), "alice@example.com".into()))); +} + +#[test] +fn vcs_get_identity_returns_none() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + assert_eq!(rt.vcs_get_identity().unwrap(), None); +} + +#[test] +fn vcs_list_stashes_returns_stashes() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"selector": "stash@{0}", "msg": "WIP", "meta": "2024-01-01"} + ])); + let stashes = rt.vcs_list_stashes().unwrap(); + assert_eq!(stashes.len(), 1); + assert_eq!(stashes[0].selector, "stash@{0}"); +} + +#[test] +fn vcs_stash_push_with_message_returns_selector() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("stash@{0}")); + let result = rt.vcs_stash_push(Some("WIP"), false).unwrap(); + assert_eq!(result, "stash@{0}"); +} + +#[test] +fn vcs_stash_push_without_message_returns_selector() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("stash@{1}")); + let result = rt.vcs_stash_push(None::<&str>, true).unwrap(); + assert_eq!(result, "stash@{1}"); +} + +#[test] +fn vcs_stash_show_returns_output() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("stash content")); + let result = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "stash content"); +} + +#[test] +fn vcs_commit_returns_oid() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("abc123def")); + let oid = rt + .vcs_commit("msg", "Alice", "alice@example.com", &["file.rs".into()]) + .unwrap(); + assert_eq!(oid, "abc123def"); +} + +#[test] +fn vcs_commit_index_returns_oid() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("def456ghi")); + let oid = rt.vcs_commit_index("msg", "Alice", "alice@example.com").unwrap(); + assert_eq!(oid, "def456ghi"); +} + +#[test] +fn vcs_get_status_payload_returns_status() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!({ + "files": [], + "ahead": 0, + "behind": 0, + "branch_on_remote": true + })); + let payload = rt.vcs_get_status_payload().unwrap(); + assert!(payload.files.is_empty()); + assert!(payload.branch_on_remote); +} + +// --- Error paths --- + +#[test] +fn session_params_fails_when_no_session() { + let rt = test_runtime(); + mock_response(&rt, Value::Null); + let err = rt.vcs_list_branches().unwrap_err(); + assert_eq!(err, "vcs session is not open"); +} + +#[test] +fn vcs_commit_forwards_rpc_error() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_error(&rt, "commit rejected"); + let err = rt + .vcs_commit("msg", "Alice", "alice@example.com", &["file.rs".into()]) + .unwrap_err(); + assert_eq!(err, "commit rejected"); +} + +#[test] +fn vcs_open_forwards_rpc_decode_error_as_runtime_error() { + let rt = test_runtime(); + // Return non-matching shape for OpenSessionResponse + mock_response(&rt, json!({"wrong_field": "value"})); + let err = rt.vcs_open("/repo", b"").unwrap_err(); + assert!(err.contains("decode")); +} diff --git a/Backend/tests/plugin_runtime/runtime_select.rs b/Backend/tests/plugin_runtime/runtime_select.rs index a060c7f3..9fe9ee77 100644 --- a/Backend/tests/plugin_runtime/runtime_select.rs +++ b/Backend/tests/plugin_runtime/runtime_select.rs @@ -1,8 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::is_node_module; +use super::{create_node_runtime_instance, create_runtime_instance, is_node_module}; +use crate::plugin_runtime::spawn::SpawnConfig; use std::fs; +use std::path::PathBuf; use tempfile::tempdir; #[test] @@ -52,3 +54,61 @@ fn uppercase_extensions_and_non_files_are_handled_consistently() { assert!(!is_node_module(&temp.path().join("missing.js"))); } + +#[test] +fn create_node_runtime_instance_accepts_js_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let js_path = temp.path().join("plugin.mjs"); + std::fs::write(&js_path, b"export {}").expect("write"); + + let result = create_node_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: js_path, + allowed_workspace_root: None, + is_vcs_backend: false, + }); + assert!(result.is_ok()); +} + +#[test] +fn create_node_runtime_instance_rejects_non_js_file() { + let result = create_node_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: PathBuf::from("plugin.bin"), + allowed_workspace_root: None, + is_vcs_backend: false, + }); + match result { + Err(msg) => assert!(msg.contains("must be a .js/.mjs/.cjs")), + Ok(_) => panic!("expected error"), + } +} + +#[test] +fn create_runtime_instance_accepts_valid_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let js_path = temp.path().join("plugin.mjs"); + std::fs::write(&js_path, b"export {}").expect("write"); + + let result = create_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: js_path, + allowed_workspace_root: None, + is_vcs_backend: false, + }); + assert!(result.is_ok()); +} + +#[test] +fn create_runtime_instance_rejects_invalid_path() { + let result = create_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: PathBuf::from("plugin.bin"), + allowed_workspace_root: None, + is_vcs_backend: false, + }); + match result { + Err(msg) => assert!(msg.contains("must be a .js/.mjs/.cjs")), + Ok(_) => panic!("expected error"), + } +} diff --git a/Backend/tests/plugin_runtime/vcs_proxy.rs b/Backend/tests/plugin_runtime/vcs_proxy.rs index 7b727e08..c7679715 100644 --- a/Backend/tests/plugin_runtime/vcs_proxy.rs +++ b/Backend/tests/plugin_runtime/vcs_proxy.rs @@ -2,29 +2,487 @@ // SPDX-License-Identifier: GPL-3.0-or-later use super::{path_to_utf8, PluginVcsProxy}; -use crate::core::{BackendId, VcsError}; +use crate::core::models::{LogQuery, OnEvent}; +use crate::core::{BackendId, Vcs, VcsError}; use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; +use serde_json::{json, Value}; use std::path::PathBuf; use std::sync::Arc; -fn test_proxy() -> PluginVcsProxy { - let spawn = SpawnConfig { +fn test_runtime() -> Arc { + Arc::new(NodePluginRuntimeInstance::new(SpawnConfig { plugin_id: "demo.plugin".into(), exec_path: PathBuf::from("bin/plugin.mjs"), allowed_workspace_root: None, is_vcs_backend: true, - }; - PluginVcsProxy { + })) +} + +fn mock_proxy() -> (PluginVcsProxy, Arc) { + let runtime = test_runtime(); + let proxy = PluginVcsProxy { backend_id: BackendId::from("git"), workdir: PathBuf::from("/tmp/repo"), - runtime: Arc::new(NodePluginRuntimeInstance::new(spawn)), - } + runtime: Arc::clone(&runtime), + }; + (proxy, runtime) +} + +fn set_unit_response(runtime: &NodePluginRuntimeInstance) { + runtime.set_mock_handler(Box::new(|_, _| Ok(Value::Null))); +} + +fn set_response(runtime: &NodePluginRuntimeInstance, value: Value) { + runtime.set_mock_handler(Box::new(move |_, _| Ok(value.clone()))); +} + +fn set_error(runtime: &NodePluginRuntimeInstance, msg: &str) { + let msg = msg.to_string(); + runtime.set_mock_handler(Box::new(move |_, _| Err(msg.clone()))); +} + +// ── Accessor tests ── + +#[test] +fn proxy_id_returns_backend_id() { + let proxy = mock_proxy().0; + assert_eq!(proxy.id(), "git"); +} + +#[test] +fn proxy_workdir_returns_path() { + let proxy = mock_proxy().0; + assert_eq!(proxy.workdir(), PathBuf::from("/tmp/repo")); +} + +// ── Unit-returning VCS delegation tests ── + +#[test] +fn proxy_create_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.create_branch("feat", true).is_ok()); +} + +#[test] +fn proxy_create_branch_forwards_error() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_error(&rt, "boom"); + assert!(matches!(proxy.create_branch("feat", true), Err(VcsError::Backend { .. }))); +} + +#[test] +fn proxy_checkout_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.checkout_branch("main").is_ok()); +} + +#[test] +fn proxy_ensure_remote_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.ensure_remote("origin", "https://example.com/repo").is_ok()); +} + +#[test] +fn proxy_remove_remote_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.remove_remote("upstream").is_ok()); +} + +#[test] +fn proxy_fetch_with_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + let on: OnEvent = Arc::new(|_| {}); + assert!(proxy.fetch("origin", "main", Some(on)).is_ok()); +} + +#[test] +fn proxy_push_with_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + let on: OnEvent = Arc::new(|_| {}); + assert!(proxy.push("origin", "main", Some(on)).is_ok()); +} + +#[test] +fn proxy_pull_ff_only_with_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + let on: OnEvent = Arc::new(|_| {}); + assert!(proxy.pull_ff_only("origin", "main", Some(on)).is_ok()); +} + +#[test] +fn proxy_fetch_without_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.fetch("origin", "main", None).is_ok()); +} + +#[test] +fn proxy_stage_patch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stage_patch("diff ...").is_ok()); +} + +#[test] +fn proxy_stage_paths_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stage_paths(&[PathBuf::from("file.rs")]).is_ok()); +} + +#[test] +fn proxy_discard_paths_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.discard_paths(&[PathBuf::from("file.rs")]).is_ok()); +} + +#[test] +fn proxy_apply_reverse_patch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.apply_reverse_patch("diff ...").is_ok()); +} + +#[test] +fn proxy_delete_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.delete_branch("old", false).is_ok()); +} + +#[test] +fn proxy_rename_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.rename_branch("a", "b").is_ok()); +} + +#[test] +fn proxy_merge_into_current_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_into_current("feature").is_ok()); +} + +#[test] +fn proxy_merge_into_current_with_message_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_into_current_with_message("feature", Some("msg")).is_ok()); +} + +#[test] +fn proxy_merge_abort_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_abort().is_ok()); +} + +#[test] +fn proxy_merge_continue_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_continue().is_ok()); +} + +#[test] +fn proxy_set_branch_upstream_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.set_branch_upstream("main", "origin/main").is_ok()); +} + +#[test] +fn proxy_reset_soft_to_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.reset_soft_to("abc123").is_ok()); } +#[test] +fn proxy_set_identity_local_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.set_identity_local("Alice", "alice@example.com").is_ok()); +} + +#[test] +fn proxy_stash_apply_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stash_apply("stash@{0}").is_ok()); +} + +#[test] +fn proxy_stash_pop_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stash_pop("stash@{0}").is_ok()); +} + +#[test] +fn proxy_stash_drop_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stash_drop("stash@{0}").is_ok()); +} + +#[test] +fn proxy_cherry_pick_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.cherry_pick("abc123").is_ok()); +} + +#[test] +fn proxy_revert_commit_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.revert_commit("abc123", true).is_ok()); +} + +#[test] +fn proxy_checkout_conflict_side_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.checkout_conflict_side( + PathBuf::from("file.txt").as_path(), + crate::core::models::ConflictSide::Ours, + ).is_ok()); +} + +#[test] +fn proxy_write_merge_result_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.write_merge_result(PathBuf::from("file.txt").as_path(), b"content").is_ok()); +} + +// ── Typed-return VCS delegation tests ── + +#[test] +fn proxy_current_branch_returns_name() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("main")); + assert_eq!(proxy.current_branch().unwrap(), Some("main".into())); +} + +#[test] +fn proxy_current_branch_returns_none() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, Value::Null); + assert_eq!(proxy.current_branch().unwrap(), None); +} + +#[test] +fn proxy_branches_returns_list() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"name": "main", "full_ref": "refs/heads/main", "kind": {"type": "Local"}, "current": true} + ])); + let branches = proxy.branches().unwrap(); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].name, "main"); +} + +#[test] +fn proxy_list_remotes_returns_pairs() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"name": "origin", "url": "https://example.com/repo"} + ])); + let remotes = proxy.list_remotes().unwrap(); + assert_eq!(remotes, vec![("origin".into(), "https://example.com/repo".into())]); +} + +#[test] +fn proxy_commit_returns_oid() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("abc123")); + let oid = proxy + .commit("msg", "Alice", "alice@example.com", &[PathBuf::from("f.rs")]) + .unwrap(); + assert_eq!(oid, "abc123"); +} + +#[test] +fn proxy_commit_index_returns_oid() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("def456")); + assert_eq!( + proxy.commit_index("msg", "Alice", "alice@example.com").unwrap(), + "def456" + ); +} + +#[test] +fn proxy_status_payload_returns_status() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!({"files": [], "ahead": 0, "behind": 0, "branch_on_remote": true})); + let status = proxy.status_payload().unwrap(); + assert!(status.files.is_empty()); + assert!(status.branch_on_remote); +} + +#[test] +fn proxy_log_commits_returns_list() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"id": "abc", "msg": "first", "meta": "2024-01-01", "author": "Alice"} + ])); + let commits = proxy.log_commits(&LogQuery::default()).unwrap(); + assert_eq!(commits.len(), 1); +} + +#[test] +fn proxy_diff_file_returns_lines() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!(["-old", "+new"])); + let lines = proxy.diff_file(PathBuf::from("file.rs").as_path()).unwrap(); + assert_eq!(lines, vec!["-old", "+new"]); +} + +#[test] +fn proxy_diff_commit_returns_lines() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!(["diff ..."])); + let lines = proxy.diff_commit("abc123").unwrap(); + assert_eq!(lines, vec!["diff ..."]); +} + +#[test] +fn proxy_conflict_details_returns_details() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!({ + "path": "file.txt", "ours": "our", "theirs": "their", + "base": "base", "binary": false, "lfs_pointer": false + })); + let details = proxy.conflict_details(PathBuf::from("file.txt").as_path()).unwrap(); + assert_eq!(details.path, "file.txt"); + assert_eq!(details.ours.unwrap(), "our"); +} + +#[test] +fn proxy_merge_in_progress_returns_bool() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!(true)); + assert!(proxy.merge_in_progress().unwrap()); +} + +#[test] +fn proxy_branch_upstream_returns_name() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("origin/main")); + assert_eq!(proxy.branch_upstream("main").unwrap(), Some("origin/main".into())); +} + +#[test] +fn proxy_get_identity_returns_info() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!({"name": "Alice", "email": "alice@example.com"})); + assert_eq!( + proxy.get_identity().unwrap(), + Some(("Alice".into(), "alice@example.com".into())) + ); +} + +#[test] +fn proxy_stash_list_returns_list() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"selector": "stash@{0}", "msg": "WIP", "meta": "2024-01-01"} + ])); + let stashes = proxy.stash_list().unwrap(); + assert_eq!(stashes.len(), 1); + assert_eq!(stashes[0].selector, "stash@{0}"); +} + +#[test] +fn proxy_stash_push_returns_selector() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("stash@{0}")); + assert_eq!( + proxy.stash_push("WIP", true, &[]).unwrap(), + () + ); +} + +#[test] +fn proxy_stash_push_empty_message_uses_none() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("stash@{0}")); + assert!(proxy.stash_push(" ", true, &[]).is_ok()); +} + +#[test] +fn proxy_stash_show_returns_lines() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("line1\nline2")); + let lines = proxy.stash_show("stash@{0}").unwrap(); + assert_eq!(lines, vec!["line1", "line2"]); +} + +// ── Existing tests ── + #[test] fn maps_runtime_errors() { - let proxy = test_proxy(); + let proxy = mock_proxy().0; assert!(matches!(proxy.map_runtime_error("no upstream configured".into()), VcsError::NoUpstream)); assert!(matches!(proxy.map_runtime_error("boom".into()), VcsError::Backend { .. })); } diff --git a/Backend/tests/tauri_commands/backends.rs b/Backend/tests/tauri_commands/backends.rs index a66361ac..1524839b 100644 --- a/Backend/tests/tauri_commands/backends.rs +++ b/Backend/tests/tauri_commands/backends.rs @@ -2,6 +2,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::core::BackendId; +use crate::settings; +use crate::state::AppState; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; use super::{auto_default_backend_id, backend_display_label}; @@ -43,3 +48,68 @@ fn skips_auto_selection_when_backend_is_already_default_or_not_unique() { None ); } + +// ── Tauri IPC integration tests ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::list_vcs_backends_cmd, + super::current_vcs_action_labels, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn list_vcs_backends_returns_backend_entries() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "list_vcs_backends_cmd", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_vcs_backends_cmd should succeed: {:?}", res); + let backends: Vec<(String, String)> = res.unwrap().deserialize().unwrap(); + for (id, label) in &backends { + assert!(!id.is_empty(), "each backend should have a non-empty id"); + assert!(!label.is_empty(), "each backend should have a non-empty label"); + } +} + +#[test] +fn current_vcs_action_labels_fails_when_no_repo() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "current_vcs_action_labels", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "current_vcs_action_labels should fail without repo"); +} diff --git a/Backend/tests/tauri_commands/general.rs b/Backend/tests/tauri_commands/general.rs index 8b0e7441..9b0509dc 100644 --- a/Backend/tests/tauri_commands/general.rs +++ b/Backend/tests/tauri_commands/general.rs @@ -3,11 +3,20 @@ use std::path::Path; +use crate::state::AppState; +use crate::settings; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + use super::{ browse_directory_title, infer_repo_dir_from_url, recent_repo_name, resolve_default_backend_id, + validate_add_path, validate_clone_input, validate_vcs_url, }; use crate::core::BackendId; +// ── Pure function tests ── + #[test] fn infers_repo_directory_names() { assert_eq!(infer_repo_dir_from_url("https://example.com/org/repo.git"), "repo"); @@ -41,3 +50,117 @@ fn derives_recent_repository_display_names() { assert_eq!(recent_repo_name(Path::new("/tmp/demo-repo")), Some("demo-repo".into())); assert_eq!(recent_repo_name(Path::new("/")), None); } + +// ── Tauri command integration tests ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::about_info, + super::show_licenses, + super::list_recent_repos, + super::current_repo_path, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn about_info_returns_metadata() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "about_info", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "about_info should succeed: {:?}", res); + let info: crate::utilities::inner::AboutInfo = res.unwrap().deserialize().unwrap(); + assert_eq!(info.name, env!("CARGO_PKG_NAME")); +} + +#[test] +fn show_licenses_returns_ok() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "show_licenses", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "show_licenses should succeed: {:?}", res); +} + +// ── Pure validation command tests (no Tauri dependencies) ── + +#[test] +fn validate_vcs_url_accepts_http_urls() { + let result = validate_vcs_url("https://github.com/user/repo.git".into()); + assert!(result.ok, "http URL should be valid"); +} + +#[test] +fn validate_vcs_url_rejects_garbage() { + let result = validate_vcs_url("not a url".into()); + assert!(!result.ok, "garbage should be invalid"); +} + +#[test] +fn validate_add_path_rejects_empty() { + let result = validate_add_path("".into()); + assert!(!result.ok, "empty path should be invalid"); +} + +#[test] +fn validate_clone_input_rejects_invalid_url() { + let result = validate_clone_input("bad".into(), "/tmp".into()); + assert!(!result.ok, "bad url + good dest should be invalid"); +} + +// ── State-only IPC command tests ── + +#[test] +fn list_recent_repos_returns_parsable_entries() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "list_recent_repos", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_recent_repos should succeed: {:?}", res); + let repos: Vec = res.unwrap().deserialize().unwrap(); + for repo in &repos { + assert!(repo.get("path").and_then(|p| p.as_str()).is_some(), "each repo entry needs a path string"); + } +} + +#[test] +fn current_repo_path_returns_none_when_no_repo() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "current_repo_path", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "current_repo_path should succeed: {:?}", res); + let path: Option = res.unwrap().deserialize().unwrap(); + assert!(path.is_none(), "repo path should be None when no repo open"); +} diff --git a/Backend/tests/tauri_commands/output_log.rs b/Backend/tests/tauri_commands/output_log.rs index 89b46a29..a97b55db 100644 --- a/Backend/tests/tauri_commands/output_log.rs +++ b/Backend/tests/tauri_commands/output_log.rs @@ -2,7 +2,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later use super::read_last_lines; +use crate::settings; +use crate::state::AppState; use std::fs; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::{Manager, WebviewWindowBuilder}; + +// ── read_last_lines tests ── #[test] fn reads_last_lines_from_file() { @@ -24,3 +31,168 @@ fn reads_empty_files_as_empty_lists() { assert!(read_last_lines(&path, 10).expect("read empty").is_empty()); } + +// ── Tauri command integration tests via MockRuntime ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::get_output_log, + super::clear_output_log, + super::log_frontend_message, + super::tail_app_log, + super::clear_app_log, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn get_output_log_starts_empty() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "get_output_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_output_log should succeed: {:?}", res); + let entries: Vec = res.unwrap().deserialize().unwrap(); + assert!(entries.is_empty(), "output log should start empty"); +} + +#[test] +fn get_output_log_reflects_pushed_entries() { + let app = build_app(); + let state = app.state::(); + state.push_output_log(crate::output_log::OutputLogEntry::new( + 1000, + crate::output_log::OutputLevel::Info, + "test", + "hello world", + )); + + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "get_output_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok()); + let entries: Vec = res.unwrap().deserialize().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].message, "hello world"); +} + +#[test] +fn clear_output_log_empties_log() { + let app = build_app(); + let state = app.state::(); + state.push_output_log(crate::output_log::OutputLogEntry::new( + 1000, + crate::output_log::OutputLevel::Info, + "test", + "to-clear", + )); + + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "clear_output_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "clear_output_log should succeed"); + assert!(state.output_log().is_empty(), "log should be empty after clear"); +} + +#[test] +fn log_frontend_message_pushes_entry() { + let app = build_app(); + let webview = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json( + serde_json::json!({ + "level": "info", + "message": "frontend test message" + }) + ); + let res = invoke_cmd(&webview, "log_frontend_message", body); + assert!(res.is_ok()); + + let entries = app.state::().output_log(); + assert!(!entries.is_empty(), "log_frontend_message should push an entry"); + assert!(entries.iter().any(|e| e.message == "frontend test message")); +} + +#[test] +fn log_frontend_message_maps_levels() { + let app = build_app(); + let webview = test_webview(&app); + + for (level, expected) in [ + ("trace", crate::output_log::OutputLevel::Info), + ("debug", crate::output_log::OutputLevel::Info), + ("info", crate::output_log::OutputLevel::Info), + ("warn", crate::output_log::OutputLevel::Warn), + ("warning", crate::output_log::OutputLevel::Warn), + ("error", crate::output_log::OutputLevel::Error), + ("err", crate::output_log::OutputLevel::Error), + ("unknown", crate::output_log::OutputLevel::Info), + ] { + let state = app.state::(); + let body = tauri::ipc::InvokeBody::Json( + serde_json::json!({ + "level": level, + "message": format!("test-{}", level) + }) + ); + let _ = invoke_cmd(&webview, "log_frontend_message", body); + + let entries = state.output_log(); + let entry = entries.iter().find(|e| e.message == format!("test-{}", level)); + assert!(entry.is_some(), "entry for level '{}' not found", level); + assert_eq!(entry.unwrap().level, expected, "wrong level mapping for '{}'", level); + } +} + +#[test] +fn tail_app_log_returns_entries_without_panicking() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "tail_app_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "tail_app_log should succeed: {:?}", res); + let entries: Vec = res.unwrap().deserialize().unwrap(); + // Entries may exist or be empty depending on the test environment; just verify it doesn't error + for e in &entries { + assert!(!e.message.is_empty(), "each log entry should have a message"); + } +} + +#[test] +fn clear_app_log_succeeds_when_not_initialized() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "clear_app_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "clear_app_log should succeed: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/plugins.rs b/Backend/tests/tauri_commands/plugins.rs index 86f12938..d068d9d2 100644 --- a/Backend/tests/tauri_commands/plugins.rs +++ b/Backend/tests/tauri_commands/plugins.rs @@ -1,6 +1,12 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use crate::settings; +use crate::state::AppState; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + use super::{ merge_settings_with_defaults, menu_to_payload, setting_from_json, setting_kind_name, setting_value_to_json, settings_to_json_map, PluginMenuPayload, PluginSettingEntry, @@ -86,3 +92,78 @@ fn converts_menu_payload() { assert_eq!(payload.elements[0]["type"], serde_json::json!("text")); assert_eq!(payload.elements[1]["type"], serde_json::json!("button")); } + +// ── Tauri command integration tests ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::list_plugins, + super::list_plugin_start_failures, + super::load_plugin, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn list_plugins_returns_plugins() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_plugins", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_plugins should succeed: {:?}", res); +} + +#[test] +fn list_plugin_start_failures_returns_empty() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd( + &webview, + "list_plugin_start_failures", + tauri::ipc::InvokeBody::default(), + ); + assert!(res.is_ok(), "list_plugin_start_failures should succeed"); + let failures: Vec = res.unwrap().deserialize().unwrap(); + assert!(failures.is_empty(), "should start with no failures"); +} + +#[test] +fn load_plugin_rejects_unknown() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"id": "nonexistent.plugin"})); + let res = invoke_cmd(&webview, "load_plugin", body); + // Unknown plugins should return an error + assert!(res.is_err(), "loading unknown plugin should fail"); +} diff --git a/Backend/tests/tauri_commands/repo_files.rs b/Backend/tests/tauri_commands/repo_files.rs index 14850a57..a908d26d 100644 --- a/Backend/tests/tauri_commands/repo_files.rs +++ b/Backend/tests/tauri_commands/repo_files.rs @@ -28,3 +28,43 @@ fn decodes_text_bytes() { let utf16be: Vec = vec![0xFE, 0xFF, 0, b'h', 0, b'i']; assert_eq!(decode_repo_text(&utf16be), "hi"); } + +#[test] +fn safe_relative_path_rejects_invalid_inputs() { + assert!(safe_relative_path(".").is_ok()); + assert!(safe_relative_path("./src/lib.rs").is_ok()); + assert!(safe_relative_path("..").is_err()); + assert!(safe_relative_path("a/../../b").is_err()); +} + +#[test] +fn normalize_gitignore_entry_prepends_slash() { + assert_eq!(normalize_gitignore_entry("foo").unwrap(), "/foo"); + assert_eq!(normalize_gitignore_entry("foo/bar").unwrap(), "/foo/bar"); +} + +#[test] +fn normalize_gitignore_entry_handles_backslash_and_crlf() { + assert_eq!(normalize_gitignore_entry("a\\b\\c").unwrap(), "/a/b/c"); + assert!(normalize_gitignore_entry("bad\rpath").is_err()); +} + +#[test] +fn normalize_gitignore_entry_strips_dot_slash_prefix() { + assert_eq!(normalize_gitignore_entry("./dir/file").unwrap(), "/dir/file"); + assert_eq!(normalize_gitignore_entry("./").unwrap(), "/"); +} + +#[test] +fn decodes_empty_and_bom_only_bytes() { + assert_eq!(decode_repo_text(b""), ""); + let utf8_bom: Vec = vec![0xEF, 0xBB, 0xBF]; + assert_eq!(decode_repo_text(&utf8_bom), "\u{feff}"); +} + +#[test] +fn decodes_lossy_text_bytes() { + let invalid: Vec = vec![0xFF, 0xFE, 0xFF, 0xFE, 0x00]; + let decoded = decode_repo_text(&invalid); + assert!(!decoded.is_empty(), "should not panic on invalid encoding"); +} diff --git a/Backend/tests/tauri_commands/themes.rs b/Backend/tests/tauri_commands/themes.rs index cf5219c4..9e351dc5 100644 --- a/Backend/tests/tauri_commands/themes.rs +++ b/Backend/tests/tauri_commands/themes.rs @@ -3,10 +3,17 @@ use std::collections::HashSet; +use crate::state::AppState; use crate::themes::ThemeSource; +use crate::settings; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; use super::{normalize_plugin_id, theme_allowed_for_enabled_plugins}; +// ── Pure function tests (existing) ── + #[test] fn normalizes_plugin_ids_for_theme_filtering() { assert_eq!(normalize_plugin_id(Some(" OpenVCS.Git ")), "openvcs.git"); @@ -41,3 +48,62 @@ fn only_allows_plugin_themes_from_enabled_plugins() { &enabled, )); } + +// ── Tauri command integration tests ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::list_themes, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn list_themes_returns_themes() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_themes", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_themes should succeed: {:?}", res); + let themes: Vec = res.unwrap().deserialize().unwrap(); + // Should return at least built-in themes + assert!(!themes.is_empty(), "should have at least built-in themes"); + // All returned themes should be non-plugin (built-in or user) + for theme in &themes { + assert!( + !matches!(theme.source, ThemeSource::Plugin), + "should not include plugin themes by default" + ); + } +} From b81f6ef835da21e15bc027bda8cee3e60314b43f Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 00:59:22 +0100 Subject: [PATCH 19/25] Added more Rust tests --- .../src/plugin_runtime/node_instance/mod.rs | 14 +- Backend/src/plugin_vcs_backends.rs | 2 +- Backend/src/tauri_commands/ssh.rs | 2 +- Backend/tests/modules/state.rs | 2 +- .../tests/plugin_runtime/node_instance/mod.rs | 2 +- .../tests/plugin_runtime/node_instance/rpc.rs | 11 +- .../tests/plugin_runtime/node_instance/vcs.rs | 2 +- Backend/tests/tauri_commands/branches.rs | 299 ++++++++++++++++++ Backend/tests/tauri_commands/commit.rs | 296 +++++++++++++++++ Backend/tests/tauri_commands/conflicts.rs | 183 +++++++++++ Backend/tests/tauri_commands/general.rs | 57 ++++ Backend/tests/tauri_commands/plugins.rs | 96 ++++++ Backend/tests/tauri_commands/remotes.rs | 115 +++++++ Backend/tests/tauri_commands/repo_files.rs | 89 ++++++ Backend/tests/tauri_commands/settings.rs | 171 ++++++++++ Backend/tests/tauri_commands/ssh.rs | 26 +- Backend/tests/tauri_commands/stash.rs | 205 +++++++++++- Backend/tests/tauri_commands/status.rs | 214 ++++++++++++- .../src/scripts/features/branches.test.ts | 2 +- .../features/repo/interactions.test.ts | 2 +- .../scripts/features/settingsCommit.test.ts | 2 +- 21 files changed, 1768 insertions(+), 24 deletions(-) diff --git a/Backend/src/plugin_runtime/node_instance/mod.rs b/Backend/src/plugin_runtime/node_instance/mod.rs index 0ba38f10..40adc3f7 100644 --- a/Backend/src/plugin_runtime/node_instance/mod.rs +++ b/Backend/src/plugin_runtime/node_instance/mod.rs @@ -32,6 +32,10 @@ use self::rpc::NodeRpcProcess; const DEFAULT_RPC_TIMEOUT_SECS: u64 = 30; const VCS_OPERATION_TIMEOUT_SECS: u64 = 60; +/// Test-only mock RPC handler type. +#[cfg(test)] +type MockRpcHandler = Box Result + Send>; + /// Parsed plugin initialize response payload. #[derive(Debug, Deserialize)] struct InitializeResponse { @@ -51,7 +55,7 @@ pub struct NodePluginRuntimeInstance { event_sink: RwLock>, /// Test-only RPC mock handler injected instead of a real process. #[cfg(test)] - mock_rpc_handler: Mutex Result + Send>>>, + mock_rpc_handler: Mutex>, } impl NodePluginRuntimeInstance { @@ -87,10 +91,7 @@ impl NodePluginRuntimeInstance { /// Installs a mock RPC handler for testing, bypassing the real process. #[cfg(test)] - pub(crate) fn set_mock_handler( - &self, - handler: Box Result + Send>, - ) { + pub(crate) fn set_mock_handler(&self, handler: MockRpcHandler) { *self.mock_rpc_handler.lock() = Some(handler); } @@ -296,8 +297,7 @@ impl NodePluginRuntimeInstance { #[cfg(test)] if let Some(handler) = self.mock_rpc_handler.lock().as_ref() { let result = handler(method, params)?; - return serde_json::from_value(result) - .map_err(|e| format!("mock rpc decode: {e}")); + return serde_json::from_value(result).map_err(|e| format!("mock rpc decode: {e}")); } let timeout = timeout_secs.or_else(|| { diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 683bd355..3bb48095 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -30,7 +30,7 @@ fn cached_backends() -> Option> { .clone() } -fn store_backends(backends: Vec) { +pub(crate) fn store_backends(backends: Vec) { *backend_cache() .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(backends); diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 46860273..01a23d72 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -91,7 +91,7 @@ fn ensure_ssh_dir() -> Result { Ok(dir) } -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] /// Process output captured from SSH-related shell commands. pub struct SshCommandOutput { /// Process exit code, or `-1` when unavailable. diff --git a/Backend/tests/modules/state.rs b/Backend/tests/modules/state.rs index 525ac129..36bb8cfc 100644 --- a/Backend/tests/modules/state.rs +++ b/Backend/tests/modules/state.rs @@ -104,7 +104,7 @@ fn output_log_truncates_at_maximum() { let state = AppState::new_with_config(AppConfig::default()); // Push more than MAX (2000) entries for i in 0..2500 { - state.push_output_log(OutputLogEntry::new(i as i64, OutputLevel::Info, "core", &i.to_string())); + state.push_output_log(OutputLogEntry::new(i as i64, OutputLevel::Info, "core", i.to_string())); } // Should have trimmed to 2000 assert_eq!(state.output_log().len(), 2000); diff --git a/Backend/tests/plugin_runtime/node_instance/mod.rs b/Backend/tests/plugin_runtime/node_instance/mod.rs index 1761601d..4eb30cda 100644 --- a/Backend/tests/plugin_runtime/node_instance/mod.rs +++ b/Backend/tests/plugin_runtime/node_instance/mod.rs @@ -280,7 +280,7 @@ fn ensure_running_fails_when_no_node() { #[test] fn set_event_sink_stores_and_clears() { - use crate::core::models::{OnEvent, VcsEvent}; + use crate::core::models::OnEvent; let runtime = test_runtime(); let sink: OnEvent = std::sync::Arc::new(|_| {}); runtime.set_event_sink(Some(Arc::clone(&sink))); diff --git a/Backend/tests/plugin_runtime/node_instance/rpc.rs b/Backend/tests/plugin_runtime/node_instance/rpc.rs index 56013e7d..c26408d6 100644 --- a/Backend/tests/plugin_runtime/node_instance/rpc.rs +++ b/Backend/tests/plugin_runtime/node_instance/rpc.rs @@ -179,13 +179,16 @@ mod call_integration { } #[test] - fn call_method_times_out_on_missing_response() { - let (process, _tx) = mock_process(); + fn call_method_errors_via_mock_handler() { + use serde_json::Value; + let rt = test_runtime(); rt.set_session_id(Some("s".into())); - rt.set_process(process); + rt.set_mock_handler(Box::new(|method: &str, _params: Value| -> Result { + Err(format!("{method} mock error")) + })); let err = rt.vcs_list_stashes().unwrap_err(); - assert!(err.contains("timed out") || err.contains("disconnected")); + assert!(err.contains("mock error"), "should propagate mock handler error: {err:?}"); } } diff --git a/Backend/tests/plugin_runtime/node_instance/vcs.rs b/Backend/tests/plugin_runtime/node_instance/vcs.rs index 0c7abeb7..d4766a18 100644 --- a/Backend/tests/plugin_runtime/node_instance/vcs.rs +++ b/Backend/tests/plugin_runtime/node_instance/vcs.rs @@ -312,7 +312,7 @@ fn vcs_list_branches_returns_branches() { let branches = rt.vcs_list_branches().unwrap(); assert_eq!(branches.len(), 2); assert_eq!(branches[0].name, "main"); - assert_eq!(branches[0].current, true); + assert!(branches[0].current); } #[test] diff --git a/Backend/tests/tauri_commands/branches.rs b/Backend/tests/tauri_commands/branches.rs index 82ee5bd9..66e112ea 100644 --- a/Backend/tests/tauri_commands/branches.rs +++ b/Backend/tests/tauri_commands/branches.rs @@ -1,7 +1,22 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use super::{apply_merge_template, repo_name_from_origin, repo_username_from_origin}; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::ipc::InvokeResponseBody; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] fn parses_repo_owner_and_name_from_urls() { @@ -44,3 +59,287 @@ fn leaves_unknown_merge_placeholders_untouched() { ); assert_eq!(rendered, "Merge feature into {repo:unknown}"); } + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + current_branch: Option, + branches: Vec, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + current_branch: Some("main".into()), + branches: vec![ + models::BranchItem { + name: "main".into(), + full_ref: "refs/heads/main".into(), + kind: models::BranchKind::Local, + current: true, + }, + models::BranchItem { + name: "develop".into(), + full_ref: "refs/heads/develop".into(), + kind: models::BranchKind::Local, + current: false, + }, + ], + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { + Ok(self.current_branch.clone()) + } + + fn branches(&self) -> Result, VcsError> { + Ok(self.branches.clone()) + } + + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_branches_app() -> (tauri::App, Arc) { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_list_branches, + super::vcs_head_status, + super::vcs_current_branch, + super::get_repo_summary, + super::vcs_checkout_branch, + super::vcs_delete_branch, + super::vcs_create_branch, + super::vcs_rename_branch, + super::vcs_merge_context, + super::vcs_merge_abort, + super::vcs_merge_continue, + super::vcs_set_upstream, + super::vcs_merge_branch, + ]) + .build(mock_context(noop_assets())) + .expect("build branches test app"); + + (app, vcs) +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_list_branches_returns_branch_list() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_list_branches", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "vcs_list_branches should succeed: {:?}", res); + let branches: Vec = res.unwrap().deserialize().unwrap(); + assert!(!branches.is_empty(), "should return branches"); + assert_eq!(branches[0]["name"], "main"); +} + +#[test] +fn vcs_head_status_propagates_log_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_head_status", tauri::ipc::InvokeBody::default()); + // TestVcs doesn't implement log_commits, so this returns Unsupported error + assert!(res.is_err(), "vcs_head_status should fail without log_commits: {:?}", res); +} + +#[test] +fn vcs_current_branch_returns_branch_name() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_current_branch", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "vcs_current_branch should succeed: {:?}", res); + let branch: String = res.unwrap().deserialize().unwrap(); + assert_eq!(branch, "main"); +} + +#[test] +fn get_repo_summary_returns_summary() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_repo_summary", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_repo_summary should succeed: {:?}", res); +} + +#[test] +fn vcs_checkout_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "feature/x"})); + let res = invoke_cmd(&wv, "vcs_checkout_branch", body); + assert!(res.is_err(), "checkout should fail (unsupported)"); +} + +#[test] +fn vcs_delete_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "feature/x", "force": false})); + let res = invoke_cmd(&wv, "vcs_delete_branch", body); + assert!(res.is_err(), "delete should fail (unsupported)"); +} + +#[test] +fn vcs_create_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "new-branch", "base": "main"})); + let res = invoke_cmd(&wv, "vcs_create_branch", body); + assert!(res.is_err(), "create should fail (unsupported)"); +} + +#[test] +fn vcs_merge_abort_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_merge_abort", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "merge abort should fail (unsupported)"); +} + +#[test] +fn vcs_merge_continue_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_merge_continue", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "merge continue should fail (unsupported)"); +} + +#[test] +fn vcs_merge_context_fails_silently_when_not_in_progress() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_merge_context", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn vcs_set_upstream_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "main", "upstream": "origin/main"})); + let res = invoke_cmd(&wv, "vcs_set_upstream", body); + assert!(res.is_err(), "set upstream should fail (unsupported)"); +} + +#[test] +fn vcs_rename_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "old-name", "new_name": "new-name"})); + let res = invoke_cmd(&wv, "vcs_rename_branch", body); + assert!(res.is_err(), "rename should fail (unsupported)"); +} + +#[test] +fn vcs_merge_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "develop"})); + let res = invoke_cmd(&wv, "vcs_merge_branch", body); + assert!(res.is_err(), "merge should fail (unsupported)"); +} diff --git a/Backend/tests/tauri_commands/commit.rs b/Backend/tests/tauri_commands/commit.rs index 68dd6bfe..67c4ea01 100644 --- a/Backend/tests/tauri_commands/commit.rs +++ b/Backend/tests/tauri_commands/commit.rs @@ -1,7 +1,22 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + use super::{build_commit_message, has_commit_selection, trimmed_non_empty}; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] fn builds_commit_messages_with_optional_descriptions() { @@ -50,3 +65,284 @@ fn returns_the_supplied_error_for_blank_inputs() { "summary required" ); } + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + identity: Mutex>, + commit_result: Mutex>, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + identity: Mutex::new(None), + commit_result: Mutex::new(None), + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { + self.commit_result.lock().unwrap().clone().ok_or_else(|| VcsError::Unsupported(self.id.clone())) + } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { + self.commit_result.lock().unwrap().clone().ok_or_else(|| VcsError::Unsupported(self.id.clone())) + } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { Ok(self.identity.lock().unwrap().clone()) } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } + + fn cherry_pick(&self, _rev: &str) -> Result<(), VcsError> { self.unsupported() } + fn revert_commit(&self, _rev: &str, _no_edit: bool) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_app_with_repo() -> (tauri::App, Arc) { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::commit_changes, + super::commit_selected, + super::commit_patch, + super::commit_patch_and_files, + super::vcs_cherry_pick_to_branch, + super::vcs_revert_commit, + ]) + .build(mock_context(noop_assets())) + .expect("build commit test app"); + + (app, vcs) +} + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::commit_changes, + super::commit_selected, + super::commit_patch, + super::commit_patch_and_files, + super::vcs_cherry_pick_to_branch, + super::vcs_revert_commit, + ]) + .build(mock_context(noop_assets())) + .expect("build commit test app") +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn commit_changes_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": null, + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "commit_changes needs a repo: {:?}", res); +} + +#[test] +fn commit_selected_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test", + "description": null, + "files": [], + "patch": "", + })); + let res = invoke_cmd(&wv, "commit_selected", body); + assert!(res.is_err(), "commit_selected needs a repo: {:?}", res); +} + +#[test] +fn commit_patch_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test", + "description": null, + "patch": "@@ -1 +1 @@\n-old\n+new\n", + })); + let res = invoke_cmd(&wv, "commit_patch", body); + assert!(res.is_err(), "commit_patch needs a repo: {:?}", res); +} + +#[test] +fn commit_patch_and_files_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test", + "description": null, + "patch": "", + "files": [], + })); + let res = invoke_cmd(&wv, "commit_patch_and_files", body); + assert!(res.is_err(), "commit_patch_and_files needs a repo: {:?}", res); +} + +#[test] +fn vcs_cherry_pick_to_branch_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "revision": "abc123", + "target_branch": "main", + })); + let res = invoke_cmd(&wv, "vcs_cherry_pick_to_branch", body); + assert!(res.is_err(), "cherry_pick needs a repo: {:?}", res); +} + +#[test] +fn vcs_revert_commit_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "revision": "abc123", + })); + let res = invoke_cmd(&wv, "vcs_revert_commit", body); + assert!(res.is_err(), "revert needs a repo: {:?}", res); +} + +#[test] +fn commit_changes_fails_with_empty_summary() { + register_test_backend("test-vcs"); + let (app, _vcs) = build_app_with_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": " ", + "description": null, + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "empty summary should fail: {:?}", res); +} + +#[test] +fn commit_changes_fails_without_identity() { + register_test_backend("test-vcs"); + let (app, _vcs) = build_app_with_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": null, + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "no identity should fail: {:?}", res); +} + +#[test] +fn commit_changes_fails_with_unsupported_commit() { + register_test_backend("test-vcs"); + let (app, vcs) = build_app_with_repo(); + *vcs.identity.lock().unwrap() = Some(("Test User".into(), "test@example.com".into())); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": "body text", + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "unsupported commit should fail: {:?}", res); +} + +#[test] +fn commit_changes_succeeds_with_valid_input() { + register_test_backend("test-vcs"); + let (app, vcs) = build_app_with_repo(); + *vcs.identity.lock().unwrap() = Some(("Test User".into(), "test@example.com".into())); + *vcs.commit_result.lock().unwrap() = Some("abc123def".into()); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": "", + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_ok(), "commit_changes should succeed: {:?}", res); + let commit_id: String = res.unwrap().deserialize().unwrap(); + assert_eq!(commit_id, "abc123def"); +} diff --git a/Backend/tests/tauri_commands/conflicts.rs b/Backend/tests/tauri_commands/conflicts.rs index ecb5408e..e88c0c06 100644 --- a/Backend/tests/tauri_commands/conflicts.rs +++ b/Backend/tests/tauri_commands/conflicts.rs @@ -1,8 +1,23 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use super::tool_args; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; use crate::settings::ExternalTool; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] fn splits_tool_path_and_arguments() { @@ -29,3 +44,171 @@ fn returns_empty_arguments_when_tool_has_no_args() { assert_eq!(path, "meld"); assert!(args.is_empty()); } + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_conflicts_app() -> (tauri::App, Arc) { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_conflict_details, + super::vcs_resolve_conflict_side, + super::vcs_save_merge_result, + super::vcs_launch_merge_tool, + ]) + .build(mock_context(noop_assets())) + .expect("build conflicts test app"); + + (app, vcs) +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_conflict_details_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs"})); + let res = invoke_cmd(&wv, "vcs_conflict_details", body); + // conflict_details has a default impl returning Unsupported + assert!(res.is_err(), "conflict_details should fail (unsupported): {:?}", res); +} + +#[test] +fn vcs_resolve_conflict_side_invalid_side_fails() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs", "side": "invalid"})); + let res = invoke_cmd(&wv, "vcs_resolve_conflict_side", body); + // The side validation happens before the Vcs call, so this returns side validation error + assert!(res.is_err(), "resolve with invalid side should fail"); +} + +#[test] +fn vcs_resolve_conflict_side_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs", "side": "ours"})); + let res = invoke_cmd(&wv, "vcs_resolve_conflict_side", body); + // checkout_conflict_side has a default impl returning Unsupported + assert!(res.is_err(), "resolve should fail (unsupported): {:?}", res); +} + +#[test] +fn vcs_save_merge_result_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs", "content": "merged content"})); + let res = invoke_cmd(&wv, "vcs_save_merge_result", body); + // write_merge_result has a default impl returning Unsupported + assert!(res.is_err(), "save merge should fail (unsupported): {:?}", res); +} + +#[test] +fn vcs_launch_merge_tool_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs"})); + let res = invoke_cmd(&wv, "vcs_launch_merge_tool", body); + // requires ExternalTool config, likely fails + let _ = res; +} diff --git a/Backend/tests/tauri_commands/general.rs b/Backend/tests/tauri_commands/general.rs index 9b0509dc..cc2b1b67 100644 --- a/Backend/tests/tauri_commands/general.rs +++ b/Backend/tests/tauri_commands/general.rs @@ -63,6 +63,15 @@ fn build_app() -> tauri::App { super::show_licenses, super::list_recent_repos, super::current_repo_path, + super::browse_directory, + super::browse_file, + super::add_repo, + super::clone_repo, + super::open_repo, + super::open_repo_dotfile, + super::open_docs, + super::exit_app, + super::check_for_updates, ]) .build(mock_context(noop_assets())) .expect("build test app") @@ -164,3 +173,51 @@ fn current_repo_path_returns_none_when_no_repo() { let path: Option = res.unwrap().deserialize().unwrap(); assert!(path.is_none(), "repo path should be None when no repo open"); } + +// ── Window command error path tests ── + +#[test] +fn add_repo_fails_without_backend() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "path": "/tmp/test-repo", + "backend_id": null, + })); + let res = invoke_cmd(&wv, "add_repo", body); + assert!(res.is_err(), "add_repo should fail without backend: {:?}", res); +} + +#[test] +fn clone_repo_fails_without_backend() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "url": "https://example.com/repo.git", + "dest": "/tmp/repo", + "backend_id": null, + })); + let res = invoke_cmd(&wv, "clone_repo", body); + assert!(res.is_err(), "clone_repo should fail without backend: {:?}", res); +} + +#[test] +fn open_repo_fails_without_backend() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "path": "/nonexistent/path", + "backend_id": null, + })); + let res = invoke_cmd(&wv, "open_repo", body); + assert!(res.is_err(), "open_repo should fail without backend: {:?}", res); +} + +#[test] +fn open_repo_dotfile_fails_without_repo() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": ".gitignore"})); + let res = invoke_cmd(&wv, "open_repo_dotfile", body); + assert!(res.is_err(), "open_repo_dotfile needs a repo: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/plugins.rs b/Backend/tests/tauri_commands/plugins.rs index d068d9d2..cc873496 100644 --- a/Backend/tests/tauri_commands/plugins.rs +++ b/Backend/tests/tauri_commands/plugins.rs @@ -104,6 +104,14 @@ fn build_app() -> tauri::App { super::list_plugins, super::list_plugin_start_failures, super::load_plugin, + super::list_installed_plugins, + super::sync_configured_plugins, + super::uninstall_plugin, + super::list_plugin_menus, + super::get_plugin_settings, + super::save_plugin_settings, + super::reset_plugin_settings, + super::set_plugin_approval, ]) .build(mock_context(noop_assets())) .expect("build test app") @@ -167,3 +175,91 @@ fn load_plugin_rejects_unknown() { // Unknown plugins should return an error assert!(res.is_err(), "loading unknown plugin should fail"); } + +#[test] +fn invoke_plugin_action_fails_for_unknown() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "plugin_id": "nonexistent", + "action_id": "test", + "payload": {}, + })); + let res = invoke_cmd(&webview, "invoke_plugin_action", body); + assert!(res.is_err(), "invoke_plugin_action for unknown plugin should fail"); +} + +#[test] +fn list_installed_plugins_returns_empty() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_installed_plugins", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn list_plugin_menus_returns_ok() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_plugin_menus", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn sync_configured_plugins_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "sync_configured_plugins", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn uninstall_plugin_fails_for_nonexistent() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"plugin_id": "nonexistent"})); + let res = invoke_cmd(&webview, "uninstall_plugin", body); + let _ = res; +} + +#[test] +fn get_plugin_settings_returns_defaults() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"plugin_id": "test.plugin"})); + let res = invoke_cmd(&webview, "get_plugin_settings", body); + let _ = res; +} + +#[test] +fn save_plugin_settings_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "plugin_id": "test.plugin", + "settings": [], + })); + let res = invoke_cmd(&webview, "save_plugin_settings", body); + let _ = res; +} + +#[test] +fn reset_plugin_settings_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"plugin_id": "test.plugin"})); + let res = invoke_cmd(&webview, "reset_plugin_settings", body); + let _ = res; +} + +#[test] +fn set_plugin_approval_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "plugin_id": "test.plugin", + "approved": true, + })); + let res = invoke_cmd(&webview, "set_plugin_approval", body); + let _ = res; +} diff --git a/Backend/tests/tauri_commands/remotes.rs b/Backend/tests/tauri_commands/remotes.rs index 1f225b97..4a7bfeb9 100644 --- a/Backend/tests/tauri_commands/remotes.rs +++ b/Backend/tests/tauri_commands/remotes.rs @@ -45,3 +45,118 @@ fn detects_fast_forward_only_divergence() { assert!(looks_like_ff_only_divergence("Cannot be fast-forwarded because branches diverged")); assert!(!looks_like_ff_only_divergence("permission denied (publickey)")); } + +// ── IPC command error path tests ── + +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_set_remote_url, + super::vcs_fetch, + super::vcs_fetch_all, + super::vcs_pull, + super::vcs_push, + super::vcs_undo_since_push, + super::vcs_undo_to_commit, + ]) + .build(mock_context(noop_assets())) + .expect("build remotes test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_set_remote_url_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "url": "https://example.com/repo.git", + })); + let res = invoke_cmd(&wv, "vcs_set_remote_url", body); + assert!(res.is_err(), "vcs_set_remote_url needs a repo: {:?}", res); +} + +#[test] +fn vcs_fetch_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "refspec": "", + })); + let res = invoke_cmd(&wv, "vcs_fetch", body); + assert!(res.is_err(), "vcs_fetch needs a repo: {:?}", res); +} + +#[test] +fn vcs_fetch_all_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_fetch_all", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "vcs_fetch_all needs a repo: {:?}", res); +} + +#[test] +fn vcs_pull_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "branch": "main", + })); + let res = invoke_cmd(&wv, "vcs_pull", body); + assert!(res.is_err(), "vcs_pull needs a repo: {:?}", res); +} + +#[test] +fn vcs_push_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "refspec": "main", + })); + let res = invoke_cmd(&wv, "vcs_push", body); + assert!(res.is_err(), "vcs_push needs a repo: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/repo_files.rs b/Backend/tests/tauri_commands/repo_files.rs index a908d26d..6de70773 100644 --- a/Backend/tests/tauri_commands/repo_files.rs +++ b/Backend/tests/tauri_commands/repo_files.rs @@ -68,3 +68,92 @@ fn decodes_lossy_text_bytes() { let decoded = decode_repo_text(&invalid); assert!(!decoded.is_empty(), "should not panic on invalid encoding"); } + +// ── IPC command error path tests ── + +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::read_repo_file_text, + super::open_repo_file, + ]) + .build(mock_context(noop_assets())) + .expect("build repo_files test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn read_repo_file_text_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "README.md"})); + let res = invoke_cmd(&wv, "read_repo_file_text", body); + assert!(res.is_err(), "read_repo_file_text needs a repo: {:?}", res); +} + +#[test] +fn open_repo_file_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "README.md"})); + let res = invoke_cmd(&wv, "open_repo_file", body); + assert!(res.is_err(), "open_repo_file needs a repo: {:?}", res); +} + +#[test] +fn read_repo_file_text_fails_with_empty_path() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": ""})); + let res = invoke_cmd(&wv, "read_repo_file_text", body); + assert!(res.is_err(), "empty path should fail: {:?}", res); +} + +#[test] +fn open_repo_file_fails_with_empty_path() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": ""})); + let res = invoke_cmd(&wv, "open_repo_file", body); + assert!(res.is_err(), "empty path should fail: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/settings.rs b/Backend/tests/tauri_commands/settings.rs index 109afaff..804a6ce5 100644 --- a/Backend/tests/tauri_commands/settings.rs +++ b/Backend/tests/tauri_commands/settings.rs @@ -2,7 +2,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later use super::diff_configs; +use crate::settings; use crate::settings::AppConfig; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] fn reports_changed_sections() { @@ -14,3 +22,166 @@ fn reports_changed_sections() { assert_eq!(diff_configs(&old_cfg, &old_cfg), Vec::::new()); assert_eq!(diff_configs(&old_cfg, &new_cfg), vec!["general".to_string(), "logging".to_string()]); } + +// ── IPC command tests ── + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::repo::Repo; + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + identity: Option<(String, String)>, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { id: BackendId::from(id), workdir, identity: Some(("User".into(), "user@test.com".into())) } + } + fn unsupported(&self) -> Result { Err(VcsError::Unsupported(self.id.clone())) } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _n: &str, _c: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _n: &str, _u: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _r: &str, _e: &str, _o: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _r: &str, _e: &str, _o: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _r: &str, _b: &str, _o: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _m: &str, _n: &str, _e: &str, _p: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _m: &str, _n: &str, _e: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _q: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _p: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _r: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _p: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _p: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _p: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _p: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _n: &str, _f: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _o: &str, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { Ok(self.identity.clone()) } + fn set_identity_local(&self, _n: &str, _e: &str) -> Result<(), VcsError> { self.unsupported() } +} + + + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::get_global_settings, + super::set_global_settings, + super::get_repo_settings, + super::set_repo_settings, + ]) + .build(mock_context(noop_assets())) + .expect("build settings test app") +} + +fn build_app_with_repo() -> tauri::App { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::get_global_settings, + super::set_global_settings, + super::get_repo_settings, + super::set_repo_settings, + ]) + .build(mock_context(noop_assets())) + .expect("build settings test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn get_global_settings_returns_default_config() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_global_settings", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_global_settings should succeed: {:?}", res); +} + +#[test] +fn set_global_settings_accepts_valid_config_struct() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + // Tauri v2 expects struct arguments wrapped in an array for some parameter shapes + let body = tauri::ipc::InvokeBody::Json(serde_json::json!([{ + "general": { "theme": "dark" }, + }])); + let res = invoke_cmd(&wv, "set_global_settings", body); + // May succeed or fail based on Tauri deserialization; just verify no crash + let _ = res; +} + +#[test] +fn get_repo_settings_returns_defaults_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_repo_settings", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_repo_settings should succeed even without repo: {:?}", res); +} + +#[test] +fn get_repo_settings_returns_defaults_with_repo() { + let app = build_app_with_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_repo_settings", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_repo_settings should succeed with repo: {:?}", res); +} + +#[test] +fn set_repo_settings_accepts_valid_config() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!([{}])); + let res = invoke_cmd(&wv, "set_repo_settings", body); + let _ = res; +} diff --git a/Backend/tests/tauri_commands/ssh.rs b/Backend/tests/tauri_commands/ssh.rs index b330b66d..abc04bd2 100644 --- a/Backend/tests/tauri_commands/ssh.rs +++ b/Backend/tests/tauri_commands/ssh.rs @@ -3,7 +3,7 @@ use super::{ clear_test_home_dir, is_executable, known_hosts_path, resolve_command, resolve_ssh_askpass, - set_test_home_dir, ssh_dir_path, ssh_key_candidates_in_dir, + set_test_home_dir, ssh_add_key, ssh_dir_path, ssh_key_candidates_in_dir, ssh_trust_host, }; use std::env; use std::ffi::OsString; @@ -131,3 +131,27 @@ fn lists_private_key_candidates_from_ssh_dir() { let names: Vec<_> = keys.into_iter().map(|k| k.name).collect(); assert_eq!(names, vec!["custom.key", "id_ed25519", "id_rsa"]); } + +#[test] +fn ssh_trust_host_rejects_empty_host() { + let err = ssh_trust_host("".into()); + assert_eq!(err, Err("Host cannot be empty".to_string())); +} + +#[test] +fn ssh_trust_host_rejects_whitespace_host() { + let err = ssh_trust_host(" ".into()); + assert_eq!(err, Err("Host cannot be empty".to_string())); +} + +#[test] +fn ssh_add_key_rejects_empty_path() { + let err = ssh_add_key("".into()); + assert_eq!(err, Err("Path cannot be empty".to_string())); +} + +#[test] +fn ssh_add_key_rejects_whitespace_path() { + let err = ssh_add_key(" ".into()); + assert_eq!(err, Err("Path cannot be empty".to_string())); +} diff --git a/Backend/tests/tauri_commands/stash.rs b/Backend/tests/tauri_commands/stash.rs index 328b319e..d45e8689 100644 --- a/Backend/tests/tauri_commands/stash.rs +++ b/Backend/tests/tauri_commands/stash.rs @@ -1,12 +1,25 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use std::path::PathBuf; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use super::{ include_untracked_or_default, stash_message_or_default, stash_paths, stash_selector_or_default, }; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] fn defaults_stash_message_and_include_untracked() { @@ -30,3 +43,193 @@ fn defaults_missing_selectors_to_empty_strings() { assert_eq!(stash_selector_or_default(None), ""); assert_eq!(stash_selector_or_default(Some("stash@{1}".into())), "stash@{1}"); } + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + stash_items: Vec, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + stash_items: vec![ + models::StashItem { + selector: "stash@{0}".into(), + msg: "WIP on main".into(), + meta: "2026-01-15".into(), + }, + ], + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } + + fn stash_list(&self) -> Result, VcsError> { + Ok(self.stash_items.clone()) + } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_stash_app() -> (tauri::App, Arc) { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_stash_list, + super::vcs_stash_push, + super::vcs_stash_apply, + super::vcs_stash_pop, + super::vcs_stash_drop, + super::vcs_stash_show, + ]) + .build(mock_context(noop_assets())) + .expect("build stash test app"); + + (app, vcs) +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_stash_list_returns_entries() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_stash_list", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "vcs_stash_list should succeed: {:?}", res); + let items: Vec = res.unwrap().deserialize().unwrap(); + assert!(!items.is_empty(), "should return stash entries"); + assert_eq!(items[0]["selector"], "stash@{0}"); +} + +#[test] +fn vcs_stash_push_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"message": "wip", "include_untracked": true})); + let res = invoke_cmd(&wv, "vcs_stash_push", body); + assert!(res.is_err(), "stash push should fail (unsupported)"); +} + +#[test] +fn vcs_stash_apply_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_apply", body); + assert!(res.is_err(), "stash apply should fail (unsupported)"); +} + +#[test] +fn vcs_stash_pop_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_pop", body); + assert!(res.is_err(), "stash pop should fail (unsupported)"); +} + +#[test] +fn vcs_stash_drop_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_drop", body); + assert!(res.is_err(), "stash drop should fail (unsupported)"); +} + +#[test] +fn vcs_stash_show_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_show", body); + assert!(res.is_err(), "stash show should fail (unsupported)"); +} diff --git a/Backend/tests/tauri_commands/status.rs b/Backend/tests/tauri_commands/status.rs index d0e84b7c..6b85caba 100644 --- a/Backend/tests/tauri_commands/status.rs +++ b/Backend/tests/tauri_commands/status.rs @@ -1,22 +1,34 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use super::normalize_log_limit; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] -/// Verifies the default history limit remains 100 commits. fn normalize_log_limit_defaults_to_100() { assert_eq!(normalize_log_limit(None), Some(100)); } #[test] -/// Verifies a zero limit requests the full history. fn normalize_log_limit_treats_zero_as_unlimited() { assert_eq!(normalize_log_limit(Some(0)), None); } #[test] -/// Verifies large limits are clamped to the backend cap. fn normalize_log_limit_clamps_large_values() { assert_eq!(normalize_log_limit(Some(2_000)), Some(1_000)); } @@ -26,3 +38,199 @@ fn normalize_log_limit_preserves_in_range_values() { assert_eq!(normalize_log_limit(Some(25)), Some(25)); assert_eq!(normalize_log_limit(Some(1_000)), Some(1_000)); } + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + log_commits: Vec, + diff_lines: Vec, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + log_commits: vec![ + models::CommitItem { + id: "abc123".into(), + msg: "initial commit".into(), + meta: "2026-01-01".into(), + author: "test".into(), + }, + ], + diff_lines: vec!["@@ -1,3 +1,4 @@".into(), " line".into()], + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { + Ok(self.log_commits.clone()) + } + + fn diff_file(&self, _path: &Path) -> Result, VcsError> { + Ok(self.diff_lines.clone()) + } + + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { + Ok(self.diff_lines.clone()) + } + + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_status_app() -> (tauri::App, Arc) { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_log, + super::vcs_diff_file, + super::vcs_diff_commit, + super::vcs_discard_paths, + super::vcs_discard_patch, + super::vcs_status, + ]) + .build(mock_context(noop_assets())) + .expect("build status test app"); + + (app, vcs) +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_status_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_status", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "vcs_status should fail: status_payload returns Unsupported"); +} + +#[test] +fn vcs_log_returns_commits() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"query": {"rev": "HEAD", "limit": 10}})); + let res = invoke_cmd(&wv, "vcs_log", body); + assert!(res.is_ok(), "vcs_log should succeed: {:?}", res); +} + +#[test] +fn vcs_diff_file_returns_diff() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs"})); + let res = invoke_cmd(&wv, "vcs_diff_file", body); + assert!(res.is_ok(), "vcs_diff_file should succeed: {:?}", res); +} + +#[test] +fn vcs_diff_commit_returns_diff() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"id": "abc123"})); + let res = invoke_cmd(&wv, "vcs_diff_commit", body); + assert!(res.is_ok(), "vcs_diff_commit should succeed: {:?}", res); +} + +#[test] +fn vcs_discard_paths_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"paths": ["src/main.rs"]})); + let res = invoke_cmd(&wv, "vcs_discard_paths", body); + assert!(res.is_err(), "discard_paths should fail (unsupported)"); +} + +#[test] +fn vcs_discard_patch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"patch": "@@ -1 +1 @@\n-old\n+new\n"})); + let res = invoke_cmd(&wv, "vcs_discard_patch", body); + assert!(res.is_err(), "discard_patch should fail (unsupported)"); +} diff --git a/Frontend/src/scripts/features/branches.test.ts b/Frontend/src/scripts/features/branches.test.ts index 274c34b5..7286ff58 100644 --- a/Frontend/src/scripts/features/branches.test.ts +++ b/Frontend/src/scripts/features/branches.test.ts @@ -331,7 +331,7 @@ describe('bindBranchUI', () => { vi.advanceTimersByTime(130); expect(document.getElementById('branch-pop')!.hidden).toBe(true); - expect(document.getElementById('branch-filter')!.value).toBe(''); + expect((document.getElementById('branch-filter') as HTMLInputElement).value).toBe(''); }); it('renderBranches handles empty branchList', async () => { diff --git a/Frontend/src/scripts/features/repo/interactions.test.ts b/Frontend/src/scripts/features/repo/interactions.test.ts index 8dc759f2..9af2b4a8 100644 --- a/Frontend/src/scripts/features/repo/interactions.test.ts +++ b/Frontend/src/scripts/features/repo/interactions.test.ts @@ -216,7 +216,7 @@ describe('onFileClick', () => { state.files = []; state.selectedFiles = new Set(); - const listEl = (await import('./context')).listEl; + const listEl = (await import('./context')).listEl!; const li = document.createElement('li'); li.className = 'row'; li.dataset.path = 'a.txt'; diff --git a/Frontend/src/scripts/features/settingsCommit.test.ts b/Frontend/src/scripts/features/settingsCommit.test.ts index e3464bf6..9fcd8426 100644 --- a/Frontend/src/scripts/features/settingsCommit.test.ts +++ b/Frontend/src/scripts/features/settingsCommit.test.ts @@ -109,7 +109,7 @@ describe('loadCommitSettingsIntoForm', () => { it('collectCommitSettings handles missing elements', () => { document.body.innerHTML = '
              '; const root = document.body.firstElementChild as HTMLElement; - const result = collectCommitSettings(root); + const result = collectCommitSettings(root)!; expect(result.commit_message_template_enabled).toBe(false); expect(result.restrict_commit_summary).toBe(false); }); From 42dde642e6bed796a1e6d8a5d00157055169bf05 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 01:27:12 +0100 Subject: [PATCH 20/25] Fixed test issues --- Backend/src/app_identity.rs | 112 +++++++++++++++++++++- Backend/src/plugin_vcs_backends.rs | 19 ++++ Backend/src/settings/persistence.rs | 2 + Backend/src/state.rs | 2 + Backend/tests/modules/app_identity.rs | 27 +++++- Backend/tests/modules/state.rs | 36 +++++++ Backend/tests/tauri_commands/backends.rs | 1 + Backend/tests/tauri_commands/branches.rs | 1 + Backend/tests/tauri_commands/commit.rs | 2 + Backend/tests/tauri_commands/conflicts.rs | 1 + Backend/tests/tauri_commands/plugins.rs | 1 + Backend/tests/tauri_commands/settings.rs | 30 ++++++ Backend/tests/tauri_commands/stash.rs | 1 + Backend/tests/tauri_commands/status.rs | 1 + 14 files changed, 230 insertions(+), 6 deletions(-) diff --git a/Backend/src/app_identity.rs b/Backend/src/app_identity.rs index 7c29dda1..2ad0662d 100644 --- a/Backend/src/app_identity.rs +++ b/Backend/src/app_identity.rs @@ -4,6 +4,99 @@ //! Channel-aware desktop identity and persistence paths. use directories::ProjectDirs; +use std::path::{Path, PathBuf}; + +/// Holds the resolved project directory paths used by OpenVCS. +/// +/// This avoids depending on the `directories::ProjectDirs` type in public +/// signatures, allowing test code to inject temporary paths for isolation. +#[derive(Clone, Debug)] +pub struct AppDirs { + config_dir: PathBuf, + data_dir: PathBuf, +} + +impl AppDirs { + /// Returns the configuration directory path. + /// + /// # Returns + /// - The config directory path. + pub fn config_dir(&self) -> &Path { + &self.config_dir + } + + /// Returns the application data directory path. + /// + /// # Returns + /// - The data directory path. + pub fn data_dir(&self) -> &Path { + &self.data_dir + } +} + +#[cfg(test)] +impl AppDirs { + pub fn new(config_dir: PathBuf, data_dir: PathBuf) -> Self { + Self { + config_dir, + data_dir, + } + } +} + +// Thread-local per-test overrides; parallel tests do not conflict. +#[cfg(test)] +std::thread_local! { + static TEST_APP_DIRS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Sets the test override for project directories. +/// +/// # Parameters +/// - `dirs`: The `AppDirs` to return from [`project_dirs()`] in test builds. +#[cfg(test)] +pub(crate) fn set_test_app_dirs(dirs: AppDirs) { + TEST_APP_DIRS.with(|tls| *tls.borrow_mut() = Some(dirs)); +} + +/// Clears the test override for project directories. +/// +/// After calling this, [`project_dirs()`] returns real paths again. +#[cfg(test)] +pub(crate) fn clear_test_app_dirs() { + TEST_APP_DIRS.with(|tls| *tls.borrow_mut() = None); +} + +/// Guards against accidental test writes to real config/recents. +/// +/// Called from `AppConfig::save()` and `save_recents_to_disk()` in +/// test builds. Panics with actionable guidance when no test override +/// is active so new tests cannot silently corrupt user data. +#[cfg(test)] +pub(crate) fn assert_test_isolation() { + let has_override = TEST_APP_DIRS.with(|tls| tls.borrow().is_some()); + assert!( + has_override, + "test must use AppDirsGuard before writing to config/recents paths;\n\ + add `let _guard = AppDirsGuard::new();` at the start of this test" + ); +} + +/// Installs temp directories as the app dirs override (leaked intentionally). +/// +/// Call at the top of any `build_app*` helper whose `AppState` may +/// eventually trigger `set_config()` or `set_current_repo()`. +#[cfg(test)] +pub(crate) fn setup_test_isolation() { + let dir = tempfile::tempdir().expect("temp dir for test isolation"); + let cfg_dir = dir.path().join("config"); + let data_dir = dir.path().join("data"); + // Drop dir immediately so no temp dir leaks. save() and + // save_recents_to_disk() both call create_dir_all before writing, + // so they recreate the paths on first use. + drop(dir); + set_test_app_dirs(AppDirs::new(cfg_dir, data_dir)); +} /// Returns the filesystem app name used for persistence. /// @@ -21,11 +114,22 @@ pub fn persistence_name() -> &'static str { /// All desktop channels preserve the legacy `OpenVCS` application name so /// existing users keep the same config and data roots. /// +/// In test builds, returns the injected test paths when +/// [`set_test_app_dirs`] has been called. +/// /// # Returns -/// - `Some(ProjectDirs)` when the platform exposes standard app directories. -/// - `None` when no platform-specific directories are available. -pub fn project_dirs() -> Option { - ProjectDirs::from("dev", "OpenVCS", persistence_name()) +/// - `Some(AppDirs)` when the platform exposes standard app directories (or a test override is set). +/// - `None` when no platform-specific directories are available and no override is configured. +pub fn project_dirs() -> Option { + #[cfg(test)] + if let Some(dirs) = TEST_APP_DIRS.with(|tls| tls.borrow().clone()) { + return Some(dirs); + } + + ProjectDirs::from("dev", "OpenVCS", persistence_name()).map(|pd| AppDirs { + config_dir: pd.config_dir().to_path_buf(), + data_dir: pd.data_dir().to_path_buf(), + }) } #[cfg(test)] diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 3bb48095..a0790f2d 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -23,7 +23,20 @@ fn backend_cache() -> &'static RwLock>> { BACKEND_CACHE.get_or_init(|| RwLock::new(None)) } +// Thread-local per-test override so parallel tests do not +// contend over the shared BACKEND_CACHE global. +#[cfg(test)] +std::thread_local! { + static TEST_BACKEND_CACHE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + fn cached_backends() -> Option> { + #[cfg(test)] + if let Some(cached) = TEST_BACKEND_CACHE.with(|tls| tls.borrow().clone()) { + return Some(cached); + } + backend_cache() .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -31,6 +44,9 @@ fn cached_backends() -> Option> { } pub(crate) fn store_backends(backends: Vec) { + #[cfg(test)] + TEST_BACKEND_CACHE.with(|tls| *tls.borrow_mut() = Some(backends.clone())); + *backend_cache() .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(backends); @@ -38,6 +54,9 @@ pub(crate) fn store_backends(backends: Vec) { /// Clears cached VCS backend discovery results. pub fn invalidate_plugin_vcs_backend_cache() { + #[cfg(test)] + TEST_BACKEND_CACHE.with(|tls| *tls.borrow_mut() = None); + *backend_cache() .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; diff --git a/Backend/src/settings/persistence.rs b/Backend/src/settings/persistence.rs index 7c7f069d..50edd69f 100644 --- a/Backend/src/settings/persistence.rs +++ b/Backend/src/settings/persistence.rs @@ -42,6 +42,8 @@ impl AppConfig { /// - `Ok(())` when the config file was written successfully. /// - `Err(io::Error)` when writing or renaming fails. pub fn save(&self) -> io::Result<()> { + #[cfg(test)] + crate::app_identity::assert_test_isolation(); let p = Self::path(); if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 9e53acb9..f1de1db0 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -290,6 +290,8 @@ fn load_recents_from_disk() -> Result, String> { /// - `Ok(())` on success. /// - `Err(String)` on serialization/write failures. fn save_recents_to_disk(list: &[PathBuf]) -> Result<(), String> { + #[cfg(test)] + crate::app_identity::assert_test_isolation(); let p = recents_file_path(); if let Some(parent) = p.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; diff --git a/Backend/tests/modules/app_identity.rs b/Backend/tests/modules/app_identity.rs index d50cf544..76692275 100644 --- a/Backend/tests/modules/app_identity.rs +++ b/Backend/tests/modules/app_identity.rs @@ -1,10 +1,33 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::persistence_name; +use super::{clear_test_app_dirs, persistence_name, project_dirs, set_test_app_dirs, AppDirs}; #[test] -/// Verifies the app identity keeps legacy persistence name. fn exposes_persistence_names() { assert_eq!(persistence_name(), "OpenVCS"); } + +#[test] +fn test_app_dirs_override_redirects_paths() { + let real_dirs = project_dirs().expect("real project dirs"); + + let dir = tempfile::tempdir().expect("temp dir for testing override"); + let temp_config = dir.path().join("config"); + let temp_data = dir.path().join("data"); + std::fs::create_dir_all(&temp_config).expect("create temp config dir"); + std::fs::create_dir_all(&temp_data).expect("create temp data dir"); + + let override_dirs = AppDirs::new(temp_config.clone(), temp_data.clone()); + set_test_app_dirs(override_dirs); + + let during = project_dirs().expect("project dirs during override"); + assert_eq!(during.config_dir(), temp_config); + assert_eq!(during.data_dir(), temp_data); + + clear_test_app_dirs(); + + let after = project_dirs().expect("project dirs after clear"); + assert_eq!(after.config_dir(), real_dirs.config_dir()); + assert_eq!(after.data_dir(), real_dirs.data_dir()); +} diff --git a/Backend/tests/modules/state.rs b/Backend/tests/modules/state.rs index 36bb8cfc..9615fe49 100644 --- a/Backend/tests/modules/state.rs +++ b/Backend/tests/modules/state.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use crate::app_identity::{AppDirs, clear_test_app_dirs, set_test_app_dirs}; use crate::core::{BackendId, Result as VcsResult, Vcs}; use crate::core::models::{BranchItem, CommitItem, LogQuery, OnEvent, StatusPayload}; use crate::output_log::{OutputLevel, OutputLogEntry}; @@ -12,6 +13,35 @@ use crate::repo_settings::RepoConfig; use crate::settings::AppConfig; use crate::state::AppState; +// ── Test isolation guard ─────────────────────────────────────────────────── + +/// RAII guard that redirects config/data/cache dirs to a temporary +/// directory for the duration of a test, then restores real paths on drop. +/// +/// This prevents tests from writing to or reading from the user's real +/// OpenVCS config, recents, and plugin directories. +struct AppDirsGuard { + _dir: tempfile::TempDir, +} + +impl AppDirsGuard { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir for test isolation"); + let cfg_dir = dir.path().join("config"); + let data_dir = dir.path().join("data"); + std::fs::create_dir_all(&cfg_dir).expect("create cfg dir"); + std::fs::create_dir_all(&data_dir).expect("create data dir"); + set_test_app_dirs(AppDirs::new(cfg_dir, data_dir)); + Self { _dir: dir } + } +} + +impl Drop for AppDirsGuard { + fn drop(&mut self) { + clear_test_app_dirs(); + } +} + // ── Dummy Vcs impl for testing ───────────────────────────────────────────── fn dummy_repo(path: &Path) -> Arc { @@ -63,6 +93,7 @@ fn constructs_state_from_config() { #[test] fn set_config_updates_snapshot() { + let _guard = AppDirsGuard::new(); let state = AppState::new_with_config(AppConfig::default()); let mut cfg = AppConfig::default(); cfg.general.default_backend = "hg".into(); @@ -120,6 +151,7 @@ fn current_repo_starts_empty() { #[test] fn set_current_repo_stores_and_retrieves_repo() { + let _guard = AppDirsGuard::new(); let state = AppState::new_with_config(AppConfig::default()); let repo = dummy_repo(Path::new("/tmp/test-repo")); state.set_current_repo(repo.clone()); @@ -129,6 +161,7 @@ fn set_current_repo_stores_and_retrieves_repo() { #[test] fn clear_current_repo_removes_active_repo() { + let _guard = AppDirsGuard::new(); let state = AppState::new_with_config(AppConfig::default()); let repo = dummy_repo(Path::new("/tmp/test-repo")); state.set_current_repo(repo); @@ -140,6 +173,7 @@ fn clear_current_repo_removes_active_repo() { #[test] fn set_current_repo_affects_recents() { + let _guard = AppDirsGuard::new(); let state = AppState::new_with_config(AppConfig::default()); let dir = tempfile::tempdir().expect("temp dir"); let repo_path = dir.path().join("my-repo"); @@ -155,6 +189,7 @@ fn set_current_repo_affects_recents() { #[test] fn set_current_repo_places_new_path_at_front() { + let _guard = AppDirsGuard::new(); let state = AppState::new_with_config(AppConfig::default()); let dir = tempfile::tempdir().expect("temp dir"); let repo_path = dir.path().join("front-repo"); @@ -169,6 +204,7 @@ fn set_current_repo_places_new_path_at_front() { #[test] fn recents_contains_paths_after_setting_current_repo() { + let _guard = AppDirsGuard::new(); let state = AppState::new_with_config(AppConfig::default()); let dir = tempfile::tempdir().expect("temp dir"); let repo_path = dir.path().join("test-repo"); diff --git a/Backend/tests/tauri_commands/backends.rs b/Backend/tests/tauri_commands/backends.rs index 1524839b..d434cd5e 100644 --- a/Backend/tests/tauri_commands/backends.rs +++ b/Backend/tests/tauri_commands/backends.rs @@ -52,6 +52,7 @@ fn skips_auto_selection_when_backend_is_already_default_or_not_unique() { // ── Tauri IPC integration tests ── fn build_app() -> tauri::App { + crate::app_identity::setup_test_isolation(); let cfg = settings::AppConfig::default(); let app_state = AppState::new_with_config(cfg); mock_builder() diff --git a/Backend/tests/tauri_commands/branches.rs b/Backend/tests/tauri_commands/branches.rs index 66e112ea..cf6eb63a 100644 --- a/Backend/tests/tauri_commands/branches.rs +++ b/Backend/tests/tauri_commands/branches.rs @@ -146,6 +146,7 @@ fn register_test_backend(backend_id: &str) { } fn build_vcs_branches_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); let repo = Arc::new(Repo::new(vcs.clone() as Arc)); let cfg = settings::AppConfig::default(); diff --git a/Backend/tests/tauri_commands/commit.rs b/Backend/tests/tauri_commands/commit.rs index 67c4ea01..0fa2fb3d 100644 --- a/Backend/tests/tauri_commands/commit.rs +++ b/Backend/tests/tauri_commands/commit.rs @@ -140,6 +140,7 @@ fn register_test_backend(backend_id: &str) { } fn build_app_with_repo() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); let repo = Arc::new(Repo::new(vcs.clone() as Arc)); let cfg = settings::AppConfig::default(); @@ -163,6 +164,7 @@ fn build_app_with_repo() -> (tauri::App, Arc) } fn build_app_no_repo() -> tauri::App { + crate::app_identity::setup_test_isolation(); let cfg = settings::AppConfig::default(); let app_state = AppState::new_with_config(cfg); mock_builder() diff --git a/Backend/tests/tauri_commands/conflicts.rs b/Backend/tests/tauri_commands/conflicts.rs index e88c0c06..58088a23 100644 --- a/Backend/tests/tauri_commands/conflicts.rs +++ b/Backend/tests/tauri_commands/conflicts.rs @@ -108,6 +108,7 @@ fn register_test_backend(backend_id: &str) { } fn build_vcs_conflicts_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); let repo = Arc::new(Repo::new(vcs.clone() as Arc)); let cfg = settings::AppConfig::default(); diff --git a/Backend/tests/tauri_commands/plugins.rs b/Backend/tests/tauri_commands/plugins.rs index cc873496..9fe6c221 100644 --- a/Backend/tests/tauri_commands/plugins.rs +++ b/Backend/tests/tauri_commands/plugins.rs @@ -96,6 +96,7 @@ fn converts_menu_payload() { // ── Tauri command integration tests ── fn build_app() -> tauri::App { + crate::app_identity::setup_test_isolation(); let cfg = settings::AppConfig::default(); let app_state = AppState::new_with_config(cfg); mock_builder() diff --git a/Backend/tests/tauri_commands/settings.rs b/Backend/tests/tauri_commands/settings.rs index 804a6ce5..b51f0331 100644 --- a/Backend/tests/tauri_commands/settings.rs +++ b/Backend/tests/tauri_commands/settings.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later use super::diff_configs; +use crate::app_identity::{AppDirs, clear_test_app_dirs, set_test_app_dirs}; use crate::settings; use crate::settings::AppConfig; use crate::state::AppState; @@ -10,6 +11,30 @@ use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INV use tauri::webview::InvokeRequest; use tauri::WebviewWindowBuilder; +// ── Test isolation guard ─────────────────────────────────────────────────── + +struct AppDirsGuard { + _dir: tempfile::TempDir, +} + +impl AppDirsGuard { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir for test isolation"); + let cfg_dir = dir.path().join("config"); + let data_dir = dir.path().join("data"); + std::fs::create_dir_all(&cfg_dir).expect("create cfg dir"); + std::fs::create_dir_all(&data_dir).expect("create data dir"); + set_test_app_dirs(AppDirs::new(cfg_dir, data_dir)); + Self { _dir: dir } + } +} + +impl Drop for AppDirsGuard { + fn drop(&mut self) { + clear_test_app_dirs(); + } +} + // ── Pure function tests ── #[test] @@ -137,6 +162,7 @@ fn invoke_cmd( #[test] fn get_global_settings_returns_default_config() { + let _guard = AppDirsGuard::new(); let app = build_app_no_repo(); let wv = test_webview(&app); @@ -146,6 +172,7 @@ fn get_global_settings_returns_default_config() { #[test] fn set_global_settings_accepts_valid_config_struct() { + let _guard = AppDirsGuard::new(); let app = build_app_no_repo(); let wv = test_webview(&app); @@ -160,6 +187,7 @@ fn set_global_settings_accepts_valid_config_struct() { #[test] fn get_repo_settings_returns_defaults_without_repo() { + let _guard = AppDirsGuard::new(); let app = build_app_no_repo(); let wv = test_webview(&app); @@ -169,6 +197,7 @@ fn get_repo_settings_returns_defaults_without_repo() { #[test] fn get_repo_settings_returns_defaults_with_repo() { + let _guard = AppDirsGuard::new(); let app = build_app_with_repo(); let wv = test_webview(&app); @@ -178,6 +207,7 @@ fn get_repo_settings_returns_defaults_with_repo() { #[test] fn set_repo_settings_accepts_valid_config() { + let _guard = AppDirsGuard::new(); let app = build_app_no_repo(); let wv = test_webview(&app); diff --git a/Backend/tests/tauri_commands/stash.rs b/Backend/tests/tauri_commands/stash.rs index d45e8689..de7aca22 100644 --- a/Backend/tests/tauri_commands/stash.rs +++ b/Backend/tests/tauri_commands/stash.rs @@ -119,6 +119,7 @@ fn register_test_backend(backend_id: &str) { } fn build_vcs_stash_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); let repo = Arc::new(Repo::new(vcs.clone() as Arc)); let cfg = settings::AppConfig::default(); diff --git a/Backend/tests/tauri_commands/status.rs b/Backend/tests/tauri_commands/status.rs index 6b85caba..ca2c13c6 100644 --- a/Backend/tests/tauri_commands/status.rs +++ b/Backend/tests/tauri_commands/status.rs @@ -123,6 +123,7 @@ fn register_test_backend(backend_id: &str) { } fn build_vcs_status_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); let repo = Arc::new(Repo::new(vcs.clone() as Arc)); let cfg = settings::AppConfig::default(); From a52ec6b07ba8ce950cf6123fd43641389144f5b7 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 10:23:58 +0100 Subject: [PATCH 21/25] Fix issues --- Backend/src/plugin_runtime/host_api.rs | 22 ++++++++--- .../src/plugin_runtime/node_instance/vcs.rs | 11 ++++++ Backend/src/plugin_runtime/protocol.rs | 2 +- Backend/src/plugin_runtime/vcs_proxy.rs | 11 ++++-- Backend/tests/plugin_runtime/host_api.rs | 8 +++- .../tests/plugin_runtime/node_instance/mod.rs | 38 ++++++++++++++++--- .../tests/plugin_runtime/node_instance/vcs.rs | 38 ++++++++++++++++++- Backend/tests/plugin_runtime/vcs_proxy.rs | 37 ++++++++++++++++-- 8 files changed, 146 insertions(+), 21 deletions(-) diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index b8114a75..10332286 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -3,16 +3,21 @@ //! Minimal host-side plugin runtime APIs. use parking_lot::RwLock; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; /// Callback type for status text updates from plugins to frontend. -type StatusEventEmitter = Box; +type StatusEventEmitter = Arc; /// Global status emitter callback used by backend->frontend bridge. -static STATUS_EVENT_EMITTER: OnceLock = OnceLock::new(); +static STATUS_EVENT_EMITTER: OnceLock>> = OnceLock::new(); /// Shared in-memory status text for plugin updates. static STATUS_TEXT: OnceLock> = OnceLock::new(); +/// Returns global status emitter storage. +fn status_event_emitter_store() -> &'static RwLock> { + STATUS_EVENT_EMITTER.get_or_init(|| RwLock::new(None)) +} + /// Returns global status storage singleton. fn status_text_store() -> &'static RwLock { STATUS_TEXT.get_or_init(|| RwLock::new(String::new())) @@ -20,7 +25,7 @@ fn status_text_store() -> &'static RwLock { /// Emits a status text event through the configured backend emitter. fn emit_status_event(message: &str) { - if let Some(emitter) = STATUS_EVENT_EMITTER.get() { + if let Some(emitter) = status_event_emitter_store().read().clone() { emitter(message); } } @@ -36,7 +41,14 @@ pub fn set_status_event_emitter(emitter: F) where F: Fn(&str) + Send + Sync + 'static, { - let _ = STATUS_EVENT_EMITTER.set(Box::new(emitter)); + *status_event_emitter_store().write() = Some(Arc::new(emitter)); +} + +/// Resets host API state between tests. +#[cfg(test)] +pub fn reset_host_api_state_for_tests() { + *status_event_emitter_store().write() = None; + status_text_store().write().clear(); } /// Sets status text without permission checks. diff --git a/Backend/src/plugin_runtime/node_instance/vcs.rs b/Backend/src/plugin_runtime/node_instance/vcs.rs index d26f7afc..595f2681 100644 --- a/Backend/src/plugin_runtime/node_instance/vcs.rs +++ b/Backend/src/plugin_runtime/node_instance/vcs.rs @@ -308,14 +308,25 @@ impl NodePluginRuntimeInstance { } /// Calls `vcs.stash-push`. + /// + /// # Parameters + /// - `message`: Optional stash message. + /// - `include_untracked`: Whether untracked files should be included. + /// - `paths`: Optional path filters to include in the stash. + /// + /// # Returns + /// - `Ok(String)` with the created stash selector. + /// - `Err(String)` when the RPC call fails. pub fn vcs_stash_push( &self, message: Option<&str>, include_untracked: bool, + paths: &[String], ) -> Result { let params = self.session_params(json!({ "message": message, "include_untracked": include_untracked, + "paths": paths, }))?; self.rpc_call(Methods::VCS_STASH_PUSH, params) } diff --git a/Backend/src/plugin_runtime/protocol.rs b/Backend/src/plugin_runtime/protocol.rs index d2b1c60e..99b2b8a5 100644 --- a/Backend/src/plugin_runtime/protocol.rs +++ b/Backend/src/plugin_runtime/protocol.rs @@ -131,7 +131,7 @@ impl Methods { pub const VCS_SET_IDENTITY_LOCAL: &'static str = "vcs.set_identity_local"; /// Lists stashes. pub const VCS_LIST_STASHES: &'static str = "vcs.list_stashes"; - /// Pushes stash. + /// Pushes stash with optional message, include-untracked flag, and path filters. pub const VCS_STASH_PUSH: &'static str = "vcs.stash_push"; /// Applies stash. pub const VCS_STASH_APPLY: &'static str = "vcs.stash_apply"; diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 5036886f..98ad4f6f 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -349,16 +349,19 @@ impl Vcs for PluginVcsProxy { &self, message: &str, include_untracked: bool, - _paths: &[PathBuf], + paths: &[PathBuf], ) -> VcsResult<()> { let message = if message.trim().is_empty() { None } else { Some(message) }; - let _ = self - .runtime - .vcs_stash_push(message, include_untracked) + let paths = paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + self.runtime + .vcs_stash_push(message, include_untracked, &paths) .map_err(|e| self.map_runtime_error(e))?; Ok(()) } diff --git a/Backend/tests/plugin_runtime/host_api.rs b/Backend/tests/plugin_runtime/host_api.rs index 8ac0e6d8..c0aa13d9 100644 --- a/Backend/tests/plugin_runtime/host_api.rs +++ b/Backend/tests/plugin_runtime/host_api.rs @@ -1,11 +1,15 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{set_status_event_emitter, set_status_text_unchecked}; +use super::{ + reset_host_api_state_for_tests, set_status_event_emitter, set_status_text_unchecked, +}; use std::sync::{Arc, Mutex}; #[test] fn trims_status_text_and_emits_once() { + reset_host_api_state_for_tests(); + let events = Arc::new(Mutex::new(Vec::::new())); let captured = Arc::clone(&events); @@ -17,4 +21,6 @@ fn trims_status_text_and_emits_once() { set_status_text_unchecked(" "); assert_eq!(events.lock().expect("lock events").as_slice(), &["ready".to_string()]); + + reset_host_api_state_for_tests(); } diff --git a/Backend/tests/plugin_runtime/node_instance/mod.rs b/Backend/tests/plugin_runtime/node_instance/mod.rs index 4eb30cda..9b752116 100644 --- a/Backend/tests/plugin_runtime/node_instance/mod.rs +++ b/Backend/tests/plugin_runtime/node_instance/mod.rs @@ -5,7 +5,7 @@ use super::NodePluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; use serde_json::{json, Value}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; fn test_runtime() -> NodePluginRuntimeInstance { NodePluginRuntimeInstance::new(SpawnConfig { @@ -231,14 +231,42 @@ fn handle_notification_unknown_method() { fn close_vcs_session_calls_rpc_when_session_active() { let runtime = test_runtime(); runtime.set_session_id(Some("session-abc".into())); - mock_response(&runtime); - // This should call rpc_call_unit with VCS_CLOSE and clear session + + let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new())); + let captured = Arc::clone(&calls); + runtime.set_mock_handler(Box::new(move |method, params| { + captured + .lock() + .expect("lock calls") + .push((method.to_string(), params.clone())); + Ok(Value::Null) + })); + + runtime.close_vcs_session(); + + assert!(runtime.vcs_session_id.lock().is_none()); + assert_eq!( + calls.lock().expect("lock calls").as_slice(), + &[("vcs.close".to_string(), json!({"session_id": "session-abc"}))] + ); } #[test] fn close_vcs_session_noop_when_no_session() { - let _runtime = test_runtime(); - // vcs_session_id is None, close_vcs_session should do nothing + let runtime = test_runtime(); + let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new())); + let captured = Arc::clone(&calls); + runtime.set_mock_handler(Box::new(move |method, params| { + captured + .lock() + .expect("lock calls") + .push((method.to_string(), params.clone())); + Ok(Value::Null) + })); + + runtime.close_vcs_session(); + + assert!(calls.lock().expect("lock calls").is_empty()); } // ── stop_process ── diff --git a/Backend/tests/plugin_runtime/node_instance/vcs.rs b/Backend/tests/plugin_runtime/node_instance/vcs.rs index d4766a18..4f9cf9eb 100644 --- a/Backend/tests/plugin_runtime/node_instance/vcs.rs +++ b/Backend/tests/plugin_runtime/node_instance/vcs.rs @@ -5,6 +5,7 @@ use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; use serde_json::{json, Value}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; fn test_runtime() -> NodePluginRuntimeInstance { NodePluginRuntimeInstance::new(SpawnConfig { @@ -440,16 +441,49 @@ fn vcs_stash_push_with_message_returns_selector() { let rt = test_runtime(); *rt.vcs_session_id.lock() = Some("s".into()); mock_response(&rt, json!("stash@{0}")); - let result = rt.vcs_stash_push(Some("WIP"), false).unwrap(); + let result = rt.vcs_stash_push(Some("WIP"), false, &[]).unwrap(); assert_eq!(result, "stash@{0}"); } +#[test] +fn vcs_stash_push_forwards_paths() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + + let captured = Arc::new(Mutex::new(None::)); + let captured_params = Arc::clone(&captured); + rt.set_mock_handler(Box::new(move |method, params| { + assert_eq!(method, "vcs.stash_push"); + *captured_params.lock().expect("lock params") = Some(params.clone()); + Ok(json!("stash@{1}")) + })); + + let result = rt + .vcs_stash_push( + Some("WIP"), + true, + &["src/lib.rs".to_string(), "README.md".to_string()], + ) + .unwrap(); + + assert_eq!(result, "stash@{1}"); + assert_eq!( + captured.lock().expect("lock params").as_ref(), + Some(&json!({ + "session_id": "s", + "message": "WIP", + "include_untracked": true, + "paths": ["src/lib.rs", "README.md"] + })) + ); +} + #[test] fn vcs_stash_push_without_message_returns_selector() { let rt = test_runtime(); *rt.vcs_session_id.lock() = Some("s".into()); mock_response(&rt, json!("stash@{1}")); - let result = rt.vcs_stash_push(None::<&str>, true).unwrap(); + let result = rt.vcs_stash_push(None::<&str>, true, &[]).unwrap(); assert_eq!(result, "stash@{1}"); } diff --git a/Backend/tests/plugin_runtime/vcs_proxy.rs b/Backend/tests/plugin_runtime/vcs_proxy.rs index c7679715..09e4b608 100644 --- a/Backend/tests/plugin_runtime/vcs_proxy.rs +++ b/Backend/tests/plugin_runtime/vcs_proxy.rs @@ -8,7 +8,7 @@ use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; use serde_json::{json, Value}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; fn test_runtime() -> Arc { Arc::new(NodePluginRuntimeInstance::new(SpawnConfig { @@ -455,10 +455,41 @@ fn proxy_stash_push_returns_selector() { let (proxy, rt) = mock_proxy(); rt.set_session_id(Some("s".into())); set_response(&rt, json!("stash@{0}")); + assert_eq!(proxy.stash_push("WIP", true, &[]).unwrap(), ()); +} + +#[test] +fn proxy_stash_push_forwards_paths() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + + let captured = Arc::new(Mutex::new(None::)); + let captured_params = Arc::clone(&captured); + rt.set_mock_handler(Box::new(move |method, params| { + assert_eq!(method, "vcs.stash_push"); + *captured_params.lock().expect("lock params") = Some(params.clone()); + Ok(json!("stash@{1}")) + })); + + proxy + .stash_push( + "WIP", + true, + &[PathBuf::from("src/lib.rs"), PathBuf::from("README.md")], + ) + .unwrap(); + assert_eq!( - proxy.stash_push("WIP", true, &[]).unwrap(), - () + captured.lock().expect("lock params").as_ref(), + Some(&json!({ + "session_id": "s", + "message": "WIP", + "include_untracked": true, + "paths": ["src/lib.rs", "README.md"] + })) ); + + rt.set_session_id(None); } #[test] From d51329acdf1e8a3313286eeb4d00694bc8dcee49 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 10:24:56 +0100 Subject: [PATCH 22/25] Update stash.test.ts --- Frontend/src/scripts/features/repo/stash.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Frontend/src/scripts/features/repo/stash.test.ts b/Frontend/src/scripts/features/repo/stash.test.ts index 6cf95431..18472571 100644 --- a/Frontend/src/scripts/features/repo/stash.test.ts +++ b/Frontend/src/scripts/features/repo/stash.test.ts @@ -130,7 +130,8 @@ describe('renderStashList', () => { const result = stashMod.renderStashList(''); const result2 = stashMod.renderStashList('query'); // Both should still work since DOM elements are cached in mock - (typeof result === 'boolean'); + expect(typeof result).toBe('boolean'); + expect(typeof result2).toBe('boolean'); if (removed) document.body.appendChild(removed); }); From e8197584413602b6c6eeab2fe2a4d6ce7a507d2e Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 10:25:51 +0100 Subject: [PATCH 23/25] Update confirmModal.test.ts --- Frontend/src/scripts/features/confirmModal.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Frontend/src/scripts/features/confirmModal.test.ts b/Frontend/src/scripts/features/confirmModal.test.ts index 3522daa4..a73b2156 100644 --- a/Frontend/src/scripts/features/confirmModal.test.ts +++ b/Frontend/src/scripts/features/confirmModal.test.ts @@ -225,12 +225,8 @@ describe('confirmWithModal', () => { it('closes previous pending promise with false when called again', async () => { const { confirmWithModal } = await import('./confirmModal'); - let prevResolved: boolean | null = null; const first = confirmWithModal({ message: 'First' }); - // Capture the first resolve - const modal = document.getElementById('confirm-modal') as any; - - const second = confirmWithModal({ message: 'Second' }); + confirmWithModal({ message: 'Second' }); const firstResult = await first; expect(firstResult).toBe(false); From 305685c8c115db0dcd531f10f2e14e89f5c06144 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 10:30:50 +0100 Subject: [PATCH 24/25] Fixed issues --- Frontend/src/scripts/features/confirmModal.test.ts | 2 -- Frontend/src/scripts/features/outputLog.test.ts | 2 +- Frontend/src/scripts/features/repo/diffView.test.ts | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Frontend/src/scripts/features/confirmModal.test.ts b/Frontend/src/scripts/features/confirmModal.test.ts index a73b2156..f266a1b6 100644 --- a/Frontend/src/scripts/features/confirmModal.test.ts +++ b/Frontend/src/scripts/features/confirmModal.test.ts @@ -235,10 +235,8 @@ describe('confirmWithModal', () => { describe('cancel button click handler', () => { it('wires cancel click to close modal', async () => { - const modals = await import('../ui/modals'); const { wireConfirmModal } = await import('./confirmModal'); wireConfirmModal(); - const cancelBtn = document.getElementById('confirm-modal-cancel-btn') as HTMLButtonElement; // cancel button has no explicit handler -- it triggers modal:closed via backdrop or data-close // The modal:closed event is what resolves false diff --git a/Frontend/src/scripts/features/outputLog.test.ts b/Frontend/src/scripts/features/outputLog.test.ts index 40cd104d..79ccb9b7 100644 --- a/Frontend/src/scripts/features/outputLog.test.ts +++ b/Frontend/src/scripts/features/outputLog.test.ts @@ -1,7 +1,7 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockInvoke = vi.fn(); const mockNotify = vi.fn(); diff --git a/Frontend/src/scripts/features/repo/diffView.test.ts b/Frontend/src/scripts/features/repo/diffView.test.ts index 2f992e50..8dadeb8b 100644 --- a/Frontend/src/scripts/features/repo/diffView.test.ts +++ b/Frontend/src/scripts/features/repo/diffView.test.ts @@ -321,7 +321,6 @@ describe('selectFile contextmenu', () => { it('attaches contextmenu handler to diffEl for hunk discard', async () => { // Need to ensure invoke returns proper diff lines so the hunk elements render const { selectFile } = await import('./diffView'); - const { state } = await import('../../state/state'); await selectFile({ path: 'a.txt', status: 'M' } as FileStatus, 0); @@ -539,7 +538,6 @@ describe('selectStashDiff', () => { const { selectStashDiff } = await import('./diffView'); await selectStashDiff(''); // Should not call invoke for empty selector - const invokeSpy = (window as any).__TAURI__.core.invoke; // If selector is empty, it still calls invoke with '' as selector // The mock will return [] but it should not crash }); From 7fad55c575aa837a62d4a05a6be99e724b96d9ba Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 30 May 2026 10:35:39 +0100 Subject: [PATCH 25/25] More issues fixed --- Frontend/src/scripts/features/repo/hotkeys.test.ts | 6 ++++-- Frontend/src/scripts/features/repo/list.test.ts | 2 +- Frontend/src/scripts/features/repo/stash.test.ts | 2 +- Frontend/src/scripts/features/repoSettings.test.ts | 4 ---- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Frontend/src/scripts/features/repo/hotkeys.test.ts b/Frontend/src/scripts/features/repo/hotkeys.test.ts index 84892ab5..8d6a8052 100644 --- a/Frontend/src/scripts/features/repo/hotkeys.test.ts +++ b/Frontend/src/scripts/features/repo/hotkeys.test.ts @@ -64,7 +64,9 @@ beforeEach(() => { mockFilterInput.value = ''; // Clean up any modals from previous tests - document.querySelectorAll('.modal').forEach((el) => el.remove()); + document.querySelectorAll('.modal').forEach((el) => { + el.remove(); + }); document.getElementById('about-modal')?.remove(); // Mock document.activeElement @@ -185,7 +187,7 @@ describe('bindRepoHotkeys', () => { }); it('deselects all files on Ctrl+A when all are already selected', async () => { - const { disableDefaultSelectAll, prefs, state } = await import('../../state/state'); + const { prefs, state } = await import('../../state/state'); prefs.tab = 'changes'; state.selectedFiles = new Set(['a.js', 'b.js']); const visibleFiles = [{ path: 'a.js' as string }, { path: 'b.js' as string }]; diff --git a/Frontend/src/scripts/features/repo/list.test.ts b/Frontend/src/scripts/features/repo/list.test.ts index 9338e6c4..bb6ea89e 100644 --- a/Frontend/src/scripts/features/repo/list.test.ts +++ b/Frontend/src/scripts/features/repo/list.test.ts @@ -471,7 +471,7 @@ describe('renderChangesList status marks', () => { it('renders conflict-mark for conflicted files', async () => { const { renderList } = await import('./list'); - const { prefs, state, isConflictStatus } = await import('../../state/state'); + const { prefs, state } = await import('../../state/state'); prefs.tab = 'changes'; state.files = [{ path: 'conflict.txt', status: 'UU' }] as any; state.selectedFiles = new Set(); diff --git a/Frontend/src/scripts/features/repo/stash.test.ts b/Frontend/src/scripts/features/repo/stash.test.ts index 18472571..cb56f34f 100644 --- a/Frontend/src/scripts/features/repo/stash.test.ts +++ b/Frontend/src/scripts/features/repo/stash.test.ts @@ -123,7 +123,7 @@ afterEach(() => { describe('renderStashList', () => { it('returns false when listEl is missing', async () => { const stashMod = await loadStash(); - const { listEl: orig } = await import('./context'); + await import('./context'); // Temporarily remove file-list to trigger early return const removed = document.getElementById('file-list'); if (removed) removed.remove(); diff --git a/Frontend/src/scripts/features/repoSettings.test.ts b/Frontend/src/scripts/features/repoSettings.test.ts index 05af9dd4..7a03bcf8 100644 --- a/Frontend/src/scripts/features/repoSettings.test.ts +++ b/Frontend/src/scripts/features/repoSettings.test.ts @@ -48,10 +48,6 @@ function mountModal(overrides?: { `; } -function getModal(): HTMLElement { - return document.getElementById('repo-settings-modal')!; -} - // --------------------------------------------------------------------------- // Test lifecycle // ---------------------------------------------------------------------------