Skip to content

Commit 1bfd922

Browse files
committed
refactor(test): deduplicates helpers, adds staggered-load test
Hoists getOrgHeaderOrder, getAccordionOrder, and 5 entry fixtures to describe scope. Adds regex metacharacter escaping to both helpers. Adds S8 test for invalidation-trickle-in guard with deferred promises. Documents .steps.tsx BDD convention in CONTRIBUTING.md. Trims verbose comment in RepoSelector.tsx. Fixes stale comment in BDD step file.
1 parent c4d0a20 commit 1bfd922

File tree

4 files changed

+86
-46
lines changed

4 files changed

+86
-46
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ pnpm test -- tests/path/to/test.ts
6565

6666
## Testing
6767

68-
Tests live in `tests/` and mirror the `src/` directory structure. Test files end in `.test.ts` or `.test.tsx`.
68+
Tests live in `tests/` and mirror the `src/` directory structure. Test files end in `.test.ts` or `.test.tsx`. BDD step definitions end in `.steps.tsx` and are also picked up by Vitest automatically.
6969

7070
Factory helpers in `tests/helpers/index.tsx` (`makeIssue`, `makePullRequest`, `makeWorkflowRun`) give you typed test fixtures — use them instead of hand-rolling objects.
7171

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
342342
new Set((props.monitoredRepos ?? []).map((r) => r.fullName))
343343
);
344344

345-
// Plain let variables, not signals — writing to a signal from inside createMemo is a
346-
// side effect that triggers re-evaluation, causing an infinite loop.
345+
// Plain let — not signals; mutating a signal inside createMemo causes infinite re-evaluation.
347346
let frozenOrder: string[] | null = null;
348347
let frozenOrgsKey = "";
349348

tests/acceptance/steps/org-order-stability.steps.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function makeOrgRepos(org: string): RepoEntry[] {
6666
}
6767

