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
16 changes: 10 additions & 6 deletions src/app/components/dashboard/JiraAssignedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,11 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
reorderTimeoutId = setTimeout(() => setReordering(false), 200);
}

function renderIssueRow(issue: JiraItem, boundary?: { isFirst: boolean; isLast: boolean }) {
// boundary.isFirst/isLast are accessors (not plain booleans) so the disabled
// state re-tracks the row's live position: keyed <For> moves cached row nodes
// on reorder without re-running the callback, so a snapshotted index() would
// freeze the first/last state to where the row was originally rendered.
function renderIssueRow(issue: JiraItem, boundary?: { isFirst: () => boolean; isLast: () => boolean }) {
const isPinned = () => pinnedJiraKeys().has(issue.key);
const browseUrl = () => isSafeJiraSiteUrl(props.siteUrl) ? `${props.siteUrl}/browse/${issue.key}` : "#";
const isIssueExpanded = () => expandByDefault() ? !toggledIssues().has(issue.key) : toggledIssues().has(issue.key);
Expand All @@ -436,11 +440,11 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
ref={(el) => { if (isCustomMode()) itemRefs.set(issue.key, el); }}
>
<Show when={isCustomMode()}>
<div class="flex flex-col shrink-0 justify-center gap-0.5 pl-2 compact:pl-1">
<div class="flex shrink-0 items-center justify-center gap-0.5 pl-2 compact:pl-1">
<button
type="button"
class="btn btn-ghost btn-xs compact:min-h-0 compact:h-6 compact:w-7 compact:px-0"
disabled={!canReorder() || reordering() || !!boundary?.isFirst}
disabled={!canReorder() || reordering() || !!boundary?.isFirst()}
aria-label={`Move up: ${issue.key}`}
title={reorderTitle()}
onClick={() => handleCustomMove(issue.key, "up")}
Expand All @@ -452,7 +456,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
<button
type="button"
class="btn btn-ghost btn-xs compact:min-h-0 compact:h-6 compact:w-7 compact:px-0"
disabled={!canReorder() || reordering() || !!boundary?.isLast}
disabled={!canReorder() || reordering() || !!boundary?.isLast()}
aria-label={`Move down: ${issue.key}`}
title={reorderTitle()}
onClick={() => handleCustomMove(issue.key, "down")}
Expand Down Expand Up @@ -759,8 +763,8 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
<For each={customPageItems()}>
{(issue, index) =>
renderIssueRow(issue, {
isFirst: page() === 0 && index() === 0,
isLast: page() === pageCount() - 1 && index() === customPageItems().length - 1,
isFirst: () => page() === 0 && index() === 0,
isLast: () => page() === pageCount() - 1 && index() === customPageItems().length - 1,
})
}
</For>
Expand Down
37 changes: 37 additions & 0 deletions tests/components/dashboard/JiraAssignedTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,43 @@ describe("JiraAssignedTab", () => {
expect(downLast.disabled).toBe(true);
});

it("re-tracks first/last disabled state on the moved rows after a reorder", () => {
// Regression guard: isFirst/isLast are accessors, not booleans snapshotted at
// first render. Keyed <For> moves reference-cached row nodes on reorder WITHOUT
// re-running the callback, so a snapshot would freeze the up/down disabled state
// to each row's original position — leaving the moved-away first row disabled and
// the new first row enabled. Drive the reorder through the reactive prop layer
// (same technique as the DOM-identity test below); no click, so the reordering()
// lockout is not involved and the assertions isolate the boundary state itself.
// Moving PROJ-1 from first to last exercises BOTH boundaries in one step.
mockJiraFilters = customFilters();
mockJiraCustomOrder = ["PROJ-1", "PROJ-2", "PROJ-3"];

const issueA = makeIssue("PROJ-1");
const issueB = makeIssue("PROJ-2");
const issueC = makeIssue("PROJ-3");

const [issues, setIssues] = createSignal<JiraIssue[]>([issueA, issueB, issueC]);
render(() => <JiraAssignedTab issues={issues()} loading={false} siteUrl={SITE_URL} />);

// Initial: PROJ-1 first (up disabled), PROJ-3 last (down disabled).
expect((screen.getByRole("button", { name: "Move up: PROJ-1" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Move down: PROJ-3" }) as HTMLButtonElement).disabled).toBe(true);

// Move PROJ-1 to the bottom; poke the signal (same object refs) to force
// filtered → filteredSorted → customPageItems → <For> to re-evaluate.
mockJiraCustomOrder = ["PROJ-2", "PROJ-3", "PROJ-1"];
setIssues([issueA, issueB, issueC]);

// New first row (PROJ-2): up disabled. Moved-to-last row (PROJ-1): up now
// enabled, down now disabled. Former-last row (PROJ-3): down now enabled.
// Pre-fix, every one of these was frozen at its first-render value.
expect((screen.getByRole("button", { name: "Move up: PROJ-2" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Move up: PROJ-1" }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole("button", { name: "Move down: PROJ-1" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Move down: PROJ-3" }) as HTMLButtonElement).disabled).toBe(false);
});

it("disables arrow buttons when a status filter is active", () => {
mockJiraFilters = customFilters({ statusCategory: "new" });
const issues = [makeIssue("PROJ-1", "PROJ", "new"), makeIssue("PROJ-2", "PROJ", "new")];
Expand Down
31 changes: 31 additions & 0 deletions tests/fetch-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterEach, beforeEach, vi } from "vitest";

// Network guard for the happy-dom unit-test suite (the "browser" project in
// vitest.workspace.ts). An unmocked fetch launches a real request via happy-dom;
// when vitest tears down the window it aborts pending requests, and happy-dom's
// abort path double-closes the response body stream ("Invalid state: Controller
// is already closed"), which surfaces as an uncaught error that fails the whole
// file — intermittently, since it only bites when the request is still in flight
// at teardown (i.e. under CI parallelism). Defaulting fetch to a fast network
// error makes a forgotten stub fail cheaply and deterministically instead of
// leaking a live request. Tests that need responses override this per-test with
// vi.stubGlobal("fetch", ...).
//
// Deliberately NOT part of tests/setup.ts: the live-network smoke suite
// (vitest.smoke.config.ts / *.smoke.test.ts) extends vitest.config.ts and shares
// tests/setup.ts, but must reach the real network. Wiring this guard only into
// the workspace browser project keeps the smoke suite unguarded.
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.reject(
new TypeError('Unmocked fetch in a unit test — stub it with vi.stubGlobal("fetch", ...)'),
),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
});
17 changes: 15 additions & 2 deletions tests/stores/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,16 +501,29 @@ describe("cross-tab auth sync", () => {
expect(mod.token()).toBe("ghs_abc");
});

it("updates token signal when another tab writes a different non-null value", () => {
it("updates token signal when another tab writes a different non-null value", async () => {
// A different non-null value fires the listener's fire-and-forget /user fetch
// (auth.ts) to refresh the profile for the adopted token. Stub it so the request
// never hits the real happy-dom network and cannot leak past teardown.
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ login: "replaced", avatar_url: "", name: null }),
});
vi.stubGlobal("fetch", fetchMock);

mod.setAuth({ access_token: "ghs_abc" });

window.dispatchEvent(new StorageEvent("storage", {
key: "github-tracker:auth-token",
newValue: "ghs_replacement",
}));

// Token updated to the replacement value
// Token updated to the replacement value synchronously
expect(mod.token()).toBe("ghs_replacement");

// Let the fire-and-forget fetch settle so nothing survives into teardown.
await new Promise((r) => setTimeout(r, 0));
});

it("same-value StorageEvent is a no-op (dedup guard)", () => {
Expand Down
4 changes: 3 additions & 1 deletion vitest.workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export default defineConfig({
environment: "happy-dom",
globals: true,
hookTimeout: 30_000,
setupFiles: ["tests/setup.ts"],
// fetch-guard.ts is browser-project-only (not in tests/setup.ts) so the
// live-network smoke suite, which shares tests/setup.ts, stays unguarded.
setupFiles: ["tests/setup.ts", "tests/fetch-guard.ts"],
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "tests/**/*.steps.tsx"],
exclude: ["tests/worker/**", "tests/**/*.smoke.test.ts"],
},
Expand Down