diff --git a/tests/unit/cached.test.ts b/tests/unit/cached.test.ts new file mode 100644 index 000000000..dcde1a2c9 --- /dev/null +++ b/tests/unit/cached.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test, vi } from "vitest"; + +import { cached } from "@app/lib/cached"; + +describe("cached", () => { + test("returns the cached result for the same key", async () => { + const f = vi.fn(async (x: number) => x * 2); + const c = cached(f, x => String(x)); + + expect(await c(1)).toBe(2); + expect(await c(1)).toBe(2); + expect(await c(2)).toBe(4); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("shares a single in-flight request between concurrent callers", async () => { + let resolve: ((value: string) => void) | undefined; + const f = vi.fn( + () => + new Promise(r => { + resolve = r; + }), + ); + const c = cached(f, () => "key"); + + const first = c(); + const second = c(); + resolve?.("done"); + + expect(await first).toBe("done"); + expect(await second).toBe("done"); + expect(f).toHaveBeenCalledTimes(1); + }); + + test("retries after a rejection", async () => { + const f = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce("ok"); + const c = cached(f, () => "key"); + + await expect(c()).rejects.toThrow("boom"); + expect(await c()).toBe("ok"); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("clear forces a new request", async () => { + const f = vi.fn(async () => "value"); + const c = cached(f, () => "key"); + + await c(); + c.clear(); + await c(); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("evicts the least recently used entry", async () => { + const f = vi.fn(async (x: number) => x); + const c = cached(f, x => String(x), { max: 2 }); + + await c(1); + await c(2); + await c(1); + await c(3); + await c(1); + expect(f).toHaveBeenCalledTimes(3); + await c(2); + expect(f).toHaveBeenCalledTimes(4); + }); + + test("expires entries after the ttl", async () => { + let now = 1000; + const f = vi.fn(async () => "value"); + const c = cached(f, () => "key", { + max: 1, + ttl: 1000, + ttlResolution: 0, + perf: { now: () => now }, + }); + + await c(); + now = 1500; + await c(); + expect(f).toHaveBeenCalledTimes(1); + now = 2500; + await c(); + expect(f).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/markdownFormat.test.ts b/tests/unit/markdownFormat.test.ts index 9ceaa1782..4b6b92d3d 100644 --- a/tests/unit/markdownFormat.test.ts +++ b/tests/unit/markdownFormat.test.ts @@ -32,7 +32,7 @@ describe("bold", () => { ["a **|word|** here", "a |word| here"], ["a |**word**| here", "a |word| here"], ["a **||** here", "a || here"], - ])("%p => %p", (input, expected) => { + ])("%j => %j", (input, expected) => { expect(format(input, "bold")).toBe(expected); }); }); @@ -43,7 +43,7 @@ describe("italic", () => { ["a _|word|_ here", "a |word| here"], ["a |_word_| here", "a |word| here"], ["**|word|**", "**_|word|_**"], - ])("%p => %p", (input, expected) => { + ])("%j => %j", (input, expected) => { expect(format(input, "italic")).toBe(expected); }); }); @@ -56,7 +56,7 @@ describe("code", () => { ["```\n|one\ntwo|\n```", "|one\ntwo|"], ["a |one\ntwo| b", "a \n```\n|one\ntwo|\n```\n b"], ["a\n|one\ntwo|\nb", "a\n```\n|one\ntwo|\n```\nb"], - ])("%p => %p", (input, expected) => { + ])("%j => %j", (input, expected) => { expect(format(input, "code")).toBe(expected); }); }); @@ -66,7 +66,7 @@ describe("link", () => { ["see |docs| now", "see [docs](||) now"], ["see || now", "see [](||) now"], ["see |https://radicle.xyz| now", "see [||](https://radicle.xyz) now"], - ])("%p => %p", (input, expected) => { + ])("%j => %j", (input, expected) => { expect(format(input, "link")).toBe(expected); }); }); @@ -95,7 +95,7 @@ describe("pasteLinkEdit", () => { "https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:z4D5U/issues/7bdaa5a", "https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:z4D5U/issues/7bdaa5a", ], - ])("links %p", (pasted, url) => { + ])("links %j", (pasted, url) => { expect(pasteLinkEdit("docs", 0, 4, pasted)?.text).toBe(`[docs](${url})`); }); @@ -109,7 +109,7 @@ describe("pasteLinkEdit", () => { "just some words", " ", "", - ])("falls through for %p", pasted => { + ])("falls through for %j", pasted => { expect(pasteLinkEdit("docs", 0, 4, pasted)).toBeUndefined(); }); }); diff --git a/tests/unit/mutexExecutor.test.ts b/tests/unit/mutexExecutor.test.ts new file mode 100644 index 000000000..b5f159e28 --- /dev/null +++ b/tests/unit/mutexExecutor.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test, vi } from "vitest"; + +import * as mutexExecutor from "@app/lib/mutexExecutor"; +import { sleep } from "@app/lib/sleep"; + +describe("executor", () => { + test("cancels running task", async () => { + const e = mutexExecutor.create(); + + const first = e.run(async () => { + await sleep(10); + return "first"; + }); + const second = e.run(async () => { + return "second"; + }); + + expect(await first).toBe(undefined); + expect(await second).toBe("second"); + + const third = e.run(async () => { + await sleep(10); + return "third"; + }); + const fourth = e.run(async () => { + return "fourth"; + }); + + expect(await third).toBe(undefined); + expect(await fourth).toBe("fourth"); + }); + + test("cancels multiple tasks", async () => { + const e = mutexExecutor.create(); + + const canceled1 = e.run(async () => { + await sleep(10); + return true; + }); + const canceled2 = e.run(async () => { + await sleep(10); + return true; + }); + const canceled3 = e.run(async () => { + await sleep(10); + return true; + }); + const last = e.run(async () => { + return true; + }); + + expect(await canceled1).toBe(undefined); + expect(await canceled2).toBe(undefined); + expect(await canceled3).toBe(undefined); + expect(await last).toBe(true); + }); + + test("triggers abort signal event", async () => { + const e = mutexExecutor.create(); + const abortListener = vi.fn(); + + void e.run(async abort => { + abort.addEventListener("abort", abortListener); + await sleep(10); + return "first"; + }); + expect(abortListener).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + void e.run(async () => {}); + expect(abortListener).toHaveBeenCalled(); + }); + + test("don’t throw error on aborted task", async () => { + const e = mutexExecutor.create(); + + const first = e.run(async () => { + await sleep(10); + throw new Error(); + }); + const second = e.run(async () => { + return "second"; + }); + + expect(await first).toBe(undefined); + expect(await second).toBe("second"); + }); +}); + +describe("worker", () => { + test("sequential work", async () => { + const w = mutexExecutor.createWorker(async (value: number) => { + await sleep(10); + return value; + }); + + const outputs: number[] = []; + w.output.onValue(value => outputs.push(value)); + + await w.submit(1); + await w.submit(2); + await w.submit(3); + + expect(outputs).toEqual([1, 2, 3]); + }); + + test("overlapping work cancels", async () => { + const w = mutexExecutor.createWorker(async (value: number) => { + await sleep(10); + return value; + }); + + const nextOutput = w.output.firstToPromise(); + + void w.submit(1); + void w.submit(2); + void w.submit(3); + + expect(await nextOutput).toEqual(3); + }); +}); diff --git a/tests/unit/repoRouter.test.ts b/tests/unit/repoRouter.test.ts new file mode 100644 index 000000000..f964e589b --- /dev/null +++ b/tests/unit/repoRouter.test.ts @@ -0,0 +1,155 @@ +import type { RepoRoute } from "@app/views/repo/router"; + +import { repoRouteToPath, repoUrlToRoute } from "@app/views/repo/router"; +import { describe, expect, test } from "vitest"; + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const peer = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; +const sha = "a".repeat(40); + +function parse(path: string): RepoRoute | null { + const url = new URL(path, "http://localhost"); + const segments = url.pathname.substring(1).split("/"); + expect(segments.shift()).toBe("repos"); + return repoUrlToRoute(segments, url.searchParams); +} + +describe("repoRouteToPath and repoUrlToRoute round trip", () => { + test.each<[string, RepoRoute]>([ + ["/repos/RID/home", { resource: "repo.home", rid }], + ["/repos/RID/home/remotes/PEER", { resource: "repo.home", rid, peer }], + [ + "/repos/RID/home/feature/branch", + { resource: "repo.home", rid, revision: "feature/branch" }, + ], + [ + "/repos/RID/home/remotes/PEER/feature/branch", + { resource: "repo.home", rid, peer, revision: "feature/branch" }, + ], + ["/repos/RID/commits", { resource: "repo.commits", rid }], + [ + "/repos/RID/commits/main", + { resource: "repo.commits", rid, revision: "main" }, + ], + [ + "/repos/RID/commits/remotes/PEER/SHA", + { resource: "repo.commits", rid, peer, revision: sha }, + ], + ["/repos/RID/commits/SHA", { resource: "repo.commit", rid, commit: sha }], + ["/repos/RID/identity", { resource: "repo.identity", rid }], + [ + "/repos/RID/issues?status=all", + { resource: "repo.issues", rid, status: "all" }, + ], + [ + "/repos/RID/issues?status=closed", + { resource: "repo.issues", rid, status: "closed" }, + ], + [ + "/repos/RID/issues/abc?status=open", + { resource: "repo.issue", rid, issue: "abc", status: "open" }, + ], + [ + "/repos/RID/patches", + { resource: "repo.patches", rid, status: undefined }, + ], + [ + "/repos/RID/patches?status=merged", + { resource: "repo.patches", rid, status: "merged" }, + ], + [ + "/repos/RID/patches/abc", + { + resource: "repo.patch", + rid, + patch: "abc", + status: undefined, + reviewId: undefined, + }, + ], + [ + "/repos/RID/patches/abc?status=draft&review=def&view=changes", + { + resource: "repo.patch", + rid, + patch: "abc", + status: "draft", + reviewId: "def", + view: "changes", + }, + ], + ])("%s", (template, route) => { + const path = template + .replace("RID", rid) + .replace("PEER", peer) + .replace("SHA", sha); + + expect(repoRouteToPath(route)).toBe(path); + expect(parse(path)).toEqual(route); + }); +}); + +describe("repoUrlToRoute", () => { + test("defaults issues listing status to all", () => { + expect(parse(`/repos/${rid}/issues`)).toEqual({ + resource: "repo.issues", + rid, + status: "all", + }); + }); + + test("falls back to all for an unknown issues listing status", () => { + expect(parse(`/repos/${rid}/issues?status=bogus`)).toEqual({ + resource: "repo.issues", + rid, + status: "all", + }); + }); + + test("defaults single issue status to all", () => { + expect(parse(`/repos/${rid}/issues/abc`)).toEqual({ + resource: "repo.issue", + rid, + issue: "abc", + status: "all", + }); + }); + + test("does not route issue creation", () => { + expect(parse(`/repos/${rid}/issues/create`)).toBeNull(); + }); + + test("ignores an unknown patch view", () => { + expect(parse(`/repos/${rid}/patches/abc?view=bogus`)).toEqual({ + resource: "repo.patch", + rid, + patch: "abc", + }); + }); + + test("treats a sha as a revision when a peer is given", () => { + expect(parse(`/repos/${rid}/commits/remotes/${peer}/${sha}`)).toEqual({ + resource: "repo.commits", + rid, + peer, + revision: sha, + }); + }); + + test("treats a commits revision that is a sha as a single commit", () => { + const route: RepoRoute = { resource: "repo.commits", rid, revision: sha }; + + expect(parse(repoRouteToPath(route))).toEqual({ + resource: "repo.commit", + rid, + commit: sha, + }); + }); + + test.each([`/repos/${rid}`, `/repos/${rid}/bogus`, "/repos/"])( + "returns null for %s", + path => { + expect(parse(path)).toBeNull(); + }, + ); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index cd43a130f..c8996b2dd 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -1,6 +1,26 @@ -import { describe, expect, test } from "vitest"; +import type { Config } from "@bindings/config/Config"; -import { coAuthors, safeHttpUrl } from "@app/lib/utils"; +import { describe, expect, test, vi } from "vitest"; + +import { + coAuthors, + explorerHost, + explorerUrl, + formatRepositoryId, + formatTimestamp, + identityKey, + isCommit, + parseNodeId, + parseRepositoryId, + pluralize, + revisionPosition, + safeHttpUrl, + truncateDid, + unqualifyBranch, +} from "@app/lib/utils"; + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const nid = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; describe("safeHttpUrl", () => { test.each([ @@ -8,7 +28,7 @@ describe("safeHttpUrl", () => { ["https://example.com/path?a=1&b=2", "https://example.com/path?a=1&b=2"], ["HTTP://EXAMPLE.com/Path", "http://example.com/Path"], ["http://example.com/