v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s

Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened.

Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it.

Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 14:35:41 +00:00
parent 25601edaf8
commit 54e86c4366
78 changed files with 10816 additions and 691 deletions

View File

@@ -235,10 +235,11 @@ export function setPlaylistImage(
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
imageMime,
playlist.id,
);
// A new cover is an edit: bumping updated_at also moves the `?v=` the
// client puts on the image URL, so a cached old cover isn't shown.
db.prepare(
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
).run(imageMime, new Date().toISOString(), playlist.id);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistUpdated(updated, playlist.isPublic);
return updated;

View File

@@ -12,7 +12,7 @@ import {
put,
test,
} from "./helpers.ts";
import { assertEquals, assertMatch } from "@std/assert";
import { assert, assertEquals, assertMatch } from "@std/assert";
function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] {
return res.body.data.dumps.map((d) => d.id);
@@ -297,6 +297,8 @@ test("playlist images: upload and serve", async () => {
});
assertEquals(res.status, 200);
assertEquals(res.body.data.imageMime, "image/png");
// A new cover counts as an edit, so image URLs versioned on it change.
assert(res.body.data.updatedAt);
const served = await get(`/api/playlists/${slug}/image`);
assertEquals(served.status, 200);
assertEquals(served.bytes, PNG);

View File

@@ -8,7 +8,8 @@
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
"i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1",
"start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts",
"test": "deno test -A api/"
"test": "deno test -A api/",
"test:web": "deno run -A npm:vitest run"
},
"nodeModulesDir": "auto",
"compilerOptions": {

486
deno.lock generated
View File

@@ -16,13 +16,17 @@
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@1": "1.0.9",
"jsr:@std/fs@1": "1.0.23",
"jsr:@std/fs@1": "1.0.24",
"jsr:@std/fs@^1.0.24": "1.0.24",
"jsr:@std/http@1": "1.0.25",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/media-types@1": "1.1.0",
"jsr:@std/path@1": "1.1.4",
"jsr:@std/path@1": "1.1.6",
"jsr:@std/path@1.0": "1.0.9",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/path@^1.1.5": "1.1.6",
"jsr:@std/path@^1.1.6": "1.1.6",
"jsr:@std/testing@^1.0.21": "1.0.21",
"jsr:@tajpouria/cors@^1.2.1": "1.2.1",
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0",
@@ -41,6 +45,10 @@
"npm:@lingui/vite-plugin@6.4.0": "6.4.0_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0",
"npm:@resvg/resvg-wasm@2.6.2": "2.6.2",
"npm:@resvg/resvg-wasm@^2.6.2": "2.6.2",
"npm:@testing-library/dom@^10.4.2": "10.4.2",
"npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.0.1__jsdom@30.1.1__vite@8.1.0___@types+node@26.0.1___jiti@2.7.0_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0",
"npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.2.17_@types+react-dom@19.2.3__@types+react@19.2.17_react@19.2.7_react-dom@19.2.7__react@19.2.7",
"npm:@testing-library/user-event@^14.6.7": "14.6.7_@testing-library+dom@10.4.2",
"npm:@types/node@^26.0.1": "26.0.1",
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17",
"npm:@types/react@^19.2.17": "19.2.17",
@@ -53,6 +61,7 @@
"npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3",
"npm:globals@^17.7.0": "17.7.0",
"npm:jiti@^2.7.0": "2.7.0",
"npm:jsdom@^30.1.1": "30.1.1",
"npm:marked@^18.0.5": "18.0.5",
"npm:nodemailer@^9.0.1": "9.0.1",
"npm:path-to-regexp@^6.3.0": "6.3.0",
@@ -66,7 +75,9 @@
"npm:typescript@*": "6.0.3",
"npm:typescript@~6.0.3": "6.0.3",
"npm:vite@*": "8.1.0_@types+node@26.0.1_jiti@2.7.0",
"npm:vite@^8.1.0": "8.1.0_@types+node@26.0.1_jiti@2.7.0"
"npm:vite@^8.1.0": "8.1.0_@types+node@26.0.1_jiti@2.7.0",
"npm:vitest@*": "5.0.2_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0",
"npm:vitest@^5.0.2": "5.0.2_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0"
},
"jsr": {
"@db/sqlite@0.13.0": {
@@ -81,7 +92,7 @@
"dependencies": [
"jsr:@std/encoding@1",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/fs@1",
"jsr:@std/path@1"
]
},
@@ -114,7 +125,7 @@
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
"jsr:@std/internal@^1.0.12"
]
},
"@std/async@1.5.1": {
@@ -138,10 +149,17 @@
"@std/fs@1.0.23": {
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
"dependencies": [
"jsr:@std/internal",
"jsr:@std/internal@^1.0.12",
"jsr:@std/path@^1.1.4"
]
},
"@std/fs@1.0.24": {
"integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877",
"dependencies": [
"jsr:@std/internal@^1.0.14",
"jsr:@std/path@^1.1.5"
]
},
"@std/http@1.0.25": {
"integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193",
"dependencies": [
@@ -151,6 +169,9 @@
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
@@ -160,7 +181,13 @@
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
"jsr:@std/internal@^1.0.12"
]
},
"@std/path@1.1.6": {
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
"dependencies": [
"jsr:@std/internal@^1.0.14"
]
},
"@std/testing@1.0.21": {
@@ -168,7 +195,10 @@
"dependencies": [
"jsr:@std/assert@^1.0.19",
"jsr:@std/async",
"jsr:@std/data-structures"
"jsr:@std/data-structures",
"jsr:@std/fs@^1.0.24",
"jsr:@std/internal@^1.0.14",
"jsr:@std/path@^1.1.6"
]
},
"@tajpouria/cors@1.2.1": {
@@ -176,6 +206,28 @@
}
},
"npm": {
"@adobe/css-tools@4.5.0": {
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="
},
"@asamuzakjp/css-color@7.1.0": {
"integrity": "sha512-s6vMrO4hds+skR2OMmdwSORZiiEbqhAjd56W/0gc/x4PYpTibh/ey5Oy9ywkZ+ycX9EgTcJruhE1T/KkbU7LNA==",
"dependencies": [
"@csstools/css-calc",
"@csstools/css-color-parser",
"@csstools/css-parser-algorithms",
"@csstools/css-tokenizer",
"lru-cache@11.5.3"
]
},
"@asamuzakjp/dom-selector@9.2.1": {
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dependencies": [
"bidi-js",
"css-tree",
"is-potential-custom-element-name",
"lru-cache@11.5.3"
]
},
"@babel/code-frame@7.29.7": {
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dependencies": [
@@ -223,7 +275,7 @@
"@babel/compat-data",
"@babel/helper-validator-option",
"browserslist",
"lru-cache",
"lru-cache@5.1.1",
"semver@6.3.1"
]
},
@@ -269,6 +321,9 @@
],
"bin": true
},
"@babel/runtime@7.29.7": {
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="
},
"@babel/template@7.29.7": {
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dependencies": [
@@ -296,9 +351,53 @@
"@babel/helper-validator-identifier"
]
},
"@bramus/specificity@2.4.2": {
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dependencies": [
"css-tree"
],
"bin": true
},
"@colors/colors@1.5.0": {
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
},
"@csstools/color-helpers@6.1.2": {
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="
},
"@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dependencies": [
"@csstools/css-parser-algorithms",
"@csstools/css-tokenizer"
]
},
"@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dependencies": [
"@csstools/color-helpers",
"@csstools/css-calc",
"@csstools/css-parser-algorithms",
"@csstools/css-tokenizer"
]
},
"@csstools/css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
"dependencies": [
"@csstools/css-tokenizer"
]
},
"@csstools/css-syntax-patches-for-csstree@1.1.14_css-tree@3.2.1": {
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dependencies": [
"css-tree"
],
"optionalPeers": [
"css-tree"
]
},
"@csstools/css-tokenizer@4.0.2": {
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="
},
"@emnapi/core@1.11.1": {
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dependencies": [
@@ -497,6 +596,9 @@
"levn"
]
},
"@exodus/bytes@1.16.0": {
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="
},
"@fontsource-variable/fraunces@5.3.0": {
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA=="
},
@@ -569,8 +671,8 @@
"@jridgewell/resolve-uri@3.1.2": {
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="
},
"@jridgewell/sourcemap-codec@1.5.5": {
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
"@jridgewell/sourcemap-codec@1.6.0": {
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="
},
"@jridgewell/trace-mapping@0.3.31": {
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
@@ -880,18 +982,81 @@
"@swc/counter"
]
},
"@testing-library/dom@10.4.2": {
"integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"dependencies": [
"@babel/code-frame",
"@babel/runtime",
"@types/aria-query",
"aria-query",
"dom-accessibility-api@0.5.16",
"lz-string",
"picocolors",
"pretty-format@27.5.1"
]
},
"@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.0.1__jsdom@30.1.1__vite@8.1.0___@types+node@26.0.1___jiti@2.7.0_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": {
"integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
"dependencies": [
"@adobe/css-tools",
"@testing-library/dom",
"aria-query",
"css.escape",
"dom-accessibility-api@0.6.3",
"picocolors",
"redent",
"vitest"
],
"optionalPeers": [
"vitest"
]
},
"@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.2.17_@types+react-dom@19.2.3__@types+react@19.2.17_react@19.2.7_react-dom@19.2.7__react@19.2.7": {
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dependencies": [
"@babel/runtime",
"@testing-library/dom",
"@types/react",
"@types/react-dom",
"react",
"react-dom"
],
"optionalPeers": [
"@types/react",
"@types/react-dom"
]
},
"@testing-library/user-event@14.6.7_@testing-library+dom@10.4.2": {
"integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
"dependencies": [
"@testing-library/dom"
]
},
"@tybys/wasm-util@0.10.3": {
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dependencies": [
"tslib"
]
},
"@types/aria-query@5.0.4": {
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
},
"@types/chai@5.2.3": {
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dependencies": [
"@types/deep-eql",
"assertion-error"
]
},
"@types/debug@4.1.13": {
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
"dependencies": [
"@types/ms"
]
},
"@types/deep-eql@4.0.2": {
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
},
"@types/esrecurse@4.3.1": {
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="
},
@@ -1080,6 +1245,22 @@
"vite"
]
},
"@vitest/mocker@5.0.2_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": {
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dependencies": [
"@jridgewell/trace-mapping",
"@vitest/spy",
"estree-walker",
"magic-string",
"vite"
],
"optionalPeers": [
"vite"
]
},
"@vitest/spy@5.0.2": {
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg=="
},
"acorn-jsx@5.3.2_acorn@8.17.0": {
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dependencies": [
@@ -1114,6 +1295,15 @@
"ansi-styles@5.2.0": {
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
},
"aria-query@5.3.0": {
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dependencies": [
"dequal"
]
},
"assertion-error@2.0.1": {
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
},
"bail@2.0.2": {
"integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="
},
@@ -1124,6 +1314,12 @@
"integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
"bin": true
},
"bidi-js@1.1.0": {
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dependencies": [
"require-from-string"
]
},
"brace-expansion@5.0.4": {
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dependencies": [
@@ -1156,6 +1352,9 @@
"ccount@2.0.1": {
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
},
"chai@6.2.2": {
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="
},
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
@@ -1234,15 +1433,35 @@
"which"
]
},
"css-tree@3.2.1": {
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dependencies": [
"mdn-data",
"source-map-js"
]
},
"css.escape@1.5.1": {
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="
},
"csstype@3.2.3": {
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"data-urls@7.0.0": {
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dependencies": [
"whatwg-mimetype",
"whatwg-url@16.0.1"
]
},
"debug@4.4.3": {
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": [
"ms"
]
},
"decimal.js@10.6.0": {
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
},
"decode-named-character-reference@1.3.0": {
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
"dependencies": [
@@ -1264,6 +1483,12 @@
"dequal"
]
},
"dom-accessibility-api@0.5.16": {
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
},
"dom-accessibility-api@0.6.3": {
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="
},
"electron-to-chromium@1.5.332": {
"integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ=="
},
@@ -1279,6 +1504,12 @@
"emojibase@17.0.0": {
"integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA=="
},
"entities@8.1.0": {
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="
},
"es-module-lexer@2.3.2": {
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="
},
"esbuild@0.25.12": {
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"optionalDependencies": [
@@ -1419,9 +1650,18 @@
"estree-util-is-identifier-name@3.0.0": {
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="
},
"estree-walker@3.0.3": {
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dependencies": [
"@types/estree"
]
},
"esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"expect-type@1.4.0": {
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="
},
"extend@3.0.2": {
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
@@ -1434,13 +1674,13 @@
"fast-levenshtein@2.0.6": {
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"fdir@6.5.0_picomatch@4.0.4": {
"fdir@6.5.0_picomatch@4.0.7": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
"picomatch@4.0.4"
"picomatch@4.0.7"
],
"optionalPeers": [
"picomatch@4.0.4"
"picomatch@4.0.7"
]
},
"file-entry-cache@8.0.0": {
@@ -1540,6 +1780,12 @@
"hermes-estree"
]
},
"html-encoding-sniffer@7.0.0": {
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dependencies": [
"@exodus/bytes"
]
},
"html-url-attributes@3.0.1": {
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="
},
@@ -1552,6 +1798,9 @@
"imurmurhash@0.1.4": {
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
},
"indent-string@4.0.0": {
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="
},
"inline-style-parser@0.2.7": {
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
},
@@ -1592,6 +1841,9 @@
"is-plain-obj@4.1.0": {
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="
},
"is-potential-custom-element-name@1.0.1": {
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
"is-unicode-supported@2.1.0": {
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="
},
@@ -1609,7 +1861,7 @@
"chalk@4.1.2",
"jest-get-type",
"leven",
"pretty-format"
"pretty-format@29.7.0"
]
},
"jiti@2.7.0": {
@@ -1622,6 +1874,31 @@
"js-tokens@4.0.0": {
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"jsdom@30.1.1": {
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dependencies": [
"@asamuzakjp/css-color",
"@asamuzakjp/dom-selector",
"@bramus/specificity",
"@csstools/css-syntax-patches-for-csstree",
"@exodus/bytes",
"css-tree",
"data-urls",
"decimal.js",
"html-encoding-sniffer",
"is-potential-custom-element-name",
"lru-cache@11.5.3",
"parse5",
"saxes",
"tough-cookie",
"undici",
"w3c-xmlserializer",
"webidl-conversions",
"whatwg-mimetype",
"whatwg-url@17.1.2",
"xml-name-validator"
]
},
"jsesc@3.1.0": {
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"bin": true
@@ -1748,12 +2025,25 @@
"longest-streak@3.1.0": {
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="
},
"lru-cache@11.5.3": {
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="
},
"lru-cache@5.1.1": {
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dependencies": [
"yallist"
]
},
"lz-string@1.5.0": {
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"bin": true
},
"magic-string@1.4.2": {
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
},
"markdown-table@3.0.4": {
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="
},
@@ -1926,6 +2216,9 @@
"@types/mdast"
]
},
"mdn-data@2.27.1": {
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="
},
"micromark-core-commonmark@2.0.3": {
"integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
"dependencies": [
@@ -2177,6 +2470,9 @@
"mimic-function@5.0.1": {
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="
},
"min-indent@1.0.1": {
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
},
"minimatch@10.2.4": {
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dependencies": [
@@ -2205,6 +2501,9 @@
"normalize-path@3.0.0": {
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
"obug@2.2.1": {
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="
},
"onetime@7.0.0": {
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dependencies": [
@@ -2259,6 +2558,12 @@
"is-hexadecimal"
]
},
"parse5@8.0.1": {
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dependencies": [
"entities"
]
},
"path-exists@4.0.0": {
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
},
@@ -2274,8 +2579,8 @@
"picomatch@2.3.1": {
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"picomatch@4.0.4": {
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
"picomatch@4.0.7": {
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="
},
"pofile-ts@4.0.3": {
"integrity": "sha512-sz1pnjgEfPyZ+QvaeX3NtCmbYnEvG01LZRLoN/uXoLtPZtxCIH5IctL7yXXc0fFyk/fqV6K8g3hlNfr6IJwupA=="
@@ -2291,12 +2596,20 @@
"prelude-ls@1.2.1": {
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
},
"pretty-format@27.5.1": {
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dependencies": [
"ansi-regex@5.0.1",
"ansi-styles@5.2.0",
"react-is@17.0.2"
]
},
"pretty-format@29.7.0": {
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dependencies": [
"@jest/schemas",
"ansi-styles@5.2.0",
"react-is"
"react-is@18.3.1"
]
},
"property-information@7.1.0": {
@@ -2325,6 +2638,9 @@
"react"
]
},
"react-is@17.0.2": {
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"react-is@18.3.1": {
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
@@ -2363,6 +2679,13 @@
"readdirp@5.0.0": {
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="
},
"redent@3.0.0": {
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dependencies": [
"indent-string",
"strip-indent"
]
},
"remark-gfm@4.0.1": {
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"dependencies": [
@@ -2401,6 +2724,9 @@
"unified"
]
},
"require-from-string@2.0.2": {
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
},
"restore-cursor@5.1.0": {
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dependencies": [
@@ -2433,6 +2759,12 @@
],
"bin": true
},
"saxes@6.0.0": {
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dependencies": [
"xmlchars"
]
},
"scheduler@0.27.0": {
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
},
@@ -2465,6 +2797,9 @@
"space-separated-tokens@2.0.2": {
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="
},
"std-env@4.2.0": {
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="
},
"stdin-discarder@0.3.2": {
"integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="
},
@@ -2502,6 +2837,12 @@
"ansi-regex@6.2.2"
]
},
"strip-indent@3.0.0": {
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dependencies": [
"min-indent"
]
},
"style-to-js@1.1.21": {
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
"dependencies": [
@@ -2520,22 +2861,50 @@
"has-flag"
]
},
"tinybench@6.2.0": {
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q=="
},
"tinyexec@1.3.1": {
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="
},
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": [
"fdir",
"picomatch@4.0.4"
"picomatch@4.0.7"
]
},
"tinypool@2.1.0": {
"integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="
},
"tldts-core@7.4.15": {
"integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A=="
},
"tldts@7.4.15": {
"integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==",
"dependencies": [
"tldts-core"
],
"bin": true
},
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
"is-number"
]
},
"tough-cookie@6.0.2": {
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dependencies": [
"tldts"
]
},
"tr46@6.0.0": {
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dependencies": [
"punycode"
]
},
"trim-lines@3.0.1": {
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
},
@@ -2575,6 +2944,9 @@
"undici-types@8.3.0": {
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"undici@8.11.2": {
"integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="
},
"unified@11.0.5": {
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"dependencies": [
@@ -2655,7 +3027,7 @@
"@types/node",
"jiti",
"lightningcss",
"picomatch@4.0.4",
"picomatch@4.0.7",
"postcss",
"rolldown",
"tinyglobby"
@@ -2669,6 +3041,60 @@
],
"bin": true
},
"vitest@5.0.2_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": {
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dependencies": [
"@types/chai",
"@types/node",
"@vitest/mocker",
"chai",
"es-module-lexer",
"expect-type",
"jsdom",
"magic-string",
"obug",
"picomatch@4.0.7",
"std-env",
"tinybench",
"tinyexec",
"tinyglobby",
"vite",
"why-is-node-running"
],
"optionalPeers": [
"@types/node",
"jsdom"
],
"bin": true
},
"w3c-xmlserializer@6.0.0": {
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dependencies": [
"xml-name-validator"
]
},
"webidl-conversions@8.0.1": {
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="
},
"whatwg-mimetype@5.0.0": {
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="
},
"whatwg-url@16.0.1": {
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dependencies": [
"@exodus/bytes",
"tr46",
"webidl-conversions"
]
},
"whatwg-url@17.1.2": {
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dependencies": [
"@exodus/bytes",
"tr46",
"webidl-conversions"
]
},
"which@2.0.2": {
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dependencies": [
@@ -2676,9 +3102,19 @@
],
"bin": true
},
"why-is-node-running@3.2.2": {
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
"bin": true
},
"word-wrap@1.2.5": {
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="
},
"xml-name-validator@5.0.0": {
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="
},
"xmlchars@2.2.0": {
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"yallist@3.1.1": {
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
},
@@ -2728,6 +3164,10 @@
"npm:@lingui/react@6.4.0",
"npm:@lingui/swc-plugin@6.5.0",
"npm:@lingui/vite-plugin@6.4.0",
"npm:@testing-library/dom@^10.4.2",
"npm:@testing-library/jest-dom@^7.0.1",
"npm:@testing-library/react@^16.3.3",
"npm:@testing-library/user-event@^14.6.7",
"npm:@types/node@^26.0.1",
"npm:@types/react-dom@^19.2.3",
"npm:@types/react@^19.2.17",
@@ -2739,6 +3179,7 @@
"npm:frimousse@0.3",
"npm:globals@^17.7.0",
"npm:jiti@^2.7.0",
"npm:jsdom@^30.1.1",
"npm:react-dom@^19.2.7",
"npm:react-hook-form@^7.80.0",
"npm:react-markdown@^10.1.0",
@@ -2747,7 +3188,8 @@
"npm:remark-gfm@^4.0.1",
"npm:typescript-eslint@^8.62.0",
"npm:typescript@~6.0.3",
"npm:vite@^8.1.0"
"npm:vite@^8.1.0",
"npm:vitest@^5.0.2"
]
}
}

View File

@@ -29,4 +29,13 @@ export default defineConfig([
],
},
},
{
// Test harness: helper modules export functions next to components, and
// probe components hand their context out to the test on purpose.
files: ["src/test/**/*.{ts,tsx}"],
rules: {
"react-refresh/only-export-components": "off",
"react-hooks/globals": "off",
},
},
]);

View File

@@ -36,14 +36,20 @@
"devDependencies": {
"@lingui/vite-plugin": "6.4.0",
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.0.1",
"@types/react-dom": "^19.2.3",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"jsdom": "^30.1.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.1.0"
"vite": "^8.1.0",
"vitest": "^5.0.2"
}
}

View File