6868
// ── Helper: flat (non-accordion) org header order ────────────────────────────
69-
// Org names follow GitHub's [A-Za-z0-9-] pattern — no regex escaping needed.
69+
// Org names follow GitHub's [A-Za-z0-9-] pattern — escaping is defensive.
7070
function getOrgHeaderOrder(orgNames: string[]): string[] {
7171
const escaped = orgNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
7272
const pattern = new RegExp(`^(${escaped.join("|")})$`);

tests/components/onboarding/RepoSelector.test.tsx

Lines changed: 83 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,23 +1503,37 @@ describe("RepoSelector — frozen org order", () => {
15031503
}
15041504

15051505
// Flat (non-accordion) mode only: org names appear as plain text nodes in headers.
1506-
// In accordion mode, org names are inside button triggers — use different query selectors (see S6).
1506+
// In accordion mode, org names are inside button triggers — use getAccordionOrder below.
15071507
function getOrgHeaderOrder(orgNames: string[]): string[] {
1508-
const pattern = new RegExp(`^(${orgNames.join("|")})$`);
1508+
const escaped = orgNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
1509+
const pattern = new RegExp(`^(${escaped.join("|")})$`);
15091510
return screen.getAllByText(pattern).map((el) => el.textContent!);
15101511
}
15111512

1513+
// Accordion mode: org names are inside button triggers — sort by DOM position.
1514+
function getAccordionOrder(orgNames: string[]): string[] {
1515+
return orgNames
1516+
.map((name) => ({ name, btn: screen.getByRole("button", { name: new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }) }))
1517+
.sort((a, b) => {
1518+
const pos = a.btn.compareDocumentPosition(b.btn);
1519+
return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
1520+
})
1521+
.map(({ name }) => name);
1522+
}
1523+
15121524
beforeEach(() => {
15131525
vi.clearAllMocks();
15141526
vi.restoreAllMocks();
15151527
});
15161528

1529+
const aliceEntry = { login: "alice", avatarUrl: "", type: "user" as const };
1530+
const acmeEntry = { login: "acme-corp", avatarUrl: "", type: "org" as const };
1531+
const betaEntry = { login: "beta-org", avatarUrl: "", type: "org" as const };
1532+
const deltaEntry = { login: "delta-inc", avatarUrl: "", type: "org" as const };
1533+
const aaaEntry = { login: "aaa-org", avatarUrl: "", type: "org" as const };
1534+
15171535
// S1: Org order remains stable after repo retry
15181536
it("org order remains stable after repo retry for a failed org", async () => {
1519-
const aliceEntry = { login: "alice", avatarUrl: "", type: "user" as const };
1520-
const acmeEntry = { login: "acme-corp", avatarUrl: "", type: "org" as const };
1521-
const betaEntry = { login: "beta-org", avatarUrl: "", type: "org" as const };
1522-
15231537
vi.mocked(api.fetchRepos)
15241538
.mockImplementation((_client, org) => {
15251539
if (org === "beta-org") return Promise.reject(new Error("beta load failed"));
@@ -1564,10 +1578,6 @@ describe("RepoSelector — frozen org order", () => {
15641578

15651579
// S2: Org order remains stable when toggling a repo checkbox
15661580
it("org order remains stable when toggling a repo checkbox", async () => {
1567-
const aliceEntry = { login: "alice", avatarUrl: "", type: "user" as const };
1568-
const acmeEntry = { login: "acme-corp", avatarUrl: "", type: "org" as const };
1569-
const betaEntry = { login: "beta-org", avatarUrl: "", type: "org" as const };
1570-
15711581
vi.mocked(api.fetchRepos).mockImplementation((_client, org) =>
15721582
Promise.resolve(makeOrgRepos(org as string))
15731583
);
@@ -1608,10 +1618,6 @@ describe("RepoSelector — frozen org order", () => {
16081618
it("frozen order is invalidated and re-sorted when a new org is added", async () => {
16091619
const { createSignal } = await import("solid-js");
16101620

1611-
const aliceEntry = { login: "alice", avatarUrl: "", type: "user" as const };
1612-
const deltaEntry = { login: "delta-inc", avatarUrl: "", type: "org" as const };
1613-
const acmeEntry = { login: "acme-corp", avatarUrl: "", type: "org" as const };
1614-
16151621
vi.mocked(api.fetchRepos).mockImplementation((_client, org) =>
16161622
Promise.resolve(makeOrgRepos(org as string))
16171623
);
@@ -1683,20 +1689,10 @@ describe("RepoSelector — frozen org order", () => {
16831689
});
16841690

16851691
// Verify initial sorted order via accordion trigger buttons
1686-
const getAccordionOrder = (orgNames: string[]) =>
1687-
orgNames
1688-
.map((name) => ({ name, btn: screen.getByRole("button", { name: new RegExp(name) }) }))
1689-
.sort((a, b) => {
1690-
const pos = a.btn.compareDocumentPosition(b.btn);
1691-
return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
1692-
})
1693-
.map(({ name }) => name);
1694-
16951692
const initialOrder = getAccordionOrder(startOrgs);
16961693
expect(initialOrder).toEqual(["alice", "acme-corp", "charlie-co", "delta-inc", "echo-labs", "foxtrot-io"]);
16971694

16981695
// Add beta-org (7 total)
1699-
const betaEntry = { login: "beta-org", avatarUrl: "", type: "org" as const };
17001696
const newOrgs = ["alice", "acme-corp", "beta-org", "charlie-co", "delta-inc", "echo-labs", "foxtrot-io"];
17011697
setSelectedOrgs(newOrgs);
17021698
setOrgEntries([...startEntries, betaEntry]);
@@ -1747,15 +1743,6 @@ describe("RepoSelector — frozen org order", () => {
17471743
});
17481744

17491745
// Verify initial sorted order via accordion trigger buttons
1750-
const getAccordionOrder = (orgNames: string[]) =>
1751-
orgNames
1752-
.map((name) => ({ name, btn: screen.getByRole("button", { name: new RegExp(name) }) }))
1753-
.sort((a, b) => {
1754-
const pos = a.btn.compareDocumentPosition(b.btn);
1755-
return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
1756-
})
1757-
.map(({ name }) => name);
1758-
17591746
const initialOrder = getAccordionOrder(sevenOrgs);
17601747
expect(initialOrder).toEqual(["alice", "acme-corp", "beta-org", "charlie-co", "delta-inc", "echo-labs", "foxtrot-io"]);
17611748

@@ -1786,11 +1773,6 @@ describe("RepoSelector — frozen org order", () => {
17861773
it("frozen order is invalidated when orgs are simultaneously added and removed", async () => {
17871774
const { createSignal } = await import("solid-js");
17881775

1789-
const aliceEntry = { login: "alice", avatarUrl: "", type: "user" as const };
1790-
const acmeEntry = { login: "acme-corp", avatarUrl: "", type: "org" as const };
1791-
const deltaEntry = { login: "delta-inc", avatarUrl: "", type: "org" as const };
1792-
const aaaEntry = { login: "aaa-org", avatarUrl: "", type: "org" as const };
1793-
17941776
vi.mocked(api.fetchRepos).mockImplementation((_client, org) =>
17951777
Promise.resolve(makeOrgRepos(org as string))
17961778
);
@@ -1839,10 +1821,6 @@ describe("RepoSelector — frozen org order", () => {
18391821
it("removing an org invalidates frozen order and re-sorts correctly", async () => {
18401822
const { createSignal } = await import("solid-js");
18411823

1842-
const aliceEntry = { login: "alice", avatarUrl: "", type: "user" as const };
1843-
const acmeEntry = { login: "acme-corp", avatarUrl: "", type: "org" as const };
1844-
const deltaEntry = { login: "delta-inc", avatarUrl: "", type: "org" as const };
1845-
18461824
vi.mocked(api.fetchRepos).mockImplementation((_client, org) =>
18471825
Promise.resolve(makeOrgRepos(org as string))
18481826
);
@@ -1884,4 +1862,67 @@ describe("RepoSelector — frozen org order", () => {
18841862
// delta-inc repo content should also be gone from the display
18851863
expect(screen.queryByText("delta-inc-repo")).toBeNull();
18861864
});
1865+
1866+
// S8: Adding 2+ orgs simultaneously with staggered loading produces correct final sort
1867+
// Verifies the invalidation→loadedCount guard interaction during trickle-in:
1868+
// after frozenOrder is nulled (key changed), sorting is deferred until all new orgs settle.
1869+
it("adding 2+ orgs simultaneously with staggered loading produces correct final sorted order", async () => {
1870+
const { createSignal } = await import("solid-js");
1871+
1872+
let resolveEcho!: (repos: RepoEntry[]) => void;
1873+
let resolveFoxtrot!: (repos: RepoEntry[]) => void;
1874+
const echoPending = new Promise<RepoEntry[]>((res) => { resolveEcho = res; });
1875+
const foxtrotPending = new Promise<RepoEntry[]>((res) => { resolveFoxtrot = res; });
1876+
1877+
vi.mocked(api.fetchRepos).mockImplementation((_client, org) => {
1878+
if (org === "echo-labs") return echoPending;
1879+
if (org === "foxtrot-io") return foxtrotPending;
1880+
return Promise.resolve(makeOrgRepos(org as string));
1881+
});
1882+
1883+
const [selectedOrgs, setSelectedOrgs] = createSignal<string[]>(["alice", "acme-corp"]);
1884+
const [orgEntries, setOrgEntries] = createSignal([aliceEntry, acmeEntry]);
1885+
1886+
render(() => (
1887+
<RepoSelector
1888+
selectedOrgs={selectedOrgs()}
1889+
orgEntries={orgEntries()}
1890+
selected={[]}
1891+
onChange={vi.fn()}
1892+
/>
1893+
));
1894+
1895+
// Wait for initial 2 orgs to load and freeze
1896+
await waitFor(() => {
1897+
screen.getByText("alice-repo");
1898+
screen.getByText("acme-corp-repo");
1899+
});
1900+
1901+
const initialOrder = getOrgHeaderOrder(["alice", "acme-corp"]);
1902+
expect(initialOrder).toEqual(["alice", "acme-corp"]);
1903+
1904+
// Simultaneously add echo-labs and foxtrot-io (both slow)
1905+
const echoEntry = { login: "echo-labs", avatarUrl: "", type: "org" as const };
1906+
const foxtrotEntry = { login: "foxtrot-io", avatarUrl: "", type: "org" as const };
1907+
setSelectedOrgs(["alice", "acme-corp", "echo-labs", "foxtrot-io"]);
1908+
setOrgEntries([aliceEntry, acmeEntry, echoEntry, foxtrotEntry]);
1909+
1910+
// Resolve echo-labs first — foxtrot-io is still loading.
1911+
// loadedCount (3 settled out of 4) < selectedOrgs.length (4),
1912+
// so the loadedCount guard should prevent a premature sort.
1913+
resolveEcho(makeOrgRepos("echo-labs"));
1914+
await waitFor(() => {
1915+
screen.getByText("echo-labs-repo");
1916+
});
1917+
1918+
// Now resolve foxtrot-io — all 4 orgs settled, sort should fire and freeze.
1919+
resolveFoxtrot(makeOrgRepos("foxtrot-io"));
1920+
await waitFor(() => {
1921+
screen.getByText("foxtrot-io-repo");
1922+
});
1923+
1924+
// Final order: alice (user first), then alphabetical: acme-corp, echo-labs, foxtrot-io
1925+
const finalOrder = getOrgHeaderOrder(["alice", "acme-corp", "echo-labs", "foxtrot-io"]);
1926+
expect(finalOrder).toEqual(["alice", "acme-corp", "echo-labs", "foxtrot-io"]);
1927+
});
18871928
});

0 commit comments

Comments
 (0)