diff options
26 files changed, 463 insertions, 341 deletions
diff --git a/package-lock.json b/package-lock.json index a170278..dd5dcdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/svelte": "^5.2.7", + "@testing-library/user-event": "^14.6.1", "@types/eslint": "^9.6.1", "@vitest/coverage-v8": "^3.0.6", "autoprefixer": "^10.4.20", @@ -67,6 +69,21 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -103,6 +120,19 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", @@ -1320,6 +1350,83 @@ "vite": "^6.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.7.tgz", + "integrity": "sha512-aGhUaFmEXEVost4QOsbHUUbHLwi7ZZRRxAHFDO2Cmr0BZD3/3+XvaYEPq70Rdw0NRNjdqZHdARBEcrCOkPuAqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2011,6 +2118,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", @@ -2018,6 +2135,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", @@ -3015,6 +3139,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -3194,6 +3325,16 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -3689,6 +3830,44 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3705,6 +3884,20 @@ "node": ">=6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index f4d78ac..a43e01e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/svelte": "^5.2.7", + "@testing-library/user-event": "^14.6.1", "@types/eslint": "^9.6.1", "@vitest/coverage-v8": "^3.0.6", "autoprefixer": "^10.4.20", diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte index 9c181e4..f7837aa 100644 --- a/ui/lib/components/ActiveChannel.svelte +++ b/ui/lib/components/ActiveChannel.svelte @@ -1,9 +1,9 @@ <script> import MessageRun from './MessageRun.svelte'; - let { messageRuns } = $props(); + let { messageRuns, deleteMessage = async (id) => {} } = $props(); </script> -{#each messageRuns as { sender, messages }} - <MessageRun {sender} {messages} /> +{#each messageRuns as { sender, ownMessage, messages }} + <MessageRun {sender} {ownMessage} {messages} {deleteMessage} /> {/each} diff --git a/ui/lib/components/ChangePassword.svelte b/ui/lib/components/ChangePassword.svelte index bf94ea7..742d4f1 100644 --- a/ui/lib/components/ChangePassword.svelte +++ b/ui/lib/components/ChangePassword.svelte @@ -1,28 +1,26 @@ <script> - import { changePassword } from '$lib/apiServer.js'; + let { changePassword = async (currentPassword, newPassword) => {} } = $props(); - let currentPassword = $state(''), - newPassword = $state(''), - confirmPassword = $state(''), - pending = $state(false), - form; + let currentPassword = $state(''); + let newPassword = $state(''); + let confirmPassword = $state(''); + let pending = $state(false); let valid = $derived(newPassword === confirmPassword && newPassword !== currentPassword); let disabled = $derived(pending || !valid); async function onsubmit(event) { event.preventDefault(); pending = true; - let response = await changePassword(currentPassword, newPassword); - switch (response.status) { - case 200: - form.reset(); - break; + try { + await changePassword(currentPassword, newPassword); + event.target.reset(); + } finally { + pending = false; } - pending = false; } </script> -<form class="form" {onsubmit} bind:this={form}> +<form class="form" {onsubmit}> <label >current password <input diff --git a/ui/lib/components/CreateChannelForm.svelte b/ui/lib/components/CreateChannelForm.svelte index 85c85bb..471c2b7 100644 --- a/ui/lib/components/CreateChannelForm.svelte +++ b/ui/lib/components/CreateChannelForm.svelte @@ -1,21 +1,22 @@ <script> - import { createChannel } from '$lib/apiServer'; + let { createChannel = async (name) => {} } = $props(); let name = $state(''); let disabled = $state(false); - async function handleSubmit(event) { + async function onsubmit(event) { event.preventDefault(); disabled = true; - const response = await createChannel(name); - if (200 <= response.status && response.status < 300) { - name = ''; + try { + await createChannel(name); + event.target.reset(); + } finally { + disabled = false; } - disabled = false; } </script> -<form onsubmit={handleSubmit}> +<form {onsubmit}> <input type="text" placeholder="create channel" bind:value={name} {disabled} /> <button type="submit">➕</button> </form> diff --git a/ui/lib/components/Invites.svelte b/ui/lib/components/Invites.svelte index 27d3754..226ccce 100644 --- a/ui/lib/components/Invites.svelte +++ b/ui/lib/components/Invites.svelte @@ -1,15 +1,11 @@ <script> - import { createInvite } from '$lib/apiServer'; import Invite from '$lib/components/Invite.svelte'; - let invites = $state([]); + let { invites, createInvite = async () => {} } = $props(); async function onsubmit(event) { event.preventDefault(); - let response = await createInvite(); - if (response.status == 200) { - invites.push(response.data); - } + await createInvite(); } </script> diff --git a/ui/lib/components/LogIn.svelte b/ui/lib/components/LogIn.svelte index 5bfdae2..c49ea3b 100644 --- a/ui/lib/components/LogIn.svelte +++ b/ui/lib/components/LogIn.svelte @@ -1,20 +1,29 @@ <script> - let { - username = $bindable(), - password = $bindable(), - legend = 'sign in', - disabled, - onsubmit - } = $props(); + let { legend = 'sign in', logIn = async (username, password) => {} } = $props(); + + let username = $state(); + let password = $state(); + let disabled = $state(false); + + async function onsubmit(event) { + event.preventDefault(); + disabled = true; + try { + await logIn(username, password); + event.target.reset(); + } finally { + disabled = false; + } + } </script> <div> <form class="form" {onsubmit}> - <label for="username"> + <label> username <input name="username" type="text" placeholder="username" bind:value={username} {disabled} /> </label> - <label for="password"> + <label> password <input name="password" diff --git a/ui/lib/components/LogOut.svelte b/ui/lib/components/LogOut.svelte index 1cb8fb5..bb24681 100644 --- a/ui/lib/components/LogOut.svelte +++ b/ui/lib/components/LogOut.svelte @@ -1,15 +1,9 @@ <script> - import { goto } from '$app/navigation'; - import { logOut } from '$lib/apiServer.js'; - import { currentUser } from '$lib/store'; + let { logOut = async () => {} } = $props(); async function onsubmit(event) { event.preventDefault(); - const response = await logOut(); - if (200 <= response.status && response.status < 300) { - currentUser.set(null); - await goto('/login'); - } + await logOut(); } </script> diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index 1b1598b..dacd900 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -1,16 +1,15 @@ <script> import { DateTime } from 'luxon'; - import { deleteMessage } from '$lib/apiServer'; function scroll(message) { message.scrollIntoView(); } - let { id, at, body, renderedBody, editable = false } = $props(); + let { id, at, body, renderedBody, editable = false, deleteMessage = async (id) => {} } = $props(); let deleteArmed = $state(false); let atFormatted = $derived(at.toLocaleString(DateTime.DATETIME_SHORT)); - function onDelete(event) { + function ondelete(event) { event.preventDefault(); if (deleteArmed) { deleteArmed = false; @@ -29,7 +28,7 @@ <div class="handle"> {atFormatted} {#if editable} - <button onclick={onDelete}>🗑️</button> + <button onclick={ondelete}>🗑️</button> {/if} </div> <section use:scroll class="message-body"> diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte index 1eb1d7b..69a8298 100644 --- a/ui/lib/components/MessageInput.svelte +++ b/ui/lib/components/MessageInput.svelte @@ -1,29 +1,30 @@ <script> - import { postToChannel } from '$lib/apiServer'; + let { sendMessage = async (message) => {} } = $props(); - let { channel } = $props(); - - let form; let value = $state(''); let disabled = $state(false); - async function onSubmit(event) { + async function onsubmit(event) { event.preventDefault(); disabled = true; - await postToChannel(channel, value); - form.reset(); - disabled = false; + try { + await sendMessage(value); + event.target.reset(); + } finally { + disabled = false; + } } - function onKeyDown(event) { + function onkeydown(event) { let modifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey; if (!modifier && event.key === 'Enter') { - onSubmit(event); + event.preventDefault(); + event.target.form.requestSubmit(); } } </script> -<form bind:this={form} onsubmit={onSubmit}> - <textarea onkeydown={onKeyDown} bind:value {disabled} placeholder="Say something..."></textarea> +<form {onsubmit}> + <textarea {onkeydown} bind:value {disabled} placeholder="Say something..."></textarea> <button type="submit">»</button> </form> diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte index bee64e8..f1facd3 100644 --- a/ui/lib/components/MessageRun.svelte +++ b/ui/lib/components/MessageRun.svelte @@ -1,18 +1,14 @@ <script> - import { logins, currentUser } from '$lib/store'; import Message from '$lib/components/Message.svelte'; - let { sender, messages } = $props(); - - let name = $derived($logins.get(sender)); - let ownMessage = $derived($currentUser !== null && $currentUser.id == sender); + let { sender, messages, ownMessage, deleteMessage = async (id) => {} } = $props(); </script> <div class="message-run" class:own-message={ownMessage} class:other-message={!ownMessage}> <span class="username"> - @{name}: + @{sender}: </span> {#each messages as message} - <Message {...message} editable={ownMessage} /> + <Message {...message} editable={ownMessage} {deleteMessage} /> {/each} </div> diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 6339abd..02f7d19 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -5,7 +5,7 @@ import { getContext, onDestroy, onMount } from 'svelte'; import TinyGesture from 'tinygesture'; - import { boot, subscribeToEvents } from '$lib/apiServer'; + import * as api from '$lib/apiServer.js'; import { channelsList, currentUser, logins, messages, onEvent } from '$lib/store'; import ChannelList from '$lib/components/ChannelList.svelte'; @@ -58,20 +58,20 @@ return; } gesture = new TinyGesture(window); - gesture.on('swiperight', (event) => { + gesture.on('swiperight', () => { pageContext.showMenu = true; }); - gesture.on('swipeleft', (event) => { + gesture.on('swipeleft', () => { pageContext.showMenu = false; }); } onMount(async () => { - let response = await boot(); + let response = await api.boot(); switch (response.status) { case 200: onBooted(response.data); - events = subscribeToEvents(response.data.resume_point); + events = api.subscribeToEvents(response.data.resume_point); events.onmessage = onEvent.fromMessage; break; case 401: @@ -109,6 +109,10 @@ event.returnValue = ''; return ''; } + + async function createChannel(name) { + await api.createChannel(name); + } </script> <svelte:window {onbeforeunload} /> @@ -125,7 +129,7 @@ <nav id="sidebar" data-expanded={pageContext.showMenu}> <ChannelList active={channel} channels={enrichedChannels} /> <div class="create-channel"> - <CreateChannelForm /> + <CreateChannelForm {createChannel} /> </div> </nav> <main> diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 84cb0ae..8de9859 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -3,10 +3,22 @@ import { page } from '$app/state'; import ActiveChannel from '$lib/components/ActiveChannel.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; - import { channelsList, messages } from '$lib/store'; + import { channelsList, currentUser, logins, messages } from '$lib/store'; + import * as api from '$lib/apiServer'; let channel = $derived(page.params.channel); - let messageRuns = $derived($messages.inChannel(channel)); + let messageRuns = $derived( + $messages.inChannel(channel).map(({ sender, messages }) => { + let senderName = $derived($logins.get(sender)); + let ownMessage = $derived($currentUser !== null && $currentUser.id === sender); + + return { + sender: senderName, + ownMessage, + messages + }; + }) + ); let activeChannel; function inView(parentElement, element) { @@ -63,13 +75,21 @@ clearTimeout(lastReadCallback); // Fine if lastReadCallback is null still. lastReadCallback = setTimeout(setLastRead, 2 * 1000); } + + async function sendMessage(message) { + await api.postToChannel(channel, message); + } + + async function deleteMessage(id) { + await api.deleteMessage(id); + } </script> <svelte:window onkeydown={handleKeydown} /> <div class="active-channel" {onscroll} bind:this={activeChannel}> - <ActiveChannel {messageRuns} /> + <ActiveChannel {messageRuns} {deleteMessage} /> </div> <div class="create-message"> - <MessageInput {channel} /> + <MessageInput {sendMessage} /> </div> diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte index 14a9db8..ab214e9 100644 --- a/ui/routes/(app)/me/+page.svelte +++ b/ui/routes/(app)/me/+page.svelte @@ -2,10 +2,35 @@ import LogOut from '$lib/components/LogOut.svelte'; import Invites from '$lib/components/Invites.svelte'; import ChangePassword from '$lib/components/ChangePassword.svelte'; + + import { goto } from '$app/navigation'; + import * as api from '$lib/apiServer.js'; + import { currentUser } from '$lib/store'; + + let invites = $state([]); + + async function logOut() { + const response = await api.logOut(); + if (200 <= response.status && response.status < 300) { + currentUser.set(null); + await goto('/login'); + } + } + + async function changePassword(currentPassword, newPassword) { + await api.changePassword(currentPassword, newPassword); + } + + async function createInvite() { + let response = await api.createInvite(); + if (response.status === 200) { + invites.push(response.data); + } + } </script> -<ChangePassword /> +<ChangePassword {changePassword} /> <hr /> -<Invites /> +<Invites {invites} {createInvite} /> <hr /> -<LogOut /> +<LogOut {logOut} /> diff --git a/ui/routes/(login)/invite/[invite]/+page.svelte b/ui/routes/(login)/invite/[invite]/+page.svelte index 0c01286..04341e5 100644 --- a/ui/routes/(login)/invite/[invite]/+page.svelte +++ b/ui/routes/(login)/invite/[invite]/+page.svelte @@ -1,26 +1,16 @@ <script> import { goto } from '$app/navigation'; - import { acceptInvite } from '$lib/apiServer'; + import * as api from '$lib/apiServer'; import LogIn from '$lib/components/LogIn.svelte'; let { data } = $props(); - let username = $state(''), - password = $state(''); - let pending = false; - let disabled = $derived(pending); - - async function onSubmit(event, inviteId) { - event.preventDefault(); - pending = true; - const response = await acceptInvite(inviteId, username, password); + async function acceptInvite(inviteId, username, password) { + const response = await api.acceptInvite(inviteId, username, password); if (200 <= response.status && response.status < 300) { - username = ''; - password = ''; await goto('/'); } - pending = false; } </script> @@ -32,5 +22,5 @@ <div class="invite-text"> <p>Hi there! {invite.issuer} invites you to the conversation.</p> </div> - <LogIn {disabled} bind:username bind:password onsubmit={(event) => onSubmit(event, invite.id)} /> + <LogIn logIn={async (username, password) => acceptInvite(invite.id, username, password)} /> {/await} diff --git a/ui/routes/(login)/login/+page.svelte b/ui/routes/(login)/login/+page.svelte index 9157cef..b1f7cf2 100644 --- a/ui/routes/(login)/login/+page.svelte +++ b/ui/routes/(login)/login/+page.svelte @@ -1,25 +1,15 @@ <script> import { goto } from '$app/navigation'; - import { logIn } from '$lib/apiServer'; + import * as api from '$lib/apiServer'; import LogIn from '$lib/components/LogIn.svelte'; - let username = '', - password = ''; - let pending = false; - $: disabled = pending; - - async function onSubmit(event) { - event.preventDefault(); - pending = true; - const response = await logIn(username, password); + async function logIn(username, password) { + const response = await api.logIn(username, password); if (200 <= response.status && response.status < 300) { - username = ''; - password = ''; await goto('/'); } - pending = false; } </script> -<LogIn {disabled} bind:username bind:password onsubmit={onSubmit} /> +<LogIn {logIn} /> diff --git a/ui/routes/(login)/setup/+page.svelte b/ui/routes/(login)/setup/+page.svelte index c63f198..0b5a824 100644 --- a/ui/routes/(login)/setup/+page.svelte +++ b/ui/routes/(login)/setup/+page.svelte @@ -1,25 +1,15 @@ <script> import { goto } from '$app/navigation'; - import { setup } from '$lib/apiServer'; + import * as api from '$lib/apiServer'; import LogIn from '$lib/components/LogIn.svelte'; - let username = $state(''), - password = $state(''); - let pending = false; - let disabled = $derived(pending); - - async function onSubmit(event) { - event.preventDefault(); - pending = true; - const response = await setup(username, password); + async function logIn(username, password) { + const response = await api.setup(username, password); if (200 <= response.status && response.status < 300) { - username = ''; - password = ''; await goto('/'); } - pending = false; } </script> -<LogIn {disabled} bind:username bind:password legend="set up" onsubmit={onSubmit} /> +<LogIn legend="set up" {logIn} /> diff --git a/ui/tests/lib/components/ActiveChannel.svelte.test.js b/ui/tests/lib/components/ActiveChannel.svelte.test.js deleted file mode 100644 index 183c823..0000000 --- a/ui/tests/lib/components/ActiveChannel.svelte.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import ActiveChannel from '$lib/components/ActiveChannel.svelte'; - -let component; - -describe('ActiveChannel', () => { - beforeEach(() => { - component = mount(ActiveChannel, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); - }); - - afterEach(() => { - unmount(component); - }); - - test('mounts', async () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/ui/tests/lib/components/ChangePassword.svelte.test.js b/ui/tests/lib/components/ChangePassword.svelte.test.js index 9eb40f5..9db6974 100644 --- a/ui/tests/lib/components/ChangePassword.svelte.test.js +++ b/ui/tests/lib/components/ChangePassword.svelte.test.js @@ -1,52 +1,66 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, describe, it, vi } from 'vitest'; import ChangePassword from '$lib/components/ChangePassword.svelte'; -import axios from 'axios'; -let component; +const user = userEvent.setup(); -let mocks = vi.hoisted(() => ({ - post: vi.fn() +const mocks = vi.hoisted(() => ({ + changePassword: vi.fn() })); -describe('ChangePassword', () => { - beforeEach(() => { - vi.mock('axios', async (importActual) => { - const actual = await importActual(); - - const mockAxios = { - default: { - ...actual.default, - create: vi.fn(() => ({ - ...actual.default.create(), - post: mocks.post - })) - } - }; - - return mockAxios; +describe('ChangePassword', async () => { + beforeEach(async () => { + render(ChangePassword, { + changePassword: mocks.changePassword }); - mocks.post.mockResolvedValue({ status: 200 }); + }); - component = mount(ChangePassword, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); + it('submits valid password changes', async () => { + const oldPassword = screen.getByLabelText('current password'); + await user.type(oldPassword, 'old password'); + + const newPassword = screen.getByLabelText('new password'); + await user.type(newPassword, 'new password'); + + const confirmPassword = screen.getByLabelText('confirm new password'); + await user.type(confirmPassword, 'new password'); + + const create = screen.getByRole('button'); + await user.click(create); + + expect(mocks.changePassword).toHaveBeenCalledExactlyOnceWith('old password', 'new password'); }); - afterEach(() => { - unmount(component); + it('is disabled when old password matches new password', async () => { + const oldPassword = screen.getByLabelText('new password'); + await user.type(oldPassword, 'old password'); + + const newPassword = screen.getByLabelText('new password'); + await user.type(newPassword, 'new password'); + + const confirmPassword = screen.getByLabelText('confirm new password'); + await user.type(confirmPassword, 'new password'); + + const create = screen.getByRole('button'); + await user.click(create); + + expect(mocks.changePassword).not.toHaveBeenCalled(); }); - test('onsubmit happy path', async () => { - // Set value in all three inputs at once: - const inputs = document.body.querySelectorAll('input[type=password]'); - inputs.value = 'pass'; - // Click the button, then flush the changes so you can synchronously write expectations - document.body.querySelector('button[type=submit]').click(); - flushSync(); + it('is disabled when new passwords differ', async () => { + const oldPassword = screen.getByLabelText('new password'); + await user.type(oldPassword, 'old password'); + + const newPassword = screen.getByLabelText('new password'); + await user.type(newPassword, 'new password A'); + + const confirmPassword = screen.getByLabelText('confirm new password'); + await user.type(confirmPassword, 'new password B'); + + const create = screen.getByRole('button'); + await user.click(create); - // expect(axios.post).toHaveBeenCalledWith('/password', { password: 'pass', to: 'pass' }); - expect(Array.from(inputs.values()).map((i) => i.value)).toEqual(['', '', '']); + expect(mocks.changePassword).not.toHaveBeenCalled(); }); }); diff --git a/ui/tests/lib/components/Channel.svelte.test.js b/ui/tests/lib/components/Channel.svelte.test.js deleted file mode 100644 index a6fdab9..0000000 --- a/ui/tests/lib/components/Channel.svelte.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import Channel from '$lib/components/Channel.svelte'; - -let component; - -describe('Channel', () => { - beforeEach(() => { - component = mount(Channel, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); - }); - - afterEach(() => { - unmount(component); - }); - - test('mounts', async () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/ui/tests/lib/components/ChannelList.svelte.test.js b/ui/tests/lib/components/ChannelList.svelte.test.js deleted file mode 100644 index 7b5ae4a..0000000 --- a/ui/tests/lib/components/ChannelList.svelte.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import ChannelList from '$lib/components/ChannelList.svelte'; - -let component; - -describe('ChannelList', () => { - beforeEach(() => { - component = mount(ChannelList, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); - }); - - afterEach(() => { - unmount(component); - }); - - test('mounts', async () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/ui/tests/lib/components/CreateChannelForm.svelte.test.js b/ui/tests/lib/components/CreateChannelForm.svelte.test.js index a4a4695..15d3cfd 100644 --- a/ui/tests/lib/components/CreateChannelForm.svelte.test.js +++ b/ui/tests/lib/components/CreateChannelForm.svelte.test.js @@ -1,52 +1,37 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, describe, it, vi } from 'vitest'; import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; -import axios from 'axios'; -let component; +const user = userEvent.setup(); -let mocks = vi.hoisted(() => ({ - post: vi.fn() +const mocks = vi.hoisted(() => ({ + createChannel: vi.fn() })); -describe('CreateChannelForm', () => { - beforeEach(() => { - vi.mock('axios', async (importActual) => { - const actual = await importActual(); - - const mockAxios = { - default: { - ...actual.default, - create: vi.fn(() => ({ - ...actual.default.create(), - post: mocks.post - })) - } - }; - - return mockAxios; +describe('CreateChannelForm', async () => { + beforeEach(async () => { + render(CreateChannelForm, { + createChannel: mocks.createChannel }); - mocks.post.mockResolvedValue({ status: 200 }); - - component = mount(CreateChannelForm, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); }); - afterEach(() => { - unmount(component); - }); + describe('creates channels', async () => { + it('with a non-empty name', async () => { + const input = screen.getByRole('textbox'); + await user.type(input, 'channel name'); - test('onsubmit happy path', async () => { - // Set value on the one input this should match: - const inputs = document.body.querySelectorAll('input[type=text]'); - inputs.value = 'channel name'; - // Click the button, then flush the changes so you can synchronously write expectations - document.body.querySelector('button[type=submit]').click(); - flushSync(); + const create = screen.getByRole('button'); + await user.click(create); - expect(mocks.post).toHaveBeenCalled(); - expect(Array.from(inputs.values()).map((i) => i.value)).toEqual(['']); + expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('channel name'); + }); + + it('with an empty name', async () => { + const create = screen.getByRole('button'); + await user.click(create); + + expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith(''); + }); }); }); diff --git a/ui/tests/lib/components/LogIn.svelte.test.js b/ui/tests/lib/components/LogIn.svelte.test.js index b64d846..ab77c11 100644 --- a/ui/tests/lib/components/LogIn.svelte.test.js +++ b/ui/tests/lib/components/LogIn.svelte.test.js @@ -1,22 +1,34 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, describe, it, vi } from 'vitest'; import LogIn from '$lib/components/LogIn.svelte'; -let component; +const user = userEvent.setup(); -describe('LogIn', () => { - beforeEach(() => { - component = mount(LogIn, { - target: document.body // `document` exists because of jsdom +const mocks = vi.hoisted(() => ({ + logIn: vi.fn() +})); + +describe('LogIn', async () => { + beforeEach(async () => { + render(LogIn, { + logIn: mocks.logIn }); - flushSync(); }); - afterEach(() => { - unmount(component); - }); + it('sends a login request', async () => { + const username = screen.getByLabelText('username'); + await user.type(username, 'my username'); + + const password = screen.getByLabelText('password'); + await user.type(password, 'my very creative and long password'); + + const signIn = screen.getByRole('button'); + await user.click(signIn); - test('mounts', async () => { - expect(component).toBeTruthy(); + expect(mocks.logIn).toHaveBeenCalledExactlyOnceWith( + 'my username', + 'my very creative and long password' + ); }); }); diff --git a/ui/tests/lib/components/MessageInput.svelte.test.js b/ui/tests/lib/components/MessageInput.svelte.test.js index 3dde5d7..508fb43 100644 --- a/ui/tests/lib/components/MessageInput.svelte.test.js +++ b/ui/tests/lib/components/MessageInput.svelte.test.js @@ -1,48 +1,37 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, describe, it, vi } from 'vitest'; import MessageInput from '$lib/components/MessageInput.svelte'; -import axios from 'axios'; -let component; +const user = userEvent.setup(); -let mocks = vi.hoisted(() => ({ - post: vi.fn() +const mocks = vi.hoisted(() => ({ + sendMessage: vi.fn() })); -describe('MessageInput', () => { - beforeEach(() => { - vi.mock('axios', async (importActual) => { - const actual = await importActual(); - - const mockAxios = { - default: { - ...actual.default, - create: vi.fn(() => ({ - ...actual.default.create(), - post: mocks.post - })) - } - }; - - return mockAxios; +describe('CreateChannelForm', async () => { + beforeEach(async () => { + render(MessageInput, { + sendMessage: mocks.sendMessage }); - mocks.post.mockResolvedValue({ status: 200 }); - - component = mount(MessageInput, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); }); - afterEach(() => { - unmount(component); - }); + describe('sends a message', async () => { + it('with non-empty content', async () => { + const input = screen.getByRole('textbox'); + await user.type(input, 'a happy surprise'); - test('onsubmit happy path', async () => { - // Click the button, then flush the changes so you can synchronously write expectations - document.body.querySelector('button[type=submit]').click(); - flushSync(); + const send = screen.getByRole('button'); + await user.click(send); - expect(mocks.post).toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledExactlyOnceWith('a happy surprise'); + }); + + it('with empty content', async () => { + const send = screen.getByRole('button'); + await user.click(send); + + expect(mocks.sendMessage).toHaveBeenCalledExactlyOnceWith(''); + }); }); }); diff --git a/ui/tests/lib/components/MessageRun.svelte.test.js b/ui/tests/lib/components/MessageRun.svelte.test.js deleted file mode 100644 index 7671c52..0000000 --- a/ui/tests/lib/components/MessageRun.svelte.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import MessageRun from '$lib/components/MessageRun.svelte'; - -let component; - -describe('MessageRun', () => { - beforeEach(() => { - component = mount(MessageRun, { - target: document.body // `document` exists because of jsdom - }); - flushSync(); - }); - - afterEach(() => { - unmount(component); - }); - - test('mounts', async () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/vite.config.js b/vite.config.js index e74647a..4bd448f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,14 +1,16 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; import { configDefaults } from 'vitest/config' +import { svelteTesting } from '@testing-library/svelte/vite' export default defineConfig({ - plugins: [sveltekit()], + plugins: [sveltekit(), svelteTesting()], test: { // If you are testing components client-side, you need to setup a DOM environment. // If not all your files should have this environment, you can use a // `// @vitest-environment jsdom` comment at the top of the test files instead. environment: 'jsdom', + restoreMocks: true, coverage: { thresholds: { statements: 49, @@ -26,7 +28,7 @@ export default defineConfig({ }, // Tell Vitest to use the `browser` entry points in `package.json` files, // even though it's running in Node - resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined, + resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined, server: { fs: { allow: [ |