@@ -249,7 +249,8 @@
pointer-events: none;
}
.dump-title-view--editable:hover .dump-title-edit-btn {
.dump-title-view--editable:hover .dump-title-edit-btn,
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
opacity: 1;
}
@@ -2304,7 +2305,8 @@ body.has-player .chat-fab {
pointer-events: none;
}
.profile-description-view--editable:hover .profile-description-edit-btn {
.profile-description-view--editable:hover .profile-description-edit-btn,
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
opacity: 1;
}
@@ -2331,7 +2333,8 @@ body.has-player .chat-fab {
background: var(--color-surface);
}
.profile-email-display:hover .profile-description-edit-btn {
.profile-email-display:hover .profile-description-edit-btn,
.profile-email-display:focus-visible .profile-description-edit-btn {
opacity: 1;
}

Binary file not shown.

View File

@@ -202,6 +202,7 @@ function CommentNode({
const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleteFailed, setDeleteFailed] = useState(false);
const innerRef = useHeightTransition<HTMLDivElement>(
`${editOpen}:${replyOpen}`,
);
@@ -227,11 +228,14 @@ function CommentNode({
async function handleDelete() {
if (!token) return;
setDeleteFailed(false);
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE",
}).catch(() => null);
if (res?.ok) {
onCommentDeleted(comment.id);
} else {
setDeleteFailed(true);
}
}
@@ -407,6 +411,11 @@ function CommentNode({
/>
)}
</div>
{deleteFailed && (
<p className="form-hint form-hint--error" role="alert">
<Trans>This comment couldn't be deleted. Try again.</Trans>
</p>
)}
{replyOpen && (
<CommentForm
editorRef={replyEditorRef}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useId } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -15,20 +15,33 @@ export function ConfirmModal(
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) {
const label = confirmLabel ?? t`Delete`;
const messageId = useId();
useScrollLock();
useEffect(() => {
// A confirm can sit on top of a Modal (deleting a chat message), whose
// own Escape handler is also on the document and was added first. Take
// the key in the capture phase and mark it handled, so the Modal leaves
// it alone and only the confirm goes.
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
if (e.key !== "Escape" || e.defaultPrevented) return;
e.preventDefault();
onCancel();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, [onCancel]);
return createPortal(
<div className="modal-backdrop" onClick={onCancel}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p>
<div
className="confirm-modal"
role="alertdialog"
aria-modal="true"
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
<p className="confirm-modal-message" id={messageId}>{message}</p>
<div className="confirm-modal-actions">
<button type="button" className="btn-secondary" onClick={onCancel}>
<Trans>Cancel</Trans>

View File

@@ -20,7 +20,8 @@ export function FeedTabBar() {
const defaultTab: FeedTab = preferredTab === "followed" && !user
? DEFAULT_FEED_TAB
: preferredTab;
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "")
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
(feedTab !== "followed" || !!user)
? (feedTab as FeedTab)
: defaultTab;

View File

@@ -13,6 +13,8 @@ import {
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
interface FilePreviewProps {
dump: Dump;
@@ -103,7 +105,7 @@ function AudioFilePreview(
type="button"
className="audio-player-btn"
onClick={handlePlayBtn}
aria-label={isPlaying ? "Pause" : "Play"}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
@@ -208,7 +210,7 @@ export default function FilePreview(
e.stopPropagation();
play(filePlayerItem(dump, fileUrl, mime, token));
}}
aria-label="Play"
aria-label={t`Play`}
>
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
<span className="rich-content-play-overlay" aria-hidden="true">
@@ -229,7 +231,7 @@ export default function FilePreview(
e.stopPropagation();
play(filePlayerItem(dump, fileUrl, mime, token));
}}
aria-label="Play"
aria-label={t`Play`}
>
{thumbOverride
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
@@ -264,6 +266,7 @@ export default function FilePreview(
<button
type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
aria-label={videoPlaying ? t`Pause` : t`Play`}
onClick={() =>
videoActive
? togglePlay()
@@ -311,7 +314,7 @@ export default function FilePreview(
return (
<a href={fileUrl} download={dump.fileName} className="file-download-link">
{mimeIcon(mime)} Download {dump.fileName}
{mimeIcon(mime)} <Trans>Download {dump.fileName}</Trans>
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
</a>
);

View File

@@ -8,6 +8,7 @@ import {
WAVEFORM_H,
} from "../utils/waveform.ts";
import { fmt } from "../utils/duration.ts";
import { t } from "@lingui/core/macro";
export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
@@ -170,10 +171,12 @@ function SeekBar(
max={duration || 1}
step={0.01}
value={current}
onMouseDown={() => onDragChange(true)}
onMouseUp={() => onDragChange(false)}
// Pointer events, so a finger holds the thumb as a mouse does.
onPointerDown={() => onDragChange(true)}
onPointerUp={() => onDragChange(false)}
onPointerCancel={() => onDragChange(false)}
onChange={(e) => onSeek(Number(e.target.value))}
aria-label="Seek"
aria-label={t`Seek`}
/>
</div>
);
@@ -369,12 +372,27 @@ export function MediaPlayer(
setPlaying(false);
onErrorRef.current?.();
};
// The element can also be started or stopped from outside the page — a
// media key, a headset, the OS's media controls — so follow it rather
// than only what our own button did.
const onPlayEvent = () => {
setPlaying(true);
onPlayStateChangeRef.current?.(true);
};
const onPauseEvent = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
};
a.addEventListener("timeupdate", onTime);
a.addEventListener("play", onPlayEvent);
a.addEventListener("pause", onPauseEvent);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEndedEvent);
a.addEventListener("error", onErrorEvent);
return () => {
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("play", onPlayEvent);
a.removeEventListener("pause", onPauseEvent);
a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEndedEvent);
a.removeEventListener("error", onErrorEvent);
@@ -462,7 +480,7 @@ export function MediaPlayer(
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
aria-label={playing ? t`Pause` : t`Play`}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
@@ -478,7 +496,7 @@ export function MediaPlayer(
type="button"
className="audio-player-vol-btn"
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
aria-label={muted ? t`Unmute` : t`Mute`}
>
<IconVolume muted={muted} />
</button>
@@ -495,7 +513,7 @@ export function MediaPlayer(
step={0.01}
value={muted ? 0 : volume}
onChange={changeVolume}
aria-label="Volume"
aria-label={t`Volume`}
/>
</div>
</div>
@@ -505,7 +523,7 @@ export function MediaPlayer(
type="button"
className="audio-player-vol-btn"
onClick={goFullscreen}
aria-label="Fullscreen"
aria-label={t`Fullscreen`}
>
<IconFullscreen />
</button>

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { useScrollLock } from "../hooks/useScrollLock.ts";
@@ -20,6 +20,7 @@ export function Modal(
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
) {
const backdropRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const requestClose = useCallback(() => {
if (onBeforeClose?.() === false) return;
@@ -44,9 +45,14 @@ export function Modal(
if (e.target === backdropRef.current) requestClose();
}}
>
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
<div
className={`modal-card${wide ? " modal-card--wide" : ""}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="modal-header">
<span className="modal-title">{title}</span>
<span className="modal-title" id={titleId}>{title}</span>
<button
type="button"
className="modal-close-btn"

View File

@@ -1,10 +1,9 @@
import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { playlistUrl } from "../utils/urls.ts";
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
import {
isPlaylistVisited,
isRecent,
@@ -40,7 +39,7 @@ export function PlaylistCard(
{playlist.imageMime
? (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
src={playlistImageUrl(playlist)}
alt=""
className="playlist-card-img"
/>

View File

@@ -42,9 +42,18 @@ export function PlaylistMembershipPanel({
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
role="checkbox"
aria-checked={m.hasDump}
tabIndex={0}
onClick={() => onToggle(m)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle(m);
}
}}
>
<span className="playlist-membership-check">
<span className="playlist-membership-check" aria-hidden="true">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">

View File

@@ -1,4 +1,5 @@
import { useContext } from "react";
import { t } from "@lingui/core/macro";
import type { RichContent } from "../model.ts";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
@@ -79,7 +80,7 @@ export default function RichContentCard(
if (isActive && current?.kind === "stream") togglePlay();
else void playOrOpen();
}}
aria-label={isPlaying ? "Pause" : "Play"}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{thumbnail}
<span className="rich-content-play-overlay">
@@ -97,8 +98,8 @@ export default function RichContentCard(
className="rich-content-compact"
onClick={(e) => e.stopPropagation()}
aria-label={richContent.siteName
? `Open on ${richContent.siteName}`
: "Open source"}
? t`Open on ${richContent.siteName}`
: t`Open source`}
>
{thumbnail}
<span className="rich-content-open-overlay" aria-hidden="true">
@@ -125,7 +126,7 @@ export default function RichContentCard(
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={() => void playOrOpen()}
aria-label="Play"
aria-label={t`Play`}
>
{thumbnailImg}
<span className="rich-content-play-overlay">▶</span>

View File

@@ -169,7 +169,13 @@ export function UserListPopover(
loadingMore: false,
};
});
});
})
// Offline or an unreadable reply: give the button back for a retry.
.catch(() =>
setState((prev) =>
prev.status === "loaded" ? { ...prev, loadingMore: false } : prev
)
);
};
if (!placement) return null;

View File

@@ -7,6 +7,8 @@ interface SubmitButtonProps {
pendingLabel?: ReactNode;
disabled?: boolean;
className?: string;
/** For an icon-only button (e.g. "✓"), its accessible name. */
"aria-label"?: string;
}
/**
@@ -19,11 +21,17 @@ export function SubmitButton({
pendingLabel,
disabled,
className = "btn-primary",
"aria-label": ariaLabel,
}: SubmitButtonProps) {
const { isSubmitting } = useFormState();
return (
<button type="submit" className={className} disabled={isSubmitting || disabled}>
<button
type="submit"
className={className}
disabled={isSubmitting || disabled}
aria-label={ariaLabel}
>
{isSubmitting && pendingLabel != null ? pendingLabel : children}
</button>
);

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -730,7 +730,8 @@ export interface CreateUrlDumpRequest {
export interface UpdateDumpRequest {
url?: string;
title?: string;
comment?: string;
/** `null` removes the note; leaving it out keeps it. */
comment?: string | null;
isPrivate?: boolean;
categoryIds?: string[];
}

View File

@@ -302,6 +302,12 @@ export function Dump() {
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const startTitleEdit = () => {
setTitleDraft(dump.title);
setTitleError(null);
setTitleEditing(true);
};
const handleTitleSave = async () => {
const trimmed = titleDraft.trim();
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
@@ -322,13 +328,14 @@ export function Dump() {
}
const updatedDump = deserializeDump(apiResponse.data);
setTitleEditing(false);
// The page stays mounted across the slug change below, so it has to be
// told about the new title itself.
setDumpState({ status: "loaded", dump: updatedDump });
if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), {
replace: true,
state: { dump: updatedDump },
});
} else {
setDumpState({ status: "loaded", dump: updatedDump });
}
} catch (err) {
setTitleError(friendlyFetchError(err));
@@ -411,11 +418,17 @@ export function Dump() {
className={`dump-title-view${
canEdit ? " dump-title-view--editable" : ""
}`}
onClick={canEdit
? () => {
setTitleDraft(dump.title);
setTitleError(null);
setTitleEditing(true);
onClick={canEdit ? startTitleEdit : undefined}
// Reachable from the keyboard too; not a button, so the
// title inside stays a heading.
tabIndex={canEdit ? 0 : undefined}
title={canEdit ? t`Edit title` : undefined}
onKeyDown={canEdit
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startTitleEdit();
}
}
: undefined}
>

View File

@@ -12,6 +12,7 @@ import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
@@ -55,6 +56,8 @@ export function DumpEdit() {
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [thumbUploading, setThumbUploading] = useState(false);
const [thumbError, setThumbError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (!selectedDump) return;
@@ -105,54 +108,49 @@ export function DumpEdit() {
}
};
const handleThumbnailChange = async (file: File) => {
const changeThumbnail = async (init: RequestInit) => {
if (state.status !== "loaded") return;
setThumbUploading(true);
setThumbError(null);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
{ method: "PUT", body: formData },
const updated = await expectOk<RawDump>(
await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
init,
),
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
setState({ status: "loaded", dump: deserializeDump(updated) });
} catch (err) {
setThumbError(friendlyFetchError(err));
} finally {
setThumbUploading(false);
}
};
const handleThumbnailReset = async () => {
if (state.status !== "loaded") return;
setThumbUploading(true);
try {
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
{ method: "DELETE" },
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
} finally {
setThumbUploading(false);
}
const handleThumbnailChange = (file: File) => {
const formData = new FormData();
formData.append("file", file);
return changeThumbnail({ method: "PUT", body: formData });
};
const handleThumbnailReset = () => changeThumbnail({ method: "DELETE" });
const handleDelete = async () => {
if (state.status !== "loaded") return;
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
setState({ status: "error", error: `Delete failed (${res.status})` });
setConfirmDelete(false);
setDeleteError(null);
try {
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? t`Delete failed (${res.status})`);
}
} catch (err) {
setDeleteError(friendlyFetchError(err));
return;
}
navigate("/", { state: { deletedDumpId: state.dump.id } });
};
@@ -252,6 +250,8 @@ export function DumpEdit() {
}
currentThumbnailSrc={currentThumbnailSrc}
thumbUploading={thumbUploading}
thumbError={thumbError}
deleteError={deleteError}
onThumbnailChange={handleThumbnailChange}
onThumbnailReset={handleThumbnailReset}
onRequestDelete={() => setConfirmDelete(true)}
@@ -276,6 +276,8 @@ interface DumpEditFormProps {
preview: React.ReactNode;
currentThumbnailSrc: string | null;
thumbUploading: boolean;
thumbError: string | null;
deleteError: string | null;
onThumbnailChange: (file: File) => void;
onThumbnailReset: () => void;
onRequestDelete: () => void;
@@ -295,6 +297,8 @@ function DumpEditForm({
preview,
currentThumbnailSrc,
thumbUploading,
thumbError,
deleteError,
onThumbnailChange,
onThumbnailReset,
onRequestDelete,
@@ -343,18 +347,29 @@ function DumpEditForm({
method: "PUT",
body: formData,
});
// Replacing the file doesn't touch visibility, so a change made in
// the same save goes separately, once the file is in.
if (isPrivate !== dump.isPrivate) {
await expectOk<RawDump>(res);
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify({ isPrivate } satisfies UpdateDumpRequest),
});
}
} else {
// An emptied note is sent as null: left out, the server keeps the old
// one.
const body: UpdateDumpRequest = dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || undefined,
comment: comment.trim() || null,
isPrivate,
categoryIds,
}
: {
title: trimmedTitle,
comment: comment.trim() || undefined,
comment: comment.trim() || null,
isPrivate,
categoryIds,
};
@@ -400,6 +415,11 @@ function DumpEditForm({
</button>
)}
</div>
{thumbError && (
<span className="form-hint form-hint--error" role="alert">
{thumbError}
</span>
)}
</div>
{dump.kind === "url"
@@ -470,6 +490,9 @@ function DumpEditForm({
</div>
<div className="dump-edit-footer">
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<div className="form-actions">
<button
type="button"

View File

@@ -91,7 +91,10 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
const defaultTab: FeedTab = preferredTab === "followed" && !user
? DEFAULT_FEED_TAB
: preferredTab;
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "")
// "followed" needs a session: a guest there (a shared link, or signed out
// on it) gets the default feed rather than an empty page.
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") &&
(rawTab !== "followed" || !!user)
? (rawTab as FeedTab)
: defaultTab;

View File

@@ -27,6 +27,7 @@ import type {
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { expectOk } from "../components/form/index.ts";
type State =
| { status: "loading" }
@@ -269,13 +270,15 @@ export function Notifications() {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
const data = body.data as PaginatedData<RawNotification>;
let cancelled = false;
(async () => {
try {
const data = await expectOk<PaginatedData<RawNotification>>(
await authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
),
);
if (cancelled) return;
setState({
status: "loaded",
items: data.items.map(deserializeNotification),
@@ -283,25 +286,31 @@ export function Notifications() {
page: 1,
loadingMore: false,
});
return authFetch(`${API_URL}/api/notifications/read-all`, {
} catch (err) {
if (!cancelled) {
setState({ status: "error", error: friendlyFetchError(err) });
}
return;
}
// Marking them read is a separate matter: if it fails, the list that
// loaded is still worth showing, and they stay unread for next time.
try {
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
});
})
.then(() => {
if (cancelled || !res.ok) return;
// Only the bell clears. The list keeps showing which ones were new
// when the page opened: flipping them to read here, a moment after
// they appear, would take the dots and the "new" count away before
// anyone could see them.
clearUnreadNotifications();
setState((s) =>
s.status === "loaded"
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
: s
);
})
.catch((err) => {
if (err instanceof TypeError) {
setState({ status: "error", error: friendlyFetchError(err) });
} else if (err instanceof Error && err.message === "Failed to load") {
setState({ status: "error", error: err.message });
}
});
} catch {
// Offline: see above.
}
})();
return () => {
cancelled = true;
};
}, [authFetch, clearUnreadNotifications]);
const [prevLastNotification, setPrevLastNotification] = useState(
@@ -316,6 +325,18 @@ export function Notifications() {
});
}
// One that arrives while the page is open is being seen: it stays
// highlighted here, but the bell shouldn't count it.
const loaded = state.status === "loaded";
useEffect(() => {
if (!lastNotification || !loaded) return;
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
.then((res) => {
if (res.ok) clearUnreadNotifications();
})
.catch(() => {});
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
const loadMore = () => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
return;

View File

@@ -24,7 +24,7 @@ import {
deserializePlaylist,
deserializePlaylistWithDumps,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
@@ -34,6 +34,7 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
@@ -108,6 +109,8 @@ export function PlaylistDetail() {
const [editOpen, setEditOpen] = useState(false);
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
const [confirmDelete, setConfirmDelete] = useState(false);
// A delete, removal or reorder the server refused.
const [actionError, setActionError] = useState<string | null>(null);
// Mirrors activeDumpIds for use in effects without adding it as a dep.
// Updated on every render via useLayoutEffect so it's always current.
@@ -469,16 +472,21 @@ export function PlaylistDetail() {
activeDumpIds.has(d.id)
);
try {
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
});
const res = await authFetch(
`${API_URL}/api/playlists/${playlistId}/order`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch {
// Back to the order the server actually kept.
fetchPlaylist();
}
};
@@ -488,10 +496,13 @@ export function PlaylistDetail() {
// Fire-and-forget the API call; animate immediately
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "DELETE",
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}).catch(() => {
// On failure, cancel the fade and restore the item
// Refused or offline: cancel the fade and restore the item
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
setActionError(t`The dump couldn't be removed. Try again.`);
});
setActiveDumpIds((prev) => {
const n = new Set(prev);
@@ -538,14 +549,34 @@ export function PlaylistDetail() {
const handleEditSaved = (updated: Playlist) => {
setEditOpen(false);
navigate(playlistUrl(updated), { replace: true });
// Shown at once rather than left to the socket's echo, which may never
// come (disconnected); a new slug also moves the URL.
setState((prev) =>
prev.status === "loaded"
? {
...prev,
playlist: { ...prev.playlist, ...updated, dumps: prev.playlist.dumps },
}
: prev
);
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
navigate(playlistUrl(updated), { replace: true });
}
};
const handleDelete = async () => {
if (!playlistId) return;
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
setConfirmDelete(false);
setActionError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
if (!res.ok) await expectOk(res); // throws the server's message
} catch (err) {
setActionError(friendlyFetchError(err));
return;
}
navigate("/");
};
@@ -604,7 +635,7 @@ export function PlaylistDetail() {
<>
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
src={playlistImageUrl(playlist)}
alt=""
className="playlist-detail-img"
/>
@@ -676,6 +707,10 @@ export function PlaylistDetail() {
</div>
</div>
{actionError && (
<ErrorCard title={t`Something went wrong`} message={actionError} />
)}
{visibleDumps.length === 0
? (
<p className="empty-state">
@@ -819,10 +854,17 @@ function PlaylistEditForm(
if (image) {
const fd = new FormData();
fd.append("file", image);
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
});
// The upload answers with the playlist as it now stands (new cover,
// new updatedAt), which is what the page should show.
onSaved(deserializePlaylist(
await expectOk<RawPlaylist>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
}),
),
));
return;
}
onSaved(updated);
@@ -840,9 +882,7 @@ function PlaylistEditForm(
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
(playlist.imageMime ? playlistImageUrl(playlist) : null)}
alt="Cover"
size={72}
className="animate-in"

View File

@@ -24,6 +24,7 @@ import {
type RawPublicUser,
} from "../model.ts";
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
type Tab = (typeof SEARCH_TABS)[number];
@@ -86,7 +87,10 @@ export function Search() {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Search failed (${res.status})`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
}
const body = await res.json() as {
success: true;
@@ -133,7 +137,17 @@ export function Search() {
}
} catch (err) {
if ((err as Error).name === "AbortError") return;
setState({ status: "error", error: (err as Error).message });
// A later page that fails leaves the results already shown; scrolling
// back to the end tries it again.
if (page > 1) {
setState((prev) =>
prev.status === "loaded"
? { ...prev, dumps: { ...prev.dumps, loadingMore: false } }
: prev
);
return;
}
setState({ status: "error", error: friendlyFetchError(err) });
}
}, [token]);

View File

@@ -107,11 +107,15 @@ function ResetRequestPanel() {
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
const onSubmit = form.submit(async ({ email }) => {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
});
// The answer is the same whether or not the address is known; only a
// server that failed to take the request is worth reporting.
await expectOk(
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
}),
);
setSent(true);
});

View File

@@ -36,6 +36,8 @@ import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { expectOk } from "../components/form/index.ts";
interface PlaylistFeed {
items: Playlist[];
@@ -321,10 +323,19 @@ export function UserPlaylists() {
}
}, [state.status, cachedCreated]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (playlistId: string) => {
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
setDeleteError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
if (!res.ok) await expectOk(res); // throws the server's message
} catch (err) {
setDeleteError(friendlyFetchError(err));
return;
}
setState((s) =>
s.status === "loaded"
? {
@@ -385,6 +396,10 @@ export function UserPlaylists() {
)}
/>
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">

View File

@@ -701,6 +701,35 @@ export function UserPublicProfile() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
// There's no page of its own for who someone follows, so the list grows
// here.
const loadMoreFollowedUsers = () => {
if (
followedState?.status !== "loaded" || followedState.users.loadingMore
) return;
const nextPage = followedState.users.page + 1;
const setUsers = (
fn: (u: PaginatedList<PublicUser>) => PaginatedList<PublicUser>,
) =>
setFollowedState((s) =>
s?.status === "loaded" ? { ...s, users: fn(s.users) } : s
);
setUsers((u) => ({ ...u, loadingMore: true }));
fetch(
`${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
)
.then(expectOk<PaginatedData<RawPublicUser>>)
.then(({ items, hasMore }) =>
setUsers((u) => ({
items: [...u.items, ...items.map(deserializePublicUser)],
hasMore,
page: nextPage,
loadingMore: false,
}))
)
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
};
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
@@ -863,7 +892,15 @@ export function UserPublicProfile() {
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
title="Edit email"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
@@ -913,6 +950,21 @@ export function UserPublicProfile() {
onClick={isOwnProfile
? () => setDescEditing(true)
: undefined}
// Reachable from the keyboard too; not a button, so the
// markdown inside keeps its own structure.
tabIndex={isOwnProfile ? 0 : undefined}
title={isOwnProfile ? t`Edit description` : undefined}
onKeyDown={isOwnProfile
? (e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
e.preventDefault();
setDescEditing(true);
}
}
: undefined}
>
{profileUser.description
? (
@@ -1077,12 +1129,16 @@ export function UserPublicProfile() {
))}
</ul>
{followedState.users.hasMore && (
<Link
to={`/users/${profileUser.username}/following`}
className="profile-view-all"
<button
type="button"
className="load-more-btn"
onClick={loadMoreFollowedUsers}
disabled={followedState.users.loadingMore}
>
<Trans>View all →</Trans>
</Link>
{followedState.users.loadingMore
? <Trans>Loading…</Trans>
: <Trans>Load more</Trans>}
</button>
)}
</>
)}
@@ -1496,13 +1552,19 @@ function UpvotedDumpList(
useEffect(() => {
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
setVotedIds(new Set(wsMyVotes));
// Only a baseline: the list as loaded stands. myVotes may still be the
// empty set from before the socket's welcome, and taking it as the
// truth would hide every dump.
prevMyVotesRef.current = new Set(wsMyVotes);
return;
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes));
setVotedIds((ids) => {
const next = new Set(ids);
for (const id of prev) if (!wsMyVotes.has(id)) next.delete(id);
for (const id of wsMyVotes) next.add(id);
return next;
});
for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(wsMyVotes);
@@ -1787,7 +1849,8 @@ function DescriptionEditor(
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim() || undefined;
const trimmed = description.trim();
// Sent even when empty: left out, the server would keep the old one.
await expectOk<RawPublicUser>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
@@ -1797,7 +1860,7 @@ function DescriptionEditor(
),
}),
);
onSaved(trimmed);
onSaved(trimmed || undefined);
});
return (

View File

@@ -72,13 +72,20 @@ export function UserUpvoted() {
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
setVotedIds(new Set(myVotes));
// Only a baseline: the list as loaded stands. myVotes may still be the
// empty set from before the socket's welcome (or the socket may never
// connect), and taking it as the truth here would hide every dump.
prevMyVotesRef.current = new Set(myVotes);
return;
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(myVotes));
// Votes cast or removed since — from this tab or another — show or fade.
setVotedIds((ids) => {
const next = new Set(ids);
for (const id of prev) if (!myVotes.has(id)) next.delete(id);
for (const id of myVotes) next.add(id);
return next;
});
for (const id of prev) if (!myVotes.has(id)) startFading(id);
for (const id of myVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(myVotes);

View File

@@ -31,6 +31,16 @@ type FeedState =
loadingMore: boolean;
};
/** Page of a followed feed. Throws the server's own message on an error
* envelope, so it isn't mistaken for a lost connection. */
async function readPage(res: Response): Promise<PaginatedData<RawDump>> {
const body = await res.json();
if (!body.success) {
throw new Error(body.error?.message ?? `HTTP ${res.status}`);
}
return body.data as PaginatedData<RawDump>;
}
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
@@ -203,9 +213,8 @@ export function FollowedFeed({
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState({
status: "loaded",
dumps: items.map(deserializeDump),
@@ -223,9 +232,8 @@ export function FollowedFeed({
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState({
status: "loaded",
dumps: items.map(deserializeDump),
@@ -278,9 +286,8 @@ export function FollowedFeed({
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState((s) =>
s.status === "loaded"
? {
@@ -313,9 +320,8 @@ export function FollowedFeed({
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState((s) =>
s.status === "loaded"
? {

View File

@@ -0,0 +1,304 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatModal } from "../../components/ChatModal.tsx";
import type { AuthResponse, ChatMessage } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeChatMessage, makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const me = makeSession({ id: "me", username: "me" });
async function setup(
history: ChatMessage[] = [],
{ session = me, welcome = {} as Record<string, unknown> } = {},
) {
route("GET /api/chat", () => wire(history));
const onClose = vi.fn();
const user = userEvent.setup();
renderApp(<ChatModal onClose={onClose} />, { session });
const socket = FakeWebSocket.last;
socket.welcome(welcome);
await screen.findByRole("dialog", { name: "Chat" });
await waitFor(() =>
expect(screen.queryByText("Loading messages…")).not.toBeInTheDocument()
);
const composer = screen.getByPlaceholderText("Type a message…");
return { user, socket, onClose, composer };
}
const sent = (socket: FakeWebSocket, type: string) =>
socket.messages.filter((m) => m.type === type);
describe("ChatModal: history", () => {
test("shows the latest messages under a day separator, grouping runs", async () => {
const t0 = Date.now() - 60_000;
await setup([
makeChatMessage({ id: "1", userId: "ann", authorUsername: "ann", body: "one", createdAt: new Date(t0) }),
makeChatMessage({ id: "2", userId: "ann", authorUsername: "ann", body: "two", createdAt: new Date(t0 + 1000) }),
makeChatMessage({ id: "3", userId: "bob", authorUsername: "bob", body: "three", createdAt: new Date(t0 + 2000) }),
]);
expect(screen.getByText("Today")).toBeInTheDocument();
// "two" continues ann's run: no second author link.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
expect(screen.getByText("two").closest(".chat-message")).toHaveClass("chat-message--grouped");
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
});
test("empty and failed states", async () => {
route("GET /api/chat", () => fail("down", 500), { once: true });
route("GET /api/chat", () => []);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await screen.findByText("Couldn't load the chat.");
await user.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No messages yet. Say hello!");
});
test("a full page offers older messages", async () => {
const page = Array.from({ length: 50 }, (_, i) =>
makeChatMessage({ id: `m${i + 10}`, body: `msg ${i + 10}`, createdAt: new Date(Date.now() - (50 - i) * 1000) })
);
route("GET /api/chat", (req) =>
req.url.searchParams.has("before")
? wire([makeChatMessage({ id: "m0", body: "the oldest", createdAt: new Date(Date.now() - 999_000) })])
: wire(page));
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load older messages" }));
await screen.findByText("the oldest");
const req = calls("GET /api/chat").find((r) => r.url.searchParams.has("before"))!;
expect(req.url.searchParams.get("beforeId")).toBe("m10");
expect(screen.queryByRole("button", { name: "Load older messages" })).not.toBeInTheDocument();
});
test("the 'new' divider sits before the first unread message from someone else", async () => {
const readAt = new Date(Date.now() - 30_000);
await setup([
makeChatMessage({ id: "old", body: "seen", createdAt: new Date(Date.now() - 60_000) }),
makeChatMessage({ id: "new1", body: "unseen", createdAt: new Date(Date.now() - 10_000) }),
], { welcome: { chatReadAt: readAt.toISOString() } });
const divider = document.querySelector(".chat-new-sep")!;
expect(divider).toHaveTextContent("New");
expect(divider.nextElementSibling).toHaveTextContent("unseen");
});
test("reading at the bottom moves the read marker", async () => {
const { socket } = await setup([makeChatMessage({ body: "hi" })]);
await waitFor(() => expect(sent(socket, "chat_read")).toHaveLength(1));
});
test("tells the server the chat is open, and closed", async () => {
const { socket } = await setup();
expect(sent(socket, "chat_focus")).toContainEqual({ type: "chat_focus", open: true });
});
});
describe("ChatModal: sending", () => {
test("Enter sends; the message shows as sending until its echo", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "hello there{Enter}");
expect(composer).toHaveValue("");
const [send] = sent(socket, "chat_send");
expect(send).toMatchObject({ body: "hello there" });
expect(screen.getByText("Sending…")).toBeInTheDocument();
socket.receive({
type: "chat_message",
nonce: send.nonce,
message: wire(makeChatMessage({ id: "echo", userId: "me", authorUsername: "me", body: "hello there", createdAt: new Date() })),
});
expect(screen.queryByText("Sending…")).not.toBeInTheDocument();
expect(screen.getByText("hello there")).toBeInTheDocument();
});
test("Shift+Enter is a new line; blank and over-long messages don't send", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "line one{Shift>}{Enter}{/Shift}line two");
expect(composer).toHaveValue("line one\nline two");
await user.clear(composer);
await user.type(composer, " {Enter}");
expect(sent(socket, "chat_send")).toHaveLength(0);
await user.clear(composer);
await user.click(composer);
await user.paste("x".repeat(2001));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText("2001 / 2000")).toBeInTheDocument();
});
test("a failed send can be retried, taken back or dropped", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "oops{Enter}");
const [send] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: send.nonce, message: "Slow down" });
const failed = screen.getByText("Slow down").closest(".chat-message")! as HTMLElement;
await user.click(within(failed).getByRole("button", { name: "Retry" }));
expect(sent(socket, "chat_send")).toHaveLength(2);
const [, retry] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: retry.nonce, message: "Still no" });
await user.type(composer, "draft");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(composer).toHaveValue("draft\n\noops");
expect(screen.queryByText("Still no")).not.toBeInTheDocument();
});
test("typing is announced, and its end too", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true });
try {
const { socket, composer } = await setup();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(composer, "abc");
expect(sent(socket, "chat_typing")).toEqual([{ type: "chat_typing", typing: true }]);
act(() => vi.advanceTimersByTime(3_600));
expect(sent(socket, "chat_typing").at(-1)).toEqual({ type: "chat_typing", typing: false });
} finally {
vi.useRealTimers();
}
});
test("the draft and reply outlive the chat", async () => {
const first = await setup([makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" })]);
await first.user.type(first.composer, "half a thought");
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
expect(stored.draft).toBe("half a thought");
});
});
describe("ChatModal: replies, edits, deletes", () => {
const theirs = () =>
makeChatMessage({ id: "t1", userId: "ann", authorUsername: "ann", body: "ask me **anything**", createdAt: new Date(Date.now() - 20_000) });
const mine = () =>
makeChatMessage({ id: "m1", userId: "me", authorUsername: "me", body: "my words", createdAt: new Date(Date.now() - 10_000) });
test("replying: the bar, the reference, and Escape cancelling just the reply", async () => {
const { user, socket, composer, onClose } = await setup([theirs()]);
const msg = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
await user.click(within(msg).getByRole("button", { name: "Reply" }));
expect(screen.getByText("Replying to")).toBeInTheDocument();
expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent("ask me anything");
await user.keyboard("{Escape}");
expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(msg).getByRole("button", { name: "Reply" }));
await user.type(composer, "sure{Enter}");
expect(sent(socket, "chat_send")[0]).toMatchObject({ body: "sure", replyToId: "t1" });
});
test("only your own messages can be edited and deleted", async () => {
await setup([theirs(), mine()]);
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
expect(within(t).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("moderators can delete anyone's", async () => {
await setup([theirs()], { session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse });
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
expect(within(t).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("Up in an empty composer edits your last message; Enter saves", async () => {
const { user, socket, composer } = await setup([theirs(), mine()]);
await user.click(composer);
await user.keyboard("{ArrowUp}");
const box = await screen.findByDisplayValue("my words");
await user.clear(box);
await user.type(box, "better words{Enter}");
expect(sent(socket, "chat_edit")).toEqual([{ type: "chat_edit", id: "m1", body: "better words" }]);
expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument();
});
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(sent(socket, "chat_edit")).toHaveLength(0);
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
test("deleting asks first; Escape on the question keeps the chat open", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Delete" }));
expect(screen.getByRole("alertdialog")).toHaveTextContent("Delete this message?");
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(m).getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
expect(sent(socket, "chat_delete")).toEqual([{ type: "chat_delete", id: "m1" }]);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(screen.queryByText("my words")).not.toBeInTheDocument();
});
test("a reply to a deleted message says so", async () => {
const { socket } = await setup([
theirs(),
makeChatMessage({ id: "r", body: "a reply", replyToId: "t1", replyToAuthor: "ann", replyToBody: "ask me" }),
]);
socket.receive({ type: "chat_message_deleted", id: "t1" });
expect(screen.getByText("deleted message").closest("button")).toBeDisabled();
});
});
describe("ChatModal: live", () => {
test("new messages arrive, and a mention of me stands out", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })),
});
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass("chat-message--mention");
});
test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo")).toHaveTextContent("🎉🎉");
});
test("who's typing", async () => {
const { socket } = await setup();
socket.receive({ type: "chat_typing_update", users: [{ userId: "ann", username: "ann", hasAvatar: false }] });
expect(screen.getByText("ann is typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [
{ userId: "ann", username: "ann", hasAvatar: false },
{ userId: "bob", username: "bob", hasAvatar: false },
],
});
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] });
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
});
test("a failed edit or delete is shown until dismissed", async () => {
const { user, socket } = await setup();
socket.receive({ type: "error", scope: "chat", message: "Not allowed" });
expect(screen.getByRole("alert")).toHaveTextContent("Not allowed");
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Not allowed")).not.toBeInTheDocument();
});
test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => {
const old = makeChatMessage({ id: "o", body: "kept", createdAt: new Date(Date.now() - 50_000) });
const edited = makeChatMessage({ id: "e", body: "before", createdAt: new Date(Date.now() - 40_000) });
const { socket } = await setup([old, edited]);
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
socket.drop();
expect(screen.getByText("Disconnected — reconnecting…")).toBeInTheDocument();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("after");
const resync = calls("GET /api/chat").at(-1)!;
expect(resync.url.searchParams.get("after")).toBe(old.createdAt.toISOString());
});
});

View File

@@ -0,0 +1,200 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { CommentThread } from "../../components/CommentThread.tsx";
import type { AuthResponse, Comment } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeComment, makeSession, wire } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
const me = makeSession({ id: "me", username: "me" });
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
const user = userEvent.setup();
function Harness() {
const [comments, setComments] = useState(initial);
return (
<CommentThread
dumpId="d1"
comments={comments}
currentUser={session?.user ?? null}
token={session?.token ?? null}
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
onCommentDeleted={(id) =>
setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true } : c))}
onCommentUpdated={(c) =>
setComments((prev) => prev.map((x) => x.id === c.id ? c : x))}
/>
);
}
renderWithContexts(<Harness />, { session, ws });
return user;
}
const created = (over: Partial<Comment>) =>
(req: { body: unknown }) =>
wire(makeComment({ dumpId: "d1", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, ...over }));
describe("CommentThread", () => {
test("counts visible comments and nests replies", () => {
setup([
makeComment({ id: "a", body: "top" }),
makeComment({ id: "b", parentId: "a", body: "reply" }),
makeComment({ id: "c", deleted: true, body: "" }),
]);
expect(screen.getByRole("heading", { name: "2 comments" })).toBeInTheDocument();
const top = screen.getByText("top").closest("li")!;
expect(within(top).getByText("reply")).toBeInTheDocument();
expect(screen.getByText("[deleted]")).toBeInTheDocument();
});
test("guests read but can't write, reply or like", () => {
setup([makeComment({ body: "hello" })], null);
expect(screen.queryByPlaceholderText("Add a comment…")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reply" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
});
test("posting a comment", async () => {
route("POST /api/dumps/:id/comments", created({ id: "new" }));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
expect(screen.getByRole("button", { name: "Post comment" })).toBeDisabled();
await user.type(box, "First!");
await user.click(screen.getByRole("button", { name: "Post comment" }));
await screen.findByText("First!", { selector: ".comment-body *" });
expect(box).toHaveValue("");
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "First!" });
expect(screen.getByRole("heading", { name: "1 comment" })).toBeInTheDocument();
});
test("Ctrl+Enter posts too; blank text doesn't", async () => {
route("POST /api/dumps/:id/comments", created({}));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
await user.type(box, " ");
await user.keyboard("{Control>}{Enter}{/Control}");
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0);
await user.type(box, "ok");
await user.keyboard("{Control>}{Enter}{/Control}");
await waitFor(() => expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1));
});
test("a failed post keeps the text and says why", async () => {
route("POST /api/dumps/:id/comments", () => fail("Comment too long", 400));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
await user.type(box, "text");
await user.click(screen.getByRole("button", { name: "Post comment" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post comment");
expect(alert).toHaveTextContent("Comment too long");
expect(box).toHaveValue("text");
});
test("the top-level Cancel only shows with text, and clears it", async () => {
const user = setup([]);
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Add a comment…"), "draft");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
});
test("replying", async () => {
route("POST /api/dumps/:id/comments", created({ id: "r", parentId: "a" }));
const user = setup([makeComment({ id: "a", body: "parent" })]);
await user.click(screen.getByRole("button", { name: "Reply" }));
const box = screen.getByPlaceholderText("Write a reply…");
await waitFor(() => expect(box).toHaveFocus());
await user.type(box, "child");
await user.click(screen.getByRole("button", { name: "Post reply" }));
await waitFor(() => expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument());
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "child", parentId: "a" });
const parent = screen.getByText("parent").closest("li")!;
expect(within(parent).getByText("child")).toBeInTheDocument();
});
test("Escape backs out of a reply", async () => {
const user = setup([makeComment({ id: "a" })]);
await user.click(screen.getByRole("button", { name: "Reply" }));
await user.type(screen.getByPlaceholderText("Write a reply…"), "nah{Escape}");
expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument();
});
test("editing your own comment", async () => {
route("PATCH /api/comments/:id", (req) =>
wire(makeComment({ id: "a", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, updatedAt: new Date() })));
const user = setup([makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
const box = screen.getByDisplayValue("old");
await user.clear(box);
await user.type(box, "new");
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("new");
expect(screen.getByText(/^edited/)).toBeInTheDocument();
});
test("saving an edit unchanged doesn't stamp it edited", async () => {
const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument());
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
});
test("only the author or a moderator can edit and delete", () => {
const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }),
makeComment({ id: "theirs", userId: "them", body: "theirs" }),
];
setup(comments);
const theirs = screen.getByText("theirs").closest("li")!;
expect(within(theirs).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(theirs).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
const mine = screen.getByText("mine").closest("li")!;
expect(within(mine).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("a moderator can delete anyone's", () => {
setup([makeComment({ userId: "them", body: "theirs" })], mod);
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("deleting asks first", async () => {
route("DELETE /api/comments/:id", () => new Response(null, { status: 204 }));
const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]);
await user.click(screen.getByRole("button", { name: "Delete" }));
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveTextContent("Delete this comment?");
await user.click(within(dialog).getByRole("button", { name: "Delete" }));
await screen.findByText("[deleted]");
});
test("a failed delete says so", async () => {
route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't be deleted/i);
expect(screen.getByText("stays")).toBeInTheDocument();
});
test("likes come from the live counts", async () => {
const castCommentLike = vi.fn();
const user = setup([makeComment({ id: "a", likeCount: 1 })], me, {
likeCounts: { a: 5 },
castCommentLike,
});
expect(screen.getByText("5")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Like" }));
expect(castCommentLike).toHaveBeenCalledWith("a", 5);
});
test("the timestamp links to the comment", () => {
setup([makeComment({ id: "a" })]);
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute("href", "/dumps/d1#comment-a");
});
});

View File

@@ -0,0 +1,336 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DumpCreateModal } from "../../components/DumpCreateModal.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const session = makeSession({ username: "me" });
const categories = [makeCategory({ id: "c1", name: "Music" })];
function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}) {
const onClose = vi.fn();
const injectDump = vi.fn();
const user = userEvent.setup();
renderWithContexts(<DumpCreateModal onClose={onClose} {...props} />, {
session,
categories,
ws: { injectDump },
});
return { user, onClose, injectDump };
}
function previewRoutes({ title = "Page title", reached = true, dupes = [] as unknown[] } = {}) {
route("GET /api/preview", (req) => ({
reached,
richContent: reached
? { type: "link", url: req.url.searchParams.get("url"), title, siteName: "Site" }
: { type: "link", url: req.url.searchParams.get("url") },
}));
route("GET /api/dumps/by-url", () => dupes);
}
const nextBtn = () => screen.getByRole("button", { name: "Next →" });
const linkBox = () => screen.getByPlaceholderText("Paste a link…");
describe("DumpCreateModal: a link", () => {
test("Next stays disabled until there is a link or a file", async () => {
const { user } = setup();
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
expect(nextBtn()).toBeDisabled();
await user.type(linkBox(), "not a url at all");
expect(nextBtn()).toBeDisabled();
});
test("previews the page and suggests its title", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "example.com/a");
expect(await screen.findByText("Fetching preview…")).toBeInTheDocument();
// The suggestion is written in an effect just after the field appears.
const title = await screen.findByLabelText("Title");
await waitFor(() => expect(title).toHaveValue("Page title"));
expect(title).toBeDisabled();
const [req] = calls("GET /api/preview");
expect(req.url.searchParams.get("url")).toBe("https://example.com/a");
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
});
test("an unreachable page says so, but can still be posted", async () => {
previewRoutes({ reached: false });
const { user } = setup();
await user.type(linkBox(), "https://down.test/");
expect(await screen.findByText(/Couldn't load a preview/)).toBeInTheDocument();
expect(nextBtn()).toBeEnabled();
});
test("warns about an earlier dump of the same link", async () => {
previewRoutes({
dupes: [
{ id: "old", slug: "old-one", title: "Old one", username: "ann", createdAt: new Date(Date.now() - 3600_000).toISOString(), voteCount: 1, commentCount: 0 },
{ id: "older", title: "Older", username: "bob", createdAt: new Date(0).toISOString(), voteCount: 0, commentCount: 0 },
],
});
const { user, onClose } = setup();
await user.type(linkBox(), "https://x.test/");
const notice = await screen.findByText(/Already dumped by ann/);
expect(notice.closest("[role=status]")).toHaveTextContent("and 1 earlier dump");
await user.click(screen.getByRole("link", { name: "Old one" }));
expect(currentPath()).toBe("/dumps/old-one");
expect(onClose).toHaveBeenCalled();
});
test("the whole way through: post, then add to a playlist", async () => {
previewRoutes();
const created = makeDump({ id: "new", slug: "page-title", title: "Page title", userId: session.user.id });
route("POST /api/dumps", () => wire(created));
const mix = makePlaylist({ id: "p1", title: "Mix" });
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(mix), hasDump: false }]);
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user, injectDump, onClose } = setup();
await user.type(linkBox(), "example.com/a");
await screen.findByLabelText("Title");
await user.click(nextBtn());
expect(screen.getByText(/example\.com\/a|Page title/)).toBeInTheDocument();
const why = screen.getByLabelText("Why?");
await waitFor(() => expect(why).toHaveFocus());
await user.type(why, " Because ");
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Dump it" }));
await screen.findByRole("dialog", { name: "Add to playlist" });
expect(calls("POST /api/dumps")[0].body).toEqual({
url: "https://example.com/a",
comment: "Because",
isPrivate: true,
categoryIds: ["c1"],
});
expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" }));
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
const row = await screen.findByRole("checkbox", { name: /Mix/ });
expect(row).toHaveAttribute("aria-checked", "false");
row.focus();
await user.keyboard(" ");
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true"));
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/p1/dumps/new");
await user.click(screen.getByRole("button", { name: "Done" }));
expect(onClose).toHaveBeenCalled();
});
test("a refused playlist toggle leaves the box as it was", async () => {
previewRoutes();
route("POST /api/dumps", () => wire(makeDump({ id: "new" })));
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(makePlaylist({ title: "Mix" })), hasDump: false }]);
route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
await user.click(await screen.findByRole("button", { name: "Dump it" }));
const row = await screen.findByRole("checkbox", { name: /Mix/ });
await user.click(row);
await act(async () => {});
expect(row).toHaveAttribute("aria-checked", "false");
});
test("a title typed by the poster is sent; a suggested one isn't", async () => {
previewRoutes();
route("POST /api/dumps", () => wire(makeDump()));
route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup();
await user.type(linkBox(), "https://a.test/");
await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Page title"));
await user.click(screen.getByRole("button", { name: "Edit title" }));
const title = screen.getByLabelText("Title");
await waitFor(() => expect(title).toHaveFocus());
await user.clear(title);
await user.type(title, "My title");
// The suggestion can be had back.
await user.click(screen.getByRole("button", { name: "Reset to the suggested title" }));
expect(title).toHaveValue("Page title");
await user.click(screen.getByRole("button", { name: "Edit title" }));
await user.clear(title);
await user.type(title, "Mine after all");
await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" }));
await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1));
expect(calls("POST /api/dumps")[0].body).toMatchObject({ title: "Mine after all" });
});
test("Enter in the link field goes to the next step rather than posting", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
expect(screen.getByLabelText("Why?")).toBeInTheDocument();
expect(calls("POST /api/dumps")).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "← Back" }));
expect(linkBox()).toHaveValue("https://a.test/");
});
test("a failed post shows the server's message and stays put", async () => {
previewRoutes();
route("POST /api/dumps", () => fail("That URL isn't allowed", 400));
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
await user.click(screen.getByRole("button", { name: "Dump it" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post");
expect(alert).toHaveTextContent("That URL isn't allowed");
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
});
test("starts from a shared URL", async () => {
previewRoutes();
setup({ initialUrl: "https://shared.test/x" });
expect(linkBox()).toHaveValue("https://shared.test/x");
await screen.findByLabelText("Title");
});
test("a link pasted anywhere in the modal lands in the link field", async () => {
previewRoutes();
setup();
act(() => {
const e = new Event("paste", { bubbles: true }) as ClipboardEvent;
Object.defineProperty(e, "clipboardData", {
value: { files: [], getData: () => "pasted.test/page" },
});
screen.getByRole("dialog").dispatchEvent(e);
});
expect(linkBox()).toHaveValue("https://pasted.test/page");
});
});
describe("DumpCreateModal: drafts", () => {
test("a dismissed draft comes back, and can be dropped", async () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({
url: "https://draft.test/",
title: "",
titleFromUser: false,
comment: "half-written",
isPublic: false,
categoryIds: ["c1"],
}));
previewRoutes();
const { user } = setup();
expect(screen.getByText("Picked up where you left off.")).toBeInTheDocument();
expect(linkBox()).toHaveValue("https://draft.test/");
await user.click(nextBtn());
expect(screen.getByLabelText("Why?")).toHaveValue("half-written");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "← Back" }));
await user.click(screen.getByRole("button", { name: "Start fresh" }));
expect(linkBox()).toHaveValue("");
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("what's typed is kept as it's typed", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://keep.test/");
expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe("https://keep.test/");
});
test("a shared URL starts clean rather than merging an old draft", () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ url: "https://old.test/", comment: "old why" }));
previewRoutes();
setup({ initialUrl: "https://new.test/" });
expect(screen.queryByText("Picked up where you left off.")).not.toBeInTheDocument();
expect(linkBox()).toHaveValue("https://new.test/");
});
});
describe("DumpCreateModal: a file", () => {
class FakeXHR {
static last: FakeXHR;
status = 0;
statusText = "";
responseText = "";
body: FormData | null = null;
private l: Record<string, Array<(e: unknown) => void>> = {};
private u: Record<string, Array<(e: unknown) => void>> = {};
upload = { addEventListener: (t: string, f: (e: unknown) => void) => (this.u[t] ??= []).push(f) };
constructor() {
FakeXHR.last = this;
}
open() {}
setRequestHeader() {}
addEventListener(t: string, f: (e: unknown) => void) {
(this.l[t] ??= []).push(f);
}
send(body: FormData) {
this.body = body;
}
progress(f: number) {
this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true }));
}
respond(status: number, data: unknown) {
this.status = status;
this.responseText = JSON.stringify(data);
this.l.load?.forEach((fn) => fn({}));
}
}
afterEach(() => vi.unstubAllGlobals());
const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" });
test("a dropped file names the dump and uploads with progress", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
expect(screen.queryByPlaceholderText("Paste a link…")).not.toBeInTheDocument();
expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument();
expect(screen.getByLabelText("Title")).toHaveValue("my song-final");
await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" }));
act(() => FakeXHR.last.progress(0.42));
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
expect(screen.getByRole("button", { name: "Uploading 42%" })).toBeDisabled();
const fd = FakeXHR.last.body!;
expect(fd.get("title")).toBe("my song-final");
expect(fd.get("isPrivate")).toBe("false");
expect(fd.get("categoryIds")).toBe("[]");
await act(async () => FakeXHR.last.respond(201, { success: true, data: wire(makeDump({ id: "f" })) }));
await screen.findByRole("dialog", { name: "Add to playlist" });
});
test("closing with a file attached asks first", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
const { user, onClose } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
const confirm = screen.getByText(/The attached file won't be kept/).closest("[role=alert]")!;
await user.click(within(confirm as HTMLElement).getByRole("button", { name: "Keep editing" }));
expect(screen.queryByText(/The attached file won't be kept/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: "Close anyway" }));
expect(onClose).toHaveBeenCalled();
});
test("without a file, closing just closes", async () => {
const { user, onClose } = setup();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
test("removing the file offers the link field again", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
const { user } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
await user.click(screen.getByRole("button", { name: "Remove file" }));
expect(linkBox()).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { useState } from "react";
import { Modal } from "../../components/Modal.tsx";
import { ConfirmModal } from "../../components/ConfirmModal.tsx";
const wrap = (ui: React.ReactNode) =>
render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
describe("Modal", () => {
test("is a labelled dialog that locks the page's scroll", () => {
wrap(<Modal title="Settings" onClose={() => {}}>body</Modal>);
expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent(
"body",
);
expect(document.documentElement.style.overflowY).toBe("hidden");
});
test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose}><button type="button">inside</button></Modal>);
await user.click(screen.getByText("inside"));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(document.querySelector(".modal-backdrop")!);
expect(onClose).toHaveBeenCalledTimes(3);
});
test("onBeforeClose can refuse", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).not.toHaveBeenCalled();
});
test("an Escape something inside already handled is left alone", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(
<Modal title="T" onClose={onClose}>
<input onKeyDown={(e) => e.key === "Escape" && e.preventDefault()} />
</Modal>,
);
await user.click(screen.getByRole("textbox"));
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
});
describe("ConfirmModal", () => {
test("asks, confirms and cancels", async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
const user = userEvent.setup();
wrap(
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />,
);
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveAccessibleDescription("Really?");
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.keyboard("{Escape}");
await user.click(document.querySelector(".modal-backdrop")!);
expect(onCancel).toHaveBeenCalledTimes(3);
});
test("takes a custom label", () => {
wrap(
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />,
);
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
});
test("Escape on a confirm over a modal closes only the confirm", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
function Stacked() {
const [confirming, setConfirming] = useState(true);
return (
<Modal title="Chat" onClose={onClose}>
{confirming && (
<ConfirmModal
message="Delete this message?"
onConfirm={() => {}}
onCancel={() => setConfirming(false)}
/>
)}
</Modal>
);
}
wrap(<Stacked />);
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Chat" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,295 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { TextEditor, type TextEditorHandle } from "../../components/TextEditor.tsx";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
import { serveEmojibase } from "../emojibase.ts";
type Props = Partial<React.ComponentProps<typeof TextEditor>>;
function setup(props: Props = {}, initial = "") {
const onChange = vi.fn();
function Harness() {
const [value, setValue] = useState(initial);
return (
<TextEditor
value={value}
onChange={(v) => {
setValue(v);
onChange(v);
}}
{...props}
/>
);
}
const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() });
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
return { user, box, onChange };
}
function imageFile(name = "a.png", type = "image/png") {
return new File([new Uint8Array([1, 2, 3])], name, { type });
}
describe("TextEditor basics", () => {
test("types through to onChange", async () => {
const { user, box, onChange } = setup({ placeholder: "Say something" });
expect(box).toHaveAttribute("placeholder", "Say something");
await user.type(box, "hello");
expect(onChange).toHaveBeenLastCalledWith("hello");
expect(box).toHaveValue("hello");
});
test("Ctrl/Cmd+Enter submits; plain Enter goes to onKeyDown", async () => {
const onSubmit = vi.fn();
const onKeyDown = vi.fn();
const { user, box } = setup({ onSubmit, onKeyDown });
await user.type(box, "x");
await user.keyboard("{Control>}{Enter}{/Control}");
expect(onSubmit).toHaveBeenCalledTimes(1);
await user.keyboard("{Meta>}{Enter}{/Meta}");
expect(onSubmit).toHaveBeenCalledTimes(2);
await user.keyboard("{Enter}");
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
});
test("an Enter confirming an IME candidate does nothing else", () => {
const onSubmit = vi.fn();
const onKeyDown = vi.fn();
const { box } = setup({ onSubmit, onKeyDown });
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true });
fireEvent.keyDown(box, { key: "Enter", keyCode: 229 });
expect(onSubmit).not.toHaveBeenCalled();
expect(onKeyDown).not.toHaveBeenCalled();
});
test("Escape cancels when there's nothing of its own to close", async () => {
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.click(box);
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
test("the counter shows from 80% of the limit and flags going over", async () => {
const { user, box } = setup({ maxLength: 10 }, "1234567");
expect(screen.queryByText(/\/ 10/)).not.toBeInTheDocument();
await user.type(box, "8");
expect(screen.getByText("8 / 10")).toBeInTheDocument();
await user.type(box, "901");
expect(screen.getByText("11 / 10")).toHaveClass("text-editor-count--danger");
expect(box).toHaveAttribute("aria-invalid", "true");
});
test("countFrom overrides when the counter appears", () => {
setup({ maxLength: 100, countFrom: 0 });
expect(screen.getByText("0 / 100")).toBeInTheDocument();
});
test("the handle focuses at the end", () => {
const ref = createRef<TextEditorHandle>();
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />);
act(() => ref.current!.focus({ atEnd: true }));
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(box).toHaveFocus();
expect(box.selectionStart).toBe(3);
});
test("disabled", () => {
setup({ disabled: true });
expect(screen.getByRole("textbox")).toBeDisabled();
expect(screen.getByRole("button", { name: "Insert emoji" })).toBeDisabled();
});
});
describe("mentions", () => {
const users = [
{ id: "1", username: "alice", avatarMime: null },
{ id: "2", username: "alex", avatarMime: null },
];
test("@ opens a listbox; Enter picks and adds a space", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "hi @al");
const list = await screen.findByRole("listbox");
expect(within(list).getAllByRole("option")).toHaveLength(2);
expect(box).toHaveAttribute("aria-activedescendant");
await user.keyboard("{ArrowDown}{Enter}");
expect(box).toHaveValue("hi @alex ");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
test("clicking a suggestion picks it", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "@ali");
await user.click(await screen.findByRole("option", { name: /alice/ }));
expect(box).toHaveValue("@alice ");
});
test("Escape closes the list, and only a second Escape cancels", async () => {
route("GET /api/users/search", () => users);
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
test("Enter with the list open picks rather than submitting", async () => {
route("GET /api/users/search", () => users);
const onKeyDown = vi.fn();
const { user, box } = setup({ onKeyDown });
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Enter}");
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
expect(box).toHaveValue("@alice ");
});
test("leaving the field closes the list", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "@al");
await screen.findByRole("listbox");
fireEvent.blur(box);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("image upload", () => {
test("a pasted image is uploaded and inserted as markdown", async () => {
route("POST /api/attachments", () => ({ id: "att1" }));
const { user, box } = setup({}, "look: ");
await user.click(box);
box.setSelectionRange(6, 6);
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
await waitFor(() =>
expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)")
);
const [req] = calls("POST /api/attachments");
expect(req.body).toBeInstanceOf(FormData);
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
});
test("a paste carrying text pastes the text", () => {
const { box } = setup();
const event = fireEvent.paste(box, {
clipboardData: { types: ["text/plain", "Files"], files: [imageFile()] },
});
expect(event).toBe(true); // not prevented
expect(calls("POST /api/attachments")).toHaveLength(0);
});
test("while uploading: read-only, says so, and holds Enter", async () => {
const pending = deferred();
route("POST /api/attachments", () => pending.promise);
const onKeyDown = vi.fn();
const { box } = setup({ onKeyDown });
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…");
expect(box).toHaveAttribute("readonly");
expect(box).toHaveAttribute("aria-busy", "true");
fireEvent.keyDown(box, { key: "Enter" });
expect(onKeyDown).not.toHaveBeenCalled();
await act(async () =>
pending.resolve(new Response(JSON.stringify({ success: true, data: { id: "z" } })))
);
await waitFor(() => expect(box).not.toHaveAttribute("readonly"));
});
test("a failed upload is reported", async () => {
route("POST /api/attachments", () => fail("Too big", 413));
const { box } = setup();
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded.");
expect(box).toHaveValue("");
expect(box).toHaveAttribute("aria-describedby");
});
test("dropping a non-image is caught and explained", () => {
const { box } = setup();
const dataTransfer = { types: ["Files"], files: [imageFile("a.pdf", "application/pdf")], dropEffect: "" };
const over = fireEvent.dragOver(box, { dataTransfer });
expect(over).toBe(false); // prevented: the browser mustn't open the file
fireEvent.drop(box, { dataTransfer });
expect(screen.getByRole("alert")).toHaveTextContent("Only images can be dropped here.");
expect(calls("POST /api/attachments")).toHaveLength(0);
});
test("dropped images upload; dragged text is left to the browser", async () => {
route("POST /api/attachments", (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` }));
const { box } = setup();
const textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } });
expect(textDrag).toBe(true);
fireEvent.drop(box, {
dataTransfer: { types: ["Files"], files: [imageFile("a.png"), imageFile("b.png")] },
});
await waitFor(() => expect(box.value).toContain("attachments/n1"));
expect(box.value.split("\n")).toHaveLength(2);
});
});
describe("emoji", () => {
test("':' and two letters opens the picker with that query; Escape closes only it", async () => {
serveEmojibase();
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "hi :astro");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
expect(search).toHaveValue("astro");
expect(box).toHaveFocus();
await user.keyboard("{Escape}");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
expect(box).toHaveValue("hi :astro");
});
test("Enter takes the best match and replaces the :query", async () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "go :astronaut");
await screen.findByRole("gridcell", { name: "Astronaut" });
await user.keyboard("{Enter}");
expect(box).toHaveValue("go 🧑‍🚀 ");
});
test("emoticons and times don't open it", async () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "ok :D at 12:30");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
});
test("the button browses and inserts at the caret", async () => {
serveEmojibase();
const { user, box } = setup({}, "ab");
await user.click(box);
box.setSelectionRange(1, 1);
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
await waitFor(() => expect(search).toHaveFocus());
await user.type(search, "astronaut");
await user.click(await screen.findByRole("gridcell", { name: "Astronaut" }));
expect(box).toHaveValue("a🧑‍🚀b");
});
test("a press outside puts the picker away", async () => {
serveEmojibase();
const { user } = setup();
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
await screen.findByRole("searchbox", { name: "Search emoji" });
await user.click(document.body);
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,292 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef } from "react";
import { Avatar } from "../../components/Avatar.tsx";
import { VoteButton } from "../../components/VoteButton.tsx";
import { LikeButton } from "../../components/LikeButton.tsx";
import { FollowPlaylistButton, FollowUserButton } from "../../components/FollowButton.tsx";
import { UserListPopover } from "../../components/UserListPopover.tsx";
import { NotificationBell } from "../../components/NotificationBell.tsx";
import { ChatButton } from "../../components/ChatButton.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
import { WSContext } from "../../contexts/WSContext.ts";
describe("Avatar", () => {
test("an image when the user has one, versioned", () => {
renderWithContexts(<Avatar userId="u1" username="ann" hasAvatar version={7} />);
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/u1?v=7",
);
});
test("initials otherwise, and after the image fails", () => {
renderWithContexts(<Avatar userId="u1" username="bob" hasAvatar />);
fireEvent.error(screen.getByRole("img"));
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByTitle("bob")).toHaveTextContent("B");
});
test("a live override wins over stale props, and retries a failed image", () => {
const { rerender } = renderWithContexts(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: false, version: 1 } }}>
<Avatar userId="u1" username="cy" hasAvatar />
</AvatarOverrideContext.Provider>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
rerender(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: true, version: 2 } }}>
<Avatar userId="u1" username="cy" hasAvatar={false} />
</AvatarOverrideContext.Provider>,
);
// rerender replaces the tree under the same router wrapper
expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("?v=2"));
});
});
describe("VoteButton / LikeButton", () => {
test("upvote and remove, passing the count on screen", async () => {
const onCast = vi.fn();
const onRemove = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<VoteButton dumpId="d" count={0} voted={false} onCast={onCast} onRemove={onRemove} />,
);
await user.click(screen.getByRole("button", { name: "Upvote" }));
expect(onCast).toHaveBeenCalledWith("d", 0);
rerender(<VoteButton dumpId="d" count={3} voted onCast={onCast} onRemove={onRemove} />);
await user.click(screen.getByRole("button", { name: "Remove vote" }));
expect(onRemove).toHaveBeenCalledWith("d", 3);
});
test("disabled for guests, with a hint", () => {
renderWithContexts(
<VoteButton dumpId="d" count={1} voted={false} disabled onCast={() => {}} onRemove={() => {}} />,
);
const btn = screen.getByRole("button", { name: "Upvote" });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute("title", "Log in to vote");
});
test("the count opens who voted, without voting", async () => {
const onCast = vi.fn();
route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })]));
const user = userEvent.setup();
renderWithContexts(
<VoteButton dumpId="d1" count={1} voted={false} onCast={onCast} onRemove={() => {}} />,
);
await user.click(screen.getByText("1"));
expect(onCast).not.toHaveBeenCalled();
expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute(
"href",
"/users/fan",
);
});
test("likes work the same way", async () => {
const onLike = vi.fn();
const onUnlike = vi.fn();
route("GET /api/comments/:id/likers", () => page([]));
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<LikeButton commentId="c" count={2} liked={false} onLike={onLike} onUnlike={onUnlike} />,
);
await user.click(screen.getByRole("button", { name: "Like" }));
expect(onLike).toHaveBeenCalledWith("c", 2);
await user.click(screen.getByText("2"));
expect(await screen.findByText("No one yet.")).toBeInTheDocument();
rerender(<LikeButton commentId="c" count={2} liked onLike={onLike} onUnlike={onUnlike} />);
await user.click(screen.getByRole("button", { name: "Remove like" }));
expect(onUnlike).toHaveBeenCalledWith("c", 2);
});
});
describe("UserListPopover", () => {
function Harness({ onClose = () => {} }: { onClose?: () => void }) {
const ref = useRef<HTMLSpanElement>(null);
return (
<>
<span ref={ref}>anchor</span>
<button type="button">outside</button>
<UserListPopover anchorRef={ref} onClose={onClose} fetchUrl="http://localhost:8000/api/x/voters" />
</>
);
}
test("pages through the list", async () => {
route("GET /api/x/voters", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "a" })], true)
: page([makeUser({ username: "b" })], false));
const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() });
expect(screen.getByText("Loading…")).toBeInTheDocument();
await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("b");
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /);
});
test("shows the server's error, and a network one", async () => {
route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true });
const first = renderWithContexts(<Harness />);
await screen.findByText("Dump not found");
first.unmount();
route("GET /api/x/voters", () => new TypeError("offline"));
renderWithContexts(<Harness />);
await screen.findByText("Could not load.");
});
test("a failed next page lets you try again", async () => {
route("GET /api/x/voters", () => page([makeUser({ username: "a" })], true), { once: true });
route("GET /api/x/voters", () => new TypeError("offline"), { once: true });
route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false));
const user = userEvent.setup();
renderWithContexts(<Harness />);
await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" }));
const retry = await screen.findByRole("button", { name: "Load more" });
expect(retry).toBeEnabled();
await user.click(retry);
await screen.findByText("b");
});
test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
route("GET /api/x/voters", () => page([]));
const onClose = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onClose={onClose} />);
await screen.findByText("No one yet.");
await user.click(screen.getByText("anchor"));
await user.click(screen.getByText("No one yet."));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByText("outside"));
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(onClose).toHaveBeenCalledTimes(3);
});
});
describe("FollowButton", () => {
const me = makeSession({ id: "me" });
test("hidden for guests and on yourself", () => {
const { unmount } = renderWithContexts(
<FollowUserButton targetUserId="x" targetUsername="x" />,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
unmount();
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("follow and unfollow a user", async () => {
const followUser = vi.fn();
const unfollowUser = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<FollowUserButton targetUserId="u" targetUsername="ann" />,
{ session: me, follows: { followUser, unfollowUser } },
);
const btn = screen.getByRole("button", { name: "Follow ann" });
expect(btn).toHaveTextContent("Follow");
await user.click(btn);
expect(followUser).toHaveBeenCalledWith("u");
expect(unfollowUser).not.toHaveBeenCalled();
});
test("shows Following, and unfollows", async () => {
const unfollowUser = vi.fn();
const user = userEvent.setup();
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { followedUserIds: new Set(["u"]), unfollowUser },
});
const btn = screen.getByRole("button", { name: "Unfollow ann" });
expect(btn).toHaveTextContent("Following");
await user.click(btn);
expect(unfollowUser).toHaveBeenCalledWith("u");
});
test("disabled until follows have loaded", () => {
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { isLoaded: false },
});
expect(screen.getByRole("button")).toBeDisabled();
});
test("playlists: only public ones can be followed", async () => {
const followPlaylist = vi.fn();
const unfollowPlaylist = vi.fn();
const user = userEvent.setup();
const priv = renderWithContexts(
<FollowPlaylistButton targetPlaylistId="p" isPublic={false} />,
{ session: me },
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
priv.unmount();
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
session: me,
follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) },
});
await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
});
});
describe("NotificationBell / ChatButton", () => {
test("the bell shows the unread count and goes to notifications", async () => {
const user = userEvent.setup();
renderWithContexts(<NotificationBell />, { ws: { unreadNotificationCount: 120 } });
const bell = screen.getByRole("button", { name: "Notifications (120 unread)" });
expect(bell).toHaveTextContent("99+");
await user.click(bell);
expect(currentPath()).toBe("/notifications");
});
test("the bell rings for a new notification, not for one already there", () => {
vi.useFakeTimers();
try {
const n1 = makeNotification({ id: "n1" });
const withLast = (n: ReturnType<typeof makeNotification>) => (
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}>
<NotificationBell />
</WSContext.Provider>
);
const { rerender } = renderWithContexts(withLast(n1));
const bell = () => screen.getByRole("button", { name: "Notifications" });
act(() => vi.advanceTimersByTime(10));
expect(bell()).not.toHaveClass("notification-bell--ringing");
rerender(withLast(makeNotification({ id: "n2" })));
act(() => vi.advanceTimersByTime(10));
expect(bell()).toHaveClass("notification-bell--ringing");
act(() => vi.advanceTimersByTime(700));
expect(bell()).not.toHaveClass("notification-bell--ringing");
} finally {
vi.useRealTimers();
}
});
test("the chat button opens the chat and shows unread", async () => {
const openChat = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
<ChatButton />
</ChatContext.Provider>,
{ ws: { unreadChatCount: 4 } },
);
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
expect(openChat).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,307 @@
import { describe, expect, test, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Markdown } from "../../components/Markdown.tsx";
import Thumbnail from "../../components/Thumbnail.tsx";
import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx";
import RichContentCard from "../../components/RichContentCard.tsx";
import { DumpCard } from "../../components/DumpCard.tsx";
import { hueFromString } from "../../utils/thumbnailTint.ts";
import { markDumpVisited } from "../../utils/visited.ts";
import { makeCategory, makeDump, makeSession } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
describe("Markdown", () => {
const md = (text: string, inline = false) =>
renderWithContexts(<Markdown inline={inline}>{text}</Markdown>);
test("renders GFM", () => {
md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done");
expect(screen.getByText("bold").tagName).toBe("STRONG");
expect(screen.getByText("gone").tagName).toBe("DEL");
expect(screen.getByRole("table").parentElement).toHaveClass("md-table-wrap");
expect(screen.getByRole("checkbox")).toBeChecked();
});
test("@mentions link to the profile, in-app", async () => {
md("hi @ann and bob@mail.com");
const link = screen.getByRole("link", { name: "@ann" });
expect(link).toHaveAttribute("href", "/users/ann");
expect(link).not.toHaveAttribute("target");
// GFM autolinks the address itself; it isn't a mention of "mail".
expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute(
"href",
"mailto:bob@mail.com",
);
expect(document.querySelector('a[href="/users/mail"]')).toBeNull();
await userEvent.click(link);
expect(currentPath()).toBe("/users/ann");
});
test("mentions in code and inside URLs are left alone", () => {
md("`@ann` and https://mastodon.social/@bob");
expect(screen.getByText("@ann").tagName).toBe("CODE");
expect(screen.getByRole("link")).toHaveAttribute(
"href",
"https://mastodon.social/@bob",
);
});
test("external links open in a new tab; internal and same-origin ones don't", () => {
md(
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
);
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("target", "_blank");
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute("target");
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute("href", "/playlists/p?x=1#y");
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute("target", "_blank");
});
test("a single newline is a line break", () => {
const { container } = md("one\ntwo\nthree");
expect(container.querySelectorAll("br")).toHaveLength(2);
});
test("code blocks keep their newlines", () => {
const { container } = md("```\na\nb\n```");
expect(container.querySelector("pre")!.textContent).toBe("a\nb\n");
expect(container.querySelectorAll("br")).toHaveLength(0);
});
test("images load lazily; inline teasers drop them", () => {
const full = md("![cat](https://x.test/cat.png)");
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy");
full.unmount();
md("![cat](https://x.test/cat.png) text", true);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByText("text")).toBeInTheDocument();
});
test("raw HTML is not rendered", () => {
const { container } = md("<script>alert(1)</script><b>x</b>");
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("b")).toBeNull();
});
test("javascript: links are neutralised", () => {
md("[click](javascript:alert(1))");
const link = screen.getByText("click").closest("a");
expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
});
});
describe("Thumbnail", () => {
const placeholder = { url: "https://site.test/page", siteName: "Site" };
test("direct, then proxied, then the placeholder", () => {
renderWithContexts(
<Thumbnail src="https://img.test/a.png" alt="art" placeholder={placeholder} />,
);
const img = screen.getByRole("img", { name: "art" });
expect(img).toHaveAttribute("src", "https://img.test/a.png");
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
fireEvent.error(img);
expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain(
"/api/proxy-image?url=",
);
fireEvent.error(screen.getByRole("img", { name: "art" }));
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
expect(screen.getByText("S")).toBeInTheDocument();
});
test("our own images skip the proxy retry", () => {
renderWithContexts(
<Thumbnail src="http://localhost:8000/api/thumbnails/d" alt="t" placeholder={placeholder} />,
);
fireEvent.error(screen.getByRole("img", { name: "t" }));
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
});
test("no src: straight to the placeholder; a new src tries again", () => {
const { rerender } = renderWithContexts(<Thumbnail placeholder={placeholder} alt="t" />);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
rerender(<Thumbnail src="https://img.test/b.png" placeholder={placeholder} alt="t" />);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
});
});
describe("ThumbnailPlaceholder", () => {
test("tints by the page colour, or by hostname", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="#123456" />,
);
const box = () => container.querySelector<HTMLElement>(".thumb-placeholder")!;
expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456");
rerender(<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="url(evil)" />);
expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test"));
});
test("favicon, then the initial when it fails; a glyph wins over the initial", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://site.test" faviconUrl="https://site.test/f.ico" />,
);
const icon = container.querySelector("img")!;
fireEvent.error(icon);
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("S")).toBeInTheDocument();
rerender(<ThumbnailPlaceholder seed="clip.mp4" glyph="🎬" />);
expect(screen.getByText("🎬")).toBeInTheDocument();
});
});
describe("RichContentCard", () => {
const yt = {
type: "youtube",
url: "https://youtube.com/watch?v=x",
title: "A video",
siteName: "YouTube",
description: "About it",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
embedUrl: "https://www.youtube.com/embed/x",
};
test("full card: play button, source link and details", async () => {
const play = vi.fn();
renderWithContexts(<RichContentCard richContent={yt} dumpHref="/dumps/d" />, {
player: { play },
});
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute("href", yt.url);
expect(screen.getByText("About it")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "embed",
embedUrl: yt.embedUrl,
dumpHref: "/dumps/d",
}));
});
test("compact: shows Pause while this card plays", () => {
renderWithContexts(<RichContentCard richContent={yt} compact />, {
player: {
playing: true,
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
},
});
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass("is-playing");
});
test("compact and unplayable: a link to the source", () => {
renderWithContexts(
<RichContentCard richContent={{ type: "link", url: "https://blog.test/p", siteName: "Blog" }} compact />,
);
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
"href",
"https://blog.test/p",
);
});
test("a dump thumbnail overrides the provider's", () => {
renderWithContexts(
<RichContentCard richContent={yt} thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d" />,
);
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src",
"http://localhost:8000/api/thumbnails/d",
);
});
test("a native stream that is the active item toggles instead of restarting", async () => {
const togglePlay = vi.fn();
const play = vi.fn();
const bc = { type: "bandcamp", url: "https://a.bandcamp.com/album/x", embedUrl: "https://bandcamp.com/EmbeddedPlayer/1" };
renderWithContexts(<RichContentCard richContent={bc} compact />, {
player: {
togglePlay,
play,
playing: true,
current: {
kind: "stream",
type: "bandcamp",
streamUrl: "s",
resolveUrl: bc.url,
resolveIndex: 0,
resolvedAt: 0,
},
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(togglePlay).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
});
describe("DumpCard", () => {
const props = {
voteCount: 3,
voted: false,
canVote: true,
castVote: vi.fn(),
removeVote: vi.fn(),
};
test("title, teaser, date, comment count and categories", () => {
const dump = makeDump({
id: "d1",
slug: "nice",
title: "Nice find",
comment: "Look **here**",
commentCount: 2,
categoryIds: ["c1", "missing"],
});
renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>, {
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
});
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute("href", "/dumps/nice");
expect(screen.getByText("here").tagName).toBe("STRONG");
expect(screen.getByText("2 comments")).toBeInTheDocument();
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
});
test("an unread dot until visited, never on your own", () => {
const dump = makeDump({ id: "d2" });
const first = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>);
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount();
const own = renderWithContexts(<ul><DumpCard dump={dump} {...props} isOwner /></ul>);
expect(own.container.querySelector(".unread-dot")).toBeNull();
own.unmount();
markDumpVisited("d2");
const seen = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>);
expect(seen.container.querySelector(".unread-dot")).toBeNull();
});
test("the private badge shows to the owner", () => {
renderWithContexts(
<ul><DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner /></ul>,
);
expect(screen.getByText("private")).toBeInTheDocument();
});
test("clicking the card opens the dump; voting doesn't", async () => {
const castVote = vi.fn();
const dump = makeDump({ id: "d3" });
renderWithContexts(
<ul><DumpCard dump={dump} {...props} castVote={castVote} /></ul>,
{ session: makeSession() },
);
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
expect(castVote).toHaveBeenCalledWith("d3", 3);
expect(currentPath()).toBe("/");
await userEvent.click(screen.getByText("1 minute ago"));
expect(currentPath()).toBe("/dumps/d3");
});
test("file dumps get a file preview; bare URLs a placeholder", () => {
const { container } = renderWithContexts(
<ul>
<DumpCard dump={makeDump({ kind: "file", fileName: "a.pdf", fileMime: "application/pdf", fileSize: 10, url: undefined })} {...props} />
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
</ul>,
);
expect(container.querySelectorAll(".thumb-placeholder").length).toBeGreaterThan(0);
const cards = container.querySelectorAll(".dump-card");
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,430 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import { useState } from "react";
import { SearchBar } from "../../components/SearchBar.tsx";
import { UserMenu } from "../../components/UserMenu.tsx";
import { PresenceRow } from "../../components/PresenceRow.tsx";
import { CategorySwitcher } from "../../components/CategorySwitcher.tsx";
import { CategorySelect } from "../../components/CategorySelect.tsx";
import { TabBar } from "../../components/TabBar.tsx";
import { FeedTabBar } from "../../components/FeedTabBar.tsx";
import { AppHeader } from "../../components/AppHeader.tsx";
import { ChatFab } from "../../components/ChatFab.tsx";
import { DumpFab } from "../../components/DumpFab.tsx";
import { Tooltip } from "../../components/Tooltip.tsx";
import { CountedInput } from "../../components/CountedInput.tsx";
import { PageError } from "../../components/PageError.tsx";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts";
import { WSContext } from "../../contexts/WSContext.ts";
import type { AuthResponse, Category } from "../../model.ts";
import { makeCategory, makeSession, makeUser } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
const categories: Category[] = [
makeCategory({ id: "c1", slug: "music", name: "Music" }),
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
];
describe("SearchBar", () => {
function Harness(props: Partial<Parameters<typeof SearchBar>[0]>) {
const [value, setValue] = useState("");
return (
<SearchBar
expanded
value={value}
onValueChange={setValue}
onSubmit={() => {}}
onCancel={() => {}}
{...props}
/>
);
}
test("submits the trimmed query; ignores a blank one", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onSubmit={onSubmit} />);
const input = screen.getByRole("searchbox", { name: "Search" });
await user.type(input, " {Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.type(input, " cats {Enter}");
expect(onSubmit).toHaveBeenCalledWith("cats");
expect(input).not.toHaveFocus();
});
test("Escape cancels instead of clearing", async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onCancel={onCancel} />);
await user.type(screen.getByRole("searchbox"), "dogs{Escape}");
expect(onCancel).toHaveBeenCalled();
expect(screen.getByRole("searchbox")).toHaveValue("dogs");
});
test("takes focus when it opens, not when it mounts open", () => {
const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} };
const { rerender } = renderWithContexts(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1");
rerender(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
});
});
describe("UserMenu", () => {
test("opens, links to profile and playlists, closes on Escape and outside", async () => {
const user = userEvent.setup();
renderWithContexts(
<>
<UserMenu user={makeUser({ username: "ann" })} />
<p>outside</p>
</>,
);
const trigger = screen.getByRole("button", { name: "User menu" });
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
"href",
"/users/ann/playlists",
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(screen.getByText("outside"));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(screen.getByRole("menuitem", { name: "@ann" }));
expect(currentPath()).toBe("/users/ann");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
describe("PresenceRow", () => {
const online = (n: number) =>
Array.from({ length: n }, (_, i) => ({
userId: `u${i}`,
username: `user${i}`,
hasAvatar: false,
}));
test("nothing when nobody's online", () => {
renderWithContexts(<PresenceRow />);
expect(document.querySelector(".index-presence")).toBeNull();
});
test("up to six seats, the rest folded into a counter", () => {
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(9) } });
expect(screen.getAllByRole("link")).toHaveLength(5);
const more = screen.getByRole("img", { name: /4 more online/ });
expect(more).toHaveTextContent("+4");
expect(more).toHaveAttribute("title", "user5, user6, user7, user8");
});
test("six or fewer all show", () => {
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(6) } });
expect(screen.getAllByRole("link")).toHaveLength(6);
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
});
test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => {
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
renderWithContexts(
<div className="bar">
<div className="tabs"><span>a</span></div>
<PresenceRow fitWithin=".bar" fitBeside=".tabs" />
</div>,
{ ws: { onlineUsers: online(9) } },
);
expect(screen.getAllByRole("link")).toHaveLength(3);
width.mockReturnValue(0);
act(() => {
globalThis.dispatchEvent(new Event("resize"));
});
width.mockRestore();
});
});
describe("CategorySwitcher", () => {
test("hidden with no categories", () => {
renderWithContexts(<CategorySwitcher />, { route: "/~/hot", path: "/:x/:feedTab" });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("switches scope, keeping the tab", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, {
categories,
route: "/music/new",
path: "/:categorySlug/:feedTab",
});
const trigger = screen.getByRole("button", { name: "Music" });
await user.click(trigger);
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("option", { name: "Cinema" }));
expect(currentPath()).toBe("/cinema/new");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
test("All is the ~ scope; an unknown slug shows as itself", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, {
categories,
route: "/gone/hot",
path: "/:categorySlug/:feedTab",
});
await user.click(screen.getByRole("button", { name: "gone" }));
await user.click(screen.getByRole("option", { name: "All" }));
expect(currentPath()).toBe("/~/hot");
});
test("Escape closes the menu", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, { categories, route: "/~/hot", path: "/~/:feedTab" });
await user.click(screen.getByRole("button", { name: "All" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("CategorySelect", () => {
test("toggle chips reflect the selection", async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
renderWithContexts(<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />, {
categories,
});
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false");
await user.click(screen.getByRole("button", { name: "Music" }));
expect(onToggle).toHaveBeenCalledWith("c1");
});
test("nothing without categories", () => {
renderWithContexts(<CategorySelect selected={new Set()} onToggle={() => {}} />);
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
});
});
describe("TabBar / FeedTabBar", () => {
test("TabBar marks the active tab and reports clicks", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<TabBar
tabs={[{ key: "a", label: "A" }, { key: "b", label: "B" }]}
activeTab="b"
onChange={onChange}
/>,
);
expect(screen.getByRole("button", { name: "B" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "A" }));
expect(onChange).toHaveBeenCalledWith("a");
});
test("guests don't get Followed; tabs navigate within the scope", async () => {
const user = userEvent.setup();
renderWithContexts(<FeedTabBar />, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories });
expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "Journal" }));
expect(currentPath()).toBe("/music/journal");
});
test("signed in, Followed is there", () => {
renderWithContexts(<FeedTabBar />, {
session: makeSession(),
route: "/~/followed",
path: "/~/:feedTab",
});
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active");
});
});
describe("AppHeader", () => {
test("guests get a login button", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(currentPath()).toBe("/login");
});
test("members get their links, chat, bell and the new-dump button", () => {
renderWithContexts(<AppHeader disableNew />, { session: makeSession({ username: "ann" }) });
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument();
const dump = screen.getByRole("button", { name: /Dump/ });
expect(dump).toBeDisabled();
expect(dump).toHaveAttribute("title", "Server unreachable");
});
test("shows the live-updates banner when disconnected", () => {
renderWithContexts(<AppHeader />, {
ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" },
});
expect(screen.getByRole("alert")).toHaveTextContent(
"Live updates unavailable. Trying to reconnect…",
);
});
test("the search toggle opens and closes, giving focus back", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
const toggle = screen.getByRole("button", { name: "Search" });
await user.click(toggle);
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute(
"aria-expanded",
"true",
);
const [input] = screen.getAllByRole("searchbox");
await user.type(input, "x");
await user.keyboard("{Escape}");
expect(screen.getByRole("button", { name: "Search" })).toHaveFocus();
expect(screen.getAllByRole("searchbox")[0]).toHaveValue("");
});
// Leaving search needs the real history stack (React Router keeps its
// index in history.state), so these use a BrowserRouter.
function Browser({ session }: { session?: AuthResponse }) {
const [authResponse, setAuthResponse] = useState(session ?? null);
function Loc() {
const l = useLocation();
return <output data-testid="loc">{l.pathname + l.search}</output>;
}
return (
<I18nProvider i18n={i18n}>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<WSContext.Provider value={defaultOf(WSContext)}>
<BrowserRouter>
<Routes>
<Route path="*" element={<AppHeader />} />
</Routes>
<Loc />
</BrowserRouter>
</WSContext.Provider>
</AuthContext.Provider>
</I18nProvider>
);
}
const loc = () => screen.getByTestId("loc").textContent;
afterEach(() => window.history.replaceState(null, "", "/"));
test("several searches, then ✕, return to where search began", async () => {
window.history.replaceState(null, "", "/start");
const user = userEvent.setup();
render(<Browser />);
await user.click(screen.getByRole("button", { name: "Search" }));
await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}");
expect(loc()).toBe("/search?q=cats");
const bar = () => screen.getAllByRole("searchbox")[0];
expect(bar()).toHaveValue("cats");
await user.clear(bar());
await user.type(bar(), "dogs{Enter}");
expect(loc()).toBe("/search?q=dogs");
await user.click(screen.getByRole("button", { name: "Cancel search" }));
await waitFor(() => expect(loc()).toBe("/start"));
});
test("Escape on the results page reverts the query instead of leaving", async () => {
window.history.replaceState(null, "", "/search?q=cats");
const user = userEvent.setup();
render(<Browser />);
const bar = screen.getAllByRole("searchbox")[0];
expect(bar).toHaveValue("cats");
await user.type(bar, "zzz{Escape}");
expect(bar).toHaveValue("cats");
expect(loc()).toBe("/search?q=cats");
});
test("✕ on results that opened the session goes home", async () => {
window.history.replaceState(null, "", "/search?q=cats");
const user = userEvent.setup();
render(<Browser />);
await user.click(screen.getByRole("button", { name: "Cancel search" }));
expect(loc()).toBe("/");
});
});
describe("floating buttons", () => {
function withChat(openChat = vi.fn()) {
return (ui: React.ReactNode) => (
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
{ui}
</ChatContext.Provider>
);
}
test("guests get neither", () => {
renderWithContexts(withChat()(<><ChatFab /><DumpFab /></>));
expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument();
});
test("they show once the header scrolls away", () => {
const openChat = vi.fn();
renderWithContexts(
withChat(openChat)(
<>
<header className="app-header" />
<ChatFab />
<DumpFab />
</>,
),
{ session: makeSession(), ws: { unreadChatCount: 2 } },
);
const header = document.querySelector(".app-header")!;
// jsdom lays nothing out: put the header on screen first.
header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
const chat = document.querySelector<HTMLButtonElement>(".chat-fab")!;
expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)");
expect(chat).toHaveAttribute("aria-hidden", "true");
expect(chat).toHaveAttribute("tabindex", "-1");
expect(document.body).toHaveClass("has-fab");
header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(chat).toHaveAttribute("aria-hidden", "false");
expect(chat).toHaveClass("chat-fab--visible");
act(() => chat.click());
expect(openChat).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible");
});
});
describe("small pieces", () => {
test("Tooltip shows on hover", async () => {
const user = userEvent.setup();
renderWithContexts(<Tooltip text="Explained"><span>hover me</span></Tooltip>);
await user.hover(screen.getByText("hover me"));
expect(screen.getByText("Explained")).toBeInTheDocument();
await user.unhover(screen.getByText("hover me"));
expect(screen.queryByText("Explained")).not.toBeInTheDocument();
});
test("CountedInput counts toward its limit", () => {
renderWithContexts(<CountedInput value={"x".repeat(90)} maxLength={100} onChange={() => {}} />);
const count = screen.getByText("90 / 100");
expect(count).toHaveClass("text-editor-count--warn");
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");
});
test("PageError wraps an alert in the page shell", () => {
renderWithContexts(<PageError message="Gone" />);
expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong");
expect(screen.getByRole("alert")).toHaveTextContent("Gone");
expect(document.querySelector(".app-header")).not.toBeNull();
});
});

View File

@@ -0,0 +1,225 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MediaPlayer } from "../../components/MediaPlayer.tsx";
import FilePreview from "../../components/FilePreview.tsx";
import { makeDump, makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
function media(container: HTMLElement) {
return container.querySelector<HTMLMediaElement>("audio, video")!;
}
function setTime(el: HTMLMediaElement, currentTime: number, duration: number) {
Object.defineProperty(el, "duration", { configurable: true, value: duration });
el.currentTime = currentTime;
fireEvent(el, new Event("durationchange"));
fireEvent(el, new Event("timeupdate"));
}
describe("MediaPlayer", () => {
test("play and pause through the button, reporting state", async () => {
const onPlayStateChange = vi.fn();
const play = vi.spyOn(HTMLMediaElement.prototype, "play");
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause");
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalled();
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(pause).toHaveBeenCalled();
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
});
test("autoplays when asked", async () => {
const onPlayStateChange = vi.fn();
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay onPlayStateChange={onPlayStateChange} />,
);
await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
});
test("a play refused by the browser stays paused", async () => {
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(new Error("NotAllowedError"));
renderWithContexts(<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay />);
await act(async () => {});
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
});
test("a pause from outside the page (a media key) is reflected", async () => {
const onPlayStateChange = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
fireEvent(media(container), new Event("pause"));
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
fireEvent(media(container), new Event("play"));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
});
test("time, duration, seeking and the end", async () => {
const onTimeUpdate = vi.fn();
const onEnded = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
/>,
);
const el = media(container);
setTime(el, 65, 200);
expect(screen.getByText("1:05")).toBeInTheDocument();
expect(screen.getByText("3:20")).toBeInTheDocument();
expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200);
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), { target: { value: "120" } });
expect(el.currentTime).toBe(120);
fireEvent(el, new Event("ended"));
expect(onEnded).toHaveBeenCalled();
});
test("while the seek bar is held, playback doesn't move the thumb — by touch too", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
const el = media(container);
setTime(el, 10, 100);
const seek = screen.getByRole("slider", { name: "Seek" });
fireEvent.pointerDown(seek, { pointerType: "touch" });
fireEvent.change(seek, { target: { value: "50" } });
setTime(el, 11, 100);
expect(screen.getByText("0:50")).toBeInTheDocument();
fireEvent.pointerUp(seek, { pointerType: "touch" });
setTime(el, 51, 100);
expect(screen.getByText("0:51")).toBeInTheDocument();
});
test("an error is reported for re-resolving", () => {
const onError = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onError={onError} />,
);
fireEvent(media(container), new Event("error"));
expect(onError).toHaveBeenCalled();
});
test("volume and mute are remembered", async () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.4" } });
expect(media(container).volume).toBeCloseTo(0.4);
await userEvent.click(screen.getByRole("button", { name: "Mute" }));
expect(media(container).muted).toBe(true);
expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({ volume: 0.4, muted: true });
expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0");
// Raising the volume unmutes.
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.7" } });
expect(media(container).muted).toBe(false);
});
test("registers seek and toggle handles for the global player", async () => {
const seekRef = { current: null as ((t: number) => void) | null };
const toggleRef = { current: null as (() => void) | null };
const { container, unmount } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" seekRef={seekRef} toggleRef={toggleRef} />,
);
act(() => seekRef.current!(33));
expect(media(container).currentTime).toBe(33);
await act(async () => toggleRef.current!());
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
unmount();
expect(seekRef.current).toBeNull();
});
test("resumes at a start offset once metadata is in", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" startTime={42} />,
);
fireEvent(media(container), new Event("loadedmetadata"));
expect(media(container).currentTime).toBe(42);
});
test("video has a fullscreen button", () => {
renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />);
expect(screen.getByRole("button", { name: "Fullscreen" })).toBeInTheDocument();
});
});
describe("FilePreview", () => {
const session = makeSession();
const file = (mime: string, over = {}) =>
makeDump({ id: "f1", kind: "file", fileName: "thing", fileMime: mime, fileSize: 2048, url: undefined, ...over });
test("images show inline, with the media token", () => {
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
"src",
`http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`,
);
});
test("other files are a download link", () => {
renderWithContexts(<FilePreview dump={file("application/zip")} />);
const link = screen.getByRole("link", { name: /Download thing/ });
expect(link).toHaveAttribute("download", "thing");
expect(link).toHaveTextContent("(2.0 KB)");
});
test("compact audio plays in the global player", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "file",
mimeType: "audio/mpeg",
title: "Dump f1",
subtitle: "thing",
dumpHref: "/dumps/f1",
artworkUrl: undefined,
}));
});
test("compact video claims its generated still as artwork", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play.mock.calls[0][0].artworkUrl).toBe("http://localhost:8000/api/thumbnails/f1");
});
test("the global video button toggles when it's the one playing", async () => {
const togglePlay = vi.fn();
const d = file("video/mp4");
renderWithContexts(<FilePreview dump={d} global />, {
player: {
togglePlay,
playing: true,
current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" },
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(togglePlay).toHaveBeenCalled();
});
test("the global audio waveform starts playback", async () => {
const play = vi.fn();
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }));
});
test("compact PDFs and text get an icon", () => {
const { container } = renderWithContexts(<FilePreview dump={file("application/pdf")} compact />);
expect(container).toHaveTextContent("📄");
});
});
import { route } from "../fetch.ts";

View File

@@ -0,0 +1,242 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useContext } from "react";
import { GlobalPlayer } from "../../components/GlobalPlayer.tsx";
import { JournalCard } from "../../components/JournalCard.tsx";
import { PlaylistCard } from "../../components/PlaylistCard.tsx";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
import { markPlaylistVisited } from "../../utils/visited.ts";
let player!: React.ContextType<typeof PlayerContext>;
function Probe() {
player = useContext(PlayerContext);
return null;
}
function renderPlayer() {
return renderWithContexts(
<PlayerProvider>
<Probe />
<GlobalPlayer />
</PlayerProvider>,
);
}
const track = (i: number): PlayerItem => ({
kind: "stream",
type: "bandcamp",
streamUrl: `https://s/${i}`,
resolveUrl: "https://a.bandcamp.com/album/x",
resolveIndex: i,
resolvedAt: Date.now(),
title: `Song ${i}`,
trackNum: i + 1,
duration: 61,
subtitle: "Band",
dumpHref: "/dumps/album",
});
describe("GlobalPlayer", () => {
test("hidden until something plays; the body makes room for it", () => {
renderPlayer();
expect(document.querySelector(".global-player")).toBeNull();
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" }));
expect(document.querySelector(".global-player--soundcloud")).not.toBeNull();
expect(document.body).toHaveClass("has-player");
act(() => player.stop());
expect(document.querySelector(".global-player")).toBeNull();
expect(document.body).not.toHaveClass("has-player");
});
test("an embed autoplays on a fresh play only", () => {
localStorage.setItem("player", JSON.stringify({
v: 2,
queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }],
index: 0,
time: 0,
owner: null,
}));
renderPlayer();
const iframe = () => document.querySelector("iframe")!;
expect(iframe().src).toBe("https://www.youtube.com/embed/abc");
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" }));
expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1");
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" }));
expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true");
act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" }));
expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1");
});
test("a YouTube playlist embed gets the JS API parameters", () => {
renderPlayer();
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" }));
const src = new URL(document.querySelector("iframe")!.src);
expect(src.searchParams.get("enablejsapi")).toBe("1");
expect(src.searchParams.get("list")).toBe("PL");
});
test("a queue: title links to the dump, tracklist, transport", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.playQueue([track(0), track(1), track(2)], 0));
expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album");
expect(screen.getByText("Band")).toBeInTheDocument();
expect(screen.getByText("1 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled();
const list = screen.getByRole("list");
expect(within(list).getAllByRole("button")).toHaveLength(3);
expect(within(list).getAllByText("1:01")).toHaveLength(3);
await user.click(screen.getByRole("button", { name: "Next track" }));
expect(screen.getByText("2 / 3")).toBeInTheDocument();
await user.click(within(list).getByRole("button", { name: /Song 2/ }));
expect(screen.getByText("3 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next track" })).toBeDisabled();
expect(list.querySelectorAll(".is-current")).toHaveLength(1);
});
test("collapse hides the tracklist; a new item opens it again", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.playQueue([track(0), track(1)]));
await user.click(screen.getByRole("button", { name: "Collapse player" }));
expect(screen.queryByRole("list")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument();
act(() => player.playQueue([track(2), track(3)]));
expect(screen.getByRole("list")).toBeInTheDocument();
});
test("the close button stops playback", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.play(track(0)));
await user.click(screen.getByRole("button", { name: "Close player" }));
expect(player.current).toBeNull();
});
test("a file plays in the built-in player", () => {
renderPlayer();
act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" }));
expect(document.querySelector(".global-player--file-video video")).not.toBeNull();
expect(screen.getByText("Clip")).toBeInTheDocument();
});
test("the resolving notice", async () => {
renderPlayer();
act(() => player.play(track(0)));
// Force a re-resolve that never lands.
const { route, deferred } = await import("../fetch.ts");
const pending = deferred();
route("GET /api/bandcamp/tracks", () => pending.promise);
act(() => player.onError());
expect(screen.getByText("Refreshing stream…")).toBeInTheDocument();
});
});
describe("JournalCard", () => {
const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() };
test("image, quote and text modes", () => {
const { container, unmount } = renderWithContexts(
<ul>
<JournalCard
dump={makeDump({ richContent: { type: "link", url: "u", thumbnailUrl: "https://i.test/a.png" } })}
shape="feature"
{...base}
/>
<JournalCard dump={makeDump({ comment: "A thought" })} shape="square" {...base} />
<JournalCard dump={makeDump()} shape="square" {...base} />
</ul>,
);
const cards = container.querySelectorAll("li");
expect(cards[0]).toHaveClass("journal-card--image", "journal-card--feature");
expect(cards[1]).toHaveClass("journal-card--quote");
expect(within(cards[1] as HTMLElement).getByText("A thought")).toBeInTheDocument();
expect(cards[2]).toHaveClass("journal-card--text");
expect(cards[2]).toHaveTextContent("🔗");
unmount();
});
test("a playable image card plays; an unplayable one navigates", async () => {
const play = vi.fn();
const user = userEvent.setup();
const yt = makeDump({
id: "y",
richContent: {
type: "youtube",
url: "https://youtu.be/y",
thumbnailUrl: "https://i.test/y.png",
embedUrl: "https://www.youtube.com/embed/y",
},
});
const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined });
const { container } = renderWithContexts(
<ul>
<JournalCard dump={yt} shape="wide" {...base} />
<JournalCard dump={pic} shape="square" {...base} />
</ul>,
{ player: { play } },
);
const cards = container.querySelectorAll("li");
await user.click(cards[0].querySelector(".journal-card-image")!);
expect(play).toHaveBeenCalledWith(expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }));
expect(currentPath()).toBe("/");
await user.click(cards[1].querySelector(".journal-card-image")!);
expect(currentPath()).toBe("/dumps/p");
});
test("a video file shows its generated still", () => {
const { container } = renderWithContexts(
<ul>
<JournalCard dump={makeDump({ id: "v", kind: "file", fileMime: "video/mp4", url: undefined })} shape="tall" {...base} />
</ul>,
);
expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v");
});
});
describe("PlaylistCard", () => {
test("title, badge, owner, count; clicking opens it and marks it seen", async () => {
const user = userEvent.setup();
const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute("href", "/playlists/mix");
expect(screen.getByText("private")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByText("1 dump")).toBeInTheDocument();
expect(screen.getByText("Tunes")).toBeInTheDocument();
await user.click(container.querySelector(".playlist-card-inner")!);
expect(currentPath()).toBe("/playlists/mix");
});
test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} isOwner /></ul>);
expect(screen.queryByText("@me")).not.toBeInTheDocument();
expect(container.querySelector(".unread-dot")).toBeNull();
});
test("a new playlist has a dot until visited", () => {
const p = makePlaylist({ id: "fresh", createdAt: new Date() });
const first = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount();
markPlaylistVisited("fresh");
const again = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(again.container.querySelector(".unread-dot")).toBeNull();
});
test("delete doesn't open the playlist", async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
renderWithContexts(<ul><PlaylistCard playlist={makePlaylist()} onDelete={onDelete} /></ul>);
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
expect(onDelete).toHaveBeenCalled();
expect(currentPath()).toBe("/");
});
});

