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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/AIMentorWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect } from "react";
import React, { useState, useEffect } from "react";
import DOMPurify from "dompurify";

interface Insight {
Expand Down
20 changes: 10 additions & 10 deletions src/components/WeeklySummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@ export default function WeeklySummaryCard() {
const [error, setError] = useState<string | null>(null);
const [isCollapsed, setIsCollapsed] = useState(false);

const maxCommits = summary?.commits
? Math.max(summary.commits.current, summary.commits.previous, 1)
: 1;
const maxCommits = summary?.commits
? Math.max(summary.commits.current, summary.commits.previous, 1)
: 1;

const maxPRs = summary?.prs
? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1)
: 1;
const maxPRs = summary?.prs
? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1)
: 1;

const maxActiveDays = summary?.activeDays
? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1)
: 1;
const maxActiveDays = summary?.activeDays
? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1)
: 1;

const fetchSummary = useCallback(() => {
setLoading(true);
Expand Down Expand Up @@ -106,7 +106,7 @@ const maxActiveDays = summary?.activeDays
<div className="mt-4 rounded-lg border border-[var(--destructive)]/20 bg-[var(--destructive)]/10 p-4 text-sm text-[var(--destructive)]">
{error}
</div>
) : summary ? (
) : (summary && summary.commits && summary.prs && summary.activeDays) ? (
<div className="mt-4 space-y-4">
{/* Commits Comparison */}
<div className="rounded-lg bg-[var(--control)] p-4">
Expand Down
165 changes: 165 additions & 0 deletions test/AIMentorWidget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AIMentorWidget } from "@/components/AIMentorWidget";
import React from "react";

const mockAIData = {
data: {
insights: [
{
id: "insight-1",
type: "consistency",
title: "High Consistency",
description: "You coded 5 days in a row!",
severity: "positive",
},
],
trend: { direction: "up", percentage: 25 },
aiSummary: "<h3>Weekly Focus</h3><p>Keep up the <strong>great work</strong> in <code>devtrack</code>!</p>",
generatedAt: "2026-06-01T07:00:00.000Z",
},
};

describe("AIMentorWidget Component", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockAIData),
} as Response)
)
);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it("renders loader initially and then renders insights and trend data", async () => {
render(React.createElement(AIMentorWidget));

// Loader status should exist initially
expect(screen.getByRole("status")).toBeInTheDocument();

await waitFor(() => {
// Heading AI Mentor
expect(screen.getByText("AI Mentor")).toBeInTheDocument();
// Trend text
expect(screen.getByText(/↑/i)).toBeInTheDocument();
expect(screen.getByText(/25%/i)).toBeInTheDocument();
// Insight card
expect(screen.getByText("High Consistency")).toBeInTheDocument();
expect(screen.getByText("You coded 5 days in a row!")).toBeInTheDocument();
});
});

it("renders safe HTML formatting post-sanitization", async () => {
render(React.createElement(AIMentorWidget));

await waitFor(() => {
// 1. Check for header element <h3>
const heading = screen.getByRole("heading", { level: 3 });
expect(heading).toBeInTheDocument();
expect(heading.textContent).toBe("Weekly Focus");

// 2. Check for strong element
const strongElement = screen.getByText("great work");
expect(strongElement.tagName).toBe("STRONG");

// 3. Check for code element
const codeElement = screen.getByText("devtrack");
expect(codeElement.tagName).toBe("CODE");
});
});

it("sanitizes and strips malicious script tags and event handlers from HTML", async () => {
const maliciousData = {
data: {
...mockAIData.data,
aiSummary: `<h3>Safe Advice</h3><script>alert('malicious script')</script><img src="x" onerror="alert('onerror attack')" /><strong onclick="alert('click attack')">Safe text inside strong</strong>`,
},
};

vi.mocked(global.fetch).mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(maliciousData),
} as Response)
);

render(React.createElement(AIMentorWidget));

await waitFor(() => {
// The safe elements should still be rendered correctly
expect(screen.getByRole("heading", { level: 3 }).textContent).toBe("Safe Advice");
expect(screen.getByText("Safe text inside strong")).toBeInTheDocument();

// The script tag should be stripped from the DOM
const aiSummaryContainer = screen.getByText("Safe Advice").parentElement;
expect(aiSummaryContainer?.innerHTML).not.toContain("<script>");

// The onerror and onclick attribute should be stripped
const img = aiSummaryContainer?.querySelector("img");
expect(img).toBeInTheDocument();
expect(img?.getAttribute("onerror")).toBeNull();

const strong = aiSummaryContainer?.querySelector("strong");
expect(strong).toBeInTheDocument();
expect(strong?.getAttribute("onclick")).toBeNull();
});
});

it("toggles collapse and expand states correctly when clicking toggle button", async () => {
render(React.createElement(AIMentorWidget));

await waitFor(() => {
expect(screen.getByText("High Consistency")).toBeInTheDocument();
});

const toggleBtn = screen.getByLabelText("Collapse AI Mentor");
expect(toggleBtn).toBeInTheDocument();

// Click collapse
fireEvent.click(toggleBtn);

// After collapse, the summary and insights should be hidden from view
await waitFor(() => {
expect(screen.queryByText("High Consistency")).not.toBeInTheDocument();
expect(screen.queryByText("Weekly Focus")).not.toBeInTheDocument();
});

// Button label should switch to Expand
const expandBtn = screen.getByLabelText("Expand AI Mentor");
expect(expandBtn).toBeInTheDocument();

// Click expand
fireEvent.click(expandBtn);

// Insights should reappear
await waitFor(() => {
expect(screen.getByText("High Consistency")).toBeInTheDocument();
expect(screen.getByText("Weekly Focus")).toBeInTheDocument();
});
});

it("renders fallback error message when API call fails", async () => {
vi.mocked(global.fetch).mockImplementationOnce(() =>
Promise.reject(new Error("Network Error"))
);

render(React.createElement(AIMentorWidget));

await waitFor(() => {
expect(
screen.getByText("AI insights are unavailable right now. Please try again later.")
).toBeInTheDocument();
});
});
});
Loading