import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { loadAllAppCss } from "../../test/cssFixture";
import { TaskChangesTab } from "../TaskChangesTab";
import type { MergeDetails, Column } from "@fusion/core";
const mockFetchTaskDiff = vi.fn();
vi.mock("../../api", () => ({
fetchTaskDiff: (...args: any[]) => mockFetchTaskDiff(...args),
}));
vi.mock("lucide-react", () => ({
FileCode: () => null,
ChevronDown: ({ size }: any) => ,
ChevronRight: ({ size }: any) => ,
ChevronLeft: ({ size }: any) => ,
AlertCircle: () => null,
GitCommit: () => null,
WrapText: ({ size }: any) => ,
Maximize2: ({ size }: any) => ,
}));
vi.mock("../ChangesDiffModal", () => ({
ChangesDiffModal: ({ isOpen, onClose }: any) =>
isOpen ?
Modal
: null,
}));
vi.mock("../../utils/highlightDiff", () => ({
highlightDiff: (diff: string) => diff,
}));
const DONE_TASK_DIFF = {
files: [
{ path: "src/app.ts", status: "modified" as const, additions: 1, deletions: 0, patch: "diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1,2 @@\n import express from \"express\";\n+import cors from \"cors\";" },
{ path: "src/new-file.ts", status: "added" as const, additions: 2, deletions: 0, patch: "diff --git a/src/new-file.ts b/src/new-file.ts\nnew file mode 100644\n@@ -0,0 +1,2 @@\n+export function hello() {}\n+export function world() {}" },
],
stats: { filesChanged: 2, additions: 3, deletions: 0 },
};
const MERGE_DETAILS: MergeDetails = {
commitSha: "abc1234567890def",
filesChanged: 2,
insertions: 3,
deletions: 0,
mergeCommitMessage: "Merge branch 'fusion/fn-001' into main",
mergedAt: "2026-01-15T10:30:00Z",
};
beforeEach(() => {
mockFetchTaskDiff.mockReset();
});
describe("TaskChangesTab — worktree-backed (non-done tasks)", () => {
it("shows 'No worktree available' when no worktree and not done", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
render(
,
);
await waitFor(() => {
expect(screen.getByText("No worktree available for this task.")).toBeTruthy();
});
});
it("shows modifiedFiles fallback when an active task has no worktree diff", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
render(
,
);
await waitFor(() => {
expect(screen.getByText("2 files modified during execution.")).toBeTruthy();
});
expect(screen.getByText("packages/core/src/store.ts")).toBeTruthy();
expect(screen.getByText("packages/core/src/types.ts")).toBeTruthy();
});
it("loads diff from fetchTaskDiff for in-progress task with worktree", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [
{ path: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "@@ -1 +1,2 @@" },
],
stats: { filesChanged: 1, additions: 1, deletions: 0 },
});
render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
expect(mockFetchTaskDiff).toHaveBeenCalledWith("FN-001", undefined, undefined);
});
it("loads diff from fetchTaskDiff for in-review task with worktree", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [
{ path: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "@@ -1 +1,2 @@" },
],
stats: { filesChanged: 1, additions: 1, deletions: 0 },
});
render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
expect(mockFetchTaskDiff).toHaveBeenCalled();
});
it("shows 'No files modified' when worktree diff returns empty", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
render(
,
);
await waitFor(() => {
expect(screen.getByText("No files modified.")).toBeTruthy();
});
});
it("shows error state when fetchTaskDiff fails", async () => {
mockFetchTaskDiff.mockRejectedValue(new Error("Network error"));
render(
,
);
await waitFor(() => {
expect(screen.getByText(/Error loading changes: Network error/)).toBeTruthy();
});
});
it("shows loading state initially", () => {
mockFetchTaskDiff.mockReturnValue(new Promise(() => {})); // never resolves
render(
,
);
expect(screen.getByText("Loading changes...")).toBeTruthy();
});
});
describe("TaskChangesTab — commit-backed (done tasks)", () => {
it("loads diff from fetchTaskDiff for done task with commitSha", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
expect(mockFetchTaskDiff).toHaveBeenCalledWith("FN-001", undefined, undefined);
});
it("shows commit metadata for done task", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("abc1234")).toBeTruthy(); // short SHA
});
expect(screen.getByText("Merge branch 'fusion/fn-001' into main")).toBeTruthy();
expect(screen.getByText(/Merged .+/)).toBeTruthy();
});
it("uses mergeDetails stats for summary", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("Files Changed (2)")).toBeTruthy();
});
expect(screen.getByText("+3")).toBeTruthy();
expect(screen.getByText("-0")).toBeTruthy();
});
it("renders stat summary on a separate line from Files Changed title", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("Files Changed (2)")).toBeTruthy();
});
// The title and stats should be in a dedicated wrapper
const titleWrapper = container.querySelector(".task-changes-header-title");
expect(titleWrapper).toBeTruthy();
// The h4 should contain "Files Changed (N)" but NOT the stat summary
const h4 = titleWrapper?.querySelector("h4");
expect(h4).toBeTruthy();
expect(h4?.textContent).toContain("Files Changed (2)");
expect(h4?.querySelector(".changes-stat-summary")).toBeNull();
// The stat summary should be a sibling of h4, not a child
const statSummary = titleWrapper?.querySelector(".task-changes-stats");
expect(statSummary).toBeTruthy();
expect(statSummary?.querySelector(".diff-add")?.textContent).toBe("+3");
expect(statSummary?.querySelector(".diff-del")?.textContent).toBe("-0");
});
it("toggling file expansion shows/hides diff content", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// First file should be auto-expanded
let diffBlocks = container.querySelectorAll(".changes-file-content");
expect(diffBlocks.length).toBe(1);
// Click to collapse
fireEvent.click(screen.getByText("src/app.ts"));
diffBlocks = container.querySelectorAll(".changes-file-content");
expect(diffBlocks.length).toBe(0);
// Click on second file to expand
fireEvent.click(screen.getByText("src/new-file.ts"));
diffBlocks = container.querySelectorAll(".changes-file-content");
expect(diffBlocks.length).toBe(1);
});
it("shows 'No files modified' with commit-specific hint when patch is empty", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
render(
,
);
await waitFor(() => {
expect(screen.getByText("No files modified.")).toBeTruthy();
});
expect(screen.getByText("No file changes were recorded in the merge commit.")).toBeTruthy();
});
it("shows error state when fetchTaskDiff fails", async () => {
mockFetchTaskDiff.mockRejectedValue(new Error("Git error"));
render(
,
);
await waitFor(() => {
expect(screen.getByText(/Error loading changes: Git error/)).toBeTruthy();
});
});
it("renders commit SHA metadata even when only commitSha is set", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// SHA metadata should render since commitSha is present
expect(container.querySelector(".commit-diff-meta")).toBeTruthy();
expect(screen.getByText("abc1234")).toBeTruthy(); // short SHA
// But message and timestamp should NOT be present since they're not set
expect(container.querySelector(".commit-diff-message")).toBeNull();
expect(container.querySelector(".commit-diff-timestamp")).toBeNull();
});
});
describe("TaskChangesTab — regression: non-done tasks still use worktree path", () => {
it("in-progress without worktree shows worktree empty state, not commit path", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
render(
,
);
await waitFor(() => {
expect(screen.getByText("No worktree available for this task.")).toBeTruthy();
});
});
it("in-review without worktree shows worktree empty state", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
render(
,
);
await waitFor(() => {
expect(screen.getByText("No worktree available for this task.")).toBeTruthy();
});
});
it("todo task never loads diff even with mergeDetails", () => {
render(
,
);
expect(screen.getByText("No worktree available for this task.")).toBeTruthy();
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
it("done task without commitSha does NOT call fetchTaskDiff — shows summary fallback", async () => {
render(
,
);
await waitFor(() => {
expect(screen.getByText("Detailed file changes unavailable.")).toBeTruthy();
});
expect(screen.getByText("No merge commit was recorded for this task.")).toBeTruthy();
// Must NOT have called fetchTaskDiff — that would trigger repo-wide fallback
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
it("done task without commitSha shows merge summary when available", async () => {
render(
,
);
await waitFor(() => {
expect(screen.getByText("Detailed file changes unavailable.")).toBeTruthy();
});
expect(screen.getByText("Merge summary: 3 files changed, +10 additions, -2 deletions.")).toBeTruthy();
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
it("done task without commitSha shows singular 'file' when filesChanged is 1", async () => {
render(
,
);
await waitFor(() => {
expect(screen.getByText("Detailed file changes unavailable.")).toBeTruthy();
});
expect(screen.getByText("Merge summary: 1 file changed, +5 additions, -0 deletions.")).toBeTruthy();
});
it("done task without commitSha falls back to modifiedFiles when available", async () => {
render(
,
);
await waitFor(() => {
expect(screen.getByText("2 files modified during execution.")).toBeTruthy();
});
expect(screen.getByText("packages/cli/src/commands/__tests__/settings.test.ts")).toBeTruthy();
expect(screen.getByText("packages/cli/src/commands/__tests__/task.test.ts")).toBeTruthy();
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
it("done task without commitSha and no mergeDetails shows fallback without summary", async () => {
render(
,
);
await waitFor(() => {
expect(screen.getByText("Detailed file changes unavailable.")).toBeTruthy();
});
expect(screen.getByText("No merge commit was recorded for this task.")).toBeTruthy();
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
});
describe("TaskChangesTab — status-to-class mapping", () => {
it("applies semantic status class for each file status", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// src/app.ts is modified, src/new-file.ts is added
const statusBadges = container.querySelectorAll(".changes-file-status");
expect(statusBadges.length).toBeGreaterThanOrEqual(2);
// Verify semantic status classes are present
const modifiedBadge = container.querySelector(".changes-file-status--modified");
expect(modifiedBadge).toBeTruthy();
expect(modifiedBadge?.textContent).toBe("M");
const addedBadge = container.querySelector(".changes-file-status--added");
expect(addedBadge).toBeTruthy();
expect(addedBadge?.textContent).toBe("A");
});
it("uses CSS classes instead of inline styles for status colors", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// Status badges should NOT have inline style attributes (colors come from CSS)
const statusBadges = container.querySelectorAll(".changes-file-status");
for (const badge of Array.from(statusBadges)) {
expect(badge.getAttribute("style")).toBeNull();
}
});
it("renders stat summary with diff-add and diff-del classes", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("Files Changed (2)")).toBeTruthy();
});
const statSummary = container.querySelector(".changes-stat-summary");
expect(statSummary).toBeTruthy();
const addStat = statSummary?.querySelector(".diff-add");
expect(addStat).toBeTruthy();
const delStat = statSummary?.querySelector(".diff-del");
expect(delStat).toBeTruthy();
});
});
describe("TaskChangesTab — file navigation", () => {
it("renders Previous and Next navigation buttons", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("Files Changed (2)")).toBeTruthy();
});
expect(screen.getByLabelText("Previous file")).toBeTruthy();
expect(screen.getByLabelText("Next file")).toBeTruthy();
});
it("shows file position indicator in current/total format", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
});
it("disables Previous button on first file", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
expect(screen.getByLabelText("Previous file")).toBeDisabled();
});
it("enables Next button on first file", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
expect(screen.getByLabelText("Next file")).not.toBeDisabled();
});
it("navigates to next file when Next is clicked", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
// Click Next
fireEvent.click(screen.getByLabelText("Next file"));
// Indicator should update to 2/2
expect(screen.getByText("2/2")).toBeTruthy();
});
it("disables Next button on last file", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
// Navigate to last file
fireEvent.click(screen.getByLabelText("Next file"));
expect(screen.getByText("2/2")).toBeTruthy();
expect(screen.getByLabelText("Next file")).toBeDisabled();
expect(screen.getByLabelText("Previous file")).not.toBeDisabled();
});
it("navigates back to previous file when Previous is clicked", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
// Go to next, then back
fireEvent.click(screen.getByLabelText("Next file"));
expect(screen.getByText("2/2")).toBeTruthy();
fireEvent.click(screen.getByLabelText("Previous file"));
expect(screen.getByText("1/2")).toBeTruthy();
});
it("expands only the navigated file", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("1/2")).toBeTruthy();
});
// Initially only first file expanded
expect(container.querySelectorAll(".changes-file-content")).toHaveLength(1);
// Navigate to second file
fireEvent.click(screen.getByLabelText("Next file"));
// Still only one file expanded (the second one)
expect(container.querySelectorAll(".changes-file-content")).toHaveLength(1);
});
});
describe("TaskChangesTab — word wrap toggle", () => {
it("renders the word wrap toggle button", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByLabelText("Toggle word wrap")).toBeTruthy();
});
});
it("defaults to word wrap ON (btn-primary active)", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
const toggle = screen.getByLabelText("Toggle word wrap");
expect(toggle.className).toContain("btn-primary");
});
});
it("applies wrap CSS class when word wrap is ON", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
const diffPatch = container.querySelector(".changes-diff-patch");
expect(diffPatch).toBeTruthy();
expect(diffPatch?.classList.contains("changes-diff-patch--wrap")).toBe(true);
expect(diffPatch?.classList.contains("changes-diff-patch--nowrap")).toBe(false);
});
it("toggles to nowrap when clicked", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// Default: wrap ON
let diffPatch = container.querySelector(".changes-diff-patch");
expect(diffPatch?.classList.contains("changes-diff-patch--wrap")).toBe(true);
// Click toggle
fireEvent.click(screen.getByLabelText("Toggle word wrap"));
// Now wrap should be OFF
diffPatch = container.querySelector(".changes-diff-patch");
expect(diffPatch?.classList.contains("changes-diff-patch--nowrap")).toBe(true);
expect(diffPatch?.classList.contains("changes-diff-patch--wrap")).toBe(false);
// Button should no longer have btn-primary
const toggle = screen.getByLabelText("Toggle word wrap");
expect(toggle.className).not.toContain("btn-primary");
});
it("updates tooltip based on wrap state", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
const toggle = screen.getByLabelText("Toggle word wrap");
expect(toggle.getAttribute("title")).toBe("Disable word wrap");
// Click to toggle OFF
fireEvent.click(toggle);
expect(toggle.getAttribute("title")).toBe("Enable word wrap");
});
});
describe("TaskChangesTab — expand button", () => {
it("renders the expand button", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
expect(screen.getByLabelText("Expand diff view")).toBeTruthy();
});
it("opens the ChangesDiffModal when expand button is clicked", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// Modal should not be visible initially
expect(screen.queryByTestId("changes-diff-modal")).toBeNull();
// Click expand
fireEvent.click(screen.getByLabelText("Expand diff view"));
// Modal should now be visible
expect(screen.getByTestId("changes-diff-modal")).toBeTruthy();
});
it("does not render expand button when no files are loaded", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
render(
,
);
await waitFor(() => {
expect(screen.getByText("No files modified.")).toBeTruthy();
});
expect(screen.queryByLabelText("Expand diff view")).toBeNull();
});
});
describe("TaskChangesTab — compact spacing class", () => {
it("renders the file list with the compact modifier class", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
const fileList = container.querySelector(".changes-file-list.task-changes-file-list--compact");
expect(fileList).toBeTruthy();
});
it("renders root element with task-changes-tab class for margin scoping", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// The task-changes-tab class on the root element enables the negative-margin
// CSS rule that reclaims horizontal space from .detail-body padding
const root = container.querySelector(".task-changes-tab");
expect(root).toBeTruthy();
// The compact file list should be a child of the root scoping element
const fileList = root?.querySelector(".changes-file-list.task-changes-file-list--compact");
expect(fileList).toBeTruthy();
});
it("diff patches have no extraneous inline padding styles", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// Diff patches should not have inline style padding — all spacing comes from CSS classes
const diffPatches = container.querySelectorAll(".changes-diff-patch");
for (const patch of Array.from(diffPatches)) {
expect(patch.getAttribute("style")).toBeNull();
}
// File content containers should also have no inline padding
const fileContents = container.querySelectorAll(".changes-file-content");
for (const content of Array.from(fileContents)) {
expect(content.getAttribute("style")).toBeNull();
}
});
});
describe("TaskChangesTab — file path display", () => {
it("renders short file paths as full text in bdo wrappers with title tooltips", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
const pathSpan = container.querySelector('.changes-file-path[title="src/app.ts"]');
expect(pathSpan).toBeTruthy();
const bdo = pathSpan?.querySelector("bdo");
expect(bdo).toBeTruthy();
expect(bdo?.getAttribute("dir")).toBe("ltr");
expect(bdo?.textContent).toBe("src/app.ts");
});
it("keeps full long path text inside the bdo element", async () => {
const longPath = "packages/dashboard/app/components/VeryLongComponentNameGoesHere.tsx";
mockFetchTaskDiff.mockResolvedValue({
files: [
{
path: longPath,
status: "modified",
additions: 1,
deletions: 0,
patch: "@@ -1 +1,2 @@",
},
],
stats: { filesChanged: 1, additions: 1, deletions: 0 },
});
const { container } = render(
,
);
await waitFor(() => {
expect(screen.getByText(longPath)).toBeTruthy();
});
const pathSpan = container.querySelector(`.changes-file-path[title="${longPath}"]`);
expect(pathSpan).toBeTruthy();
expect(pathSpan?.querySelector("bdo")?.textContent).toBe(longPath);
});
it("applies RTL ellipsis CSS for left truncation", () => {
const css = loadAllAppCss();
const ruleMatch = css.match(/\.task-changes-file-list--compact\s+\.changes-file-path\s*\{([^}]*)\}/);
expect(ruleMatch).toBeTruthy();
const rule = ruleMatch![1];
expect(rule).toContain("direction: rtl;");
expect(rule).toContain("text-overflow: ellipsis;");
});
});
describe("TaskChangesTab — action button sizing", () => {
it("keeps Refresh and expand buttons on the same compact height", () => {
const css = loadAllAppCss();
const sharedButtonRuleMatch = css.match(
/\.task-changes-tab\s+\.changes-header-actions-secondary\s*>\s*\.btn\s*\{([^}]*)\}/s,
);
expect(sharedButtonRuleMatch).toBeTruthy();
const sharedButtonRule = sharedButtonRuleMatch![1];
const heightMatch = sharedButtonRule.match(/height:\s*([^;]+);/);
expect(heightMatch).toBeTruthy();
const expectedHeight = heightMatch![1].trim();
const iconButtonRuleMatch = css.match(
/\.task-changes-tab\s+\.changes-header-actions-secondary\s*>\s*\.btn-icon\s*\{([^}]*)\}/s,
);
expect(iconButtonRuleMatch).toBeTruthy();
const iconButtonRule = iconButtonRuleMatch![1];
expect(iconButtonRule).toContain(`width: ${expectedHeight};`);
expect(iconButtonRule).toContain(`min-width: ${expectedHeight};`);
expect(iconButtonRule).toContain(`min-height: ${expectedHeight};`);
});
});