View File

@@ -0,0 +1,370 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useContext, useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
import type { AuthResponse } from "../../model.ts";
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
const embed = (n: number): PlayerItem => ({
kind: "embed",
embedUrl: `https://www.youtube.com/embed/v${n}`,
type: "youtube",
title: `Video ${n}`,
});
const file = (n: number): PlayerItem => ({
kind: "file",
fileUrl: `http://localhost:8000/api/files/f${n}?token=secret`,
mimeType: "audio/mpeg",
});
const stream = (i: number, over: Partial<PlayerItem> = {}) =>
({
kind: "stream",
type: "bandcamp",
streamUrl: `https://s/${i}`,
resolveUrl: "https://a.bandcamp.com/album/x",
resolveIndex: i,
resolvedAt: Date.now(),
title: `Track ${i}`,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
...over,
}) as Extract<PlayerItem, { kind: "stream" }>;
const albumResponse = (indexes: number[]) => ({
sourceUrl: "https://a.bandcamp.com/album/x",
itemType: "album",
artist: "Band",
resolvedAt: Date.now(),
tracks: indexes.map((i) => ({
index: i,
title: `Track ${i}`,
streamUrl: `https://fresh/${i}`,
streamable: true,
capped: false,
})),
});
let setSession: (s: AuthResponse | null) => void = () => {};
function wrapperFor(initial: AuthResponse | null) {
return function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(initial);
setSession = setAuthResponse;
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<PlayerProvider>{children}</PlayerProvider>
</AuthContext.Provider>
);
};
}
function setup(session: AuthResponse | null = null) {
const hook = renderHook(() => useContext(PlayerContext), {
wrapper: wrapperFor(session),
});
return { hook, p: () => hook.result.current };
}
const stored = () => JSON.parse(localStorage.getItem("player") ?? "null");
afterEach(() => vi.useRealTimers());
describe("queue", () => {
test("play() makes a one-item queue that autoplays", () => {
const { p } = setup();
act(() => p().play(embed(1)));
expect(p().current).toEqual(embed(1));
expect(p().queue).toHaveLength(1);
expect(p().autoplay).toBe(true);
expect(p().hasNext).toBe(false);
});
test("playQueue clamps the start; an empty queue is ignored", () => {
const { p } = setup();
act(() => p().playQueue([embed(1), embed(2)], 9));
expect(p().queueIndex).toBe(1);
act(() => p().playQueue([], 0));
expect(p().queue).toHaveLength(2);
act(() => p().playQueue([embed(1), embed(2)], -3));
expect(p().queueIndex).toBe(0);
});
test("next / previous / playAt", () => {
const { p } = setup();
act(() => p().playQueue([embed(1), embed(2), embed(3)]));
act(() => p().next());
expect(p().queueIndex).toBe(1);
expect(p().hasPrevious).toBe(true);
act(() => p().playAt(2));
expect(p().queueIndex).toBe(2);
act(() => p().next());
expect(p().queueIndex).toBe(2);
act(() => p().playAt(7));
expect(p().queueIndex).toBe(2);
act(() => p().previous());
expect(p().queueIndex).toBe(1);
});
test("previous restarts the track past three seconds", () => {
const { p } = setup();
const seek = vi.fn();
act(() => p().playQueue([embed(1), embed(2)], 1));
p().seekRef.current = seek;
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(10, 100));
act(() => p().previous());
expect(seek).toHaveBeenCalledWith(0);
expect(p().queueIndex).toBe(1);
expect(p().currentTime).toBe(0);
});
test("previous on the first track restarts it", () => {
const { p } = setup();
const seek = vi.fn();
act(() => p().playQueue([embed(1), embed(2)]));
p().seekRef.current = seek;
act(() => p().previous());
expect(seek).toHaveBeenCalledWith(0);
});
test("picking the playing row restarts it, and resumes it if paused", () => {
const { p } = setup();
const seek = vi.fn();
const toggle = vi.fn();
act(() => p().playQueue([embed(1), embed(2)]));
p().seekRef.current = seek;
p().toggleRef.current = toggle;
act(() => p().onPlayStateChange(true));
act(() => p().playAt(0));
expect(seek).toHaveBeenCalledWith(0);
expect(toggle).not.toHaveBeenCalled();
act(() => p().onPlayStateChange(false));
act(() => p().playAt(0));
expect(toggle).toHaveBeenCalledTimes(1);
});
test("the end of a track advances; the end of the queue stops", () => {
const { p } = setup();
act(() => p().playQueue([embed(1), embed(2)]));
act(() => p().onPlayStateChange(true));
act(() => p().onEnded());
expect(p().queueIndex).toBe(1);
act(() => p().onPlayStateChange(true));
act(() => p().onEnded());
expect(p().queueIndex).toBe(1);
expect(p().playing).toBe(false);
});
test("stop empties the player", () => {
const { p } = setup();
act(() => p().play(embed(1)));
act(() => p().stop());
expect(p().current).toBeNull();
expect(stored()).toBeNull();
});
test("stale time updates from the old media are ignored until the new one plays", () => {
const { p } = setup();
act(() => p().play(embed(1)));
act(() => p().onTimeUpdate(42, 100));
expect(p().currentTime).toBe(0);
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(5, 100));
expect(p().currentTime).toBe(5);
expect(p().duration).toBe(100);
});
test("seekTo and togglePlay reach the registered media", () => {
const { p } = setup();
const seek = vi.fn();
const toggle = vi.fn();
p().seekRef.current = seek;
p().toggleRef.current = toggle;
act(() => p().seekTo(12));
act(() => p().togglePlay());
expect(seek).toHaveBeenCalledWith(12);
expect(toggle).toHaveBeenCalled();
expect(p().currentTime).toBe(12);
});
});
describe("persistence", () => {
test("the queue is saved with its owner, and the time on pagehide", () => {
const session = makeSession({ id: "me" });
const { p } = setup(session);
act(() => p().playQueue([file(1), file(2)], 1));
expect(stored()).toMatchObject({ v: 2, index: 1, owner: "me", time: 0 });
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(33, 100));
globalThis.dispatchEvent(new Event("pagehide"));
expect(stored().time).toBe(33);
});
test("a saved session comes back paused at its offset", () => {
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [embed(1), embed(2)], index: 1, time: 17, owner: null }),
);
const { p } = setup();
expect(p().queueIndex).toBe(1);
expect(p().startTime).toBe(17);
expect(p().autoplay).toBe(false);
});
test("a v1 single-item session is still read", () => {
localStorage.setItem("player", JSON.stringify({ item: embed(3), time: 4 }));
const { p } = setup();
expect(p().current).toEqual(embed(3));
expect(p().startTime).toBe(4);
});
test("another account's queue isn't restored", () => {
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [file(1)], index: 0, time: 0, owner: "someone-else" }),
);
const { p } = setup(makeSession({ id: "me" }));
expect(p().current).toBeNull();
});
test("an ownerless session holding file URLs isn't restored; one of embeds is", () => {
localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 }));
expect(setup().p().current).toBeNull();
localStorage.setItem("player", JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }));
expect(setup().p().current).toEqual(embed(1));
});
test("junk in storage is ignored", () => {
localStorage.setItem("player", "{nope");
expect(setup().p().current).toBeNull();
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }),
);
expect(setup().p().current).toBeNull();
});
test("signing out stops a signed-in listener's queue", () => {
const { p } = setup(makeSession({ id: "me" }));
act(() => p().play(file(1)));
act(() => setSession(null));
expect(p().current).toBeNull();
});
test("signing in keeps a guest's queue", () => {
const { p } = setup(null);
act(() => p().play(embed(1)));
act(() => setSession(makeSession({ id: "me" })));
expect(p().current).toEqual(embed(1));
expect(stored().owner).toBe("me");
});
});
describe("expiring streams", () => {
test("a stream error re-resolves and resumes on the same track", async () => {
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1, 2]));
const { p } = setup();
act(() => p().playQueue([stream(0), stream(1), stream(2)], 1));
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(40, 200));
act(() => p().onError());
expect(p().resolving).toBe(true);
await waitFor(() => expect(p().resolving).toBe(false));
expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" });
expect(p().startTime).toBe(40);
expect(p().autoplay).toBe(true);
});
test("a track gone from the release restarts from the first one", async () => {
route("GET /api/bandcamp/tracks", () => albumResponse([0, 2]));
const { p } = setup();
act(() => p().playQueue([stream(0), stream(1)], 1));
act(() => p().onPlayStateChange(true));
act(() => p().onTimeUpdate(40, 200));
act(() => p().onError());
await waitFor(() => expect(p().resolving).toBe(false));
expect(p().queueIndex).toBe(0);
expect(p().startTime).toBe(0);
});
test("a resolve that fails falls back to the embed", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502));
const { p } = setup();
act(() => p().play(stream(0)));
act(() => p().onError());
await waitFor(() => expect(p().current?.kind).toBe("embed"));
expect(p().current).toMatchObject({
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
title: "Track 0",
});
});
test("no embed to fall back to: the player stops", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502));
const { p } = setup();
act(() => p().play(stream(0, { embedUrl: undefined })));
act(() => p().onError());
await waitFor(() => expect(p().current).toBeNull());
});
test("a track that dies again right after a re-resolve is skipped", async () => {
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
const { p } = setup();
act(() => p().playQueue([stream(0), stream(1)], 0));
act(() => p().onError());
await waitFor(() => expect(p().resolving).toBe(false));
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
act(() => p().onError());
expect(p().queueIndex).toBe(1);
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
});
test("a resolve that lands after the user moved on is dropped", async () => {
const pending = deferred();
route("GET /api/bandcamp/tracks", () => pending.promise);
const { p } = setup();
act(() => p().play(stream(0)));
act(() => p().onError());
act(() => p().play(embed(9)));
await act(async () =>
pending.resolve(
new Response(JSON.stringify({ success: true, data: albumResponse([0]) })),
)
);
expect(p().current).toEqual(embed(9));
expect(p().resolving).toBe(false);
});
test("errors on non-stream items are ignored", () => {
const { p } = setup();
act(() => p().play(embed(1)));
act(() => p().onError());
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(0);
});
test("a stale restored stream is refreshed up front, without autoplay", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(10 * STREAM_STALE_MS);
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
localStorage.setItem(
"player",
JSON.stringify({
v: 2,
queue: [stream(0, { resolvedAt: 0 }), stream(1, { resolvedAt: 0 })],
index: 1,
time: 25,
owner: null,
}),
);
const { p } = setup();
await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" }));
expect(p().autoplay).toBe(false);
expect(p().startTime).toBe(25);
});
});
beforeEach(() => {
setSession = () => {};
});

