- GitManager: переработан в GitWorkspacePanel с вкладками - Добавлен GitLifecycleHeader с быстрыми действиями - RepositoryDashboardGrid: поддержка ReviewToggle, badges, env filter - HelpTooltip: универсальный компонент подсказок с тестами - GitManagerModel: доработаны экшены, добавлен isReady, loadDefaultBranch - Локализация en/ru для Git UI - tailwind: добавлен animation-delay-200 - ConfirmDialog: a11y-атрибуты для кнопок
576 lines
34 KiB
TypeScript
576 lines
34 KiB
TypeScript
// frontend/src/lib/models/__tests__/GitManagerModel.test.ts
|
|
// #region GitManagerModelTests [C:3] [TYPE Module] [SEMANTICS test,model,git-manager]
|
|
// @BRIEF L1 unit tests for GitManagerModel — no DOM render, mocks all API + toast + i18n.
|
|
// @RELATION BINDS_TO -> [GitManagerModel]
|
|
// @TEST_INVARIANT: derived-computations -> VERIFIED_BY: [test_hasWorkspaceChanges_true_when_changes, test_changedFilesCount_aggregates_all_categories, test_derived_no_workspaceStatus]
|
|
// @TEST_INVARIANT: loading-state-lifecycle -> VERIFIED_BY: [test_checkStatus_sets_checkingStatus_lifecycle, test_handleCommit_sets_committing_lifecycle]
|
|
// @TEST_INVARIANT: mutual-exclusion -> VERIFIED_BY: [test_mutual_exclusion_defaults_all_false]
|
|
// @TEST_INVARIANT: error-handling -> VERIFIED_BY: [test_handleCommit_sets_gitError_on_api_failure, test_handleCommit_clears_gitError_before_operation]
|
|
// @TEST_INVARIANT: commit-calls-api -> VERIFIED_BY: [test_handleCommit_calls_gitService_commit_with_correct_args, test_handleCommit_calls_push_when_autoPushAfterCommit]
|
|
// @TEST_INVARIANT: promote-calls-api -> VERIFIED_BY: [test_handlePromote_calls_gitService_promote, test_handlePromote_validates_branches]
|
|
// @TEST_INVARIANT: pull-calls-api -> VERIFIED_BY: [test_handlePull_calls_gitService_pull]
|
|
// @TEST_INVARIANT: clearGitError -> VERIFIED_BY: [test_clearGitError_resets_both_fields]
|
|
// @TEST_EDGE: missing_field -> empty commitMessage blocks handleCommit
|
|
// @TEST_EDGE: invalid_type -> numeric dashboard id blocks checkStatus
|
|
// @TEST_EDGE: external_fail -> API throws on handlePull
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// ── Mocks ────────────────────────────────────────────────────────
|
|
vi.mock("../../../services/gitService.js", () => ({
|
|
gitService: { getConfigs: vi.fn(), getBranches: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), commit: vi.fn(), push: vi.fn(), pull: vi.fn(), promote: vi.fn(), sync: vi.fn(), getMergeStatus: vi.fn(), getMergeConflicts: vi.fn(), resolveMergeConflicts: vi.fn(), abortMerge: vi.fn(), continueMerge: vi.fn(), getRepositoryBinding: vi.fn(), initRepository: vi.fn(), createRemoteRepository: vi.fn() },
|
|
}));
|
|
vi.mock("$lib/api.js", () => ({ api: { getEnvironmentsList: vi.fn(), postApi: vi.fn() } }));
|
|
vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn() }));
|
|
vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) }));
|
|
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
|
|
vi.mock("svelte/store", () => ({ get: vi.fn(() => ({ git: {} })) }));
|
|
|
|
import { GitManagerModel } from "../GitManagerModel.svelte.ts";
|
|
import { gitService } from "../../../services/gitService.js";
|
|
import { api } from "$lib/api.js";
|
|
import { addToast } from "$lib/toasts.svelte.js";
|
|
|
|
interface WorkspaceStatusFixture { is_dirty?: boolean; staged_files?: string[]; modified_files?: string[]; untracked_files?: string[]; current_branch?: string; }
|
|
|
|
function createModel(overrides: Partial<{ dashboardId: string; envId: string | null; dashboardTitle: string }> = {}): GitManagerModel {
|
|
return new GitManagerModel({ dashboardId: "test-dashboard", envId: "env-123", dashboardTitle: "Test Dashboard", ...overrides });
|
|
}
|
|
function makeWs(overrides: Partial<WorkspaceStatusFixture> = {}): WorkspaceStatusFixture {
|
|
return { is_dirty: false, staged_files: [], modified_files: [], untracked_files: [], current_branch: "main", ...overrides };
|
|
}
|
|
|
|
describe("GitManagerModel — L1 invariants (no render)", () => {
|
|
let model: GitManagerModel;
|
|
beforeEach(() => { vi.clearAllMocks(); model = createModel(); });
|
|
|
|
describe("constructor", () => {
|
|
it("stores props", () => { expect(model.dashboardId).toBe("test-dashboard"); expect(model.envId).toBe("env-123"); expect(model.dashboardTitle).toBe("Test Dashboard"); });
|
|
it("defaults when called empty", () => { const m = new GitManagerModel(); expect(m.dashboardId).toBe(""); expect(m.envId).toBeNull(); });
|
|
});
|
|
|
|
describe("derived state", () => {
|
|
it("hasWorkspaceChanges false when status null", () => { model.workspaceStatus = null; expect(model.hasWorkspaceChanges).toBe(false); });
|
|
it("hasWorkspaceChanges true when staged", () => { model.workspaceStatus = makeWs({ staged_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
|
|
it("hasWorkspaceChanges true when modified", () => { model.workspaceStatus = makeWs({ modified_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
|
|
it("hasWorkspaceChanges true when untracked", () => { model.workspaceStatus = makeWs({ untracked_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
|
|
it("hasWorkspaceChanges false when empty", () => { model.workspaceStatus = makeWs(); expect(model.hasWorkspaceChanges).toBe(false); });
|
|
it("changedFilesCount aggregates all", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); });
|
|
it("changedFilesCount 0 when null", () => { model.workspaceStatus = null; expect(model.changedFilesCount).toBe(0); });
|
|
it("changedFilesCount 0 when empty", () => { model.workspaceStatus = makeWs(); expect(model.changedFilesCount).toBe(0); });
|
|
it("resolvedEnvId returns envId", () => { model.envId = "env-456"; expect(model.resolvedEnvId).toBe("env-456"); });
|
|
it("hasOriginConfigMismatch false when same host", () => {
|
|
model.repositoryBindingRemoteUrl = "https://git.example.com/repo.git";
|
|
model.repositoryConfigUrl = "https://git.example.com/config";
|
|
expect(model.hasOriginConfigMismatch).toBe(false);
|
|
});
|
|
it("pushProviderLabel default is git", () => { expect(model.pushProviderLabel).toBe("git"); });
|
|
});
|
|
|
|
describe("mutual exclusion — defaults", () => {
|
|
it("all false except checkingStatus", () => {
|
|
expect(model.checkingStatus).toBe(true); expect(model.loading).toBe(false); expect(model.workspaceLoading).toBe(false);
|
|
expect(model.committing).toBe(false); expect(model.isPulling).toBe(false); expect(model.isPushing).toBe(false);
|
|
expect(model.generatingMessage).toBe(false); expect(model.promoting).toBe(false);
|
|
expect(model.mergeResolveInProgress).toBe(false); expect(model.mergeAbortInProgress).toBe(false);
|
|
expect(model.mergeContinueInProgress).toBe(false); expect(model.creatingRemoteRepo).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("clearGitError resets error", () => {
|
|
model.gitError = { message: "Broke", status: 500, error_code: null, files: [], next_steps: [], detail: null };
|
|
model.clearGitError();
|
|
expect(model.gitError).toBeNull();
|
|
});
|
|
it("handleCommit sets gitError on failure", async () => {
|
|
model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] });
|
|
vi.mocked(gitService.commit).mockRejectedValue(new Error("Fail"));
|
|
await model.handleCommit();
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
it("handleCommit clears before operation", async () => {
|
|
model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] });
|
|
model.gitError = { message: "Old", status: 500, error_code: null, files: [], next_steps: [], detail: null };
|
|
vi.mocked(gitService.commit).mockRejectedValue(new Error("New"));
|
|
await model.handleCommit();
|
|
expect(model.gitError!.message).toBe("New");
|
|
});
|
|
});
|
|
|
|
describe("loading state lifecycle", () => {
|
|
it("checkStatus transitions checkingStatus", async () => {
|
|
vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
expect(model.checkingStatus).toBe(true); await model.checkStatus(); expect(model.checkingStatus).toBe(false); expect(model.initialized).toBe(true);
|
|
});
|
|
it("checkStatus sets false on failure", async () => {
|
|
vi.mocked(gitService.getBranches).mockRejectedValue(new Error("No repo"));
|
|
await model.checkStatus(); expect(model.initialized).toBe(false); expect(model.checkingStatus).toBe(false);
|
|
});
|
|
it("handleCommit sets committing lifecycle", async () => {
|
|
model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] });
|
|
vi.mocked(gitService.commit).mockImplementation(() => new Promise((r) => setTimeout(r, 10)));
|
|
const p = model.handleCommit(); expect(model.committing).toBe(true); await p; expect(model.committing).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("guard conditions", () => {
|
|
it("handleCommit returns early when message empty", async () => { await model.handleCommit(); expect(gitService.commit).not.toHaveBeenCalled(); });
|
|
it("handleCommit returns early when no changes", async () => { model.commitMessage = "fix"; await model.handleCommit(); expect(gitService.commit).not.toHaveBeenCalled(); });
|
|
it("handlePromote validates branches different", async () => {
|
|
model.promoteFromBranch = "main"; model.promoteToBranch = "main";
|
|
await model.handlePromote(); expect(gitService.promote).not.toHaveBeenCalled();
|
|
});
|
|
it("handlePromote validates reason in direct mode", async () => {
|
|
model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "direct";
|
|
await model.handlePromote(); expect(gitService.promote).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("handleCommit — API", () => {
|
|
it("calls commit with correct args", async () => {
|
|
model.commitMessage = "fix: x"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = false;
|
|
vi.mocked(gitService.commit).mockResolvedValue({});
|
|
await model.handleCommit();
|
|
expect(gitService.commit).toHaveBeenCalledWith("test-dashboard", "fix: x", [], "env-123");
|
|
});
|
|
it("calls push when autoPushAfterCommit", async () => {
|
|
model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = true;
|
|
vi.mocked(gitService.commit).mockResolvedValue({}); vi.mocked(gitService.push).mockResolvedValue({});
|
|
await model.handleCommit(); expect(gitService.push).toHaveBeenCalled();
|
|
});
|
|
it("does not push when autoPushAfterCommit false", async () => {
|
|
model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = false;
|
|
vi.mocked(gitService.commit).mockResolvedValue({});
|
|
await model.handleCommit(); expect(gitService.push).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("handlePromote — API", () => {
|
|
it("calls promote with correct args in MR mode", async () => {
|
|
model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr";
|
|
vi.mocked(gitService.promote).mockResolvedValue({ url: "https://example.com/mr/1" });
|
|
await model.handlePromote();
|
|
expect(gitService.promote).toHaveBeenCalledWith("test-dashboard", expect.objectContaining({ mode: "mr" }), "env-123");
|
|
});
|
|
it("calls promote with reason in direct mode", async () => {
|
|
model.promoteFromBranch = "dev"; model.promoteToBranch = "preprod"; model.promoteMode = "direct"; model.promoteReason = "Hotfix";
|
|
vi.mocked(gitService.promote).mockResolvedValue({});
|
|
await model.handlePromote();
|
|
expect(gitService.promote).toHaveBeenCalledWith("test-dashboard", expect.objectContaining({ reason: "Hotfix" }), "env-123");
|
|
});
|
|
});
|
|
|
|
describe("handlePull — API", () => {
|
|
it("calls pull with correct args", async () => {
|
|
vi.mocked(gitService.pull).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
await model.handlePull(); expect(gitService.pull).toHaveBeenCalledWith("test-dashboard", "env-123"); expect(model.isPulling).toBe(false);
|
|
});
|
|
it("opens merge dialog on 409", async () => {
|
|
const err = new Error("Unfinished merge"); (err as any).status = 409; (err as any).detail = { error_code: "GIT_UNFINISHED_MERGE", message: "Merge in progress", repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", manual_commands: ["git merge --abort"], next_steps: ["Abort"] };
|
|
vi.mocked(gitService.pull).mockRejectedValue(err); vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true });
|
|
await model.handlePull(); expect(model.showUnfinishedMergeDialog).toBe(true);
|
|
});
|
|
it("sets gitError on non-merge failure", async () => {
|
|
vi.mocked(gitService.pull).mockRejectedValue(new Error("Network"));
|
|
await model.handlePull(); expect(model.gitError).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("handlePush — API", () => {
|
|
it("calls push", async () => {
|
|
vi.mocked(gitService.push).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
await model.handlePush(); expect(gitService.push).toHaveBeenCalledWith("test-dashboard", "env-123");
|
|
});
|
|
it("sets gitError on failure", async () => { vi.mocked(gitService.push).mockRejectedValue(new Error("Rejected")); await model.handlePush(); expect(model.gitError).not.toBeNull(); });
|
|
});
|
|
|
|
describe("handleSync — API", () => {
|
|
it("calls sync", async () => {
|
|
vi.mocked(gitService.sync).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
await model.handleSync(); expect(gitService.sync).toHaveBeenCalledWith("test-dashboard", "env-123", "env-123");
|
|
});
|
|
it("sets gitError on failure", async () => { vi.mocked(gitService.sync).mockRejectedValue(new Error("Sync fail")); await model.handleSync(); expect(model.gitError).not.toBeNull(); });
|
|
});
|
|
|
|
describe("handleGenerateMessage — API", () => {
|
|
it("calls postApi and sets message", async () => {
|
|
vi.mocked(api.postApi).mockResolvedValue({ message: "AI message" });
|
|
await model.handleGenerateMessage();
|
|
expect(api.postApi).toHaveBeenCalledWith(expect.stringContaining("/generate-message"), undefined, { suppressToast: true });
|
|
expect(model.commitMessage).toBe("AI message"); expect(model.generatingMessage).toBe(false);
|
|
});
|
|
it("sets gitError on failure", async () => {
|
|
vi.mocked(api.postApi).mockRejectedValue(new Error("Gen failed"));
|
|
await model.handleGenerateMessage(); expect(model.gitError).not.toBeNull(); expect(model.generatingMessage).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("checkStatus — guard paths", () => {
|
|
it("blocks numeric dashboardId", async () => {
|
|
model.dashboardId = "12345"; await model.checkStatus();
|
|
expect(model.initialized).toBe(false); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Numeric ID"), "error");
|
|
});
|
|
it("sets false when no resolvedEnvId", async () => {
|
|
const m = createModel({ envId: null }); await m.checkStatus();
|
|
expect(m.initialized).toBe(false); expect(m.checkingStatus).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("loadWorkspace", () => {
|
|
it("returns early when not initialized", async () => { model.initialized = false; await model.loadWorkspace(); expect(gitService.getStatus).not.toHaveBeenCalled(); });
|
|
it("loads workspace on success", async () => {
|
|
model.initialized = true; vi.mocked(gitService.getStatus).mockResolvedValue(makeWs({ staged_files: ["x"] }));
|
|
vi.mocked(gitService.getDiff).mockResolvedValueOnce("staged"); vi.mocked(gitService.getDiff).mockResolvedValueOnce("unstaged");
|
|
await model.loadWorkspace();
|
|
expect(model.workspaceStatus).toEqual(makeWs({ staged_files: ["x"] })); expect(model.workspaceDiff).toContain("staged");
|
|
});
|
|
it("sets gitError on failure", async () => { model.initialized = true; vi.mocked(gitService.getStatus).mockRejectedValue(new Error("Fail")); await model.loadWorkspace(); expect(model.gitError).not.toBeNull(); });
|
|
});
|
|
|
|
describe("handleInit", () => {
|
|
it("returns early when missing config or url", async () => { await model.handleInit(); expect(gitService.initRepository).not.toHaveBeenCalled(); });
|
|
it("calls init on success", async () => {
|
|
model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git"; vi.mocked(gitService.initRepository).mockResolvedValue({});
|
|
await model.handleInit();
|
|
expect(gitService.initRepository).toHaveBeenCalledWith("test-dashboard", "cfg-1", "https://example.com/repo.git", "env-123");
|
|
});
|
|
});
|
|
|
|
describe("handleCreateRemoteRepo", () => {
|
|
it("returns early when no config", async () => {
|
|
await model.handleCreateRemoteRepo();
|
|
expect(model.showCreateRepoDialog).toBe(false);
|
|
expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
|
|
});
|
|
it("handles 409 already exists error", async () => {
|
|
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test";
|
|
await model.handleCreateRemoteRepo();
|
|
expect(model.showCreateRepoDialog).toBe(true);
|
|
model.pendingRepoName = "my-repo";
|
|
const err = new Error("already exists"); (err as any).status = 409;
|
|
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(err);
|
|
await model.confirmCreateRemoteRepo();
|
|
expect(addToast).toHaveBeenCalledWith("Already exists", "warning", 0);
|
|
});
|
|
it("handles other error via _setGitError", async () => {
|
|
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
|
|
await model.handleCreateRemoteRepo();
|
|
expect(model.showCreateRepoDialog).toBe(true);
|
|
model.pendingRepoName = "my-repo";
|
|
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error"));
|
|
await model.confirmCreateRemoteRepo();
|
|
expect(model.gitError).not.toBeNull();
|
|
expect(model.creatingRemoteRepo).toBe(false);
|
|
});
|
|
it("returns early when user cancels prompt", async () => {
|
|
model.configs = [{ id: "cfg-1", provider: "github" }];
|
|
await model.handleCreateRemoteRepo();
|
|
expect(model.showCreateRepoDialog).toBe(true);
|
|
expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
|
|
// Close dialog without confirming
|
|
model.showCreateRepoDialog = false;
|
|
model.pendingRepoName = "";
|
|
});
|
|
});
|
|
|
|
describe("handleInit — guard paths", () => {
|
|
it("blocks when missing resolvedEnvId and non-numeric", async () => {
|
|
model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git";
|
|
const m = createModel({ envId: null }); m.selectedConfigId = "cfg-1"; m.remoteUrl = "https://example.com/repo.git";
|
|
await m.handleInit();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Environment must be selected"), "error");
|
|
expect(gitService.initRepository).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("initialize", () => {
|
|
it("loads configs and checks status", async () => {
|
|
vi.mocked(gitService.getConfigs).mockResolvedValue([{ id: "cfg-1", provider: "github" }]);
|
|
vi.mocked(gitService.getBranches).mockResolvedValue(["main"]);
|
|
vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]);
|
|
vi.mocked(gitService.getRepositoryBinding).mockResolvedValue({ provider: "github", remote_url: "https://example.com/repo.git", config_id: "cfg-1" });
|
|
await model.initialize();
|
|
expect(model.configs).toHaveLength(1);
|
|
expect(model.loading).toBe(false);
|
|
expect(model.initialized).toBe(true);
|
|
});
|
|
it("handles config fetch error", async () => {
|
|
vi.mocked(gitService.getConfigs).mockRejectedValue(new Error("Fail"));
|
|
vi.mocked(gitService.getBranches).mockResolvedValue(["main"]);
|
|
vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValue([]);
|
|
await model.initialize();
|
|
expect(model.configs).toEqual([]);
|
|
expect(model.loading).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("loadCurrentEnvironmentStage", () => {
|
|
it("fetches env stage and sets defaults", async () => {
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]);
|
|
await model.loadCurrentEnvironmentStage();
|
|
expect(model.currentEnvStage).toBe("DEV");
|
|
});
|
|
it("handles error gracefully", async () => {
|
|
const m = createModel({ envId: "env-99" });
|
|
vi.mocked(api.getEnvironmentsList).mockRejectedValue(new Error("API error"));
|
|
const { log } = await import("$lib/cot-logger");
|
|
await m.loadCurrentEnvironmentStage();
|
|
expect(log).toHaveBeenCalledWith(
|
|
expect.any(String), "EXPLORE", expect.any(String), expect.any(Object), expect.any(String),
|
|
);
|
|
});
|
|
it("returns early when no envId", async () => {
|
|
const m = createModel({ envId: null });
|
|
await m.loadCurrentEnvironmentStage();
|
|
expect(api.getEnvironmentsList).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("openDeployModal", () => {
|
|
it("opens modal when stage is not PROD", () => {
|
|
model.currentEnvStage = "DEV";
|
|
model.openDeployModal();
|
|
expect(model.showDeployModal).toBe(true);
|
|
});
|
|
it("requires confirmation for PROD", () => {
|
|
model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard";
|
|
model.openDeployModal();
|
|
expect(model.showDeployModal).toBe(false);
|
|
expect(model.showDeployConfirm).toBe(true);
|
|
expect(model.deployConfirmSlug).toBe("test-dashboard");
|
|
});
|
|
it("opens modal when PROD confirmed correctly", () => {
|
|
model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard";
|
|
model.openDeployModal();
|
|
model.confirmDeploy("test-dashboard");
|
|
expect(model.showDeployModal).toBe(true);
|
|
expect(model.showDeployConfirm).toBe(false);
|
|
});
|
|
it("keeps deploy modal closed when PROD confirmation fails", () => {
|
|
model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard";
|
|
model.openDeployModal();
|
|
model.confirmDeploy("wrong-slug");
|
|
expect(model.showDeployModal).toBe(false);
|
|
expect(model.showDeployConfirm).toBe(false);
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("PROD"), "error");
|
|
});
|
|
});
|
|
|
|
describe("handleSync — numeric guard", () => {
|
|
it("blocks numeric dashboardId", async () => {
|
|
model.dashboardId = "12345"; await model.handleSync();
|
|
expect(gitService.sync).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("derived — origin/config host mismatch", () => {
|
|
it("hasOriginConfigMismatch true when hosts differ", () => {
|
|
model.repositoryBindingRemoteUrl = "https://host1.example.com/repo.git";
|
|
model.repositoryConfigUrl = "https://host2.example.com/config";
|
|
expect(model.hasOriginConfigMismatch).toBe(true);
|
|
});
|
|
it("hasOriginConfigMismatch false when one host missing", () => {
|
|
model.repositoryBindingRemoteUrl = "";
|
|
model.repositoryConfigUrl = "https://host2.example.com/config";
|
|
expect(model.hasOriginConfigMismatch).toBe(false);
|
|
});
|
|
it("originHost extracts http host from URL", () => {
|
|
model.repositoryBindingRemoteUrl = "https://gitlab.com/org/repo.git";
|
|
expect(model.originHost).toBe("gitlab.com");
|
|
});
|
|
});
|
|
|
|
describe("merge recovery — extended", () => {
|
|
it("loadMergeRecoveryState loads and updates context", async () => {
|
|
vi.mocked(gitService.getMergeStatus).mockResolvedValue({
|
|
has_unfinished_merge: true, repository_path: "/repo", git_dir: "/repo/.git",
|
|
current_branch: "main", merge_head: "feature", merge_message_preview: "Merge msg", conflicts_count: 2,
|
|
});
|
|
model.unfinishedMergeContext = { nextSteps: ["Step 1"], commands: ["cmd1"] } as any;
|
|
await model.loadMergeRecoveryState();
|
|
expect(model.unfinishedMergeContext?.repositoryPath).toBe("/repo");
|
|
expect(model.unfinishedMergeContext?.conflictsCount).toBe(2);
|
|
expect(model.mergeRecoveryLoading).toBe(false);
|
|
});
|
|
it("loadMergeRecoveryState closes dialog when no unfinished merge", async () => {
|
|
model.showUnfinishedMergeDialog = true;
|
|
vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: false });
|
|
await model.loadMergeRecoveryState();
|
|
expect(model.showUnfinishedMergeDialog).toBe(false);
|
|
});
|
|
it("loadMergeRecoveryState sets gitError on failure", async () => {
|
|
vi.mocked(gitService.getMergeStatus).mockRejectedValue(new Error("Merge error"));
|
|
await model.loadMergeRecoveryState();
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
it("handleOpenConflictResolver loads conflicts", async () => {
|
|
vi.mocked(gitService.getMergeConflicts).mockResolvedValue([{ file_path: "file1.ts" }]);
|
|
await model.handleOpenConflictResolver();
|
|
expect(model.mergeConflicts).toHaveLength(1);
|
|
expect(model.showConflictResolver).toBe(true);
|
|
expect(model.mergeRecoveryLoading).toBe(false);
|
|
});
|
|
it("handleOpenConflictResolver shows info when no conflicts", async () => {
|
|
vi.mocked(gitService.getMergeConflicts).mockResolvedValue([]);
|
|
await model.handleOpenConflictResolver();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("No unresolved"), "info");
|
|
});
|
|
it("handleOpenConflictResolver sets gitError on failure", async () => {
|
|
vi.mocked(gitService.getMergeConflicts).mockRejectedValue(new Error("Fail"));
|
|
await model.handleOpenConflictResolver();
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
it("handleResolveConflicts processes event detail", async () => {
|
|
vi.mocked(gitService.resolveMergeConflicts).mockResolvedValue({});
|
|
vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: false });
|
|
vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
await model.handleResolveConflicts(new CustomEvent("resolve", { detail: { "file1.ts": "ours" } }));
|
|
expect(gitService.resolveMergeConflicts).toHaveBeenCalled();
|
|
expect(model.mergeResolveInProgress).toBe(false);
|
|
});
|
|
it("handleResolveConflicts warns on empty detail", async () => {
|
|
await model.handleResolveConflicts(new CustomEvent("resolve", { detail: {} }));
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("resolutions"), "warning");
|
|
expect(gitService.resolveMergeConflicts).not.toHaveBeenCalled();
|
|
});
|
|
it("handleResolveConflicts sets gitError on failure", async () => {
|
|
vi.mocked(gitService.resolveMergeConflicts).mockRejectedValue(new Error("Resolve fail"));
|
|
await model.handleResolveConflicts(new CustomEvent("resolve", { detail: { "x": "y" } }));
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
it("handleAbortUnfinishedMerge calls abort and resets", async () => {
|
|
vi.mocked(gitService.abortMerge).mockResolvedValue({});
|
|
vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
await model.handleAbortUnfinishedMerge();
|
|
expect(gitService.abortMerge).toHaveBeenCalledWith("test-dashboard", "env-123");
|
|
expect(model.mergeAbortInProgress).toBe(false);
|
|
});
|
|
it("handleAbortUnfinishedMerge sets gitError on failure", async () => {
|
|
vi.mocked(gitService.abortMerge).mockRejectedValue(new Error("Abort fail"));
|
|
await model.handleAbortUnfinishedMerge();
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
it("handleContinueUnfinishedMerge calls continue", async () => {
|
|
vi.mocked(gitService.continueMerge).mockResolvedValue({});
|
|
vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
await model.handleContinueUnfinishedMerge();
|
|
expect(gitService.continueMerge).toHaveBeenCalledWith("test-dashboard", "", "env-123");
|
|
expect(model.mergeContinueInProgress).toBe(false);
|
|
});
|
|
it("handleContinueUnfinishedMerge loads recovery on failure", async () => {
|
|
vi.mocked(gitService.continueMerge).mockRejectedValue(new Error("Continue fail"));
|
|
vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true });
|
|
await model.handleContinueUnfinishedMerge();
|
|
expect(model.mergeContinueInProgress).toBe(false);
|
|
// _setGitError sets gitError before loadMergeRecoveryState clears it
|
|
// but we verify the loading flag is reset
|
|
});
|
|
it("handleCopyUnfinishedMergeCommands copies to clipboard", async () => {
|
|
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
|
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
|
|
model.unfinishedMergeContext = { commands: ["git merge --abort"] } as any;
|
|
await model.handleCopyUnfinishedMergeCommands();
|
|
expect(writeTextSpy).toHaveBeenCalledWith("git merge --abort");
|
|
expect(model.copyingUnfinishedMergeCommands).toBe(false);
|
|
writeTextSpy.mockRestore();
|
|
});
|
|
it("handleCopyUnfinishedMergeCommands warns when no commands", async () => {
|
|
model.unfinishedMergeContext = { commands: [] } as any;
|
|
await model.handleCopyUnfinishedMergeCommands();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("копирования"), "warning");
|
|
});
|
|
it("handleCopyUnfinishedMergeCommands handles clipboard error", async () => {
|
|
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard blocked")) } });
|
|
model.unfinishedMergeContext = { commands: ["git status"] } as any;
|
|
await model.handleCopyUnfinishedMergeCommands();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('скопировать'), "error");
|
|
expect(model.copyingUnfinishedMergeCommands).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("openUnfinishedMergeDialogFromError", () => {
|
|
it("returns true when context found", () => {
|
|
const err = new Error("Unfinished merge");
|
|
(err as any).status = 409;
|
|
(err as any).detail = { error_code: "GIT_UNFINISHED_MERGE", repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", manual_commands: ["git merge --abort"], next_steps: ["Abort"] };
|
|
const result = model.openUnfinishedMergeDialogFromError(err);
|
|
expect(result).toBe(true);
|
|
expect(model.showUnfinishedMergeDialog).toBe(true);
|
|
expect(model.unfinishedMergeContext).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("coverage — remaining uncovered paths", () => {
|
|
it("buildGitErrorFromException handles falsy error", () => {
|
|
(model as any)._setGitError(null);
|
|
expect(model.gitError).not.toBeNull();
|
|
expect(model.gitError!.message).toBe('Неизвестная ошибка Git');
|
|
});
|
|
|
|
it("_snapshot returns helper object and calls resolveEnvId", () => {
|
|
const snap = model._snapshot();
|
|
const envId = snap.resolveEnvId();
|
|
expect(typeof envId).toBe("string");
|
|
expect(typeof snap.normalizeEnvStageFn).toBe("function");
|
|
});
|
|
|
|
it("initialize handles getRepositoryBinding failure", async () => {
|
|
vi.mocked(gitService.getConfigs).mockResolvedValue([{ id: "cfg-1" }]);
|
|
vi.mocked(gitService.getBranches).mockResolvedValue(["main"]);
|
|
vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
|
|
vi.mocked(gitService.getDiff).mockResolvedValue("");
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123" }]);
|
|
vi.mocked(gitService.getRepositoryBinding).mockRejectedValue(new Error("Binding fail"));
|
|
await model.initialize();
|
|
expect(model.repositoryProvider).toBe("");
|
|
expect(model.repositoryBindingRemoteUrl).toBe("");
|
|
});
|
|
|
|
it("handlePromote sets error on API failure", async () => {
|
|
model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr";
|
|
vi.mocked(gitService.promote).mockRejectedValue(new Error("Promote fail"));
|
|
await model.handlePromote();
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
|
|
it("handleInit sets error on init failure", async () => {
|
|
model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git";
|
|
vi.mocked(gitService.initRepository).mockRejectedValue(new Error("Init fail"));
|
|
await model.handleInit();
|
|
expect(model.gitError).not.toBeNull();
|
|
});
|
|
|
|
it("handleCreateRemoteRepo succeeds with clone_url", async () => {
|
|
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
|
|
model.dashboardTitle = "Test Dash";
|
|
await model.handleCreateRemoteRepo();
|
|
expect(model.showCreateRepoDialog).toBe(true);
|
|
model.pendingRepoName = "my-repo";
|
|
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" });
|
|
await model.confirmCreateRemoteRepo();
|
|
expect(model.remoteUrl).toBe("https://github.com/org/repo.git");
|
|
});
|
|
|
|
it("handleCreateRemoteRepo succeeds with html_url", async () => {
|
|
model.configs = [{ id: "cfg-1", provider: "gitlab" }]; model.selectedConfigId = "cfg-1";
|
|
model.dashboardTitle = "Test Dash";
|
|
await model.handleCreateRemoteRepo();
|
|
expect(model.showCreateRepoDialog).toBe(true);
|
|
model.pendingRepoName = "my-repo";
|
|
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" });
|
|
await model.confirmCreateRemoteRepo();
|
|
expect(model.remoteUrl).toBe("https://gitlab.com/org/repo");
|
|
});
|
|
});
|
|
});
|
|
// #endregion GitManagerModelTests
|