Skip to content

Commit 314c4a5

Browse files
committed
fix: addresses PR review findings across all domains
1 parent 1478042 commit 314c4a5

File tree

10 files changed

+162
-85
lines changed

10 files changed

+162
-85
lines changed

src/app/components/onboarding/OnboardingWizard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export default function OnboardingWizard() {
121121
type="button"
122122
onClick={handleFinish}
123123
disabled={selectedRepos().length === 0}
124-
class="ml-auto rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600"
124+
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600"
125125
>
126126
{selectedRepos().length === 0
127127
? "Finish Setup"

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export default function RepoSelector(props: RepoSelectorProps) {
132132
const client = getClient();
133133
if (!client) return;
134134

135+
const version = effectVersion;
136+
135137
setOrgStates((prev) =>
136138
prev.map((s) =>
137139
s.org === org ? { ...s, loading: true, error: null } : s
@@ -143,13 +145,15 @@ export default function RepoSelector(props: RepoSelectorProps) {
143145

144146
void fetchRepos(client, org, type)
145147
.then((repos) => {
148+
if (version !== effectVersion) return;
146149
setOrgStates((prev) =>
147150
prev.map((s) =>
148151
s.org === org ? { ...s, repos, loading: false, error: null } : s
149152
)
150153
);
151154
})
152155
.catch((err) => {
156+
if (version !== effectVersion) return;
153157
const message =
154158
err instanceof Error ? err.message : "Failed to load repositories";
155159
setOrgStates((prev) =>
@@ -370,7 +374,6 @@ export default function RepoSelector(props: RepoSelectorProps) {
370374
class="max-h-[300px] overflow-y-auto"
371375
role="region"
372376
aria-label={`${state().org} repositories`}
373-
data-testid={`repo-scroll-${state().org}`}
374377
>
375378
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
376379
<Index each={visible()}>

src/app/components/settings/SettingsPage.tsx

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,19 @@ export default function SettingsPage() {
205205
async function mergeNewOrgs() {
206206
const client = getClient();
207207
if (!client) return;
208+
const snapshot = [...config.selectedOrgs];
208209
try {
209210
const allOrgs = await fetchOrgs(client);
210211
// Case-insensitive comparison — GitHub logins are case-insensitive
211-
const currentSet = new Set(config.selectedOrgs.map((o) => o.toLowerCase()));
212+
const currentSet = new Set(snapshot.map((o) => o.toLowerCase()));
212213
const newOrgs = allOrgs
213214
.map((o) => o.login)
214215
.filter((login) => !currentSet.has(login.toLowerCase()));
215216
if (newOrgs.length > 0) {
216-
const merged = [...config.selectedOrgs, ...newOrgs];
217+
const merged = [...snapshot, ...newOrgs];
217218
setLocalOrgs(merged);
218219
saveWithFeedback({ selectedOrgs: merged });
219-
console.info(`[settings] merged ${newOrgs.length} new org(s): ${newOrgs.join(", ")}`);
220+
console.info(`[settings] merged ${newOrgs.length} new org(s)`);
220221
}
221222
} catch {
222223
// Non-fatal — user can manually manage orgs
@@ -421,25 +422,27 @@ export default function SettingsPage() {
421422
</div>
422423
</div>
423424

424-
<div class="border-t border-gray-100 pt-3 dark:border-gray-700 flex items-center justify-between">
425-
<div>
426-
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">Repositories</p>
427-
<p class="text-xs text-gray-500 dark:text-gray-400">
428-
{localRepos().length} selected
429-
</p>
430-
<Show when={localRepos().length > 50}>
431-
<p class="text-xs text-amber-600 dark:text-amber-400">
432-
Tracking {localRepos().length} repos will use significant API quota per poll cycle
425+
<div class="border-t border-gray-100 pt-3 dark:border-gray-700">
426+
<div class="flex items-center justify-between">
427+
<div>
428+
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">Repositories</p>
429+
<p class="text-xs text-gray-500 dark:text-gray-400">
430+
{localRepos().length} selected
433431
</p>
434-
</Show>
432+
<Show when={localRepos().length > 50}>
433+
<p class="text-xs text-amber-600 dark:text-amber-400">
434+
Tracking {localRepos().length} repos will use significant API quota per poll cycle
435+
</p>
436+
</Show>
437+
</div>
438+
<button
439+
type="button"
440+
onClick={() => setRepoPanelOpen((v) => !v)}
441+
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
442+
>
443+
Manage Repositories
444+
</button>
435445
</div>
436-
<button
437-
type="button"
438-
onClick={() => setRepoPanelOpen((v) => !v)}
439-
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
440-
>
441-
Manage Repositories
442-
</button>
443446
</div>
444447
<Show when={repoPanelOpen()}>
445448
<div class="rounded-lg border border-gray-200 p-4 dark:border-gray-700">

src/app/lib/oauth.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export function generateOAuthState(): string {
1010
.replace(/=/g, "");
1111
}
1212

13+
export function sanitizeReturnTo(returnTo: string | null): string {
14+
return returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
15+
? returnTo
16+
: "/";
17+
}
18+
1319
export function buildAuthorizeUrl(options?: { returnTo?: string }): string {
1420
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID as string;
1521
const state = generateOAuthState();

src/app/pages/OAuthCallback.tsx

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createSignal, onMount, Show } from "solid-js";
22
import { useNavigate } from "@solidjs/router";
33
import { setAuth, validateToken, clearAuth } from "../stores/auth";
4-
import { OAUTH_STATE_KEY, OAUTH_RETURN_TO_KEY } from "../lib/oauth";
4+
import { OAUTH_STATE_KEY, OAUTH_RETURN_TO_KEY, sanitizeReturnTo } from "../lib/oauth";
5+
import LoadingSpinner from "../components/shared/LoadingSpinner";
56

67
interface TokenResponse {
78
access_token: string;
@@ -63,12 +64,7 @@ export default function OAuthCallback() {
6364
return;
6465
}
6566

66-
// Only allow internal paths (prevent open redirect)
67-
const target =
68-
returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
69-
? returnTo
70-
: "/";
71-
navigate(target, { replace: true });
67+
navigate(sanitizeReturnTo(returnTo), { replace: true });
7268
} catch {
7369
setError("A network error occurred. Please try again.");
7470
}
@@ -81,30 +77,7 @@ export default function OAuthCallback() {
8177
when={error()}
8278
fallback={
8379
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md p-8 flex flex-col items-center gap-4">
84-
<svg
85-
class="animate-spin h-8 w-8 text-gray-500"
86-
xmlns="http://www.w3.org/2000/svg"
87-
fill="none"
88-
viewBox="0 0 24 24"
89-
aria-label="Loading"
90-
>
91-
<circle
92-
class="opacity-25"
93-
cx="12"
94-
cy="12"
95-
r="10"
96-
stroke="currentColor"
97-
stroke-width="4"
98-
/>
99-
<path
100-
class="opacity-75"
101-
fill="currentColor"
102-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
103-
/>
104-
</svg>
105-
<p class="text-gray-600 dark:text-gray-400">
106-
Completing sign in...
107-
</p>
80+
<LoadingSpinner size="md" label="Completing sign in..." />
10881
</div>
10982
}
11083
>

tests/components/OAuthCallback.test.tsx

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -373,26 +373,6 @@ describe("OAuthCallback", () => {
373373
expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
374374
});
375375

376-
// Verify the returnTo validation logic directly — the navigate() call itself
377-
// cannot be intercepted due to SolidJS router limitations (partial mock of
378-
// @solidjs/router renders empty div — project gotcha), but we can verify
379-
// the logic by testing the validation conditions match the implementation.
380-
it("returnTo validation accepts internal paths and rejects external URLs", () => {
381-
// This tests the SAME validation logic used on OAuthCallback line 67-70:
382-
// returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
383-
const validate = (returnTo: string | null) =>
384-
returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
385-
? returnTo
386-
: "/";
387-
388-
expect(validate("/settings")).toBe("/settings");
389-
expect(validate("/dashboard")).toBe("/dashboard");
390-
expect(validate("/")).toBe("/");
391-
expect(validate("https://evil.com")).toBe("/");
392-
expect(validate("//evil.com")).toBe("/");
393-
expect(validate("javascript:alert(1)")).toBe("/");
394-
expect(validate(null)).toBe("/");
395-
expect(validate("")).toBe("/");
396-
});
376+
// returnTo validation is tested via sanitizeReturnTo in tests/lib/oauth.test.ts
397377

398378
});

tests/components/onboarding/OnboardingWizard.test.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,42 @@ describe("OnboardingWizard", () => {
117117
vi.mocked(apiModule.fetchOrgs).mockReturnValue(new Promise(() => {}));
118118
render(() => <OnboardingWizard />);
119119
await waitFor(() => {
120-
expect(screen.getByTestId("loading-spinner")).toBeDefined();
120+
screen.getByTestId("loading-spinner");
121+
});
122+
});
123+
124+
it("redirects to /dashboard when onboardingComplete is already true", async () => {
125+
Object.assign(configStore.config, { onboardingComplete: true });
126+
render(() => <OnboardingWizard />);
127+
await waitFor(() => {
128+
expect(window.location.replace).toHaveBeenCalledWith("/dashboard");
129+
});
130+
expect(apiModule.fetchOrgs).not.toHaveBeenCalled();
131+
Object.assign(configStore.config, { onboardingComplete: false });
132+
});
133+
134+
it("retry clears error and shows RepoSelector on success", async () => {
135+
const user = userEvent.setup();
136+
vi.mocked(apiModule.fetchOrgs)
137+
.mockRejectedValueOnce(new Error("Network error"))
138+
.mockResolvedValueOnce(mockOrgs);
139+
render(() => <OnboardingWizard />);
140+
await waitFor(() => {
141+
screen.getByText("Retry");
142+
});
143+
await user.click(screen.getByText("Retry"));
144+
await waitFor(() => {
145+
screen.getByTestId("repo-selector");
146+
});
147+
expect(screen.queryByText(/Network error/i)).toBeNull();
148+
});
149+
150+
it("shows error when getClient returns null", async () => {
151+
const { getClient } = await import("../../../src/app/services/github");
152+
vi.mocked(getClient).mockReturnValueOnce(null);
153+
render(() => <OnboardingWizard />);
154+
await waitFor(() => {
155+
screen.getByText(/No GitHub client available/i);
121156
});
122157
});
123158

tests/components/onboarding/RepoSelector.test.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ describe("RepoSelector", () => {
313313
expect(orgHeaders[1].textContent).toBe("active-org");
314314
});
315315

316-
it("each org group has a scroll container with data-testid=repo-scroll-{org}", async () => {
316+
it("each org group has a scrollable region with aria-label", async () => {
317317
vi.mocked(api.fetchRepos).mockImplementation((_client, org) => {
318318
if (org === "myorg") return Promise.resolve(myorgRepos);
319319
return Promise.resolve(otherorgRepos);
@@ -325,8 +325,8 @@ describe("RepoSelector", () => {
325325
screen.getByText("repo-a");
326326
screen.getByText("repo-c");
327327
});
328-
expect(screen.getByTestId("repo-scroll-myorg")).toBeDefined();
329-
expect(screen.getByTestId("repo-scroll-otherog")).toBeDefined();
328+
screen.getByRole("region", { name: "myorg repositories" });
329+
screen.getByRole("region", { name: "otherog repositories" });
330330
});
331331

332332
it("scroll container has max-h-[300px] and overflow-y-auto classes", async () => {
@@ -337,8 +337,28 @@ describe("RepoSelector", () => {
337337
await waitFor(() => {
338338
screen.getByText("repo-a");
339339
});
340-
const scrollContainer = screen.getByTestId("repo-scroll-myorg");
340+
const scrollContainer = screen.getByRole("region", { name: "myorg repositories" });
341341
expect(scrollContainer.classList.contains("max-h-[300px]")).toBe(true);
342342
expect(scrollContainer.classList.contains("overflow-y-auto")).toBe(true);
343343
});
344+
345+
it("skips internal fetchOrgs when orgEntries prop is provided", async () => {
346+
vi.mocked(api.fetchOrgs).mockClear();
347+
vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos);
348+
const preloaded = [
349+
{ login: "myorg", avatarUrl: "", type: "org" as const },
350+
];
351+
render(() => (
352+
<RepoSelector
353+
selectedOrgs={["myorg"]}
354+
orgEntries={preloaded}
355+
selected={[]}
356+
onChange={vi.fn()}
357+
/>
358+
));
359+
await waitFor(() => {
360+
screen.getByText("repo-a");
361+
});
362+
expect(api.fetchOrgs).not.toHaveBeenCalled();
363+
});
344364
});

tests/components/settings/SettingsPage.test.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ vi.mock("../../../src/app/stores/cache", () => ({
3333
}));
3434

3535
vi.mock("../../../src/app/services/github", () => ({
36-
getClient: () => ({}),
36+
getClient: vi.fn(() => ({})),
3737
}));
3838

3939
vi.mock("../../../src/app/services/api", () => ({
@@ -143,7 +143,6 @@ describe("SettingsPage — rendering", () => {
143143
it("renders a back to dashboard link", () => {
144144
renderSettings();
145145
const backLink = screen.getByRole("link", { name: /back to dashboard/i });
146-
expect(backLink).toBeDefined();
147146
expect(backLink.getAttribute("href")).toBe("/dashboard");
148147
});
149148

@@ -193,8 +192,7 @@ describe("SettingsPage — rendering", () => {
193192
describe("SettingsPage — Refresh interval", () => {
194193
it("shows current refresh interval value", () => {
195194
renderSettings();
196-
const select = screen.getByDisplayValue("5 minutes (default)");
197-
expect(select).toBeDefined();
195+
screen.getByDisplayValue("5 minutes (default)");
198196
});
199197

200198
it("changing refresh interval calls updateConfig", async () => {
@@ -284,6 +282,8 @@ describe("SettingsPage — GitHub Actions", () => {
284282
expect(workflowInput).toBeDefined();
285283
});
286284

285+
// NumberInput uses onInput — fireEvent.input sets the value atomically, while
286+
// userEvent.type fires per-keystroke triggering intermediate valid values.
287287
it("changing max workflows per repo updates config", () => {
288288
renderSettings();
289289
const inputs = screen.getAllByRole("spinbutton");
@@ -557,14 +557,18 @@ describe("SettingsPage — Re-auth button", () => {
557557
vi.unstubAllEnvs();
558558
});
559559

560-
it("'Grant more orgs' button is disabled after click (reentrancy guard)", async () => {
561-
const user = userEvent.setup();
560+
it("'Grant more orgs' button is disabled after click and re-enables after timeout", async () => {
561+
vi.useFakeTimers();
562+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
562563
vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
563564
renderSettings();
564565
const btn = screen.getByRole("button", { name: "Grant more orgs" });
565566
await user.click(btn);
566567
expect(btn.hasAttribute("disabled")).toBe(true);
568+
vi.advanceTimersByTime(3000);
569+
expect(btn.hasAttribute("disabled")).toBe(false);
567570
vi.unstubAllEnvs();
571+
vi.useRealTimers();
568572
});
569573
});
570574

@@ -615,4 +619,28 @@ describe("SettingsPage — Auto-merge orgs on mount", () => {
615619
renderSettings();
616620
expect(apiModule.fetchOrgs).not.toHaveBeenCalled();
617621
});
622+
623+
it("silently handles fetchOrgs rejection without breaking", async () => {
624+
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
625+
updateConfig({ selectedOrgs: ["existing-org"] });
626+
vi.mocked(apiModule.fetchOrgs).mockRejectedValue(new Error("Network error"));
627+
renderSettings();
628+
await waitFor(() => {
629+
expect(apiModule.fetchOrgs).toHaveBeenCalled();
630+
});
631+
// Config unchanged — error was swallowed
632+
expect(config.selectedOrgs).toEqual(["existing-org"]);
633+
});
634+
635+
it("skips merge when getClient returns null", async () => {
636+
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
637+
const github = await import("../../../src/app/services/github");
638+
vi.mocked(github.getClient).mockReturnValueOnce(null);
639+
renderSettings();
640+
// MERGE_ORGS_KEY still removed synchronously before mergeNewOrgs
641+
await waitFor(() => {
642+
expect(sessionStorage.getItem(MERGE_ORGS_KEY)).toBeNull();
643+
});
644+
expect(apiModule.fetchOrgs).not.toHaveBeenCalled();
645+
});
618646
});

0 commit comments

Comments
 (0)