View File

@@ -0,0 +1,648 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { useContext } from "react";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { WSProvider } from "../../contexts/WSProvider.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { useWS } from "../../hooks/useWS.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { FakeWebSocket } from "../ws.ts";
import { defaultRoutes } from "../render.tsx";
import {
makeChatMessage,
makeComment,
makeDump,
makeNotification,
makePlaylist,
makeSession,
makeUser,
storeSession,
wire,
} from "../fixtures.ts";
const me = makeSession({ id: "user-me", username: "me" });
function wrapper({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<WSProvider>{children}</WSProvider>
</AuthProvider>
);
}
function setup({ signedIn = true } = {}) {
if (signedIn) storeSession(me);
defaultRoutes({ session: signedIn ? me : null });
const hook = renderHook(
() => ({
ws: useWS(),
auth: useAuth(),
avatars: useContext(AvatarOverrideContext),
}),
{ wrapper },
);
const socket = FakeWebSocket.last;
return { hook, socket, ws: () => hook.result.current.ws };
}
beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }));
afterEach(() => vi.useRealTimers());
describe("connection", () => {
test("connects with the session token, and without one for guests", () => {
const { socket } = setup();
expect(socket.url).toBe(
`ws://localhost:8000/ws?token=${encodeURIComponent(me.token)}`,
);
FakeWebSocket.reset();
localStorage.clear();
const guest = setup({ signedIn: false });
expect(guest.socket.url).toBe("ws://localhost:8000/ws");
});
test("connecting → connected on open", () => {
const { socket, ws } = setup();
expect(ws().wsStatus).toBe("connecting");
socket.open();
expect(ws().wsStatus).toBe("connected");
expect(ws().wsErrorMessage).toBeNull();
});
test("a socket stuck connecting times out with a message", () => {
const { ws } = setup();
act(() => vi.advanceTimersByTime(2_500));
expect(ws().wsStatus).toBe("disconnected");
expect(ws().wsErrorMessage).toMatch(/Can't connect/);
});
test("reconnects with exponential backoff after a drop", () => {
const { socket, ws } = setup();
socket.welcome();
socket.drop();
expect(ws().wsStatus).toBe("disconnected");
expect(ws().wsErrorMessage).toMatch(/temporarily disconnected/);
expect(FakeWebSocket.instances).toHaveLength(1);
act(() => vi.advanceTimersByTime(500));
expect(FakeWebSocket.instances).toHaveLength(2);
FakeWebSocket.last.drop();
act(() => vi.advanceTimersByTime(999));
expect(FakeWebSocket.instances).toHaveLength(2);
act(() => vi.advanceTimersByTime(1));
expect(FakeWebSocket.instances).toHaveLength(3);
});
test("answers pings", () => {
const { socket } = setup();
socket.open();
socket.receive({ type: "ping" });
expect(socket.messages).toEqual([{ type: "pong" }]);
});
test("ignores garbage frames", () => {
const { socket, ws } = setup();
socket.open();
socket.receiveRaw("not json");
socket.receiveRaw(JSON.stringify({ nope: 1 }));
socket.receiveRaw("null");
expect(ws().wsStatus).toBe("connected");
});
test("welcome loads presence, votes, likes and unread counts", () => {
const { socket, ws } = setup();
socket.welcome({
users: [{ userId: "u1", username: "a", hasAvatar: false }],
myVotes: ["d1"],
myCommentLikes: ["c1"],
unreadNotificationCount: 4,
unreadChatCount: 2,
chatReadAt: "2026-01-01T00:00:00.000Z",
});
expect(ws().onlineUsers).toHaveLength(1);
expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().myLikes.has("c1")).toBe(true);
expect(ws().unreadNotificationCount).toBe(4);
expect(ws().unreadChatCount).toBe(2);
expect(ws().chatReadAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
expect(document.title).toMatch(/^\(6\)/);
});
test("a reconnect bumps the epoch; the first connect doesn't", () => {
const { socket, ws } = setup();
socket.welcome();
expect(ws().connectionEpoch).toBe(0);
socket.drop();
act(() => vi.advanceTimersByTime(500));
FakeWebSocket.last.welcome();
expect(ws().connectionEpoch).toBe(1);
});
test("force_logout signs out", () => {
const { socket, hook } = setup();
socket.welcome();
socket.receive({ type: "force_logout" });
expect(hook.result.current.auth.user).toBeNull();
});
test("signing out resets live state and reconnects as a guest", () => {
const { socket, hook, ws } = setup();
socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "x" })) });
act(() => hook.result.current.auth.logout());
expect(ws().myVotes.size).toBe(0);
expect(ws().recentDumps).toEqual([]);
expect(ws().unreadNotificationCount).toBe(0);
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
expect(FakeWebSocket.last.url).toBe("ws://localhost:8000/ws");
});
test("unmounting closes the socket for good", () => {
const { socket, hook } = setup();
socket.welcome();
hook.unmount();
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
act(() => vi.advanceTimersByTime(60_000));
expect(FakeWebSocket.instances).toHaveLength(1);
});
});
describe("votes", () => {
test("cast is optimistic, sent, and confirmed by the ack", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().castVote("d1", 4));
expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().voteCounts.d1).toBe(5);
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 7 });
expect(ws().voteCounts.d1).toBe(7);
act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true);
});
test("an unconfirmed vote rolls back after the ack timeout", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().castVote("d1", 2));
act(() => vi.advanceTimersByTime(5_000));
expect(ws().myVotes.has("d1")).toBe(false);
expect(ws().voteCounts.d1).toBe(2);
});
test("a server error naming the dump rolls back at once", () => {
const { socket, ws } = setup();
socket.welcome({ myVotes: ["d1"] });
act(() => ws().removeVote("d1", 3));
expect(ws().voteCounts.d1).toBe(2);
socket.receive({ type: "error", message: "nope", dumpId: "d1", action: "remove" });
expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().voteCounts.d1).toBe(3);
});
test("casting twice or removing an absent vote does nothing", () => {
const { socket, ws } = setup();
socket.welcome({ myVotes: ["d1"] });
act(() => ws().castVote("d1", 1));
act(() => ws().removeVote("d2", 1));
expect(socket.messages).toEqual([]);
});
test("remove never goes below zero", () => {
const { socket, ws } = setup();
socket.welcome({ myVotes: ["d1"] });
act(() => ws().removeVote("d1", 0));
expect(ws().voteCounts.d1).toBe(0);
});
test("offline, the vote is shown then rolled back", () => {
const { ws } = setup();
act(() => ws().castVote("d1", 0));
expect(ws().voteCounts.d1).toBe(1);
act(() => vi.advanceTimersByTime(5_000));
expect(ws().voteCounts.d1).toBe(0);
});
test("votes_update from another tab of mine syncs myVotes", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "cast" });
expect(ws().myVotes.has("d9")).toBe(true);
expect(ws().lastVoteEvent).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast" });
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast" });
expect(ws().voteCounts.d9).toBe(2);
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove" });
expect(ws().myVotes.has("d9")).toBe(false);
});
test("a welcome supersedes pending rollbacks", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().castVote("d1", 0));
socket.receive({
type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [],
unreadNotificationCount: 0, unreadChatCount: 0,
});
act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true);
});
});
describe("comment likes", () => {
test("like, ack, unlike, error rollback", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().castCommentLike("c1", 0));
expect(ws().likeCounts.c1).toBe(1);
expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1" });
socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1 });
act(() => ws().removeCommentLike("c1"));
expect(ws().likeCounts.c1).toBe(0);
expect(socket.messages).toContainEqual({ type: "comment_like_remove", commentId: "c1" });
socket.receive({ type: "error", commentId: "c1" });
expect(ws().myLikes.has("c1")).toBe(true);
expect(ws().likeCounts.c1).toBe(1);
});
test("times out like votes do", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().castCommentLike("c1", 5));
act(() => vi.advanceTimersByTime(5_000));
expect(ws().likeCounts.c1).toBe(5);
expect(ws().myLikes.has("c1")).toBe(false);
});
test("comment_likes_update from my other tab", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "comment_likes_update", commentId: "c2", likeCount: 3, likerId: "user-me", action: "cast" });
expect(ws().myLikes.has("c2")).toBe(true);
expect(ws().likeCounts.c2).toBe(3);
expect(ws().lastLikeEvent?.commentId).toBe("c2");
});
});
describe("dumps, playlists, comments, users", () => {
test("created dumps go on top, deduplicated, and undo a deletion", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(ws().deletedDumpIds.has("a")).toBe(true);
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "again" })) });
expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]);
expect(ws().recentDumps[0].title).toBe("again");
expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date);
expect(ws().deletedDumpIds.has("a")).toBe(false);
});
test("the live buffer is capped at 100", () => {
const { socket, ws } = setup();
socket.welcome();
for (let i = 0; i < 105; i++) {
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })) });
}
expect(ws().recentDumps).toHaveLength(100);
expect(ws().recentDumps[0].id).toBe("d104");
});
test("an update refreshes a live entry but doesn't make it an arrival", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })) });
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "a", title: "two" })) });
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "old" })) });
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([["a", "two"]]);
expect(ws().lastDumpEvent?.id).toBe("old");
});
test("deletion removes from the live buffer", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(ws().recentDumps).toEqual([]);
});
test("injectDump adds my own new dump once", () => {
const { ws } = setup();
const d = makeDump({ id: "mine" });
act(() => ws().injectDump(d));
act(() => ws().injectDump(d));
expect(ws().recentDumps).toEqual([d]);
});
test("playlist events", () => {
const { socket, ws } = setup();
socket.welcome();
const p = makePlaylist({ id: "p" });
socket.receive({ type: "playlist_created", playlist: wire(p) });
expect(ws().lastPlaylistEvent).toMatchObject({ type: "created", playlistId: "p" });
socket.receive({ type: "playlist_updated", playlist: wire(p) });
expect(ws().lastPlaylistEvent?.type).toBe("updated");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"] });
expect(ws().lastPlaylistEvent).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"] });
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
expect(ws().lastPlaylistEvent).toEqual({ type: "deleted", playlistId: "p", userId: "u" });
});
test("comment events", () => {
const { socket, ws } = setup();
socket.welcome();
const c = makeComment({ id: "c", dumpId: "d" });
socket.receive({ type: "comment_created", comment: wire(c) });
expect(ws().lastCommentEvent).toMatchObject({ type: "created", dumpId: "d" });
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
socket.receive({ type: "comment_updated", comment: wire(c) });
expect(ws().lastCommentEvent?.type).toBe("updated");
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
expect(ws().lastCommentEvent).toEqual({ type: "deleted", dumpId: "d", commentId: "c" });
});
test("user_updated overrides that user's avatar everywhere", () => {
const { socket, hook } = setup();
socket.welcome();
const u = makeUser({ id: "u1", avatarMime: "image/png", updatedAt: new Date(5000) });
socket.receive({ type: "user_updated", user: wire(u) });
expect(hook.result.current.avatars.u1).toEqual({ hasAvatar: true, version: 5000 });
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
});
test("presence updates", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }] });
expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]);
});
});
describe("notifications", () => {
test("a new one bumps the count and is remembered", () => {
const { socket, ws } = setup();
socket.welcome({ unreadNotificationCount: 1 });
socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })) });
expect(ws().unreadNotificationCount).toBe(2);
expect(ws().lastNotification?.id).toBe("n");
act(() => ws().clearUnreadNotifications());
expect(ws().unreadNotificationCount).toBe(0);
});
});
describe("chat", () => {
test("messages are buffered once, and count as unread unless mine", () => {
const { socket, ws } = setup();
socket.welcome();
const m = makeChatMessage({ id: "m1", userId: "other" });
socket.receive({ type: "chat_message", message: wire(m) });
socket.receive({ type: "chat_message_updated", message: wire(m) });
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: "m2", userId: "user-me" })) });
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]);
expect(ws().unreadChatCount).toBe(1);
});
test("the buffer is capped at 200", () => {
const { socket, ws } = setup();
socket.welcome();
for (let i = 0; i < 205; i++) {
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })) });
}
expect(ws().chatMessages).toHaveLength(200);
expect(ws().chatMessages[0].id).toBe("m5");
});
test("with the chat open and visible, arrivals aren't unread", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().setChatOpen(true));
expect(socket.messages).toContainEqual({ type: "chat_focus", open: true });
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
expect(ws().unreadChatCount).toBe(0);
});
test("an open chat in a hidden tab still counts unread", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().setChatOpen(true));
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(socket.messages.at(-1)).toEqual({ type: "chat_focus", open: false });
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
expect(ws().unreadChatCount).toBe(1);
});
test("sending shows a pending message, retired by its echo", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" }));
const [pending] = ws().pendingChatMessages;
expect(pending).toMatchObject({ body: "hello", status: "sending" });
expect(socket.messages).toContainEqual({
type: "chat_send",
body: "hello",
nonce: pending.nonce,
replyToId: "r",
});
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ userId: "user-me", body: "hello" })),
nonce: pending.nonce,
});
expect(ws().pendingChatMessages).toEqual([]);
act(() => vi.advanceTimersByTime(20_000));
expect(ws().pendingChatMessages).toEqual([]);
});
test("blank messages aren't sent", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().sendChatMessage(" "));
expect(ws().pendingChatMessages).toEqual([]);
expect(socket.messages).toEqual([]);
});
test("offline sends fail at once", () => {
const { ws } = setup();
act(() => ws().sendChatMessage("hi"));
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" });
expect(ws().pendingChatMessages[0].error).toMatch(/offline/);
});
test("no echo in time marks the send failed; retry uses a fresh nonce", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().sendChatMessage("hi"));
const first = ws().pendingChatMessages[0].nonce;
act(() => vi.advanceTimersByTime(10_000));
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" });
act(() => ws().retryChatMessage(first));
const retry = ws().pendingChatMessages[0];
expect(retry.status).toBe("sending");
expect(retry.nonce).not.toBe(first);
expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(2);
// The first attempt's late echo doesn't retire the retry.
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ userId: "user-me", body: "hi" })),
nonce: first,
});
expect(ws().pendingChatMessages).toHaveLength(1);
});
test("a server rejection fails the pending send; discard drops it", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().sendChatMessage("hi"));
const { nonce } = ws().pendingChatMessages[0];
socket.receive({ type: "error", scope: "chat", nonce, message: "Too long" });
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long" });
act(() => ws().discardChatMessage(nonce));
expect(ws().pendingChatMessages).toEqual([]);
});
test("a chat error without a nonce is shown until dismissed", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "error", scope: "chat" });
expect(ws().chatError?.message).toBe("Something went wrong");
socket.receive({ type: "error", scope: "chat", message: "Not yours" });
expect(ws().chatError?.message).toBe("Not yours");
act(() => ws().dismissChatError());
expect(ws().chatError).toBeNull();
});
test("a dropped socket fails in-flight sends", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().sendChatMessage("hi"));
socket.drop();
expect(ws().pendingChatMessages[0].error).toMatch(/Connection lost/);
});
test("edits and deletes", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().editChatMessage("m1", " new "));
act(() => ws().editChatMessage("m1", " "));
act(() => ws().deleteChatMessage("m1"));
expect(socket.messages).toEqual([
{ type: "chat_edit", id: "m1", body: "new" },
{ type: "chat_delete", id: "m1" },
]);
const m = makeChatMessage({ id: "m1", body: "old" });
socket.receive({ type: "chat_message", message: wire(m) });
socket.receive({ type: "chat_message_updated", message: wire({ ...m, body: "new" }) });
expect(ws().chatMessages[0].body).toBe("new");
socket.receive({ type: "chat_message_updated", message: wire(makeChatMessage({ id: "m2" })) });
expect(ws().chatMessages).toHaveLength(2);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]);
expect(ws().deletedChatIds.has("m1")).toBe(true);
});
test("typing: sent and received", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().setChatTyping(true));
expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]);
socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }] });
expect(ws().typingUsers).toHaveLength(1);
});
test("the read marker clears the badge at once and is throttled to the server", () => {
const { socket, ws } = setup();
socket.welcome({ unreadChatCount: 3 });
act(() => ws().markChatRead(new Date(1_000_000)));
expect(ws().unreadChatCount).toBe(0);
act(() => vi.advanceTimersByTime(0));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1);
act(() => ws().markChatRead(new Date(2_000_000)));
act(() => ws().markChatRead(new Date(3_000_000)));
act(() => vi.advanceTimersByTime(999));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1);
act(() => vi.advanceTimersByTime(1));
const reads = socket.messages.filter((m) => m.type === "chat_read");
expect(reads).toHaveLength(2);
expect(reads[1].at).toBe(new Date(3_000_000).toISOString());
// Older than what's been sent: ignored.
act(() => ws().markChatRead(new Date(10)));
act(() => vi.advanceTimersByTime(5_000));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(2);
});
test("a marker set offline goes out with the next welcome", () => {
const { socket, ws } = setup();
act(() => ws().markChatRead(new Date(5_000_000)));
act(() => vi.advanceTimersByTime(2_000));
socket.welcome();
expect(socket.messages).toContainEqual({
type: "chat_read",
at: new Date(5_000_000).toISOString(),
});
});
test("chat_read_update from another tab moves the marker", () => {
const { socket, ws } = setup();
socket.welcome({ unreadChatCount: 5 });
socket.receive({ type: "chat_read_update", readAt: "2026-05-01T00:00:00.000Z", unread: 1 });
expect(ws().unreadChatCount).toBe(1);
expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z"));
});
test("reconcile replaces stale copies, drops deleted ones, retires landed sends", () => {
const { socket, ws } = setup();
socket.welcome();
const t0 = Date.now();
const at = (s: number) => new Date(t0 + s * 1000);
const early = makeChatMessage({ id: "early", createdAt: at(-100) });
const edited = makeChatMessage({ id: "edited", body: "old", createdAt: at(-50) });
const gone = makeChatMessage({ id: "gone", createdAt: at(-40) });
for (const m of [early, edited, gone]) {
socket.receive({ type: "chat_message", message: wire(m) });
}
act(() => ws().sendChatMessage("did it land?"));
socket.drop();
expect(ws().pendingChatMessages[0].status).toBe("failed");
act(() =>
ws().reconcileChat(
[
{ ...edited, body: "new" },
makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1) }),
],
at(-60),
at(60),
)
);
expect(ws().chatMessages.map((m) => [m.id, m.body])).toEqual([
["early", early.body],
["edited", "new"],
]);
expect(ws().pendingChatMessages).toEqual([]);
});
test("a reconnect with the chat closed drops the stale buffer", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
socket.drop();
act(() => vi.advanceTimersByTime(500));
FakeWebSocket.last.welcome();
expect(ws().chatMessages).toEqual([]);
});
test("a reconnect with the chat open keeps it, and re-announces focus", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().setChatOpen(true));
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
socket.drop();
act(() => vi.advanceTimersByTime(500));
FakeWebSocket.last.welcome();
expect(ws().chatMessages).toHaveLength(1);
expect(FakeWebSocket.last.messages).toContainEqual({ type: "chat_focus", open: true });
});
});

