Skip to content
Merged
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
241 changes: 241 additions & 0 deletions src/__tmp__/generationHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { describe, it, expect } from "vitest";
import {
appendGeneration,
clearHistory,
GENERATION_HISTORY_KEY,
loadHistory,
MAX_HISTORY_ENTRIES,
MAX_MARKDOWN_CHARS,
removeHistoryEntry,
saveHistory,
type GenerationHistoryEntry,
} from "@/lib/generationHistory";

type FakeStorage = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
dump: () => Record<string, string>;
};

function createFakeStorage(initial?: Record<string, string>): FakeStorage {
const store = new Map<string, string>(Object.entries(initial ?? {}));
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value);
},
removeItem: (key) => {
store.delete(key);
},
dump: () => Object.fromEntries(store),
};
}

function makeEntry(
url: string,
language = "English",
markdown = "# Readme",
): GenerationHistoryEntry {
return {
id: `id-${url}-${language}`,
url,
language,
markdown,
createdAt: 1_000_000,
};
}

describe("appendGeneration", () => {
it("adds a new entry at the front and normalizes the URL", () => {
const next = appendGeneration(
[],
" https://github.com/Owner/Repo/ ",
"English",
"# Hi",
);
expect(next).toHaveLength(1);
expect(next[0].url).toBe("https://github.com/Owner/Repo");
expect(next[0].language).toBe("English");
expect(next[0].markdown).toBe("# Hi");
});

it("dedupes by URL + language, moving the existing entry to the front", () => {
const first = [makeEntry("https://github.com/a/b", "English")];
const oldDate = 100;
const firstWithDate = [{ ...first[0], createdAt: oldDate }];
const next = appendGeneration(
firstWithDate,
"https://github.com/A/B",
"English",
"# v2",
500,
);
expect(next).toHaveLength(1);
expect(next[0].markdown).toBe("# v2");
expect(next[0].createdAt).toBe(500);
});

it("keeps separate entries for different languages", () => {
const next = appendGeneration(
[makeEntry("https://github.com/a/b", "English")],
"https://github.com/a/b",
"Spanish",
"# v2",
);
expect(next).toHaveLength(2);
});

it("evicts the oldest entries beyond the maximum", () => {
let entries: GenerationHistoryEntry[] = [];
for (let i = 0; i < MAX_HISTORY_ENTRIES + 5; i++) {
entries = appendGeneration(
entries,
`https://github.com/owner/repo${i}`,
"English",
"# x",
);
}
expect(entries).toHaveLength(MAX_HISTORY_ENTRIES);
expect(entries[0].url).toBe(
`https://github.com/owner/repo${MAX_HISTORY_ENTRIES + 4}`,
);
});

it("truncates oversized markdown", () => {
const hugeMarkdown = "x".repeat(MAX_MARKDOWN_CHARS + 500);
const next = appendGeneration(
[],
"https://github.com/a/b",
"English",
hugeMarkdown,
);
expect(next[0].markdown.length).toBe(MAX_MARKDOWN_CHARS);
});
});

describe("loadHistory", () => {
it("returns [] when no key exists", () => {
expect(loadHistory(createFakeStorage())).toEqual([]);
});

it("returns [] for corrupt JSON", () => {
const storage = createFakeStorage({
[GENERATION_HISTORY_KEY]: "{not json",
});
expect(loadHistory(storage)).toEqual([]);
});

it("returns [] when the payload is not an array", () => {
const storage = createFakeStorage({
[GENERATION_HISTORY_KEY]: JSON.stringify({ url: "x" }),
});
expect(loadHistory(storage)).toEqual([]);
});

it("drops malformed entries and keeps valid ones", () => {
const payload = [
makeEntry("https://github.com/good/repo"),
{ url: "https://github.com/broken/repo" },
"junk",
null,
];
const storage = createFakeStorage({
[GENERATION_HISTORY_KEY]: JSON.stringify(payload),
});
const loaded = loadHistory(storage);
expect(loaded).toHaveLength(1);
expect(loaded[0].url).toBe("https://github.com/good/repo");
});

it("enforces the entry cap and markdown cap on load", () => {
const many = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, i) =>
makeEntry(`https://github.com/o/r${i}`),
).map((entry) => ({
...entry,
markdown: entry.markdown.repeat(MAX_MARKDOWN_CHARS),
}));
const storage = createFakeStorage({
[GENERATION_HISTORY_KEY]: JSON.stringify(many),
});
const loaded = loadHistory(storage);
expect(loaded).toHaveLength(MAX_HISTORY_ENTRIES);
expect(
loaded.every((entry) => entry.markdown.length <= MAX_MARKDOWN_CHARS),
).toBe(true);
});
});

describe("saveHistory", () => {
it("round-trips entries through localStorage", () => {
const storage = createFakeStorage();
const entries = [makeEntry("https://github.com/a/b", "English")];
const persisted = saveHistory(entries, storage);
expect(persisted).toEqual(entries);
expect(loadHistory(storage)).toEqual(entries);
});

it("returns null when no storage is available", () => {
expect(
saveHistory([makeEntry("https://github.com/a/b", "English")], null),
).toBeNull();
});

it("returns the persisted subset when the quota forces eviction", () => {
const store = new Map<string, string>();
const storage: FakeStorage = {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
const parsedLength = (JSON.parse(value) as unknown[]).length;
if (parsedLength >= 4) {
throw new Error("QuotaExceededError");
}
store.set(key, value);
},
removeItem: (key) => {
store.delete(key);
},
dump: () => Object.fromEntries(store),
};

let entries: GenerationHistoryEntry[] = [];
for (let i = 0; i < 6; i++) {
entries = appendGeneration(
entries,
`https://github.com/owner/repo${i}`,
"English",
"# x",
);
}
expect(entries).toHaveLength(6);

const persisted = saveHistory(entries, storage);
expect(persisted).not.toBeNull();
expect(persisted!.length).toBeLessThan(6);
expect(persisted).toEqual(loadHistory(storage));
});
});