View File

@@ -0,0 +1,313 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { FollowProvider } from "../../contexts/FollowProvider.tsx";
import { CategoriesProvider } from "../../contexts/CategoriesProvider.tsx";
import { ThemeProvider } from "../../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../../contexts/ChatProvider.tsx";
import { useAuth } from "../../hooks/useAuth.ts";
import { useFollows } from "../../hooks/useFollows.ts";
import { useCategories } from "../../hooks/useCategories.ts";
import { useTheme } from "../../hooks/useTheme.ts";
import { useChat } from "../../hooks/useChat.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, deferred, fail, ok, route } from "../fetch.ts";
import {
makeCategory,
makeSession,
makeToken,
storeSession,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
const authWrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe("AuthProvider", () => {
test("starts from the stored session", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user?.username).toBe("ann");
});
test("a token the server rejects is dropped", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => new Response(null, { status: 401 }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user).toBeNull());
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
`Bearer ${session.token}`,
);
});
test("a role changed on the server is picked up", async () => {
const session = makeSession({ role: "user" });
storeSession(session);
route("GET /api/users/me", () => wire({ ...session.user, role: "moderator" }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
"moderator",
);
});
test("offline doesn't sign anyone out", async () => {
storeSession(makeSession());
route("GET /api/users/me", () => new TypeError("offline"));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
expect(result.current.user).not.toBeNull();
});
test("revalidates when the tab regains focus", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
act(() => {
globalThis.dispatchEvent(new Event("focus"));
});
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
});
test("a session without a media token fetches one", async () => {
const session = { ...makeSession(), mediaToken: undefined };
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
"m-1",
);
});
test("follows sign-in and sign-out from other tabs", async () => {
route("GET /api/users/me", () => ok(null));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user).toBeNull();
const session = makeSession({ username: "elsewhere" });
storeSession(session);
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: AUTH_STORAGE_KEY }));
});
expect(result.current.user?.username).toBe("elsewhere");
localStorage.removeItem(AUTH_STORAGE_KEY);
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
});
expect(result.current.user).not.toBeNull();
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(result.current.user).toBeNull();
});
test("a 401 for an old token doesn't sign out a newer session", async () => {
const first = makeSession();
storeSession(first);
const pending = deferred();
route("GET /api/users/me", () => pending.promise, { once: true });
route("GET /api/users/me", () => wire(first.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
const second = { ...first, token: makeToken() };
act(() => result.current.login(second));
await act(async () => pending.resolve(new Response(null, { status: 401 })));
expect(result.current.token).toBe(second.token);
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
second.token,
);
});
});
describe("FollowProvider", () => {
function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/follows/status", () => status);
return renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
}
test("loads what the user follows", async () => {
const { result } = setup();
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.followedUserIds.has("u1")).toBe(true);
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
});
test("guests follow nobody and never ask", async () => {
const { result } = renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
await act(async () => {});
expect(result.current.isLoaded).toBe(false);
expect(calls("GET /api/follows/status")).toHaveLength(0);
});
test("follow and unfollow are optimistic and roll back on failure", async () => {
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
route("POST /api/follows/users/:id", () => ({}));
await act(() => result.current.followUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("DELETE /api/follows/users/:id", () => fail("no", 500));
await act(() => result.current.unfollowUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
await act(() => result.current.followPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
route("POST /api/follows/playlists/:id", () => ({}));
await act(() => result.current.followPlaylist("p2"));
route("DELETE /api/follows/playlists/:id", () => ({}));
await act(() => result.current.unfollowPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
"/api/follows/playlists/p2",
);
});
});
describe("CategoriesProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<CategoriesProvider>{children}</CategoriesProvider>
);
test("loads and indexes categories", async () => {
const music = makeCategory({ id: "c1", slug: "music" });
route("GET /api/categories", () => wire([music]));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.byId.get("c1")?.slug).toBe("music");
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
});
test("refresh refetches", async () => {
let list = [makeCategory({ id: "a" })];
route("GET /api/categories", () => wire(list));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.categories).toHaveLength(1));
list = [...list, makeCategory({ id: "b" })];
await act(() => result.current.refresh());
expect(result.current.categories).toHaveLength(2);
});
test("a failure still ends loading", async () => {
route("GET /api/categories", () => fail("x", 500));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.categories).toEqual([]);
});
});
describe("ThemeProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const root = document.documentElement;
test("smooth and auto by default, with no attributes", () => {
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("smooth");
expect(result.current.colorScheme).toBe("auto");
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("applies and persists a choice", () => {
const { result } = renderHook(useTheme, { wrapper });
act(() => result.current.setStyle("brutalist"));
act(() => result.current.setColorScheme("dark"));
expect(root.getAttribute("data-style")).toBe("brutalist");
expect(root.getAttribute("data-color-scheme")).toBe("dark");
expect(localStorage.getItem("style")).toBe("brutalist");
expect(localStorage.getItem("color-scheme")).toBe("dark");
act(() => result.current.setStyle("smooth"));
act(() => result.current.setColorScheme("auto"));
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("restores the stored theme, migrating the legacy name", () => {
localStorage.setItem("style", "nyt");
localStorage.setItem("color-scheme", "light");
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("nyt");
expect(result.current.colorScheme).toBe("light");
localStorage.setItem("style", "default");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe("smooth");
});
});
describe("ChatProvider", () => {
test("opens only for signed-in users", async () => {
const guest = renderHook(useChat, {
wrapper: ({ children }) => (
<AuthProvider>
<MemoryRouter>
<ChatProvider>{children}</ChatProvider>
</MemoryRouter>
</AuthProvider>
),
});
act(() => guest.result.current.openChat());
expect(guest.result.current.isChatOpen).toBe(false);
});
test("?chat=open deep-links into it, and closing strips the param", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
expect(chat.isChatOpen).toBe(true);
await screen.findByRole("dialog", { name: "Chat" });
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
expect(currentPath()).toBe("/somewhere?x=1");
});
test("openChat / closeChat", () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session });
act(() => chat.openChat());
expect(chat.isChatOpen).toBe(true);
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
});
});

11
src/test/emojibase.ts Normal file
View File

@@ -0,0 +1,11 @@
import { route } from "./fetch.ts";
import enData from "emojibase-data/en/data.json";
import enMessages from "emojibase-data/en/messages.json";
/** Serves the emoji picker's data as the app does, from /emojibase/<ver>/. */
export function serveEmojibase() {
route("GET /emojibase/:version/en/data.json", () => new Response(JSON.stringify(enData)));
route("GET /emojibase/:version/en/messages.json", () => new Response(JSON.stringify(enMessages)));
route("HEAD /emojibase/:version/en/data.json", () => new Response(null, { headers: { ETag: "x" } }));
route("HEAD /emojibase/:version/en/messages.json", () => new Response(null, { headers: { ETag: "y" } }));
}

159
src/test/fetch.ts Normal file
View File

@@ -0,0 +1,159 @@
import { vi } from "vitest";
/**
* A routing fetch stub. Tests register handlers by method and path pattern
* (`:param` segments match one segment); every request is recorded, and one no
* handler matches is answered 404 so a missing route shows up as a failure in
* the UI rather than a hang.
*/
export interface RecordedRequest {
method: string;
url: URL;
path: string;
headers: Headers;
body: unknown;
params: Record<string, string>;
}
type Handler = (
req: RecordedRequest,
) => Response | Promise<Response> | unknown;
interface Route {
method: string;
pattern: RegExp;
keys: string[];
handler: Handler;
once: boolean;
}
let routes: Route[] = [];
export let requests: RecordedRequest[] = [];
function compile(path: string) {
const keys: string[] = [];
const src = path.replace(/:(\w+)/g, (_, k) => {
keys.push(k);
return "([^/]+)";
});
return { pattern: new RegExp(`^${src}$`), keys };
}
/** A success envelope, as every API route returns. */
export function ok(data: unknown, status = 200): Response {
return new Response(JSON.stringify({ success: true, data }), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** An error envelope. */
export function fail(message: string, status = 400): Response {
return new Response(
JSON.stringify({ success: false, error: { message } }),
{ status, headers: { "Content-Type": "application/json" } },
);
}
/**
* Registers a handler. `spec` is "METHOD /path/:param". A handler may return a
* Response, or plain data to be wrapped in a success envelope.
*
* Standing routes match newest first, so a test can override a default.
* `once` routes answer a single request each, in the order they were added,
* ahead of any standing route — a queue of responses for a sequence of calls.
*/
export function route(
spec: string,
handler: Handler,
{ once = false, fallback = false } = {},
) {
const [method, path] = spec.split(" ");
const { pattern, keys } = compile(path);
const r = { method, pattern, keys, handler, once };
// A fallback answers only when nothing else does, whenever it was added —
// for defaults that a test may have overridden before they were installed.
if (fallback) {
routes.push(r);
return;
}
// Both kinds go in just before the first standing route: after the queued
// once-routes, ahead of older standing ones.
const at = routes.findIndex((x) => !x.once);
routes.splice(at === -1 ? routes.length : at, 0, r);
}
export function resetFetch() {
routes = [];
requests = [];
vi.stubGlobal("fetch", fetchStub);
}
/** Requests made to `spec` ("METHOD /path"), pattern-matched like routes. */
export function calls(spec: string): RecordedRequest[] {
const [method, path] = spec.split(" ");
const { pattern } = compile(path);
return requests.filter((r) => r.method === method && pattern.test(r.path));
}
async function readBody(body: BodyInit | null | undefined): Promise<unknown> {
if (body == null) return undefined;
if (typeof body === "string") {
try {
return JSON.parse(body);
} catch {
return body;
}
}
return body; // FormData and friends, as passed
}
async function fetchStub(
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> {
const href = typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(href, location.href);
const method = (init.method ?? "GET").toUpperCase();
if (init.signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
const req: RecordedRequest = {
method,
url,
path: url.pathname,
headers: new Headers(init.headers),
body: await readBody(init.body),
params: {},
};
requests.push(req);
for (const r of routes) {
if (r.method !== method) continue;
const m = r.pattern.exec(url.pathname);
if (!m) continue;
r.keys.forEach((k, i) => (req.params[k] = decodeURIComponent(m[i + 1])));
if (r.once) routes = routes.filter((x) => x !== r);
const result = await r.handler(req);
if (result instanceof Response) return result;
if (result instanceof Error) throw result;
return ok(result);
}
return fail(`No test route for ${method} ${url.pathname}`, 404);
}
/** A promise with its resolver exposed, for holding a response back. */
export function deferred<T = Response>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

144
src/test/fixtures.ts Normal file
View File

@@ -0,0 +1,144 @@
import type {
AuthResponse,
Category,
ChatMessage,
Comment,
Dump,
Notification,
Playlist,
PublicUser,
} from "../model.ts";
import { AUTH_STORAGE_KEY } from "../utils/authStorage.ts";
let seq = 0;
const nextId = (prefix: string) => `${prefix}-${++seq}`;
/** An unsigned JWT-shaped token; the client only ever reads `exp`. */
export function makeToken(
{ expiresIn = 3600, sub = "u" }: { expiresIn?: number; sub?: string } = {},
): string {
const b64 = (o: unknown) =>
btoa(JSON.stringify(o)).replace(/=+$/, "").replace(/\+/g, "-").replace(
/\//g,
"_",
);
const exp = Math.floor(Date.now() / 1000) + expiresIn;
return `${b64({ alg: "HS256" })}.${b64({ sub, exp, n: ++seq })}.sig`;
}
export function makeUser(over: Partial<PublicUser> = {}): PublicUser {
const id = over.id ?? nextId("user");
return {
id,
username: over.username ?? `user${seq}`,
role: "user",
createdAt: new Date("2026-01-01T00:00:00Z"),
...over,
};
}
export function makeSession(over: Partial<PublicUser> = {}): AuthResponse {
const user = makeUser(over);
return {
token: makeToken({ sub: user.id }),
mediaToken: `media-${user.id}`,
user,
};
}
/** Puts a session in storage, where AuthProvider picks it up on mount. */
export function storeSession(session: AuthResponse) {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
}
export function makeDump(over: Partial<Dump> = {}): Dump {
const id = over.id ?? nextId("dump");
return {
id,
kind: "url",
title: `Dump ${id}`,
userId: "user-owner",
createdAt: new Date(Date.now() - 60_000),
url: `https://example.com/${id}`,
voteCount: 0,
commentCount: 0,
isPrivate: false,
...over,
};
}
export function makePlaylist(over: Partial<Playlist> = {}): Playlist {
const id = over.id ?? nextId("playlist");
return {
id,
userId: "user-owner",
title: `Playlist ${id}`,
isPublic: true,
createdAt: new Date("2026-02-01T00:00:00Z"),
dumpCount: 0,
...over,
};
}
export function makeComment(over: Partial<Comment> = {}): Comment {
const id = over.id ?? nextId("comment");
return {
id,
dumpId: "dump-x",
userId: "user-owner",
body: `Comment ${id}`,
createdAt: new Date(Date.now() - 30_000),
deleted: false,
likeCount: 0,
authorUsername: "owner",
...over,
};
}
export function makeChatMessage(over: Partial<ChatMessage> = {}): ChatMessage {
const id = over.id ?? nextId("msg");
return {
id,
userId: "user-other",
body: `Message ${id}`,
createdAt: new Date(Date.now() - 10_000),
authorUsername: "other",
...over,
};
}
export function makeCategory(over: Partial<Category> = {}): Category {
const id = over.id ?? nextId("cat");
return {
id,
slug: over.slug ?? `cat${seq}`,
name: over.name ?? `Category ${seq}`,
position: 0,
createdAt: new Date("2026-01-01T00:00:00Z"),
...over,
};
}
export function makeNotification(
over: Partial<Notification> = {},
): Notification {
return {
id: over.id ?? nextId("notif"),
userId: "user-me",
type: "user_followed",
data: { followerId: "user-f", followerUsername: "fan" },
read: false,
createdAt: new Date(Date.now() - 5_000),
...over,
};
}
/** A value as it crosses the wire: dates become ISO strings. */
export function wire<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
/** A paginated envelope payload. */
export function page<T>(items: T[], hasMore = false) {
return { items: wire(items), total: items.length, hasMore };
}

View File

@@ -0,0 +1,321 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useRef, useState } from "react";
import { MemoryRouter } from "react-router";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { useUserDumpFeed } from "../../hooks/useUserDumpFeed.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { FakeIntersectionObserver } from "../setup.ts";
afterEach(() => {
vi.unstubAllGlobals();
document.head.querySelectorAll("script").forEach((s) => s.remove());
});
describe("usePlayRichContent", () => {
// The Bandcamp mode is read from a <meta> tag once, at module load.
async function load(mode: "embed" | "native") {
document.head.innerHTML = `<meta name="bandcamp-player" content="${mode}">`;
vi.resetModules();
const hookMod = await import("../../hooks/usePlayRichContent.ts");
const ctxMod = await import("../../contexts/PlayerContext.ts");
const play = vi.fn();
const playQueue = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<ctxMod.PlayerContext.Provider
value={{
...(ctxMod.PlayerContext as unknown as { _currentValue: object })._currentValue,
play,
playQueue,
} as never}
>
{children}
</ctxMod.PlayerContext.Provider>
);
const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
}
afterEach(() => {
document.head.innerHTML = "";
});
const yt = {
type: "youtube",
url: "https://youtu.be/x",
title: "Vid",
embedUrl: "https://www.youtube.com/embed/x",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
siteName: "YouTube",
};
const bc = {
type: "bandcamp",
url: "https://a.bandcamp.com/album/x",
title: "Album",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
};
test("embeds play in the iframe", async () => {
const { hook, play } = await load("embed");
let started!: boolean;
await act(async () => {
started = await hook.result.current.playRichContent(yt, "/dumps/1");
});
expect(started).toBe(true);
expect(play).toHaveBeenCalledWith({
kind: "embed",
embedUrl: yt.embedUrl,
title: "Vid",
type: "youtube",
dumpHref: "/dumps/1",
artworkUrl: yt.thumbnailUrl,
subtitle: "YouTube",
});
});
test("nothing to embed: not started", async () => {
const { hook, play, canPlay } = await load("embed");
const bare = { type: "link", url: "https://x" };
expect(canPlay(bare)).toBe(false);
let started = true;
await act(async () => {
started = await hook.result.current.playRichContent(bare);
});
expect(started).toBe(false);
expect(play).not.toHaveBeenCalled();
});
test("native Bandcamp plays a resolved queue", async () => {
route("GET /api/bandcamp/tracks", () => ({
sourceUrl: bc.url,
itemType: "album",
resolvedAt: 1,
tracks: [{ index: 0, title: "T", streamUrl: "https://s/0", streamable: true, capped: false }],
}));
const { hook, playQueue, canPlay } = await load("native");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).toHaveBeenCalledWith(
[expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
0,
);
expect(hook.result.current.pending).toBe(false);
});
test("native Bandcamp that can't resolve falls back to the embed", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
route("GET /api/bandcamp/tracks", () => fail("x", 500));
const { hook, play, playQueue } = await load("native");
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).not.toHaveBeenCalled();
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "embed" }));
});
test("an unknown mode reads as embed", async () => {
const { canPlay } = await load("bogus" as "embed");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
});
});
describe("useUserDumpFeed", () => {
const ann = makeUser({ username: "ann" });
function setup(opts: { session?: ReturnType<typeof makeSession> | null } = {}) {
const session = opts.session ?? null;
function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(session);
return (
<MemoryRouter>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
</MemoryRouter>
);
}
return renderHook(
({ username }: { username?: string }) =>
useUserDumpFeed(username, "dumps", "cache-key"),
{
initialProps: { username: "ann" as string | undefined },
wrapper: Wrapper,
},
);
}
test("loads the profile and the first page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
const session = makeSession();
const { result } = setup({ session });
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true);
const [req] = calls("GET /api/users/:u/dumps");
expect(req.url.searchParams.get("page")).toBe("1");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("an unknown user is an error", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("error"));
});
test("an offline load says so", async () => {
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
}));
});
test("scrolling to the sentinel loads the next page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const sentinel = document.createElement("div");
act(() => (result.current.sentinelRef as (el: HTMLDivElement) => void)(sentinel));
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
await waitFor(() => {
const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]);
});
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
expect(s.hasMore).toBe(false);
expect(s.page).toBe(2);
});
test("changing user resets to loading", async () => {
route("GET /api/users/:u", (req) => wire(makeUser({ username: req.params.u })));
route("GET /api/users/:u/dumps", () => page([]));
const { result, rerender } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
rerender({ username: "bob" });
expect(result.current.state.status).toBe("loading");
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
});
test("setItems edits the loaded list", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setItems((prev) => prev.slice(1)));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
expect(s.items.map((d) => d.id)).toEqual(["b"]);
});
});
describe("useYouTubePlaylist", () => {
type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void };
function installYT(ids = ["a", "b", "c"]) {
const player = {
events: {} as Events,
index: 0,
playVideo: vi.fn(),
playVideoAt: vi.fn(),
nextVideo: vi.fn(),
previousVideo: vi.fn(),
getPlaylist: () => ids,
getPlaylistIndex() {
return this.index;
},
destroy: vi.fn(),
};
(globalThis as unknown as { YT: unknown }).YT = {
Player: class {
constructor(_el: unknown, opts: { events: Events }) {
player.events = opts.events;
return player;
}
},
};
return player;
}
// The IFrame API loads once per document and is cached in the module.
async function setup(embedUrl: string | null) {
vi.resetModules();
const { useYouTubePlaylist } = await import("../../hooks/useYouTubePlaylist.ts");
return renderHook(({ url }) => {
const ref = useRef<HTMLIFrameElement | null>(document.createElement("iframe"));
return useYouTubePlaylist(url, ref);
}, { initialProps: { url: embedUrl } });
}
test("titles from the server, order and position from the player", async () => {
// The API script loads once per document; later tests find YT ready.
const player = installYT();
route("GET /api/youtube/playlist", () => ({
tracks: [
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "c", title: "C", duration: 30 },
],
}));
const { result } = await setup("https://www.youtube.com/embed/videoseries?list=PL1");
await waitFor(() => expect(result.current.rows).toHaveLength(2));
expect(result.current.ready).toBe(false);
await waitFor(() => expect(player.events.onReady).toBeDefined());
player.index = 1;
act(() => player.events.onReady!());
expect(result.current.ready).toBe(true);
expect(result.current.index).toBe(1);
// The player's list wins, matched to the titles by id.
expect(result.current.rows).toEqual([
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "b", title: undefined, duration: undefined },
{ videoId: "c", title: "C", duration: 30 },
]);
act(() => result.current.playAt(2));
act(() => result.current.next());
act(() => result.current.previous());
expect(player.playVideoAt).toHaveBeenCalledWith(2);
expect(player.nextVideo).toHaveBeenCalled();
expect(player.previousVideo).toHaveBeenCalled();
});
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
await setup("https://www.youtube.com/embed/x?list=PL2");
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
document.dispatchEvent(new Event("visibilitychange"));
for (let i = 0; i < 5; i++) act(() => player.events.onStateChange!({ data: 2 }));
expect(player.playVideo).toHaveBeenCalledTimes(3);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
act(() => player.events.onStateChange!({ data: 2 }));
expect(player.playVideo).toHaveBeenCalledTimes(3);
});
test("no list in the URL: no rows, no requests", async () => {
const { result } = await setup("https://www.youtube.com/embed/x");
expect(result.current.rows).toEqual([]);
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
});
test("a new embed drops the old list; unmount destroys the player", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
const { result, rerender, unmount } = await setup("https://www.youtube.com/embed/x?list=PL3");
await waitFor(() => expect(result.current.rows).toHaveLength(1));
await waitFor(() => expect(player.events.onReady).toBeDefined());
route("GET /api/youtube/playlist", () => fail("x", 500));
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
expect(result.current.rows).toEqual([]);
expect(result.current.index).toBe(-1);
unmount();
expect(player.destroy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
import { calls, deferred, route } from "../fetch.ts";
function key(k: string, mods: Partial<KeyboardEvent> = {}) {
return {
key: k,
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...mods,
} as unknown as KeyboardEvent<HTMLTextAreaElement>;
}
describe("useEmojiTrigger", () => {
function setup() {
const replace = vi.fn();
const hook = renderHook(() => useEmojiTrigger(replace));
const type = (value: string, cursor = value.length) =>
act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
return { replace, hook, type };
}
test("opens inline on ':' and two letters", () => {
const { hook, type } = setup();
type("hello :s");
expect(hook.result.current.emojiMode).toBe("closed");
type("hello :sm");
expect(hook.result.current.emojiMode).toBe("inline");
expect(hook.result.current.emojiQuery).toBe("sm");
});
test("emoticons, times and labels don't trigger", () => {
const { hook, type } = setup();
for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
type(text);
expect(hook.result.current.emojiMode, text).toBe("closed");
}
});
test("Unicode names work", () => {
const { hook, type } = setup();
type(":cœur");
expect(hook.result.current.emojiQuery).toBe("cœur");
});
test("not inside code", () => {
const { hook, type } = setup();
type("`:smile");
expect(hook.result.current.emojiMode).toBe("closed");
});
test("closes when the caret leaves the query", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("a caret move alone never opens it", () => {
const { hook } = setup();
act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("an inline pick replaces the query and adds a space", () => {
const { hook, type, replace } = setup();
type("hi :smi");
act(() => hook.result.current.selectEmoji("😄"));
expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
expect(hook.result.current.emojiMode).toBe("closed");
});
test("browse mode inserts at the caret and ignores typing", () => {
const { hook, type, replace } = setup();
act(() => hook.result.current.openEmojiBrowser(2, 4));
type("ab");
expect(hook.result.current.emojiMode).toBe("browse");
act(() => hook.result.current.setEmojiQuery("cat"));
expect(hook.result.current.emojiQuery).toBe("cat");
act(() => hook.result.current.selectEmoji("🐱"));
expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
});
test("selecting or querying while closed does nothing", () => {
const { hook, replace } = setup();
act(() => hook.result.current.setEmojiQuery("x"));
act(() => hook.result.current.selectEmoji("😀"));
expect(replace).not.toHaveBeenCalled();
expect(hook.result.current.emojiQuery).toBe("");
});
test("closeEmoji", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.closeEmoji());
expect(hook.result.current.emojiMode).toBe("closed");
});
});
describe("useMentionAutocomplete", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const users = [
{ id: "1", username: "alice", avatarMime: null },
{ id: "2", username: "alex", avatarMime: null },
{ id: "3", username: "albert", avatarMime: null },
];
function setup() {
const replace = vi.fn();
const hook = renderHook(() => useMentionAutocomplete(replace));
const type = async (value: string, cursor = value.length) => {
act(() => hook.result.current.syncMention(value, cursor, true));
await act(() => vi.advanceTimersByTimeAsync(200));
};
return { replace, hook, type };
}
test("looks up the @query after a pause and opens with results", async () => {
route("GET /api/users/search", (req) =>
users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!)));
const { hook, type } = setup();
await type("hi @al");
expect(hook.result.current.mentionOpen).toBe(true);
expect(hook.result.current.mentionResults).toHaveLength(3);
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe("al");
});
test("debounces keystrokes into one lookup", async () => {
route("GET /api/users/search", () => users);
const { hook } = setup();
act(() => hook.result.current.syncMention("@a", 2, true));
act(() => hook.result.current.syncMention("@al", 3, true));
act(() => hook.result.current.syncMention("@ali", 4, true));
await act(() => vi.advanceTimersByTimeAsync(200));
expect(calls("GET /api/users/search")).toHaveLength(1);
});
test("no results keeps it closed", async () => {
route("GET /api/users/search", () => []);
const { hook, type } = setup();
await type("@zz");
expect(hook.result.current.mentionOpen).toBe(false);
});
test("an email address or a link label isn't a mention", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("me@al");
await type("[@al");
await type("`@al");
expect(calls("GET /api/users/search")).toHaveLength(0);
expect(hook.result.current.mentionOpen).toBe(false);
});
test("a stale lookup can't open over a newer query", async () => {
const slow = deferred();
route("GET /api/users/search", () => slow.promise, { once: true });
route("GET /api/users/search", () => []);
const { hook, type } = setup();
await type("@al");
await type("@alz");
await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users }))));
expect(hook.result.current.mentionOpen).toBe(false);
});
test("arrows wrap, Enter picks, Tab picks", async () => {
route("GET /api/users/search", () => users);
const { hook, type, replace } = setup();
await type("hey @al");
const down = key("ArrowDown");
act(() => {
hook.result.current.handleMentionKeyDown(key("ArrowUp"));
});
expect(hook.result.current.mentionSelectedIndex).toBe(2);
act(() => {
hook.result.current.handleMentionKeyDown(down);
});
expect(down.preventDefault).toHaveBeenCalled();
expect(hook.result.current.mentionSelectedIndex).toBe(0);
act(() => {
hook.result.current.handleMentionKeyDown(key("ArrowDown"));
});
let handled = false;
act(() => {
handled = hook.result.current.handleMentionKeyDown(key("Enter"));
});
expect(handled).toBe(true);
expect(replace).toHaveBeenCalledWith(4, 7, "@alex", true);
expect(hook.result.current.mentionOpen).toBe(false);
await type("@al");
act(() => {
hook.result.current.handleMentionKeyDown(key("Tab"));
});
expect(replace).toHaveBeenLastCalledWith(0, 3, "@alice", true);
});
test("Shift+Enter is left to the editor", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al");
let handled = true;
act(() => {
handled = hook.result.current.handleMentionKeyDown(
key("Enter", { shiftKey: true }),
);
});
expect(handled).toBe(false);
});
test("Escape closes just the list and stops there", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al");
const esc = key("Escape");
act(() => {
hook.result.current.handleMentionKeyDown(esc);
});
expect(esc.stopPropagation).toHaveBeenCalled();
expect(hook.result.current.mentionOpen).toBe(false);
});
test("keys pass through while closed", () => {
const { hook } = setup();
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false);
});
test("moving the caret off the mention closes it", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al and more");
// typed at position 3
await type("@al and more", 3);
expect(hook.result.current.mentionOpen).toBe(true);
act(() => hook.result.current.syncMention("@al and more", 12, false));
expect(hook.result.current.mentionOpen).toBe(false);
});
test("a caret move within the same query keeps it open", async () => {
route("GET /api/users/search", () => users);
const { hook, type } = setup();
await type("@al");
act(() => hook.result.current.syncMention("@al", 3, false));
expect(hook.result.current.mentionOpen).toBe(true);
});
test("a failed lookup is quiet", async () => {
route("GET /api/users/search", () => new TypeError("offline"));
const { hook, type } = setup();
await type("@al");
expect(hook.result.current.mentionOpen).toBe(false);
});
test("unmounting cancels a pending lookup", async () => {
route("GET /api/users/search", () => users);
const { hook } = setup();
act(() => hook.result.current.syncMention("@al", 3, true));
hook.unmount();
await vi.advanceTimersByTimeAsync(500);
expect(calls("GET /api/users/search")).toHaveLength(0);
});
});

View File

@@ -0,0 +1,452 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { type ReactNode, useRef } from "react";
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router";
import { useFading } from "../../hooks/useFading.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useTabParam } from "../../hooks/useTabParam.ts";
import {
setTitleBadge,
SITE_NAME,
useDocumentTitle,
} from "../../hooks/useDocumentTitle.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
import { useScrollLock } from "../../hooks/useScrollLock.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts";
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
import { isDumpVisited } from "../../utils/visited.ts";
import { FakeIntersectionObserver } from "../setup.ts";
afterEach(() => vi.useRealTimers());
describe("useFading", () => {
beforeEach(() => vi.useFakeTimers());
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
expect(result.current.fading).toEqual({ a: "cooldown" });
act(() => vi.advanceTimersByTime(2000));
expect(result.current.fading).toEqual({ a: "dismissing" });
act(() => vi.advanceTimersByTime(350));
expect(result.current.fading).toEqual({});
});
test("starting twice is one animation", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
expect(result.current.fading).toEqual({ a: "dismissing" });
});
test("cancel during cooldown and during dismissal", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => result.current.cancelFading("a"));
expect(result.current.fading).toEqual({});
act(() => vi.advanceTimersByTime(5000));
expect(result.current.fading).toEqual({});
act(() => result.current.startFading("b"));
act(() => vi.advanceTimersByTime(2100));
act(() => result.current.cancelFading("b"));
expect(result.current.fading).toEqual({});
// Cancelled means it can start over.
act(() => result.current.startFading("b"));
expect(result.current.fading).toEqual({ b: "cooldown" });
});
test("cancelAll", () => {
const { result } = renderHook(useFading);
act(() => {
result.current.startFading("a");
result.current.startFading("b");
});
act(() => result.current.cancelAll());
expect(result.current.fading).toEqual({});
});
});
describe("useFeedCache", () => {
// A MemoryRouter's first entry is a POP, as after Back.
const wrapper = () => ({ children }: { children: ReactNode }) => (
<MemoryRouter>{children}</MemoryRouter>
);
test("restores a saved feed on a back/forward (POP) visit", () => {
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
const second = renderHook(
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
{ wrapper: wrapper() },
);
expect(second.result.current.cached).toMatchObject({
items: [{ n: 10 }],
page: 2,
hasMore: true,
scrollY: 300,
});
});
test("an expired entry is ignored", () => {
vi.useFakeTimers();
const first = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([], 1, false, 0));
vi.advanceTimersByTime(11 * 60 * 1000);
const second = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(second.result.current.cached).toBeNull();
});
test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem(
"k",
JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }),
);
function Probe() {
const { cached } = useFeedCache("k", (x) => x);
return <p>{cached ? "cached" : "fresh"}</p>;
}
function Home() {
const navigate = useNavigate();
return <button type="button" onClick={() => navigate("/feed")}>go</button>;
}
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Probe />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("go"));
expect(screen.getByText("fresh")).toBeInTheDocument();
});
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
sessionStorage.setItem("k", "{");
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(result.current.cached).toBeNull();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
});
});
describe("useTabParam", () => {
const TABS = ["dumps", "playlists", "votes"] as const;
function setup(path: string, state?: unknown) {
let loc!: ReturnType<typeof useLocation>;
const hook = renderHook(() => {
loc = useLocation();
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[{ pathname: path.split("?")[0], search: path.includes("?") ? "?" + path.split("?")[1] : "", state }]}>
{children}
</MemoryRouter>
),
});
return { hook, loc: () => loc };
}
test("reads the tab after /~/", () => {
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe("playlists");
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists");
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
});
test("the default tab collapses to the base path", () => {
const { hook, loc } = setup("/users/ann/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/users/ann");
act(() => hook.result.current[1]("playlists"));
expect(loc().pathname).toBe("/users/ann/~/playlists");
});
test("at the root the default stays explicit", () => {
const { hook, loc } = setup("/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/~/dumps");
});
test("keeps the query string and the entry's state", () => {
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
act(() => hook.result.current[1]("votes"));
expect(loc().pathname).toBe("/search/~/votes");
expect(loc().search).toBe("?q=cats");
expect(loc().state).toEqual({ back: "/x" });
});
});
describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => {
const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) => useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } },
);
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
act(() => setTitleBadge(3));
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
act(() => setTitleBadge(150));
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
rerender({ title: null });
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
act(() => setTitleBadge(0));
expect(document.title).toBe(SITE_NAME);
// undefined leaves it be
rerender({ title: undefined });
expect(document.title).toBe(SITE_NAME);
});
test("the site name defaults to gerbeur when unsubstituted", () => {
expect(SITE_NAME).toBe("gerbeur");
});
});
describe("useInfiniteScroll", () => {
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
const ref = useInfiniteScroll(onMore, enabled);
return <div ref={ref} data-testid="sentinel" />;
}
const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() =>
obs.callback(
[{ isIntersecting } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
};
test("loads more when the sentinel shows, only while enabled", () => {
const onMore = vi.fn();
const { rerender } = render(<Feed onMore={onMore} enabled />);
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
intersect(false);
expect(onMore).not.toHaveBeenCalled();
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled={false} />);
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
});
});
describe("useScrollLock", () => {
function Lock() {
useScrollLock();
return null;
}
test("counts nested locks", () => {
const root = document.documentElement;
const a = render(<Lock />);
const b = render(<Lock />);
expect(root.style.overflowY).toBe("hidden");
a.unmount();
expect(root.style.overflowY).toBe("hidden");
b.unmount();
expect(root.style.overflowY).toBe("");
});
});
describe("useScrollSave", () => {
test("debounces scroll positions while enabled", () => {
vi.useFakeTimers();
const onSave = vi.fn();
const { rerender, unmount } = renderHook(
({ on }) => useScrollSave(on, onSave),
{ initialProps: { on: true } },
);
globalThis.dispatchEvent(new Event("scroll"));
globalThis.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(100);
expect(onSave).toHaveBeenCalledTimes(1);
rerender({ on: false });
globalThis.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(100);
expect(onSave).toHaveBeenCalledTimes(1);
unmount();
});
});
describe("stored preferences", () => {
test("useDefaultFeedTab validates and persists", () => {
localStorage.setItem("default-feed-tab", "bogus");
const { result } = renderHook(useDefaultFeedTab);
expect(result.current[0]).toBe("journal");
act(() => result.current[1]("new"));
expect(result.current[0]).toBe("new");
expect(localStorage.getItem("default-feed-tab")).toBe("new");
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
});
test("useSoundsEnabled", () => {
const { result } = renderHook(useSoundsEnabled);
expect(result.current[0]).toBe(true);
act(() => result.current[1](false));
expect(result.current[0]).toBe(false);
expect(localStorage.getItem("sounds")).toBe("off");
});
});
describe("useDumpCardLink", () => {
function Card() {
const link = useDumpCardLink({ id: "d1", slug: "hello" });
return (
<div data-testid="card" {...link.cardProps}>
<a href="/elsewhere">inner link</a>
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1");
}}
>
play
</button>
</div>
);
}
function setup() {
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Card />} />
<Route path="/dumps/:id" element={<p>dump page</p>} />
</Routes>
</MemoryRouter>,
);
return { open };
}
test("a click navigates and marks visited", () => {
setup();
fireEvent.click(screen.getByTestId("card"));
expect(screen.getByText("dump page")).toBeInTheDocument();
expect(isDumpVisited("d1")).toBe(true);
});
test("ctrl/cmd/shift-click opens a new tab", () => {
const { open } = setup();
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
});
test("a middle click opens a new tab, except over a link", () => {
const { open } = setup();
const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
screen.getByTestId("card").dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
expect(open).toHaveBeenCalledTimes(1);
fireEvent(screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
expect(open).toHaveBeenCalledTimes(1);
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true }));
expect(open).toHaveBeenCalledTimes(1);
});
test("a modified click on the play button opens the dump instead of playing", () => {
const { open } = setup();
const play = screen.getByText("play");
fireEvent.click(play, { metaKey: true });
expect(open).toHaveBeenCalledTimes(1);
expect(play.dataset.played).toBeUndefined();
fireEvent.click(play);
expect(play.dataset.played).toBe("1");
});
});
describe("useAnchoredFloat", () => {
function Float({ open, top }: { open: boolean; top: number }) {
const ref = useRef<HTMLDivElement>(null);
const style = useAnchoredFloat(ref, open, {
width: 300,
chromeHeight: 40,
minContentHeight: 100,
maxContentHeight: 400,
});
return (
<>
<div
ref={(el) => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect;
}
}}
/>
<output data-testid="style">{JSON.stringify(style)}</output>
</>
);
}
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
beforeEach(() => {
vi.stubGlobal("innerWidth", 1024);
vi.stubGlobal("innerHeight", 768);
});
test("null while closed", () => {
render(<Float open={false} top={100} />);
expect(styleOf()).toBeNull();
});
test("opens below an anchor near the top, clamped into the viewport", () => {
render(<Float open top={100} />);
const s = styleOf();
expect(s.position).toBe("fixed");
expect(s.top).toBe(124);
expect(s.bottom).toBe("auto");
expect(s.left).toBe(1024 - 300 - 8);
expect(s["--float-max-h"]).toBe("400px");
});
test("flips above an anchor near the bottom", () => {
render(<Float open top={700} />);
const s = styleOf();
expect(s.bottom).toBe(768 - 700 + 4);
expect(s.top).toBe("auto");
});
});
describe("useHeightTransition", () => {
test("animates from the old height to the new one when watch changes", () => {
const animate = vi.spyOn(Element.prototype, "animate");
let height = 100;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
() => ({ height }) as DOMRect,
);
function Box({ open }: { open: boolean }) {
const ref = useHeightTransition<HTMLDivElement>(open);
return <div ref={ref} />;
}
const { rerender } = render(<Box open={false} />);
height = 250;
rerender(<Box open />);
expect(animate).toHaveBeenCalledTimes(1);
const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]];
expect(frames[0].height).toBe("100px");
expect(frames[1].height).toBe("250px");
// No change in height, no animation.
rerender(<Box open={false} />);
expect(animate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,238 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { WSContext, type WSContextValue } from "../../contexts/WSContext.ts";
import type { Dump, Playlist } from "../../model.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { defaultOf } from "../render.tsx";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultOf(WSContext);
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return { Wrapper, emit: (patch: Partial<WSContextValue>) => act(() => push(patch)) };
}
function useList<T>(initial: T[]) {
const [items, setItems] = useState(initial);
return { items, setItems };
}
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => {
function setup(initial: Dump[], opts?: Parameters<typeof useDumpListSync>[1]) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
useDumpListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([makeDump({ id: "a" }), makeDump({ id: "b" })]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
});
test("updates a dump in place", () => {
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
expect(hook.result.current.items[0].title).toBe("new");
});
test("a dump gone private leaves others' lists but not the owner's", () => {
const a = makeDump({ id: "a" });
const viewer = setup([a]);
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(viewer.hook.result.current.items).toEqual([]);
const owner = setup([a], { isOwner: true });
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
});
test("re-inserts only the owner's dumps, and only when asked", () => {
const mine = makeDump({ id: "m", userId: "me" });
const theirs = makeDump({ id: "t", userId: "them" });
const none = setup([]);
none.emit({ lastDumpEvent: mine });
expect(none.hook.result.current.items).toEqual([]);
const owned = setup([], { ownerId: "me" });
owned.emit({ lastDumpEvent: theirs });
expect(owned.hook.result.current.items).toEqual([]);
owned.emit({ lastDumpEvent: mine });
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
const skip = setup([], { ownerId: "me", skipReinsert: true });
skip.emit({ lastDumpEvent: mine });
expect(skip.hook.result.current.items).toEqual([]);
const privateOne = setup([], { ownerId: "me" });
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
expect(privateOne.hook.result.current.items).toEqual([]);
});
test("comment events move the count, never below zero; edits don't", () => {
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(0);
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
expect(hook.result.current.items).toHaveLength(1);
});
});
describe("usePlaylistListSync", () => {
function setup(
initial: Playlist[],
opts?: Parameters<typeof usePlaylistListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
usePlaylistListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } });
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
byOwner.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]);
priv.emit({
lastPlaylistEvent: {
type: "created",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
expect(followed.hook.result.current.items).toEqual([]);
});
test("updated: in place, removed when private, re-added when public", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "two" } },
});
expect(hook.result.current.items[0].title).toBe("two");
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, isPublic: false } },
});
expect(hook.result.current.items).toEqual([]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "back" } },
});
expect(hook.result.current.items[0].title).toBe("back");
});
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts);
emit({ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p } });
expect(hook.result.current.items).toEqual([]);
}
});
test("deleted and deletedPlaylistIds both remove", () => {
const { hook, emit } = setup([
makePlaylist({ id: "a" }),
makePlaylist({ id: "b" }),
makePlaylist({ id: "c" }),
]);
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
emit({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(hook.result.current.items)).toEqual(["c"]);
});
test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y", "z"] },
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
});
});
describe("usePositionAwareSync", () => {
test("a dump that goes private and back returns to its old place", () => {
const ws = liveWS();
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
// Wire the pair exactly as UserPublicProfile does.
const both = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Dump | null>(null);
usePositionAwareSync(
items,
setItems,
event,
(d) => d.isPrivate,
(d) => !d.isPrivate,
);
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
return { items, setEvent };
}, { wrapper: ws.Wrapper });
const b = initial[1];
act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
});
test("a position is only used once, and clamps to the list end", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null);
usePositionAwareSync(items, setItems, event, (p) => !p.isPublic, (p) => p.isPublic);
return { items, setItems, setEvent };
});
const c = initial[2];
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" }));
expect(ids(hook.result.current.items)).toEqual(["a"]);
});
});

View File