describe("removeHistoryEntry", () => {
it("removes the requested entry and keeps the rest", () => {
const entries = [
makeEntry("https://github.com/a/b", "English"),
makeEntry("https://github.com/c/d", "English"),
];
const next = removeHistoryEntry(entries, entries[0].id);
expect(next).toHaveLength(1);
expect(next[0].url).toBe("https://github.com/c/d");
});
});

describe("clearHistory", () => {
it("removes the stored key", () => {
const storage = createFakeStorage({
[GENERATION_HISTORY_KEY]: JSON.stringify([
makeEntry("https://github.com/a/b"),
]),
});
clearHistory(storage);
expect(storage.dump()).toEqual({});
});
});
80 changes: 77 additions & 3 deletions src/app/generate/GeneratePageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { SearchInput } from "@/components/Generator/SearchInput";
import { MarkdownPreview } from "@/components/Generator/MarkdownPreview";
import { GenerationHistory } from "@/components/Generator/GenerationHistory";
import LoadingOverlay from "@/components/Generator/LoadingOverlay";
import { navLinks } from "@/constants/navLinks";
import { TerminalMockup } from "@/components/sections/TerminalMockup";
import {
appendGeneration,
clearHistory,
loadHistory,
saveHistory,
GENERATION_HISTORY_KEY,
type GenerationHistoryEntry,
} from "@/lib/generationHistory";

interface GeneratePageProps {
repoSlug?: string;
Expand All @@ -20,6 +29,14 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) {
const [authRequired, setAuthRequired] = useState(false);
const [privateRepoConsentRequired, setPrivateRepoConsentRequired] =
useState(false);
const [history, setHistory] = useState<GenerationHistoryEntry[]>([]);
const [activeHistoryEntryId, setActiveHistoryEntryId] = useState<string>();
const [restoredForm, setRestoredForm] = useState<{
url: string;
language: string;
} | null>(null);
const [restoreKey, setRestoreKey] = useState(0);
const previewRef = useRef<HTMLDivElement>(null);

// Optional: Update document title for SPA navigation
useEffect(() => {
Expand All @@ -31,6 +48,18 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) {
}
}, [repoSlug]);

useEffect(() => {
setHistory(loadHistory());

const handleStorage = (event: StorageEvent) => {
if (event.key === GENERATION_HISTORY_KEY) {
setHistory(loadHistory());
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);

const handleGenerate = async (
githubUrl: string,
language: string = "English",
Expand Down Expand Up @@ -83,6 +112,16 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) {
const data = await response.json();
if (data && typeof data.markdown === "string") {
setMarkdown(data.markdown);
const nextHistory = appendGeneration(
history,
githubUrl,
language,
data.markdown,
);
const persisted = saveHistory(nextHistory);
const committedHistory = persisted ?? nextHistory;
setHistory(committedHistory);
setActiveHistoryEntryId(committedHistory[0]?.id);
} else {
setMarkdown("");
throw new Error(
Expand All @@ -106,6 +145,26 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) {
setAuthRequired(false);
};

const handleRestoreGeneration = (entry: GenerationHistoryEntry) => {
setRestoredForm({ url: entry.url, language: entry.language });
setActiveHistoryEntryId(entry.id);
setRestoreKey((key) => key + 1);
setMarkdown(entry.markdown);
setErrorMessage(null);
setErrorCode(null);
setAuthRequired(false);
setPrivateRepoConsentRequired(false);
previewRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
};

const handleClearHistory = () => {
setHistory([]);
clearHistory();
};

return (
<div className="relative min-h-screen bg-black text-white">
{/* UI LOADING OVERLAY
Expand All @@ -120,17 +179,32 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) {
Generate Your AI-Powered README
</h1>
<SearchInput
key={`${restoreKey}|${restoredForm?.url ?? ""}|${restoredForm?.language ?? ""}`}
onGenerate={handleGenerate}
isLoading={isLoading}
initialValue={repoSlug ? `https://github.com/${repoSlug}` : ""}
initialValue={
restoredForm?.url ??
(repoSlug ? `https://github.com/${repoSlug}` : "")
}
initialLanguage={restoredForm?.language ?? "English"}
ariaLabel="Enter GitHub repository URL to generate README"
serverError={errorMessage}
authRequired={authRequired}
serverErrorCode={errorCode}
privateRepoConsentRequired={privateRepoConsentRequired}
onClearPrivateRepoConsent={clearGenerateFormState}
/>
<MarkdownPreview content={markdown} />
<div className="mt-4">
<GenerationHistory
entries={history}
activeEntryId={activeHistoryEntryId}
onRestore={handleRestoreGeneration}
onClearAll={handleClearHistory}
/>
</div>
<div ref={previewRef} className="scroll-mt-24">
<MarkdownPreview content={markdown} />
</div>
</main>
<TerminalMockup />
<Footer />
Expand Down
Loading