@@ -0,0 +1,261 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import type { AuthResponse } from "../../model.ts";
import { useAuth, useRequiredAuth } from "../../hooks/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, route } from "../fetch.ts";
import { makeSession, makeToken, storeSession } from "../fixtures.ts";
function wrapperFor(initial: AuthResponse | null) {
return function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(initial);
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
);
};
}
const stored = () => JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
describe("useAuth", () => {
test("exposes the session", () => {
const session = makeSession({ username: "ann" });
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
expect(result.current.user?.username).toBe("ann");
expect(result.current.token).toBe(session.token);
expect(result.current.mediaToken).toBe(session.mediaToken);
});
test("mediaToken falls back to the session token on old sessions", () => {
const session = { ...makeSession(), mediaToken: undefined };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
expect(result.current.mediaToken).toBe(session.token);
});
test("login stores the session; logout clears it", () => {
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
const session = makeSession();
act(() => result.current.login(session));
expect(result.current.user?.id).toBe(session.user.id);
expect(stored().token).toBe(session.token);
act(() => result.current.logout());
expect(result.current.user).toBeNull();
expect(stored()).toBeNull();
});
test("a stale logout doesn't sign out a newer session", () => {
const first = makeSession();
storeSession(first);
const { result } = renderHook(useAuth, { wrapper: wrapperFor(first) });
const staleLogout = result.current.logout;
const second = { ...first, token: makeToken() };
act(() => result.current.login(second));
act(() => staleLogout());
expect(result.current.token).toBe(second.token);
expect(stored().token).toBe(second.token);
});
test("updateSession merges and persists; does nothing signed out", () => {
const session = makeSession();
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
act(() => result.current.updateSession({ mediaToken: "fresh" }));
expect(result.current.mediaToken).toBe("fresh");
expect(stored().mediaToken).toBe("fresh");
const out = renderHook(useAuth, { wrapper: wrapperFor(null) });
act(() => out.result.current.updateSession({ mediaToken: "x" }));
expect(out.result.current.user).toBeNull();
});
});
describe("authFetch", () => {
test("sends the bearer token and a JSON content type", async () => {
const session = makeSession();
route("POST /api/thing", () => ({}));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
await result.current.authFetch("http://localhost:8000/api/thing", {
method: "POST",
body: "{}",
headers: { "X-Extra": "1" },
});
const [req] = calls("POST /api/thing");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
expect(req.headers.get("Content-Type")).toBe("application/json");
expect(req.headers.get("X-Extra")).toBe("1");
});
test("leaves Content-Type to the browser for FormData", async () => {
route("POST /api/upload", () => ({}));
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
await result.current.authFetch("http://localhost:8000/api/upload", {
method: "POST",
body: new FormData(),
});
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(false);
});
test("no Authorization header when signed out", async () => {
route("GET /api/open", () => ({}));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
await result.current.authFetch("http://localhost:8000/api/open");
expect(calls("GET /api/open")[0].headers.has("Authorization")).toBe(false);
});
test("a 401 signs the session out", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/secret", () => new Response(null, { status: 401 }));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
result.current.authFetch("http://localhost:8000/api/secret")
);
expect(res.status).toBe(401);
expect(result.current.user).toBeNull();
expect(stored()).toBeNull();
});
test("an expired token never leaves the client", async () => {
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
result.current.authFetch("http://localhost:8000/api/x")
);
expect(res.status).toBe(401);
expect(calls("GET /api/x")).toHaveLength(0);
expect(result.current.user).toBeNull();
});
});
describe("authUpload", () => {
class FakeXHR {
static last: FakeXHR;
method = "";
url = "";
headers: Record<string, string> = {};
status = 0;
statusText = "";
responseText = "";
body: unknown;
private listeners: Record<string, Array<(e: unknown) => void>> = {};
private uploadListeners: Record<string, Array<(e: unknown) => void>> = {};
upload = {
addEventListener: (t: string, fn: (e: unknown) => void) =>
(this.uploadListeners[t] ??= []).push(fn),
};
constructor() {
FakeXHR.last = this;
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
addEventListener(t: string, fn: (e: unknown) => void) {
(this.listeners[t] ??= []).push(fn);
}
send(body: unknown) {
this.body = body;
}
progress(loaded: number, total: number, lengthComputable = true) {
this.uploadListeners.progress?.forEach((f) =>
f({ loaded, total, lengthComputable })
);
}
uploaded() {
this.uploadListeners.load?.forEach((f) => f({}));
}
respond(status: number, text: string) {
this.status = status;
this.responseText = text;
this.listeners.load?.forEach((f) => f({}));
}
error() {
this.listeners.error?.forEach((f) => f({}));
}
}
afterEach(() => vi.unstubAllGlobals());
test("reports progress, then null while the server works, then the response", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
const session = makeSession();
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const progress: Array<number | null> = [];
const body = new FormData();
const promise = result.current.authUpload(
"http://localhost:8000/api/dumps",
body,
(f) => progress.push(f),
);
const xhr = FakeXHR.last;
expect(xhr.method).toBe("POST");
expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`);
expect(xhr.body).toBe(body);
xhr.progress(50, 100);
xhr.progress(10, 0, false);
xhr.uploaded();
xhr.respond(201, JSON.stringify({ success: true, data: { id: "d" } }));
const res = await promise;
expect(progress).toEqual([0.5, null, null]);
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ success: true, data: { id: "d" } });
});
test("a network failure rejects like fetch does", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT");
expect(FakeXHR.last.method).toBe("PUT");
FakeXHR.last.error();
await expect(promise).rejects.toBeInstanceOf(TypeError);
});
test("a 401 signs out", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {});
act(() => FakeXHR.last.respond(401, ""));
await promise;
await waitFor(() => expect(result.current.user).toBeNull());
});
test("an expired token short-circuits", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
FakeXHR.last = undefined as unknown as FakeXHR;
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
result.current.authUpload("u", new FormData(), () => {})
);
expect(res.status).toBe(401);
expect(FakeXHR.last).toBeUndefined();
});
});
describe("useRequiredAuth", () => {
test("throws outside a session", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(useRequiredAuth, { wrapper: wrapperFor(null) }))
.toThrow(/Invariant/);
});
test("returns the user inside one", () => {
const { result } = renderHook(useRequiredAuth, {
wrapper: wrapperFor(makeSession({ username: "z" })),
});
expect(result.current.user.username).toBe("z");
});
});

View File

@@ -0,0 +1,338 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { Dump } from "../../pages/Dump.tsx";
import { DumpEdit } from "../../pages/DumpEdit.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeComment,
makeDump,
makePlaylist,
makeSession,
makeUser,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
const other = makeSession({ id: "other", username: "other" });
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
function dumpRoutes(dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"] })) {
route("GET /api/dumps/:id", () => wire(dump));
route("GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" })));
route("GET /api/dumps/:id/comments", () => []);
route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []);
return dump;
}
function openDump(session: ReturnType<typeof makeSession> | null = null, url = "/dumps/a-find") {
const user = userEvent.setup();
renderApp(<Dump />, {
route: url,
path: "/dumps/:selectedDump",
session,
categories: [music],
extraRoutes: <Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />,
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
}
describe("Dump page", () => {
test("shows the dump, its author, categories and note", async () => {
dumpRoutes();
openDump();
expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner");
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
expect(screen.getByText("reasons").tagName).toBe("STRONG");
await waitFor(() => expect(document.title).toMatch(/^A find · /));
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find");
});
test("a missing dump is an error page", async () => {
route("GET /api/dumps/:id", () => fail("Dump not found", 404));
route("GET /api/dumps/:id/comments", () => []);
route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []);
openDump();
expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found");
expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument();
});
test("the owner can rename it in place, following the new slug", async () => {
dumpRoutes();
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner" })));
const { user } = openDump(owner);
await user.click(await screen.findByRole("heading", { name: "A find" }));
const input = screen.getByDisplayValue("A find");
await user.clear(input);
await user.type(input, "A better find{Enter}");
expect(await screen.findByRole("heading", { name: "A better find" })).toBeInTheDocument();
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find" });
});
test("a refused rename says why; Escape backs out", async () => {
dumpRoutes();
route("PUT /api/dumps/:id", () => fail("Title taken", 400));
const { user } = openDump(owner);
await user.click(await screen.findByRole("heading", { name: "A find" }));
await user.type(screen.getByDisplayValue("A find"), "!{Enter}");
expect(await screen.findByText("Title taken")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
});
test("others can't rename or edit it", async () => {
dumpRoutes();
const { user } = openDump(other);
await user.click(await screen.findByRole("heading", { name: "A find" }));
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Edit" })).not.toBeInTheDocument();
});
test("the owner gets an Edit link", async () => {
dumpRoutes();
const { user } = openDump(owner);
await user.click(await screen.findByRole("link", { name: "Edit" }));
expect(currentPath()).toBe("/dumps/a-find/edit");
});
test("the title can be edited from the keyboard", async () => {
dumpRoutes();
const { user } = openDump(owner);
await screen.findByRole("heading", { name: "A find" });
screen.getByTitle("Edit title").focus();
await user.keyboard("{Enter}");
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
});
test("votes go through the socket", async () => {
dumpRoutes(makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }));
const { user, socket } = openDump(other);
await user.click(await screen.findByRole("button", { name: "Upvote" }));
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
expect(screen.getByRole("button", { name: "Remove vote" })).toHaveTextContent("3");
});
test("comments load, and live ones arrive", async () => {
dumpRoutes();
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]));
const { socket } = openDump(other);
await screen.findByText("first");
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })) });
expect(screen.getByText("second")).toBeInTheDocument();
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
expect(screen.getByText("[deleted]")).toBeInTheDocument();
// Another dump's comment isn't shown here.
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" })) });
expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
});
test("related dumps and the playlists it's in", async () => {
dumpRoutes();
route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })]));
route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })]));
openDump();
expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument();
expect(screen.getByText("Links here")).toBeInTheDocument();
expect(await screen.findByText("Best of")).toBeInTheDocument();
});
test("a live update of this dump is applied", async () => {
const d = dumpRoutes();
const { socket } = openDump();
await screen.findByRole("heading", { name: "A find" });
socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) });
expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument();
});
test("adding to a playlist", async () => {
dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => [
{ playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true },
]);
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
const row = await screen.findByRole("checkbox", { name: /Mine/ });
expect(row).toHaveAttribute("aria-checked", "true");
await user.click(row);
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "false"));
});
test("a new playlist can be made from there, with the dump in it", async () => {
dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => []);
route("POST /api/playlists", (req) => wire(makePlaylist({ id: "np", title: (req.body as { title: string }).title })));
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
await user.click(await screen.findByRole("button", { name: "+ New playlist" }));
await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
const row = await screen.findByRole("checkbox", { name: /Fresh/ });
expect(row).toHaveAttribute("aria-checked", "true");
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/np/dumps/d1");
});
test("scrolls to a linked comment", async () => {
dumpRoutes();
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]));
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
openDump(null, "/dumps/a-find#comment-c9");
await screen.findByText("linked");
await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass("comment-node--highlight"));
expect(scroll).toHaveBeenCalled();
});
});
describe("DumpEdit page", () => {
function openEdit(dump: ReturnType<typeof makeDump>, session = owner) {
route("GET /api/dumps/:id", () => wire(dump));
const user = userEvent.setup();
renderApp(
<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>,
{
route: `/dumps/${dump.slug ?? dump.id}/edit`,
path: "/dumps/:selectedDump/edit",
session,
categories: [music],
extraRoutes: (
<>
<Route path="/dumps/:selectedDump" element={<p>dump page</p>} />
<Route path="/" element={<p>home</p>} />
</>
),
},
);
FakeWebSocket.last.welcome();
return user;
}
const urlDump = () =>
makeDump({
id: "d1",
slug: "a-find",
title: "A find",
userId: "owner",
url: "https://a.test/",
comment: "old why",
categoryIds: [],
richContent: { type: "link", url: "https://a.test/", title: "Page's own title" },
});
test("saves title, note, categories and visibility", async () => {
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" })));
const user = openEdit(urlDump());
const title = await screen.findByLabelText("Title");
expect(document.title).toMatch(/^Editing A find/);
await user.clear(title);
await user.type(title, "New title");
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
expect(currentPath()).toBe("/dumps/new-title");
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
url: "https://a.test/",
title: "New title",
comment: "old why",
isPrivate: true,
categoryIds: ["c1"],
});
});
test("clearing the note removes it", async () => {
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" })));
const user = openEdit(urlDump());
await user.clear(await screen.findByLabelText("Why?"));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null);
});
test("the title resets to the page's own", async () => {
const user = openEdit(urlDump());
const title = await screen.findByLabelText("Title");
await user.click(screen.getByRole("button", { name: "Reset to default" }));
expect(title).toHaveValue("Page's own title");
expect(screen.getByRole("button", { name: "Reset to default" })).toBeDisabled();
});
test("a refused save stays on the form", async () => {
route("PUT /api/dumps/:id", () => fail("Invalid URL", 400));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Save" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Could not save");
expect(alert).toHaveTextContent("Invalid URL");
});
test("refreshing the metadata", async () => {
route("POST /api/dumps/:id/refresh-metadata", () =>
wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } }));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Refresh metadata" }));
expect(await screen.findByText("New blurb")).toBeInTheDocument();
});
test("replacing a file keeps a visibility change made alongside", async () => {
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3" }));
route("PUT /api/dumps/:id", (req) => wire({ ...fileDump, ...(req.body as object) }));
const user = openEdit(fileDump);
await screen.findByText("song.mp3");
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
const [put] = calls("PUT /api/dumps/:id/file");
expect((put.body as FormData).get("file")).toBeInstanceOf(File);
expect(calls("PUT /api/dumps/:id")[0]?.body).toMatchObject({ isPrivate: true });
});
test("deleting asks, then goes home", async () => {
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
await screen.findByText("home");
expect(JSON.parse(document.querySelector('[data-testid="location"]')!.getAttribute("data-state")!))
.toEqual({ deletedDumpId: "d1" });
});
test("a delete that can't reach the server says so", async () => {
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
});
test("a thumbnail that won't upload says so", async () => {
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
const user = openEdit(urlDump());
await screen.findByLabelText("Title");
const picker = document.querySelector<HTMLInputElement>('.dump-edit-thumbnail-row input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" }));
expect(await screen.findByText("Not an image")).toBeInTheDocument();
});
test("guests are sent to log in", () => {
renderApp(<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, {
route: "/dumps/x/edit",
path: "/dumps/:selectedDump/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
expect(screen.getByText("login page")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,198 @@
import { describe, expect, test } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "react-router";
import { Index } from "../../pages/Index.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const HOUR = 3_600_000;
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
function Scoped() {
const { scope } = useParams();
const categorySlug = scope === "~" ? undefined : scope;
return <Index key={scope} categorySlug={categorySlug} />;
}
function setup(
url: string,
{ session = null as ReturnType<typeof makeSession> | null } = {},
) {
const user = userEvent.setup();
renderApp(<Scoped />, {
route: url,
path: "/:scope/:feedTab",
session,
categories: [music],
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title, .journal-card-title"))
.map((a) => a.textContent);
describe("Index feeds", () => {
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) });
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) });
test("New sorts by date, Hot by score", async () => {
route("GET /api/dumps/", () => page([older, newer]));
const { user } = setup("/~/new");
await screen.findByText("Brand new");
expect(titles()).toEqual(["Brand new", "Old but loved"]);
await user.click(screen.getAllByRole("button", { name: "Hot" })[0]);
expect(currentPath()).toBe("/~/hot");
expect(titles()).toEqual(["Old but loved", "Brand new"]);
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
// One fetch: switching tabs re-sorts what's loaded.
expect(calls("GET /api/dumps/")).toHaveLength(1);
});
test("Journal lays the feed out as a mosaic", async () => {
route("GET /api/dumps/", () => page([older, newer]));
setup("/~/journal");
await screen.findByText("Brand new");
expect(document.querySelector(".journal-grid")).not.toBeNull();
});
test("an empty feed and a failed one", async () => {
route("GET /api/dumps/", () => page([]));
const first = setup("/~/new");
await screen.findByText("No dumps yet. Be the first!");
first.socket.drop();
});
test("a failed load shows an error and disables posting", async () => {
route("GET /api/dumps/", () => fail("boom", 500));
setup("/~/new", { session: makeSession() });
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
});
test("sends the token so your own private dumps come back", async () => {
const session = makeSession();
route("GET /api/dumps/", () => page([]));
setup("/~/new", { session });
await screen.findByText("No dumps yet. Be the first!");
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("scrolling to the end loads the next page", async () => {
route("GET /api/dumps/", (req) =>
req.url.searchParams.get("page") === "1"
? page([newer], true)
: page([older], false));
setup("/~/new");
await screen.findByText("Brand new");
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never));
await screen.findByText("Old but loved");
expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2");
});
test("live: a new dump goes on top; a deleted one goes away", async () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) });
expect(titles()[0]).toBe("Just now");
socket.receive({ type: "dump_deleted", dumpId: "old" });
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
});
test("live votes update the count", async () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" });
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31");
});
test("guests can't vote", async () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/new");
await screen.findByText("Old but loved");
expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled();
});
test("a reconnect backfills what was missed", async () => {
route("GET /api/dumps/", () => page([older]), { once: true });
route("GET /api/dumps/", () => page([newer, older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("Brand new");
});
test("a category scopes the feed, and live arrivals to it", async () => {
route("GET /api/categories/:slug/dumps", () => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]));
const { socket } = setup("/music/new");
await screen.findByText("A song");
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe("music");
await waitFor(() => expect(document.title).toMatch(/^Music · /));
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) });
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
expect(screen.getByText("Another song")).toBeInTheDocument();
});
test("an unknown category is a 404", async () => {
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
setup("/nonsense/new");
expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument();
});
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/followed");
await screen.findByText("Old but loved");
});
test("the Android share sheet opens the new-dump form with the link", async () => {
route("GET /api/dumps/", () => page([]));
route("GET /api/preview", () => ({ reached: false, richContent: null }));
route("GET /api/dumps/by-url", () => []);
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession() });
const dialog = await screen.findByRole("dialog", { name: "New dump" });
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/");
});
});
describe("Followed feed", () => {
test("from people and from playlists", async () => {
const session = makeSession();
route("GET /api/follows/feed/users", () => page([makeDump({ title: "By a friend" })]));
route("GET /api/follows/feed/playlists", () => page([]));
const { user } = setup("/~/followed", { session });
await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument();
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("a category narrows it", async () => {
route("GET /api/follows/feed/users", () => page([]));
route("GET /api/follows/feed/playlists", () => page([]));
setup("/music/followed", { session: makeSession() });
await screen.findByText("Follow some users to see their dumps here.");
expect(calls("GET /api/follows/feed/users")[0].url.searchParams.get("category")).toBe("music");
});
test("a server error reads as one, not as a lost connection", async () => {
route("GET /api/follows/feed/users", () => fail("Internal error", 500));
route("GET /api/follows/feed/playlists", () => page([]));
setup("/~/followed", { session: makeSession() });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Internal error");
expect(alert).not.toHaveTextContent(/Could not connect/);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const me = makeSession({ id: "me" });
const DAY = 86_400_000;
function open(items: Notification[], { hasMore = false } = {}) {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([]));
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length });
return { user, socket };
}
const n = {
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }),
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }),
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }),
added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }),
upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }),
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }),
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }),
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }),
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }),
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }),
};
describe("Notifications", () => {
test("every kind reads right and links to its subject", async () => {
open(Object.values(n));
const link = async (text: RegExp) => (await screen.findByText(text)).closest("a")!.getAttribute("href");
expect(await link(/started following you/)).toBe("/users/fan");
expect(await link(/followed your playlist/)).toBe("/playlists/p1");
expect(await link(/^posted$/)).toBe("/dumps/d1");
expect(await link(/was added to/)).toBe("/dumps/d2");
expect(await link(/^upvoted$/)).toBe("/dumps/d3");
expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1");
expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2");
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href"));
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]);
expect(screen.getByText("a comment")).toBeInTheDocument();
expect(screen.getByText("the chat")).toBeInTheDocument();
expect(screen.getByText("Faves")).toBeInTheDocument();
});
test("grouped by day", async () => {
open([
makeNotification({ createdAt: new Date() }),
makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }),
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
]);
expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument();
});
test("opening them marks them read and clears the bell, but still shows which were new", async () => {
open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/);
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
);
expect(document.querySelectorAll(".notif-dot")).toHaveLength(1);
expect(screen.getByText(/started following you/).closest("li"))
.toHaveClass("notification-item--unread");
expect(document.querySelector(".notifications-unread-pill")).toHaveTextContent("1 new");
});
test("a list that loaded stays shown if marking it read fails", async () => {
route("POST /api/notifications/read-all", () => new TypeError("offline"));
open([n.follow]);
expect(await screen.findByText(/started following you/)).toBeInTheDocument();
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText(/started following you/)).toBeInTheDocument();
});
test("a failed load shows the server's reason", async () => {
route("GET /api/notifications", () => fail("Database is locked", 500));
renderApp(<Notifications />, { session: me });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Database is locked");
expect(calls("POST /api/notifications/read-all")).toHaveLength(0);
});
test("empty", async () => {
open([]);
expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument();
});
test("a new one arrives at the top while the page is open", async () => {
const { socket } = open([n.follow]);
await screen.findByText(/started following you/);
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
const items = document.querySelectorAll(".notification-item");
expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread");
// Seen as it arrives: marked read, so the bell doesn't count it.
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(2));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
);
});
test("load more", async () => {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1"
? page([n.follow], true)
: page([n.upvoted], false));
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load more" }));
await screen.findByText(/^upvoted$/);
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,220 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
import type { Dump, Playlist } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
const visitor = makeSession({ id: "visitor" });
const dumps = () => [
makeDump({ id: "a", title: "Alpha" }),
makeDump({ id: "b", title: "Bravo" }),
makeDump({ id: "c", title: "Charlie" }),
];
function open(
{ session = owner as ReturnType<typeof makeSession> | null, playlist = {} as Partial<Playlist>, items = dumps() as Dump[] } = {},
) {
const pl = makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
ownerUsername: "owner",
description: "Good **tunes**",
...playlist,
});
route("GET /api/playlists/:id", () => wire({ ...pl, dumps: items }));
const user = userEvent.setup();
renderApp(<PlaylistDetail />, {
route: "/playlists/mix",
path: "/playlists/:playlistId",
session,
extraRoutes: <Route path="/" element={<p>home</p>} />,
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket, pl };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
describe("PlaylistDetail", () => {
test("shows the playlist and its dumps in order", async () => {
open({ session: null });
expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument();
expect(screen.getByText("tunes").tagName).toBe("STRONG");
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner");
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
});
test("not found", async () => {
route("GET /api/playlists/:id", () => fail("nope", 404));
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" });
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found");
});
test("a visitor can follow a public playlist, not edit it", async () => {
open({ session: visitor });
await screen.findByRole("heading", { name: "Mix" });
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument();
});
test("the cover is versioned on the last edit", async () => {
open({ playlist: { imageMime: "image/png", updatedAt: new Date(5000) } });
await screen.findByRole("heading", { name: "Mix" });
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/image?v=5000",
);
});
test("editing the description shows at once, without waiting for the socket", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getByPlaceholderText("Description (optional)");
await user.clear(box);
await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true });
expect(currentPath()).toBe("/playlists/mix");
});
test("a new title moves to the new slug", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const title = screen.getByPlaceholderText("Playlist title");
await user.clear(title);
await user.type(title, "Remix");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(currentPath()).toBe("/playlists/remix"));
});
test("a new cover shows once saved; a refused one says why", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" })));
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true });
route("POST /api/playlists/:id/image", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/image?v=9000",
)
);
});
test("Escape leaves the editor", async () => {
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.type(screen.getByPlaceholderText("Playlist title"), "{Escape}");
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
});
test("removing a dump fades it out, with an undo", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
route("PUT /api/playlists/:pid/order", () => ({}));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b");
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" }));
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] });
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("a refused removal puts the dump back and says so", async () => {
route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed");
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument();
});
test("dragging reorders, and a refused reorder reloads the real order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open();
await screen.findByText("Charlie");
const items = () => Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
for (const el of items()) {
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
}
const [a, , c] = items();
fireEvent.dragStart(a);
fireEvent.dragOver(c, { clientY: 90 });
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
fireEvent.dragEnd(items()[2]);
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] });
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2));
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]));
});
test("deleting the playlist asks, then goes home", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByText("home")).toBeInTheDocument();
});
test("a refused delete stays and says why", async () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/playlists/mix");
});
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" })));
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"] });
expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) });
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument();
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" });
expect(await screen.findByText("home")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from "vitest";
import { act, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts";
function results(
{ dumps = [] as ReturnType<typeof makeDump>[], hasMore = false, users = [] as ReturnType<typeof makeUser>[], playlists = [] as ReturnType<typeof makePlaylist>[] } = {},
) {
return {
dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore },
users: wire(users),
playlists: wire(playlists),
};
}
function open(url: string, session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session });
return user;
}
describe("Search", () => {
test("no query: a prompt, no request", () => {
open("/search");
expect(screen.getByText("Enter a query to search.")).toBeInTheDocument();
expect(calls("GET /api/search")).toHaveLength(0);
});
test("results by tab, with counts", async () => {
route("GET /api/search", () =>
results({
dumps: [makeDump({ title: "Cat video" })],
users: [makeUser({ username: "catlover", description: "meow" })],
playlists: [makePlaylist({ title: "Cats" })],
}));
const user = open("/search?q=cat", makeSession());
await screen.findByText("Cat video");
const [req] = calls("GET /api/search");
expect(req.url.searchParams.get("q")).toBe("cat");
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
await user.click(screen.getByRole("button", { name: "Users (1)" }));
expect(currentPath()).toBe("/search/~/users?q=cat");
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover");
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
expect(screen.getByText("Cats")).toBeInTheDocument();
});
test("nothing found", async () => {
route("GET /api/search", () => results());
const user = open("/search?q=zzz");
expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Users (0)" }));
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument();
});
test("a failed search says why", async () => {
route("GET /api/search", () => fail("Query too short", 400));
open("/search?q=a");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Search failed");
expect(alert).toHaveTextContent("Query too short");
});
test("more dumps on scroll; a failed page keeps what's shown", async () => {
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
route("GET /api/search", () => new TypeError("offline"), { once: true });
route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }));
open("/search?q=x");
await screen.findByText("First");
const scroll = () => {
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
};
scroll();
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
scroll();
await screen.findByText("Second");
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2");
});
test("a new query replaces the results", async () => {
route("GET /api/search", (req) =>
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] }));
const user = open("/search?q=one");
await screen.findByText("About one");
const [box] = screen.getAllByRole("searchbox");
await user.clear(box);
await user.type(box, "two{Enter}");
await screen.findByText("About two");
expect(screen.queryByText("About one")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserPlaylists } from "../../pages/UserPlaylists.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makePlaylist, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const ann = makeUser({ id: "ann", username: "ann" });
const annSession = makeSession({ id: "ann", username: "ann" });
function open(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/playlists", () =>
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true));
route("GET /api/users/:u/followed-playlists", () =>
page([makePlaylist({ id: "f1", title: "Someone's mix", userId: "bob", ownerUsername: "bob" })]));
const user = userEvent.setup();
renderApp(<UserPlaylists />, { route: "/users/ann/playlists", path: "/users/:username/playlists", session });
return { user, socket: FakeWebSocket.last };
}
describe("UserPlaylists", () => {
test("created and followed playlists", async () => {
open();
expect(await screen.findByText("Her mix")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Created (1+)" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Followed (1)" })).toBeInTheDocument();
expect(screen.getByText("Someone's mix")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Delete playlist" })).not.toBeInTheDocument();
});
test("the owner creates one and it goes on top", async () => {
route("POST /api/playlists", (req) =>
wire(makePlaylist({ id: "new", title: (req.body as { title: string }).title, userId: "ann" })));
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: /New playlist/ }));
const dialog = screen.getByRole("dialog", { name: "New playlist" });
await user.type(within(dialog).getByPlaceholderText("Title"), "Road trip");
await user.click(within(dialog).getByRole("button", { name: "Private" }));
await user.click(within(dialog).getByRole("button", { name: "Create" }));
expect(await screen.findByText("Road trip")).toBeInTheDocument();
expect(calls("POST /api/playlists")[0].body).toEqual({ title: "Road trip", isPublic: false });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
test("the owner deletes one, after confirming", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
await screen.findByText("No playlists yet.");
});
test("a refused delete keeps the playlist and says why", async () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(screen.getByText("Her mix")).toBeInTheDocument();
});
test("live: her new public playlist appears, one gone private leaves", async () => {
const { socket } = open();
await screen.findByText("Her mix");
socket.welcome();
socket.receive({ type: "playlist_created", playlist: wire(makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" })) });
expect(screen.getByText("Brand new mix")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Her mix", userId: "ann", isPublic: false })) });
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,316 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
import type { PublicUser } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeDump,
makePlaylist,
makeSession,
makeUser,
page,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" });
const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" });
function open(
{
session = null as ReturnType<typeof makeSession> | null,
user = ann as PublicUser,
url = "/users/ann",
dumps = [makeDump({ id: "d1", title: "Her dump", userId: "ann" })],
votes = [makeDump({ id: "v1", title: "She liked this" })],
categories = [] as ReturnType<typeof makeCategory>[],
} = {},
) {
route("GET /api/users/:u", () => wire(user));
route("GET /api/users/:u/dumps", () => page(dumps));
route("GET /api/users/:u/votes", () => page(votes));
route("GET /api/users/:u/playlists", () => page([makePlaylist({ title: "Her mix", userId: "ann" })]));
const u = userEvent.setup();
renderApp(<UserPublicProfile />, {
route: url,
path: "/users/:username/*",
session,
categories,
});
return { user: u, socket: FakeWebSocket.last };
}
describe("UserPublicProfile", () => {
test("header, description, and the dumps tab", async () => {
open();
expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root");
expect(screen.getByText("dump").tagName).toBe("STRONG");
expect(screen.getByText("Her dump")).toBeInTheDocument();
expect(screen.getByText("She liked this")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^ann · /));
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
});
test("an unknown user", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
route("GET /api/users/:u/votes", () => page([]));
route("GET /api/users/:u/playlists", () => page([]));
renderApp(<UserPublicProfile />, { route: "/users/nobody", path: "/users/:username/*" });
expect(await screen.findByRole("alert")).toHaveTextContent("User not found");
});
test("a role chip for moderators, and none for users", async () => {
open({ user: { ...ann, role: "moderator" } });
expect(await screen.findByText("Moderator")).toBeInTheDocument();
});
test("tabs are in the URL", async () => {
const { user } = open();
await screen.findByRole("heading", { name: "ann" });
await user.click(screen.getByRole("button", { name: "Playlists" }));
expect(currentPath()).toBe("/users/ann/~/playlists");
expect(screen.getByText("Her mix")).toBeInTheDocument();
});
test("followed: people and playlists, with more people on demand", async () => {
route("GET /api/users/:u/followed-users", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "bob" })], true)
: page([makeUser({ username: "cy" })]));
route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })]));
const { user } = open({ url: "/users/ann/~/followed" });
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute("href", "/users/bob");
expect(screen.getByText("Their mix")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
});
test("invitees as a tree", async () => {
route("GET /api/users/:u/invitees", () => wire([
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
]));
open({ url: "/users/ann/~/invitees" });
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!;
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
});
test("visitors can follow; signed in, a settings tab is only on your own profile", async () => {
open({ session: makeSession({ id: "bob" }) });
expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
});
describe("your own profile", () => {
test("edit the description; clearing it really clears it", async () => {
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
const { user } = open({ session: annSession });
const view = (await screen.findByText("dump")).closest("[title='Edit description']") as HTMLElement;
view.focus();
await user.keyboard("{Enter}");
const box = screen.getByPlaceholderText("Who am I?");
await user.clear(box);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Who am I?")).toBeInTheDocument();
expect(calls("PATCH /api/users/me")[0].body).toEqual({ description: "" });
});
test("edit the email", async () => {
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
const input = screen.getByDisplayValue("ann@x.test");
await user.clear(input);
await user.type(input, "new@x.test{Enter}");
expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("new@x.test");
});
test("a refused email says why", async () => {
route("PATCH /api/users/me", () => fail("Email already in use", 409));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Email already in use");
});
test("upload an avatar", async () => {
route("POST /api/avatars/me", () => wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }));
const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
await waitFor(() =>
expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/ann?v=7000",
)
);
});
test("a rejected avatar says why", async () => {
route("POST /api/avatars/me", () => fail("Not an image", 400));
const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
});
test("invite someone: a link to copy", async () => {
route("POST /api/invites", () => ({ token: "tok123" }));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "+ Invite someone" }));
const link = await screen.findByRole("textbox", { name: "Invite link" });
expect(link).toHaveValue(`${location.origin}/register?token=tok123`);
await user.click(screen.getByRole("button", { name: "Copy" }));
// user-event stands in for the clipboard.
expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`);
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
});
test("your upvotes show before the live connection welcomes you", async () => {
open({ session: annSession });
// No welcome is sent.
expect(await screen.findByText("She liked this")).toBeInTheDocument();
});
test("unvoting fades it out of your upvoted column", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { socket } = open({ session: annSession });
await screen.findByText("She liked this");
socket.welcome({ myVotes: ["v1"] });
socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 });
expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading");
} finally {
vi.useRealTimers();
}
});
test("settings: theme, colour scheme, default tab and sounds", async () => {
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Brutalist" }));
expect(document.documentElement.getAttribute("data-style")).toBe("brutalist");
await user.click(screen.getByRole("button", { name: "Dark" }));
expect(document.documentElement.getAttribute("data-color-scheme")).toBe("dark");
await user.click(screen.getByRole("button", { name: "New" }));
expect(localStorage.getItem("default-feed-tab")).toBe("new");
await user.click(screen.getByRole("button", { name: "Off" }));
expect(localStorage.getItem("sounds")).toBe("off");
document.documentElement.removeAttribute("data-style");
document.documentElement.removeAttribute("data-color-scheme");
});
test("change password keeps you signed in with the fresh tokens", async () => {
route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" }));
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Change password…" }));
const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "oldpassword");
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
await user.type(within(dialog).getByLabelText("Confirm new password"), "different");
expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument();
await user.clear(within(dialog).getByLabelText("Confirm new password"));
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword");
await user.click(within(dialog).getByRole("button", { name: "Change password" }));
expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token");
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
currentPassword: "oldpassword",
newPassword: "newpassword",
});
});
test("a wrong current password is reported", async () => {
route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400));
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Change password…" }));
const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "wrong");
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect");
});
test("log out", async () => {
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "Log out" }));
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("manage", () => {
test("an admin can change someone's role", async () => {
route("PATCH /api/users/:id/role", (req) => wire({ ...ann, role: (req.body as { role: string }).role }));
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
await user.click(await screen.findByRole("button", { name: "Moderator" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active"));
expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role");
expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator");
});
test("a refused role change says why", async () => {
route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400));
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
await user.click(await screen.findByRole("button", { name: "Admin" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin");
});
test("non-admins get no manage tab", async () => {
open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" });
await screen.findByRole("heading", { name: "ann" });
expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument();
expect(screen.getByText("Her dump")).toBeInTheDocument();
});
test("on your own profile, an admin manages categories", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
let list = [music];
route("GET /api/categories", () => wire(list));
route("POST /api/categories", (req) => {
const c = makeCategory({ id: "c2", ...(req.body as object) });
list = [...list, c];
return wire(c);
});
route("DELETE /api/categories/:id", () => {
list = list.filter((c) => c.id !== "c1");
return null;
});
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
await user.click(await screen.findByRole("button", { name: "Add category" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "cinema");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument();
expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "cinema" });
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument());
});
test("a refused category is reported, and Escape closes the editor", async () => {
route("GET /api/categories", () => []);
route("POST /api/categories", () => fail("Slug already taken", 409));
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
await user.click(await screen.findByRole("button", { name: "Add category" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken");
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserDumps } from "../../pages/UserDumps.tsx";
import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
import { fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const ann = makeUser({ id: "ann", username: "ann" });
const annSession = makeSession({ id: "ann", username: "ann" });
function openDumps(session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup();
renderApp(<UserDumps />, { route: "/users/ann/dumps", path: "/users/:username/dumps", session });
return { user, socket: FakeWebSocket.last };
}
describe("UserDumps", () => {
test("lists a user's dumps under a header back to the profile", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ title: "Hers", userId: "ann" })]));
openDumps();
expect(await screen.findByText("Hers")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.queryByRole("button", { name: /New dump/ })).not.toBeInTheDocument();
});
test("an unknown user", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
openDumps();
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← Back to profile" })).toBeInTheDocument();
});
test("on your own page: a new-dump button, and your dumps going private stay", async () => {
route("GET /api/users/:u", () => wire(ann));
const mine = makeDump({ id: "m", title: "Mine", userId: "ann" });
route("GET /api/users/:u/dumps", () => page([mine]));
const { user, socket } = openDumps(annSession);
await screen.findByText("Mine");
socket.welcome();
socket.receive({ type: "dump_updated", dump: wire({ ...mine, isPrivate: true }) });
expect(screen.getByText("Mine")).toBeInTheDocument();
route("GET /api/preview", () => ({ reached: false, richContent: null }));
await user.click(screen.getByRole("button", { name: /New dump/ }));
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
});
test("a visitor loses a dump that goes private, and gets it back where it was", async () => {
route("GET /api/users/:u", () => wire(ann));
const list = ["x", "y", "z"].map((id) => makeDump({ id, title: `T-${id}`, userId: "ann" }));
route("GET /api/users/:u/dumps", () => page(list));
const { socket } = openDumps();
await screen.findByText("T-z");
socket.welcome();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: true }) });
expect(screen.queryByText("T-y")).not.toBeInTheDocument();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: false }) });
const titles = Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
});
});
describe("UserUpvoted", () => {
const voted = () => [makeDump({ id: "v1", title: "Liked one" }), makeDump({ id: "v2", title: "Liked two" })];
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/votes", () => page(voted()));
const user = userEvent.setup();
renderApp(<UserUpvoted />, { route: "/users/ann/upvoted", path: "/users/:username/upvoted", session });
return { user, socket: FakeWebSocket.last };
}
test("lists what they upvoted", async () => {
openVotes();
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Upvoted" })).toBeInTheDocument();
});
test("your own list shows even before the live connection says what you voted", async () => {
openVotes(annSession);
// The socket is never opened: no welcome, no myVotes.
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByText("Liked two")).toBeInTheDocument();
});
test("unvoting on your own list fades the dump out", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { socket } = openVotes(annSession);
await screen.findByText("Liked one");
socket.welcome({ myVotes: ["v1", "v2"] });
socket.receive({ type: "votes_update", dumpId: "v1", voteCount: 0, voterId: "ann", action: "remove" });
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass("dump-card--fading");
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("their new upvote appears at the top", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "v3", title: "Fresh like" })));
const { socket } = openVotes();
await screen.findByText("Liked one");
socket.welcome();
socket.receive({ type: "votes_update", dumpId: "v3", voteCount: 1, voterId: "ann", action: "cast" });
await waitFor(() =>
expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like")
);
});
});

View File

@@ -0,0 +1,237 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { UserLogin } from "../../pages/UserLogin.tsx";
import { UserRegister } from "../../pages/UserRegister.tsx";
import { ResetPassword } from "../../pages/ResetPassword.tsx";
import { NotFound } from "../../pages/NotFound.tsx";
import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("UserLogin", () => {
function setup() {
const user = userEvent.setup();
renderWithContexts(<UserLogin />, { route: "/login", path: "/login", extraRoutes: home });
return user;
}
test("logs in, stores the session and goes home", async () => {
const session = makeSession({ username: "ann" });
route("POST /api/users/login", () => wire(session));
const user = setup();
expect(document.title).toMatch(/^Log in · /);
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/login")[0].body).toEqual({ username: "ann", password: "hunter22" });
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username).toBe("ann");
});
test("both fields are required", async () => {
const user = setup();
// The header has a "Log in" button too; this is the form's.
await user.click(document.querySelector<HTMLButtonElement>(".auth-card button[type=submit]")!);
expect(calls("POST /api/users/login")).toHaveLength(0);
expect(screen.getByPlaceholderText("Username")).toHaveAttribute("aria-invalid", "true");
});
test("shows the server's refusal", async () => {
route("POST /api/users/login", () => fail("Invalid credentials", 401));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Login failed");
expect(alert).toHaveTextContent("Invalid credentials");
expect(currentPath()).toBe("/login");
});
test("says when the server can't be reached", async () => {
route("POST /api/users/login", () => new TypeError("Failed to fetch"));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
});
test("the button says so while logging in", async () => {
const pending = deferred();
route("POST /api/users/login", () => pending.promise);
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("button", { name: "Logging in…" })).toBeDisabled();
pending.resolve(new Response(JSON.stringify({ success: true, data: wire(makeSession()) })));
await screen.findByText("home page");
});
test("password reset request", async () => {
route("POST /api/users/request-password-reset", () => ({}));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(screen.getByPlaceholderText("Your email address"), " a@b.c {Enter}");
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({ email: "a@b.c" });
});
test("a failed reset request is reported, not claimed as sent", async () => {
route("POST /api/users/request-password-reset", () => fail("Internal error", 500));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(screen.getByPlaceholderText("Your email address"), "a@b.c{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Request failed");
expect(screen.queryByText(/you'll receive a reset link/)).not.toBeInTheDocument();
});
});
describe("UserRegister", () => {
function setup(search = "?token=inv-1") {
const user = userEvent.setup();
renderWithContexts(<UserRegister />, {
route: `/register${search}`,
path: "/register",
extraRoutes: home,
});
return user;
}
test("no token: an invalid invite, no request", () => {
setup("");
expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite");
expect(calls("GET /api/invites/:t")).toHaveLength(0);
});
test("an unknown token is invalid", async () => {
route("GET /api/invites/:t", () => fail("nope", 404));
setup();
expect(screen.getByText("Checking invite…")).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("already used");
});
test("registers with a valid invite", async () => {
route("GET /api/invites/:t", () => ({}));
const session = makeSession({ username: "new_one" });
route("POST /api/users/register", () => wire(session));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/register")[0].body).toEqual({
username: "new_one",
password: "longenough",
inviteToken: "inv-1",
email: "n@o.pe",
});
expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1");
});
test("validates the username and password length", async () => {
route("GET /api/invites/:t", () => ({}));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "bad name!");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "short{Enter}");
expect(await screen.findByText(/letters, numbers, or underscores/)).toBeInTheDocument();
expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
expect(calls("POST /api/users/register")).toHaveLength(0);
});
test("shows a server refusal (username taken)", async () => {
route("GET /api/invites/:t", () => ({}));
route("POST /api/users/register", () => fail("Username already taken", 409));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "taken");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Username already taken");
});
});
describe("ResetPassword", () => {
function setup(search = "?token=rst") {
const user = userEvent.setup();
renderWithContexts(<ResetPassword />, {
route: `/reset-password${search}`,
path: "/reset-password",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user;
}
test("no token: an invalid link", () => {
setup("");
expect(screen.getByRole("heading", { name: "Invalid link" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login");
});
test("the two passwords must match", async () => {
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "different");
expect(await screen.findByText("Passwords do not match")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set new password" }));
expect(calls("POST /api/users/reset-password")).toHaveLength(0);
});
test("sets the password, then offers login", async () => {
route("POST /api/users/reset-password", () => ({}));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
await screen.findByRole("heading", { name: "Password updated" });
expect(calls("POST /api/users/reset-password")[0].body).toEqual({ token: "rst", newPassword: "longenough" });
await user.click(screen.getByRole("button", { name: "Go to login" }));
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("an expired token is reported", async () => {
route("POST /api/users/reset-password", () => fail("Reset link expired", 400));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Reset link expired");
});
});
describe("route guards and 404", () => {
test("guest-only pages send members home", () => {
renderWithContexts(<RestrictedGuest><p>form</p></RestrictedGuest>, {
session: makeSession(),
route: "/login",
path: "/login",
extraRoutes: home,
});
expect(screen.getByText("home page")).toBeInTheDocument();
});
test("member-only pages send guests to login", () => {
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
route: "/notifications",
path: "/notifications",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("each guard lets the right people through", () => {
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
session: makeSession(),
});
expect(screen.getByText("secret")).toBeInTheDocument();
});
test("NotFound", async () => {
renderWithContexts(<NotFound />, { route: "/nope" });
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute("href", "/");
});
});

4
src/test/po.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.po" {
import type { Messages } from "@lingui/core";
export const messages: Messages;
}

213
src/test/render.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { type ReactElement, type ReactNode, useState } from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import type { AuthResponse, Category } from "../model.ts";
import { AuthContext } from "../contexts/AuthContext.ts";
import { AuthProvider } from "../contexts/AuthProvider.tsx";
import { WSContext, type WSContextValue } from "../contexts/WSContext.ts";
import { WSProvider } from "../contexts/WSProvider.tsx";
import { FollowContext, type FollowContextValue } from "../contexts/FollowContext.ts";
import { FollowProvider } from "../contexts/FollowProvider.tsx";
import {
CategoriesContext,
type CategoriesContextValue,
} from "../contexts/CategoriesContext.ts";
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
import { PlayerContext, type PlayerContextValue } from "../contexts/PlayerContext.ts";
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../contexts/ChatProvider.tsx";
import { route } from "./fetch.ts";
import { storeSession } from "./fixtures.ts";
/** Renders the current location into the DOM, for asserting navigation. */
export function LocationProbe() {
const loc = useLocation();
return (
<div hidden data-testid="location" data-state={JSON.stringify(loc.state ?? null)}>
{loc.pathname + loc.search}
</div>
);
}
/** The path the probe last saw. */
export function currentPath(): string {
return document.querySelector('[data-testid="location"]')?.textContent ?? "";
}
interface RouterOptions {
/** Initial URL, or several for a history stack (the last is current). */
route?: string | string[];
/** Route pattern the element is mounted on, for useParams. */
path?: string;
/** Extra routes to mount next to it, e.g. a redirect target. */
extraRoutes?: ReactNode;
}
function Router(
{ children, route = "/", path = "*", extraRoutes }: RouterOptions & {
children: ReactNode;
},
) {
const entries = Array.isArray(route) ? route : [route];
return (
<MemoryRouter initialEntries={entries} initialIndex={entries.length - 1}>
<Routes>
<Route path={path} element={children} />
{extraRoutes}
</Routes>
<LocationProbe />
</MemoryRouter>
);
}
// ── Stubbed contexts ─────────────────────────────────────────────────────────
export interface StubOptions extends RouterOptions {
session?: AuthResponse | null;
ws?: Partial<WSContextValue>;
follows?: Partial<FollowContextValue>;
categories?: Category[] | Partial<CategoriesContextValue>;
player?: Partial<PlayerContextValue>;
}
function StubAuth(
{ session, children }: { session: AuthResponse | null; children: ReactNode },
) {
const [authResponse, setAuthResponse] = useState(session);
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
);
}
function categoriesValue(
c: StubOptions["categories"],
): CategoriesContextValue {
const list = Array.isArray(c) ? c : c?.categories ?? [];
return {
categories: list,
byId: new Map(list.map((x) => [x.id, x])),
isLoaded: true,
refresh: async () => {},
...(Array.isArray(c) ? {} : c),
};
}
/** A context's default value — what a consumer outside any provider gets.
* React keeps it on the context object; there is no public accessor. */
export function defaultOf<T>(context: React.Context<T>): T {
return (context as unknown as { _currentValue: T })._currentValue;
}
/**
* Renders `ui` with context values supplied directly — no network, no socket.
* For components whose inputs are the contexts they read.
*/
export function renderWithContexts(
ui: ReactElement,
opts: StubOptions = {},
renderOptions?: RenderOptions,
) {
const defaultWS = defaultOf(WSContext);
const defaultFollows = defaultOf(FollowContext);
const defaultPlayer = defaultOf(PlayerContext);
// A wrapper rather than one tree, so `rerender(ui)` keeps the contexts.
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
<StubAuth session={opts.session ?? null}>
<PlayerContext.Provider value={{ ...defaultPlayer, ...opts.player }}>
<WSContext.Provider value={{ ...defaultWS, ...opts.ws }}>
<FollowContext.Provider
value={{ ...defaultFollows, isLoaded: true, ...opts.follows }}
>
<CategoriesContext.Provider value={categoriesValue(opts.categories)}>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSContext.Provider>
</PlayerContext.Provider>
</StubAuth>
</I18nProvider>
);
return render(ui, { ...renderOptions, wrapper: Wrapper });
}
// ── The real providers ───────────────────────────────────────────────────────
export interface AppOptions extends RouterOptions {
/** Signed in as this session (stored before mount, as after a login). */
session?: AuthResponse | null;
categories?: Category[];
}
/** The routes every provider tree calls on mount, as fallbacks: a test's
* own routes for the same paths win, whenever they were added. */
export function defaultRoutes(
{ session, categories = [] }: Pick<AppOptions, "session" | "categories"> = {},
) {
const fallback = { fallback: true };
route(
"GET /api/categories",
() => categories.map((c) => JSON.parse(JSON.stringify(c))),
fallback,
);
route("GET /api/follows/status", () => ({
followedUserIds: [],
followedPlaylistIds: [],
}), fallback);
// Standing, not fallbacks: a page test's own `GET /api/users/:u` would
// otherwise answer for "me" and hand the session someone else's role.
route(
"GET /api/users/me",
() => JSON.parse(JSON.stringify(session?.user ?? null)),
);
route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" }));
}
/**
* Renders `ui` inside the same provider tree as App — real auth, socket (a
* FakeWebSocket), follows, categories, player and chat — over a memory router.
* Register routes the page needs with `route()` before or after calling this;
* the defaults for the providers' own requests are installed first so a test
* can override them.
*/
export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
if (opts.session) storeSession(opts.session);
defaultRoutes(opts);
const { route: initial = "/", path = "*", extraRoutes } = opts;
const entries = Array.isArray(initial) ? initial : [initial];
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
<ThemeProvider>
<AuthProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
<CategoriesProvider>
<MemoryRouter
initialEntries={entries}
initialIndex={entries.length - 1}
>
<ChatProvider>
<Routes>
<Route path={path} element={children} />
{extraRoutes}
</Routes>
<LocationProbe />
</ChatProvider>
</MemoryRouter>
</CategoriesProvider>
</FollowProvider>
</WSProvider>
</PlayerProvider>
</AuthProvider>
</ThemeProvider>
</I18nProvider>
);
return render(ui, { wrapper: Wrapper });
}

95
src/test/setup.ts Normal file
View File

@@ -0,0 +1,95 @@
// Runs before every test file: DOM matchers, the English catalog, and the
// browser APIs jsdom leaves out that the app reaches for at render time.
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, beforeEach, vi } from "vitest";
import { i18n } from "@lingui/core";
import { messages } from "../locales/en.po";
import { FakeWebSocket } from "./ws.ts";
import { resetFetch } from "./fetch.ts";
// Lazy chunks and the app's own debounces (a link preview waits 600ms) make
// the default 1s too tight once every file runs in parallel.
configure({ asyncUtilTimeout: 4000 });
i18n.load("en", messages);
i18n.activate("en");
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = [];
elements = new Set<Element>();
callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
FakeIntersectionObserver.instances.push(this);
}
observe(el: Element) {
this.elements.add(el);
}
unobserve(el: Element) {
this.elements.delete(el);
}
disconnect() {
this.elements.clear();
}
takeRecords() {
return [];
}
}
class FakeResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
Object.assign(globalThis, {
IntersectionObserver: FakeIntersectionObserver,
ResizeObserver: FakeResizeObserver,
});
if (!globalThis.matchMedia) {
globalThis.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as MediaQueryList;
}
Element.prototype.scrollIntoView ??= function () {};
Element.prototype.animate ??= function () {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation;
};
Element.prototype.getAnimations ??= () => [];
globalThis.scrollTo = () => {};
HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
};
HTMLMediaElement.prototype.pause = function () {};
HTMLMediaElement.prototype.load = function () {};
// The emoji picker probes emoji support on a canvas; jsdom has none.
HTMLCanvasElement.prototype.getContext = (() => null) as never;
// jsdom has no layout; execCommand("insertText") is how the editor types.
document.execCommand ??= () => false;
beforeEach(() => {
resetFetch();
FakeWebSocket.reset();
vi.stubGlobal("WebSocket", FakeWebSocket);
FakeIntersectionObserver.instances = [];
});
afterEach(() => {
cleanup();
localStorage.clear();
sessionStorage.clear();
document.documentElement.removeAttribute("style");
});
export { FakeIntersectionObserver };

View File

@@ -0,0 +1,171 @@
import { describe, expect, test } from "vitest";
import {
composeJournal,
hasQuote,
hasThumbnail,
type JournalShape,
} from "../../utils/journalLayout.ts";
import { hotScore } from "../../utils/hotScore.ts";
import type { Dump } from "../../model.ts";
import { makeDump } from "../fixtures.ts";
const HOUR = 3_600_000;
const SPAN: Record<JournalShape, [number, number]> = {
feature: [2, 2],
wide: [2, 1],
tall: [1, 2],
square: [1, 1],
};
/** Replays the layout onto a 4-column grid, failing on overlap. Returns rows. */
function tile(shapes: JournalShape[]): boolean[][] {
const grid: boolean[][] = [];
const row = (r: number) => (grid[r] ??= [false, false, false, false]);
let cursor = 0;
for (const shape of shapes) {
while (row(Math.floor(cursor / 4))[cursor % 4]) cursor++;
const r = Math.floor(cursor / 4), c = cursor % 4;
const [w, h] = SPAN[shape];
for (let dr = 0; dr < h; dr++) {
for (let dc = 0; dc < w; dc++) {
if (c + dc >= 4) throw new Error(`${shape} overflows at ${r},${c}`);
if (row(r + dr)[c + dc]) throw new Error(`overlap at ${r + dr},${c + dc}`);
row(r + dr)[c + dc] = true;
}
}
}
return grid;
}
function feed(n: number, withArt: (i: number) => boolean): Dump[] {
return Array.from({ length: n }, (_, i) =>
makeDump({
id: `d${i}`,
// Strictly decreasing heat, so the ranking is the index order.
createdAt: new Date(Date.now() - (i + 1) * HOUR),
richContent: withArt(i)
? { type: "link", url: "https://x", thumbnailUrl: "https://x/t.png" }
: undefined,
}));
}
describe("hotScore", () => {
test("more votes is hotter, older is colder", () => {
const now = makeDump({ createdAt: new Date(), voteCount: 0 });
const voted = { ...now, voteCount: 5 };
const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) };
expect(hotScore(voted)).toBeGreaterThan(hotScore(now));
expect(hotScore(old)).toBeLessThan(hotScore(now));
});
});
describe("hasThumbnail / hasQuote", () => {
test("image and video files have art; other files don't", () => {
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" })))
.toBe(true);
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "video/mp4" })))
.toBe(true);
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "audio/mpeg" })))
.toBe(false);
});
test("an uploaded thumbnail or a rich preview image counts", () => {
expect(hasThumbnail(makeDump({ thumbnailMime: "image/webp" }))).toBe(true);
expect(
hasThumbnail(
makeDump({ richContent: { type: "x", url: "u", thumbnailUrl: "t" } }),
),
).toBe(true);
expect(hasThumbnail(makeDump())).toBe(false);
});
test("a quote needs non-blank text", () => {
expect(hasQuote(makeDump({ comment: " " }))).toBe(false);
expect(hasQuote(makeDump({ comment: "yes" }))).toBe(true);
});
});
describe("composeJournal", () => {
test("nothing in, nothing out", () => {
expect(composeJournal([])).toEqual([]);
});
test("sorts by heat and leads with a feature when the top dump has art", () => {
const dumps = feed(5, () => true).reverse();
const [block] = composeJournal(dumps);
expect(block.entries.map((e) => e.dump.id)).toEqual([
"d0",
"d1",
"d2",
"d3",
"d4",
]);
expect(block.entries[0].shape).toBe("feature");
});
test("a bare note on top gives way to art from just behind it", () => {
const dumps = feed(6, (i) => i === 2);
const [block] = composeJournal(dumps);
expect(block.entries[0].dump.id).toBe("d2");
expect(block.entries[0].shape).toBe("feature");
});
test("art too far down doesn't jump the queue", () => {
const dumps = feed(8, (i) => i === 5);
const [block] = composeJournal(dumps);
expect(block.entries[0].dump.id).toBe("d0");
expect(block.entries[0].shape).toBe("wide");
});
test("only dumps with art take the big footprints", () => {
const blocks = composeJournal(feed(40, (i) => i % 3 === 0));
for (const { entries } of blocks) {
for (const { dump, shape } of entries) {
if (shape === "tall" || shape === "feature") {
expect(hasThumbnail(dump)).toBe(true);
}
}
}
});
for (const [name, art] of [
["all art", () => true],
["no art", () => false],
["mixed", (i: number) => i % 2 === 0],
["sparse", (i: number) => i % 7 === 0],
] as const) {
test(`tiles without gaps or overlap (${name})`, () => {
for (const n of [1, 2, 3, 7, 13, 25, 60]) {
const blocks = composeJournal(feed(n, art));
expect(blocks.flatMap((b) => b.entries)).toHaveLength(n);
for (const block of blocks) {
const grid = tile(block.entries.map((e) => e.shape));
// Every row but the last closes flush.
grid.slice(0, -1).forEach((r) => expect(r).toEqual([true, true, true, true]));
}
}
});
}
test("long feeds break into blocks, each opening on a hero", () => {
const blocks = composeJournal(feed(60, () => true));
expect(blocks.length).toBeGreaterThan(1);
for (const block of blocks) {
expect(["feature", "wide"]).toContain(block.entries[0].shape);
expect(block.key).toBe(block.entries[0].dump.id);
}
});
test("no break leaves a fragment at the tail", () => {
const blocks = composeJournal(feed(60, () => true));
expect(blocks.at(-1)!.entries.length).toBeGreaterThanOrEqual(6);
});
test("appending a page doesn't reshuffle what came before", () => {
const first = composeJournal(feed(30, (i) => i % 2 === 0));
const more = composeJournal(feed(60, (i) => i % 2 === 0));
const flat = (bs: typeof first) =>
bs.flatMap((b) => b.entries.map((e) => `${e.dump.id}:${e.shape}`));
// Shapes near the old tail may grow once more cards follow, but the order
// and the opening stretch are fixed.
expect(flat(more).slice(0, 20)).toEqual(flat(first).slice(0, 20));
});
});

166
src/test/utils/misc.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { can } from "../../utils/permissions.ts";
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
import { relativeTime } from "../../utils/relativeTime.ts";
import {
hueFromString,
initialsFor,
isSafeHex,
tintFor,
} from "../../utils/thumbnailTint.ts";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
normalizeUrl,
playlistUrl,
} from "../../utils/urls.ts";
import { API_URL } from "../../config/api.ts";
import { i18n } from "../../i18n.ts";
describe("can", () => {
test("admins can do everything, moderators moderate, users nothing", () => {
expect(can({ role: "admin" }, "category:manage")).toBe(true);
expect(can({ role: "admin" }, "user:manage")).toBe(true);
expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
expect(can({ role: "moderator" }, "category:manage")).toBe(false);
expect(can({ role: "user" }, "dump:moderate")).toBe(false);
});
test("no user, no permission", () => {
expect(can(null, "dump:moderate")).toBe(false);
expect(can(undefined, "dump:moderate")).toBe(false);
});
});
describe("proxyImageUrl", () => {
test("http images go through the proxy; https ones don't", () => {
expect(proxyImageUrl("http://img.test/a.png")).toBe(
`${API_URL}/api/proxy-image?url=${encodeURIComponent("http://img.test/a.png")}`,
);
expect(proxyImageUrl("https://img.test/a.png")).toBe(
"https://img.test/a.png",
);
});
test("force proxies https too", () => {
expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
"/api/proxy-image?url=",
);
});
test("relative, local and non-http URLs are left alone", () => {
expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
"http://localhost:8000/a.png",
);
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe("http://127.0.0.1/a.png");
expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);
});
});
describe("canProxyImage", () => {
test("third-party http(s) only", () => {
expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
expect(canProxyImage("/relative.png")).toBe(false);
expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
expect(canProxyImage("http://[bad")).toBe(false);
});
});
describe("relativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
});
afterEach(() => vi.useRealTimers());
const ago = (s: number) => new Date(Date.now() - s * 1000);
test("picks the largest sensible unit", () => {
expect(relativeTime(ago(0))).toBe("now");
expect(relativeTime(ago(30))).toBe("30 seconds ago");
expect(relativeTime(ago(120))).toBe("2 minutes ago");
expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
expect(relativeTime(ago(86400))).toBe("yesterday");
expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
});
test("follows the active locale", () => {
try {
i18n.load("fr", {});
i18n.activate("fr");
expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
} finally {
i18n.activate("en");
}
});
});
describe("thumbnail tint", () => {
test("isSafeHex accepts only canonical #rrggbb", () => {
expect(isSafeHex("#a1b2c3")).toBe(true);
expect(isSafeHex("#A1B2C3")).toBe(false);
expect(isSafeHex("#abc")).toBe(false);
expect(isSafeHex("red")).toBe(false);
expect(isSafeHex("#000000;background:url(x)")).toBe(false);
expect(isSafeHex(undefined)).toBe(false);
});
test("hueFromString is stable and in range", () => {
expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
test("tintFor prefers the page colour, then the hostname sans www", () => {
expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
"#112233",
);
expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
hueFromString("a.b"),
);
expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
});
test("initialsFor takes the first letter or digit", () => {
expect(initialsFor(" bandcamp", undefined)).toBe("B");
expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
expect(initialsFor("«Élan»", undefined)).toBe("É");
expect(initialsFor("", "")).toBe("?");
});
});
describe("urls", () => {
test("dumpFileUrl versions by size and carries the token", () => {
expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe(
`${API_URL}/api/files/d1?v=42`,
);
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
`${API_URL}/api/files/d1?v=0&token=t%20o%2Bk`,
);
});
test("dumpThumbnailUrl", () => {
expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
`${API_URL}/api/thumbnails/d1?token=tk`,
);
});
test("normalizeUrl adds a scheme when there's none", () => {
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
expect(normalizeUrl(" ")).toBe("");
});
test("slug beats id in page URLs", () => {
expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, test } from "vitest";
import {
deserializeAuthResponse,
deserializeChatMessage,
deserializeComment,
deserializeDump,
deserializeNotification,
deserializePlaylistMembership,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../../model.ts";
describe("parseAPIResponse", () => {
test("accepts either envelope", () => {
expect(parseAPIResponse({ success: true, data: 1 })).toEqual({
success: true,
data: 1,
});
expect(parseAPIResponse({ success: false, error: { message: "x" } }))
.toMatchObject({ success: false });
});
test("rejects anything else", () => {
expect(() => parseAPIResponse(null)).toThrow(/Unexpected/);
expect(() => parseAPIResponse({ data: 1 })).toThrow(/Unexpected/);
expect(() => parseAPIResponse("ok")).toThrow(/Unexpected/);
});
});
describe("deserializers", () => {
const createdAt = "2026-03-01T10:00:00.000Z";
const updatedAt = "2026-03-02T10:00:00.000Z";
test("revive createdAt and an optional updatedAt", () => {
const d = deserializeDump({
id: "d",
kind: "url",
title: "t",
userId: "u",
createdAt,
voteCount: 0,
commentCount: 0,
isPrivate: false,
});
expect(d.createdAt).toEqual(new Date(createdAt));
expect(d.updatedAt).toBeUndefined();
const c = deserializeComment({
id: "c",
dumpId: "d",
userId: "u",
body: "b",
createdAt,
updatedAt,
deleted: false,
likeCount: 0,
authorUsername: "a",
});
expect(c.updatedAt).toEqual(new Date(updatedAt));
const m = deserializeChatMessage({
id: "m",
userId: "u",
body: "b",
createdAt,
authorUsername: "a",
});
expect(m.createdAt.getTime()).toBe(Date.parse(createdAt));
const n = deserializeNotification({
id: "n",
userId: "u",
type: "user_followed",
data: { followerId: "f", followerUsername: "fan" },
read: false,
createdAt,
});
expect(n.createdAt).toBeInstanceOf(Date);
});
test("nested values are revived too", () => {
const auth = deserializeAuthResponse({
token: "t",
user: { id: "u", username: "u", role: "user", createdAt },
});
expect(auth.user.createdAt).toBeInstanceOf(Date);
const p = deserializePlaylistWithDumps({
id: "p",
userId: "u",
title: "t",
isPublic: true,
createdAt,
dumps: [{
id: "d",
kind: "url",
title: "t",
userId: "u",
createdAt,
voteCount: 0,
commentCount: 0,
isPrivate: false,
}],
});
expect(p.dumps[0].createdAt).toBeInstanceOf(Date);
const mem = deserializePlaylistMembership({
playlist: { id: "p", userId: "u", title: "t", isPublic: true, createdAt },
hasDump: true,
});
expect(mem.playlist.createdAt).toBeInstanceOf(Date);
expect(mem.hasDump).toBe(true);
});
});

View File

@@ -0,0 +1,197 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { resolveBandcampQueue } from "../../utils/bandcamp.ts";
import {
isStreamStale,
resolveStreamQueue,
STREAM_STALE_MS,
} from "../../utils/streamSources.ts";
import {
embedListId,
fetchPlaylistTracks,
withJsApi,
} from "../../utils/youtube.ts";
import { extractPeaks } from "../../utils/waveform.ts";
import { calls, fail, route } from "../fetch.ts";
import type { PlayerItem } from "../../contexts/PlayerContext.ts";
import { playerItemKey } from "../../contexts/PlayerContext.ts";
afterEach(() => vi.useRealTimers());
const album = {
sourceUrl: "https://artist.bandcamp.com/album/a",
itemType: "album",
title: "A",
artist: "Artist",
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{ index: 0, trackNum: 1, title: "One", duration: 100, streamUrl: "https://s/1", streamable: true, capped: false },
{ index: 1, trackNum: 2, title: "Two", streamUrl: null, streamable: false, capped: true },
{ index: 2, trackNum: 3, title: "Three", duration: 50, streamUrl: "https://s/3", streamable: true, capped: false },
],
};
describe("resolveBandcampQueue", () => {
test("turns the streamable tracks into a queue", async () => {
route("GET /api/bandcamp/tracks", () => album);
const items = await resolveBandcampQueue(album.sourceUrl, {
dumpHref: "/dumps/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
title: "One",
subtitle: "Artist",
trackNum: 1,
duration: 100,
artworkUrl: album.artworkUrl,
dumpHref: "/dumps/x",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: 1000,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items[1]).toMatchObject({ resolveIndex: 2 });
const [req] = calls("GET /api/bandcamp/tracks");
expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
expect(req.url.searchParams.has("force")).toBe(false);
});
test("force asks the server to skip its cache", async () => {
route("GET /api/bandcamp/tracks", () => album);
await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("falls back to the given artwork", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
const [item] = await resolveBandcampQueue(album.sourceUrl, {
fallbackArtworkUrl: "https://mine/art.png",
});
expect(item.artworkUrl).toBe("https://mine/art.png");
});
test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
route("GET /api/bandcamp/tracks", () => ({
...album,
tracks: [album.tracks[1]],
}));
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
});
});
describe("stream sources", () => {
const stream: Extract<PlayerItem, { kind: "stream" }> = {
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: Date.now(),
artworkUrl: "https://old/art.png",
dumpHref: "/dumps/x",
};
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
const items = await resolveStreamQueue(stream);
expect(items[0].artworkUrl).toBe("https://old/art.png");
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("an unknown stream type has no resolver", async () => {
await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
.toThrow(/no resolver/);
});
test("isStreamStale after the window", () => {
vi.useFakeTimers();
vi.setSystemTime(10 * STREAM_STALE_MS);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(false);
expect(
isStreamStale({ ...stream, resolvedAt: Date.now() - STREAM_STALE_MS - 1 }),
).toBe(true);
});
test("playerItemKey ignores the signed URL", () => {
expect(playerItemKey(stream)).toBe(
playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
);
expect(playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }))
.toBe("file:f");
expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
.toBe("embed:e");
});
});
describe("youtube", () => {
test("embedListId reads ?list= from either embed shape", () => {
expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
.toBe("PL1");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe("PL2");
expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
expect(embedListId("nonsense")).toBeNull();
});
test("withJsApi adds the API parameters and our origin", () => {
const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
expect(url.searchParams.get("enablejsapi")).toBe("1");
expect(url.searchParams.get("origin")).toBe(location.origin);
expect(url.searchParams.get("list")).toBe("PL");
expect(withJsApi("bad url")).toBe("bad url");
});
test("fetchPlaylistTracks", async () => {
route("GET /api/youtube/playlist", (req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] });
await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
{ videoId: "a", title: "A", duration: 3 },
]);
await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
route("GET /api/youtube/playlist", () => fail("x", 500));
await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
});
});
describe("extractPeaks", () => {
test("decodes once per URL and takes each block's peak", async () => {
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
const decode = vi.fn(async () => ({ getChannelData: () => data }));
const close = vi.fn();
vi.stubGlobal(
"AudioContext",
class {
decodeAudioData = decode;
close = close;
},
);
route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
expect(close).toHaveBeenCalled();
await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect(decode).toHaveBeenCalledTimes(1);
expect(calls("GET /api/files/:id")).toHaveLength(1);
});
test("a failed fetch throws", async () => {
route("GET /api/files/:id", () => fail("gone", 404));
await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
.toThrow(/404/);
});
});

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// sound.ts keeps its AudioContext and chat throttle at module level, so each
// test loads a fresh copy against a fake Web Audio.
class FakeAudioContext {
static instances: FakeAudioContext[] = [];
state: "suspended" | "running" = "running";
currentTime = 0;
destination = {};
oscillators = 0;
constructor() {
FakeAudioContext.instances.push(this);
}
resume = vi.fn(async () => {
this.state = "running";
});
createOscillator() {
this.oscillators++;
const node = {
type: "",
frequency: { setValueAtTime() {} },
connect: () => ({ connect() {} }),
start() {},
stop() {},
};
return node;
}
createGain() {
return {
gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} },
};
}
}
async function load() {
vi.resetModules();
return await import("../../utils/sound.ts");
}
const notes = () =>
FakeAudioContext.instances.reduce((n, c) => n + c.oscillators, 0);
beforeEach(() => {
FakeAudioContext.instances = [];
vi.stubGlobal("AudioContext", FakeAudioContext);
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
});
afterEach(() => vi.useRealTimers());
describe("sounds preference", () => {
test("on by default, stored as on/off", async () => {
const s = await load();
expect(s.readSoundsEnabled()).toBe(true);
s.writeSoundsEnabled(false);
expect(localStorage.getItem("sounds")).toBe("off");
expect(s.readSoundsEnabled()).toBe(false);
});
test("unavailable storage leaves sounds on", async () => {
const s = await load();
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
expect(s.readSoundsEnabled()).toBe(true);
});
});
describe("playChime", () => {
test("a notification plays two notes, a chat message one", async () => {
const s = await load();
s.playChime("notify", "n1");
expect(notes()).toBe(2);
s.playChime("chat", "c1");
expect(notes()).toBe(3);
});
test("silent when sounds are off", async () => {
const s = await load();
s.writeSoundsEnabled(false);
s.playChime("notify", "n1");
expect(notes()).toBe(0);
});
test("one chime per event across tabs", async () => {
const s = await load();
s.playChime("notify", "same");
s.playChime("notify", "same");
expect(notes()).toBe(2);
expect(localStorage.getItem("sounds-last-claim")).toBe("notify:same");
});
test("chat blips are throttled", async () => {
const s = await load();
s.playChime("chat", "a");
s.playChime("chat", "b");
expect(notes()).toBe(1);
vi.advanceTimersByTime(4_001);
s.playChime("chat", "c");
expect(notes()).toBe(2);
});
test("a suspended context stays quiet until a gesture unlocks it", async () => {
const s = await load();
s.playChime("notify", "warmup"); // creates the context
const ctx = FakeAudioContext.instances[0];
ctx.state = "suspended";
s.playChime("notify", "n2");
expect(ctx.oscillators).toBe(2);
globalThis.dispatchEvent(new Event("keydown"));
expect(ctx.resume).toHaveBeenCalled();
});
test("previewChime resumes and plays regardless of the preference", async () => {
const s = await load();
s.writeSoundsEnabled(false);
s.previewChime("chat");
expect(notes()).toBe(1);
FakeAudioContext.instances[0].state = "suspended";
s.previewChime("notify");
await vi.runAllTimersAsync();
expect(notes()).toBe(3);
});
test("no Web Audio, no crash", async () => {
vi.stubGlobal("AudioContext", class {
constructor() {
throw new Error("unsupported");
}
});
const s = await load();
expect(() => s.playChime("notify", "x")).not.toThrow();
expect(() => s.previewChime("chat")).not.toThrow();
});
});

View File

@@ -0,0 +1,183 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AUTH_STORAGE_KEY,
forgetStoredSession,
isTokenExpired,
readStoredSession,
storeSession,
} from "../../utils/authStorage.ts";
import {
clearDumpDraft,
EMPTY_DUMP_DRAFT,
isDumpDraftEmpty,
loadDumpDraft,
saveDumpDraft,
} from "../../utils/dumpDraft.ts";
import {
isDumpVisited,
isPlaylistVisited,
isRecent,
markDumpVisited,
markPlaylistVisited,
} from "../../utils/visited.ts";
import { makeSession, makeToken, wire } from "../fixtures.ts";
afterEach(() => vi.useRealTimers());
describe("isTokenExpired", () => {
test("reads exp from the payload", () => {
expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false);
expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true);
});
test("a token it can't read is treated as expired", () => {
expect(isTokenExpired("garbage")).toBe(true);
expect(isTokenExpired("a.b.c")).toBe(true);
});
test("no exp means it doesn't expire", () => {
const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`;
expect(isTokenExpired(token)).toBe(false);
});
});
describe("stored session", () => {
test("round-trips, with dates revived", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
const read = readStoredSession();
expect(read?.user.username).toBe("ann");
expect(read?.user.createdAt).toBeInstanceOf(Date);
});
test("nothing stored", () => {
expect(readStoredSession()).toBeNull();
});
test("unparseable, expired and roleless sessions are discarded", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{not json");
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const expired = makeSession();
expired.token = makeToken({ expiresIn: -1 });
storeSession(expired);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const legacy = wire(makeSession()) as unknown as {
user: { role?: string };
};
delete legacy.user.role;
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(legacy));
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession only forgets the session holding that token", () => {
const session = makeSession();
storeSession(session);
forgetStoredSession("some-older-token");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull();
forgetStoredSession(session.token);
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession clears an unreadable entry", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{bad");
forgetStoredSession("anything");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("dump draft", () => {
const draft = {
...EMPTY_DUMP_DRAFT,
url: "https://a.b",
comment: "why",
};
test("an empty draft is empty; any content isn't", () => {
expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe(
false,
);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe(
false,
);
});
test("a title from the preview isn't worth keeping; a typed one is", () => {
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe(
true,
);
expect(
isDumpDraftEmpty({
...EMPTY_DUMP_DRAFT,
title: "Mine",
titleFromUser: true,
}),
).toBe(false);
});
test("save, load and clear", () => {
saveDumpDraft(draft);
expect(loadDumpDraft()).toEqual(draft);
clearDumpDraft();
expect(loadDumpDraft()).toBeNull();
});
test("saving an empty draft removes the stored one", () => {
saveDumpDraft(draft);
saveDumpDraft(EMPTY_DUMP_DRAFT);
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("a malformed stored draft is sanitised or ignored", () => {
localStorage.setItem(
"gerbeur.dumpDraft",
JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }),
);
expect(loadDumpDraft()).toEqual({
...EMPTY_DUMP_DRAFT,
comment: "c",
categoryIds: ["a"],
});
localStorage.setItem("gerbeur.dumpDraft", "nope");
expect(loadDumpDraft()).toBeNull();
});
test("storage that throws never breaks posting", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceeded");
});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
expect(() => saveDumpDraft(draft)).not.toThrow();
expect(loadDumpDraft()).toBeNull();
});
});
describe("visited", () => {
test("dumps and playlists are tracked apart", () => {
markDumpVisited("a");
markPlaylistVisited("b");
expect(isDumpVisited("a")).toBe(true);
expect(isDumpVisited("b")).toBe(false);
expect(isPlaylistVisited("b")).toBe(true);
expect(isPlaylistVisited("a")).toBe(false);
});
test("a corrupt entry reads as nothing visited", () => {
localStorage.setItem("visited_dumps", "{");
expect(isDumpVisited("a")).toBe(false);
markDumpVisited("a");
expect(isDumpVisited("a")).toBe(true);
});
test("isRecent is a seven-day window", () => {
const day = 24 * 3600 * 1000;
expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false);
});
});

157
src/test/utils/text.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { describe, expect, test } from "vitest";
import { insideCode, isEmojiOnly } from "../../utils/editorText.ts";
import { markdownToPlainText, mentionsUser } from "../../utils/chatText.ts";
import { charCountClass, overLimit } from "../../utils/charCount.ts";
import { formatBytes, titleFromFileName } from "../../utils/format.ts";
import { fmt } from "../../utils/duration.ts";
import { friendlyFetchError } from "../../utils/apiError.ts";
describe("insideCode", () => {
test("plain text is not code", () => {
expect(insideCode("hello @bob")).toBe(false);
});
test("an unclosed inline span is code", () => {
expect(insideCode("try `@bob")).toBe(true);
expect(insideCode("try `a` then `@bob")).toBe(true);
});
test("a closed inline span is not", () => {
expect(insideCode("try `a` @bob")).toBe(false);
});
test("an open fence is code, a closed one is not", () => {
expect(insideCode("```\n@bob")).toBe(true);
expect(insideCode("```js\nx\n```\n@bob")).toBe(false);
});
test("the fence line itself is not an inline span", () => {
expect(insideCode("text\n```\ncode\n```")).toBe(false);
});
test("backticks on an earlier line don't carry over", () => {
expect(insideCode("a ` b\n@bob")).toBe(false);
});
});
describe("isEmojiOnly", () => {
test("one to three emoji", () => {
expect(isEmojiOnly("🎉")).toBe(true);
expect(isEmojiOnly("👍👍")).toBe(true);
expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true);
});
test("more than the max", () => {
expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false);
expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true);
});
test("a ZWJ sequence, a skin tone and a flag count as one each", () => {
expect(isEmojiOnly("👨‍👩‍👧‍👦")).toBe(true);
expect(isEmojiOnly("👍🏽")).toBe(true);
expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true);
});
test("text, digits and symbols are not emoji", () => {
expect(isEmojiOnly("")).toBe(false);
expect(isEmojiOnly("hi 🎉")).toBe(false);
expect(isEmojiOnly("123")).toBe(false);
expect(isEmojiOnly("#")).toBe(false);
expect(isEmojiOnly("1🎉")).toBe(false);
});
});
describe("mentionsUser", () => {
test("case-insensitive whole-word match", () => {
expect(mentionsUser("hey @Alice!", "alice")).toBe(true);
expect(mentionsUser("hey @alicex", "alice")).toBe(false);
});
test("follows the server's rule: not in an email, a link or brackets", () => {
expect(mentionsUser("mail bob@alice", "alice")).toBe(false);
expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
expect(mentionsUser("(@alice)", "alice")).toBe(false);
});
test("any of several mentions", () => {
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
});
});
describe("markdownToPlainText", () => {
test("links become their text, images their alt or a label", () => {
expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
"see this now",
);
expect(markdownToPlainText("![cat](a.png)", "Image")).toBe("cat");
expect(markdownToPlainText("![](a.png)", "Image")).toBe("Image");
});
test("markup characters and block markers go", () => {
expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
"Title quote bold code",
);
});
test("a body cut off mid-link doesn't show a half URL", () => {
expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
"look here",
);
expect(markdownToPlainText("pic ![alt](https://exa", "Image")).toBe(
"pic alt",
);
expect(markdownToPlainText("pic ![](https://exa", "Image")).toBe(
"pic Image",
);
expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
});
test("whitespace collapses to one line", () => {
expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
});
});
describe("character limits", () => {
test("charCountClass warns from 85% and flags the limit", () => {
expect(charCountClass(10, 100)).toBe("");
expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
});
test("overLimit is strictly past it", () => {
expect(overLimit("abc", 3)).toBe(false);
expect(overLimit("abcd", 3)).toBe(true);
});
});
describe("formatBytes", () => {
test("B, KB and MB", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
});
});
describe("titleFromFileName", () => {
test("drops the extension and turns underscores into spaces", () => {
expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe(
"holiday photo-01.final",
);
});
test("a dotfile keeps its name", () => {
expect(titleFromFileName(".gitignore")).toBe(".gitignore");
});
test("a name that is only separators falls back to itself", () => {
expect(titleFromFileName("___.txt")).toBe("___.txt");
});
});
describe("fmt", () => {
test("m:ss", () => {
expect(fmt(0)).toBe("0:00");
expect(fmt(65.9)).toBe("1:05");
expect(fmt(3600)).toBe("60:00");
});
test("non-finite reads as zero", () => {
expect(fmt(NaN)).toBe("0:00");
expect(fmt(Infinity)).toBe("0:00");
});
});
describe("friendlyFetchError", () => {
test("a TypeError is a connection problem", () => {
expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch(
/Could not connect/,
);
});
test("other errors keep their message", () => {
expect(friendlyFetchError(new Error("Nope"))).toBe("Nope");
expect(friendlyFetchError("weird")).toBe("An unexpected error occurred.");
});
});

101
src/test/ws.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { IncomingWSMessage } from "../model.ts";
import { act } from "@testing-library/react";
/**
* Stands in for the browser WebSocket. Every socket the app opens is kept in
* `FakeWebSocket.instances`; a test opens it, feeds it server messages and
* reads back what the client sent.
*/
export class FakeWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
static reset() {
FakeWebSocket.instances = [];
}
/** The newest socket the app opened. */
static get last(): FakeWebSocket {
const ws = FakeWebSocket.instances.at(-1);
if (!ws) throw new Error("no WebSocket was opened");
return ws;
}
readyState = FakeWebSocket.CONNECTING;
sent: string[] = [];
onopen: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onclose: ((e: CloseEvent) => void) | null = null;
onerror: ((e: Event) => void) | null = null;
url: string;
constructor(url: string) {
super();
this.url = url;
FakeWebSocket.instances.push(this);
}
send(data: string) {
if (this.readyState !== FakeWebSocket.OPEN) {
throw new Error("send on a socket that isn't open");
}
this.sent.push(data);
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.(new Event("close") as CloseEvent);
}
/** What the client sent, parsed. */
get messages(): Array<Record<string, unknown>> {
return this.sent.map((s) => JSON.parse(s));
}
// ── Server side ───────────────────────────────────────────────────────────
open() {
act(() => {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
});
}
receive(msg: IncomingWSMessage | Record<string, unknown>) {
act(() => {
this.onmessage?.(
new MessageEvent("message", { data: JSON.stringify(msg) }),
);
});
}
receiveRaw(data: string) {
act(() => {
this.onmessage?.(new MessageEvent("message", { data }));
});
}
/** The server drops the connection. */
drop() {
act(() => this.close());
}
/** Opens the socket and sends a welcome, as the server does on connect. */
welcome(overrides: Partial<Extract<IncomingWSMessage, { type: "welcome" }>> = {}) {
this.open();
this.receive({
type: "welcome",
users: [],
myVotes: [],
myCommentLikes: [],
unreadNotificationCount: 0,
unreadChatCount: 0,
...overrides,
});
}
}

View File

@@ -37,3 +37,12 @@ export function dumpUrl(dump: { id: string; slug?: string }): string {
export function playlistUrl(playlist: { id: string; slug?: string }): string {
return `/playlists/${playlist.slug ?? playlist.id}`;
}
/** A playlist's cover. Versioned on `updatedAt`, which a new cover bumps, so a
* replaced image isn't served from the browser's cache. */
export function playlistImageUrl(
playlist: { id: string; updatedAt?: Date },
): string {
const v = playlist.updatedAt ? `?v=${playlist.updatedAt.getTime()}` : "";
return `${API_URL}/api/playlists/${playlist.id}/image${v}`;
}

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config.ts";
// The app's own Vite config, so the tests compile exactly what ships: the SWC
// React transform with the Lingui macro plugin, and .po catalogs through the
// Lingui loader.
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["src/test/setup.ts"],
restoreMocks: true,
unstubGlobals: true,
css: false,
},
}),
);