diff --git a/ts/packages/web-console/README.md b/ts/packages/web-console/README.md index ea964652..994d6c29 100644 --- a/ts/packages/web-console/README.md +++ b/ts/packages/web-console/README.md @@ -8,8 +8,11 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c - **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes. - **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for. - **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark. -- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning. -- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button. +- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`). +- **Naming**: a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself (always shown as the peer's own claim, in italics under "calls itself" with the short id beside it, since a stranger chooses the name and two peers can claim the same one; flagged when it copies a petname this viewer holds), else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way. +- **Trust**: an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently, dialled with only the presented certificates that are already remembered. A connected node that announces certificates unlike the remembered ones is reported and then pinned: the announcement arrived on a session this console had already pinned, so it is applied, with a dismissable notice. Each discovered-peer row names the connection whose directory gossiped it, and the device the gossip claimed for that connection's address is labelled as a claim, since nothing on the connection authenticates it. +- **Activity and health**: the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when each peer advertised itself, by its own clock. +- **First run and search**: a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to. ## How it maps onto core's ports diff --git a/ts/packages/web-console/src/App.css.ts b/ts/packages/web-console/src/App.css.ts index eed59363..94646cf9 100644 --- a/ts/packages/web-console/src/App.css.ts +++ b/ts/packages/web-console/src/App.css.ts @@ -1,8 +1,58 @@ -// The one piece of layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer directory and frame log tables don't stretch edge-to-edge on a desktop monitor. +// The layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer tables don't stretch edge-to-edge on a desktop monitor, and turning a peer table into a stack of labelled cards on a phone. -import { style } from "@vanilla-extract/css"; +import { globalStyle, style } from "@vanilla-extract/css"; export const appShell = style({ maxWidth: "64rem", marginInline: "auto", + // A long address or device-id with no break opportunity would otherwise widen the page past a phone's viewport. + overflowWrap: "anywhere", +}); + +/** Mantine's `xs` breakpoint (the same 36em vite.config.ts hands the PostCSS pipeline), below which a peer table no longer fits a row of columns. */ +const PHONE_MEDIA = "screen and (max-width: 36em)"; + +/** A table whose rows become cards on a phone: the header row is hidden and each cell shows its column's name (its `data-label`) above its value. Use it through `StackedTable`, which also states each element's ARIA role, because changing `display` on table elements makes Safari drop their table semantics. */ +export const stackedTable = style({}); + +globalStyle(`${stackedTable} thead`, { + "@media": { + [PHONE_MEDIA]: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + clip: "rect(0 0 0 0)", + }, + }, +}); + +globalStyle(`${stackedTable} tr`, { + "@media": { + [PHONE_MEDIA]: { + display: "block", + paddingBlock: "0.5rem", + }, + }, +}); + +globalStyle(`${stackedTable} td`, { + "@media": { + [PHONE_MEDIA]: { + display: "block", + border: "none", + paddingBlock: "0.125rem", + }, + }, +}); + +globalStyle(`${stackedTable} td::before`, { + "@media": { + [PHONE_MEDIA]: { + content: "attr(data-label)", + display: "block", + fontSize: "0.75rem", + opacity: 0.7, + }, + }, }); diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index bb5bfb55..da3c83a0 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -40,6 +40,10 @@ import { useCertificateTrust } from "./hooks/use-certificate-trust.js"; import { PeerNamesProvider } from "./components/PeerNamesProvider.js"; import { PeerName } from "./components/PeerName.js"; import { SelfNameField } from "./components/SelfNameField.js"; +import { OnboardingIntro } from "./components/OnboardingIntro.js"; +import { SearchPanel } from "./components/SearchPanel.js"; +import { useIntro } from "./hooks/use-intro.js"; +import type { PreferencesStore } from "./preferences-store.js"; import type { NameStore } from "./name-store.js"; import { ConversationList } from "./components/ConversationList.js"; import { RoomPanel } from "./components/RoomPanel.js"; @@ -60,6 +64,8 @@ export interface AppProps { certificateMemory: CertificateMemory; /** The petnames this viewer has given peers and this console's own display name, kept in the console's own storage. */ nameStore: NameStore; + /** Interface preferences kept in the console's own storage, such as whether the first-run intro has been dismissed. */ + preferences: PreferencesStore; /** Attempts same-device node auto-discovery once, on mount. Defaults to the real `discoverLocalNode` (a no-op when this console is served from a loopback origin, a real localhost probe otherwise); tests inject a fake to avoid depending on `location`/`fetch`. */ discoverLocalNode?: () => Promise; // Seeds the Node field. Left as a prop (rather than App reading location/import.meta.env itself) so App stays the plain, testable component its own header comment describes; main.tsx computes the real value via default-hub-address.ts. @@ -97,6 +103,7 @@ export function App({ messageStore, certificateMemory, nameStore, + preferences, discoverLocalNode = discoverLocalNodeDefault, defaultAddress = DEFAULT_ADDRESS, }: Readonly): React.JSX.Element { @@ -105,6 +112,7 @@ export function App({ const [connections, setConnections] = useState([]); const [discovered, setDiscovered] = useState([]); const [failures, setFailures] = useState([]); + const intro = useIntro(preferences); const trust = useCertificateTrust(certificateMemory, clock); const roomMessaging = useRoomMessaging(identity, clock, messageStore); const [selectedPath, setSelectedPath] = useState(); @@ -387,7 +395,23 @@ export function App({ return ( - wire-mesh console + + wire-mesh console + {intro.ready && !intro.visible && ( + + )} + + {intro.error !== undefined && ( + + {intro.error} + + )} + {intro.visible && }
@@ -463,6 +487,10 @@ export function App({ {failure.reason}. Messages go through the hub instead. ))} + ) : ( - - - - device - addresses - advertised - - - - - {directory.map((entry) => ( - - - - - {entry.advert.addresses.join(", ")} - - - - {formatAgo( - now - entry.advert["snapshot-seconds"] * MS_PER_SECOND, - )} - - - - - - - - ))} - -
+ ({ + key: deviceHex(entry.device), + cells: [ + , + entry.advert.addresses.join(", "), + + + {formatAgo( + now - entry.advert["snapshot-seconds"] * MS_PER_SECOND, + )} + + , + , + ], + }))} + /> )} diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index 413c1c38..cbb6e3a9 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -3,7 +3,6 @@ import { Badge, Group, NavLink, Stack, Text } from "@mantine/core"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; -import { labelText } from "../peer-names.js"; export interface ConversationListProps { conversations: readonly ConversationView[]; @@ -29,9 +28,7 @@ export function ConversationList({ - labelText(names.labelOf(hex)), - )} + label={participantLabel(conversation, names)} description={ conversation.status === "connected" ? undefined : "offline" } diff --git a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx index e553d4b2..91bf68d2 100644 --- a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx +++ b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx @@ -1,9 +1,12 @@ // A confirmation gate for gossip-driven peer discovery (wire-mesh#187): each row is one device this console's own gossip-expansion has surfaced from a connected node's directory but not yet dialled. Dialling a gossiped address unconditionally would be the same class of risk as a webpage probing a user's internal network (Private Network Access attacks) -- a lying peer in the directory could name an address that isn't a wire-mesh node at all -- so this console never auto-connects to one; every row waits here until this console's own user explicitly connects or dismisses it. -import { Button, Card, Group, Table, Text } from "@mantine/core"; +import { Fragment } from "react"; +import { Button, Card, Group, Text } from "@mantine/core"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceHex } from "./ConnectionPanel.js"; import { PeerLabel } from "./PeerLabel.js"; +import { StackedTable } from "./StackedTable.js"; +import type { StackedColumn } from "./StackedTable.js"; /** Where a discovered peer was learned of: the connection whose directory gossiped it, and, when that connection was itself reached by gossip, the device the gossip claimed lives at its address. */ export interface DiscoveredVia { @@ -20,6 +23,13 @@ export interface DiscoveredPeerRow { via: DiscoveredVia; } +const COLUMNS: readonly StackedColumn[] = [ + { label: "device" }, + { label: "addresses" }, + { label: "gossiped by" }, + { label: "actions", headerless: true }, +]; + export interface DiscoveredPeersPanelProps { peers: readonly DiscoveredPeerRow[]; onConnect: (key: string) => void; @@ -44,61 +54,49 @@ export function DiscoveredPeersPanel({ to, which is the only thing vouching for it: the address it lists may not be a wire-mesh node at all. Nothing is dialled until you connect. - - - - device - addresses - gossiped by - - - - - {peers.map((peer) => ( - - - - - {peer.addresses.join(", ")} - - {peer.via.claimedDevice !== undefined && ( - <> - - claimed to be - - - - )} - - over {peer.via.address} - - - - - - - - - - ))} - -
+ ({ + key: peer.key, + cells: [ + , + peer.addresses.join(", "), + + {peer.via.claimedDevice !== undefined && ( + <> + + claimed to be + + + + )} + + over {peer.via.address} + + , + + + + , + ], + }))} + /> ); } diff --git a/ts/packages/web-console/src/components/OnboardingIntro.tsx b/ts/packages/web-console/src/components/OnboardingIntro.tsx new file mode 100644 index 00000000..613d6d38 --- /dev/null +++ b/ts/packages/web-console/src/components/OnboardingIntro.tsx @@ -0,0 +1,61 @@ +// The first-run walkthrough: how to connect, who the directory shows, why a discovered peer waits for an explicit connect, and what works offline. Dismissing it is remembered by the caller; it can be brought back from the header. + +import { Button, Card, List, Text, Title } from "@mantine/core"; + +export interface OnboardingIntroProps { + onDismiss: () => void; +} + +export function OnboardingIntro({ + onDismiss, +}: Readonly): React.JSX.Element { + return ( + + + Welcome to the wire-mesh console + + + + + Connect. + {" "} + Enter a node's address in the Node field and press Connect. A + console served by a node starts with that node's address filled + in, so there is usually nothing to type. The status line shows the + connection coming up. + + + + See who is here. + {" "} + Once connected, the node's peer directory lists the devices it + knows. Message opens a conversation with one. Devices appear under the + name they chose for themselves, or a short id; use the pencil beside a + device to give it a name of your own, which only this console keeps. + + + + Discovered peers wait for you. + {" "} + A node you are connected to can tell you about other nodes. Nothing + vouches for those addresses except the node that listed them, and one + may not be a wire-mesh node at all, so the console never dials one by + itself. Each waits in the Discovered peers list until you press + Connect or dismiss it. + + + + Offline. + {" "} + The console itself loads without a network, and conversations are kept + on this device, so history stays readable. Sending needs a route to + the peer, through a node or directly; a message that could not be sent + stays with its reason until you retry or dismiss it. + + + + + ); +} diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index 5a722d7b..7a98b86f 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -4,7 +4,7 @@ import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; import { SubmitRow } from "web-ui-primitives"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; -import { labelText } from "../peer-names.js"; +import { PeerName } from "./PeerName.js"; import { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -42,7 +42,9 @@ export function RoomPanel({ return ( - {view.participants.join(", ")} + + {participantLabel(view, names)} + {routeLabel(view.via)} @@ -52,7 +54,8 @@ export function RoomPanel({ - {view.pendingJoinRequest.requesterHex} wants to message you + {" "} + wants to message you ] }, + { key: "b", cells: ["Grace", ] }, + ]} + /> + , + ); + return container; +} + +function rolesOf(container: HTMLElement, selector: string): (string | null)[] { + return [...container.querySelectorAll(selector)].map((element) => + element.getAttribute("role"), + ); +} + +describe("StackedTable", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("states every table role explicitly, since the phone layout changes display and Safari then drops the native ones", () => { + const container = renderTable(); + + expect(rolesOf(container, "thead, tbody")).toEqual([ + "rowgroup", + "rowgroup", + ]); + expect(rolesOf(container, "tr")).toEqual(["row", "row", "row"]); + expect(rolesOf(container, "th")).toEqual(["columnheader", "columnheader"]); + expect(rolesOf(container, "td")).toEqual(["cell", "cell", "cell", "cell"]); + }); + + it("labels each cell with its column, and gives a headerless column no header text", () => { + const container = renderTable(); + + expect( + [...container.querySelectorAll("tbody td")].map((cell) => + cell.getAttribute("data-label"), + ), + ).toEqual(["name", "actions", "name", "actions"]); + expect( + [...container.querySelectorAll("th")].map((cell) => cell.textContent), + ).toEqual(["name", ""]); + }); +}); diff --git a/ts/packages/web-console/test/conversations.unit.test.ts b/ts/packages/web-console/test/conversations.unit.test.ts index c7d2b59c..bc9d2a3a 100644 --- a/ts/packages/web-console/test/conversations.unit.test.ts +++ b/ts/packages/web-console/test/conversations.unit.test.ts @@ -18,7 +18,8 @@ import { type RelayRoute, } from "../src/conversations.js"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; -import { shortId } from "../src/peer-names.js"; +import { labelPeer } from "../src/peer-names.js"; +import type { PeerLabel } from "../src/peer-names.js"; import type { StoredMessage } from "../src/message-store.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -431,17 +432,33 @@ describe("participantsOf", () => { }); describe("participantLabel", () => { + const names = { + labelOf: (hex: string): PeerLabel => + labelPeer(hex, undefined, undefined, new Set()), + }; + it("shortens each participant's device-id and joins them", () => { expect( participantLabel( { roomPath: DM, participants: [PEER, OTHER_PEER] }, - shortId, + names, ), ).toBe("222222222222, 333333333333"); }); + it("writes a self-asserted name with the short id beside it", () => { + const claiming = { + labelOf: (hex: string): PeerLabel => + labelPeer(hex, undefined, "Support", new Set()), + }; + + expect( + participantLabel({ roomPath: DM, participants: [PEER] }, claiming), + ).toBe("Support (222222222222)"); + }); + it("falls back to the room path when nobody else is in the room", () => { - expect(participantLabel({ roomPath: DM, participants: [] }, shortId)).toBe( + expect(participantLabel({ roomPath: DM, participants: [] }, names)).toBe( DM, ); }); diff --git a/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts new file mode 100644 index 00000000..ecc1499a --- /dev/null +++ b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts @@ -0,0 +1,74 @@ +// A real-browser check of the first-run intro and the phone layout, against a real relay and two independent console instances: a device's intro appears once and stays dismissed after a reload, and on a phone-width viewport the peer directory, which has a real row because a second console is connected, stacks into a card that fits the screen, where the same table on a desktop-width viewport keeps its columns. + +import { type Page, chromium, expect, test } from "@playwright/test"; +import { RELAY_ADDRESS, VITE_PORT } from "../../playwright.config.js"; + +const PHONE_VIEWPORT = { width: 360, height: 640 }; +const DIRECTORY_TIMEOUT_MS = 15_000; +const TEST_TIMEOUT_MS = 60_000; + +/** Whether the page fits its viewport horizontally: a table too wide for a phone widens the document past it. */ +async function fitsViewportWidth(page: Readonly): Promise { + return page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ); +} + +/** How the browser lays out a directory cell: a table cell on a wide screen, a block once the row has stacked into a card. */ +async function directoryCellDisplay(page: Readonly): Promise { + return page + .locator('td[data-label="device"]') + .first() + .evaluate((cell) => getComputedStyle(cell).display); +} + +test("the intro shows once per device, and the directory stacks into cards on a phone screen", async () => { + test.setTimeout(TEST_TIMEOUT_MS); + const appUrl = `http://localhost:${String(VITE_PORT)}/`; + const phoneBrowser = await chromium.launch(); + const peerBrowser = await chromium.launch(); + try { + const phone = await ( + await phoneBrowser.newContext({ viewport: PHONE_VIEWPORT }) + ).newPage(); + phone.on("pageerror", (error) => { + throw error; + }); + const peer = await (await peerBrowser.newContext()).newPage(); + peer.on("pageerror", (error) => { + throw error; + }); + + await phone.goto(appUrl); + await expect(phone.getByTestId("onboarding-intro")).toBeVisible(); + await phone.getByRole("button", { name: "Got it" }).click(); + await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); + await phone.reload(); + await expect( + phone.getByRole("button", { name: "How this works" }), + ).toBeVisible(); + await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); + + await peer.goto(appUrl); + for (const page of [phone, peer]) { + await page.getByLabel("Node").fill(RELAY_ADDRESS); + await page.getByRole("button", { name: "Connect" }).click(); + } + + const messageButton = phone.getByRole("button", { name: "Message" }); + await expect(messageButton).toBeVisible({ timeout: DIRECTORY_TIMEOUT_MS }); + expect(await fitsViewportWidth(phone)).toBe(true); + expect(await directoryCellDisplay(phone)).toBe("block"); + await expect(peer.getByRole("button", { name: "Message" })).toBeVisible({ + timeout: DIRECTORY_TIMEOUT_MS, + }); + expect(await directoryCellDisplay(peer)).toBe("table-cell"); + const box = await messageButton.boundingBox(); + expect(box).not.toBeNull(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual( + PHONE_VIEWPORT.width, + ); + } finally { + await Promise.all([phoneBrowser.close(), peerBrowser.close()]); + } +}); diff --git a/ts/packages/web-console/test/names-harness.tsx b/ts/packages/web-console/test/names-harness.tsx index cc5c046f..c60664a6 100644 --- a/ts/packages/web-console/test/names-harness.tsx +++ b/ts/packages/web-console/test/names-harness.tsx @@ -1,9 +1,14 @@ // Wraps a component in the naming context over an in-memory name store, the way App does for the real tree. +import { useEffect } from "react"; import { MantineProvider } from "@mantine/core"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { PeerNamesProvider } from "../src/components/PeerNamesProvider.js"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; import { createNameStore } from "../src/name-store.js"; +import { selfNameExtension } from "../src/peer-names.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; import type { NameStore } from "../src/name-store.js"; export function memoryNameStore(): NameStore { @@ -23,3 +28,27 @@ export function WithNames({ ); } + +/** Feeds the naming context a directory in which `deviceHex` asserts `selfName` about itself, the way a connection panel does with a connected node's directory. */ +export function AssertsSelfName({ + deviceHex, + selfName, +}: Readonly<{ deviceHex: string; selfName: string }>): null { + const { observeDirectory } = usePeerNames(); + useEffect(() => { + const device = deviceIdFromHex(deviceHex); + observeDirectory([ + { + device, + advert: { + device, + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...selfNameExtension(selfName), + }, + }, + ]); + }, [deviceHex, selfName, observeDirectory]); + return null; +} diff --git a/ts/packages/web-console/test/preferences-store.unit.test.ts b/ts/packages/web-console/test/preferences-store.unit.test.ts new file mode 100644 index 00000000..a9aa734d --- /dev/null +++ b/ts/packages/web-console/test/preferences-store.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createPreferencesStore } from "../src/preferences-store.js"; + +describe("preferences store", () => { + it("has not dismissed the intro on a fresh device", async () => { + expect( + await createPreferencesStore(createMemoryStorage()).introDismissed(), + ).toBe(false); + }); + + it("remembers a dismissal across store instances over the same storage, and forgets it when reset", async () => { + const storage = createMemoryStorage(); + await createPreferencesStore(storage).setIntroDismissed(true); + + expect(await createPreferencesStore(storage).introDismissed()).toBe(true); + + await createPreferencesStore(storage).setIntroDismissed(false); + + expect(await createPreferencesStore(storage).introDismissed()).toBe(false); + }); +}); diff --git a/ts/packages/web-console/test/search.unit.test.ts b/ts/packages/web-console/test/search.unit.test.ts new file mode 100644 index 00000000..41a0c176 --- /dev/null +++ b/ts/packages/web-console/test/search.unit.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import type { ConversationView } from "../src/conversations.js"; +import type { StoredMessage } from "../src/message-store.js"; +import { deviceIdFromFillHex } from "./hex.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; +import { + MAX_SEARCH_RESULTS, + queryTerms, + searchConversations, +} from "../src/search.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const OLDEST = 10; +const MIDDLE = 20; +const NEWEST = 30; +const THIRD_MESSAGE = 3; + +function message( + text: string, + sentAt: number, + direction: StoredMessage["direction"] = "received", +): StoredMessage { + return { direction, text, messageId: new Uint8Array([sentAt]), sentAt }; +} + +function conversation( + digit: string, + overrides: Partial = {}, +): ConversationView { + const peer = digit.repeat(DEVICE_ID_HEX_LENGTH); + return { + roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${peer}`, + participants: [peer], + status: "connected", + via: "direct", + messages: [], + notices: [], + outgoing: [], + pendingJoinRequest: undefined, + unread: 0, + ...overrides, + }; +} + +/** A verified notice entry whose claims are well-formed but carry filler for everything the search does not read. */ +function notice( + text: string, + postedAt: number, +): ConversationView["notices"][number] { + const content = new TextEncoder().encode(text); + return { + verified: true, + contentType: "text/plain", + plaintext: content, + claims: { + room: conversation("2").roomPath, + poster: deviceIdFromFillHex("33"), + "poster-key": syntheticAdvertProof()["identity-key"], + token: [new Uint8Array(), {}, null, new Uint8Array()], + "notice-id": new Uint8Array(), + "posted-at": postedAt, + "content-type": "text/plain", + content, + }, + }; +} + +describe("queryTerms", () => { + it("lowercases and splits on whitespace, dropping blanks", () => { + expect(queryTerms(" Hello WORLD ")).toEqual(["hello", "world"]); + expect(queryTerms(" ")).toEqual([]); + }); +}); + +describe("searchConversations", () => { + it("finds messages containing every term, in any case and order", () => { + const results = searchConversations( + [ + conversation("2", { + messages: [ + message("Lunch at the harbour", 1), + message("harbour closed", 2), + message("nothing here", THIRD_MESSAGE), + ], + }), + ], + "HARBOUR lunch", + ); + + expect(results.map((result) => result.text)).toEqual([ + "Lunch at the harbour", + ]); + }); + + it("searches messages and readable notices together, newest first, and says which is which", () => { + const first = conversation("2", { + messages: [message("deploy notes", OLDEST)], + notices: [notice("deploy window agreed", NEWEST)], + }); + const second = conversation("3", { + messages: [message("deploy done", MIDDLE)], + }); + + const results = searchConversations([first, second], "deploy"); + + expect( + results.map((result) => [result.kind, result.text, result.roomPath]), + ).toEqual([ + ["notice", "deploy window agreed", first.roomPath], + ["message", "deploy done", second.roomPath], + ["message", "deploy notes", first.roomPath], + ]); + }); + + it("reports whether a matching message was sent or received", () => { + const [sent] = searchConversations( + [conversation("2", { messages: [message("ping", 1, "sent")] })], + "ping", + ); + + expect(sent?.direction).toBe("sent"); + }); + + it("never matches a notice this console cannot decrypt, nor a blank query", () => { + const unreadable: ConversationView["notices"][number] = { verified: true }; + const conversations = [ + conversation("2", { + messages: [message("hello", 1)], + notices: [unreadable], + }), + ]; + + expect(searchConversations(conversations, "hello")).toHaveLength(1); + expect(searchConversations(conversations, " ")).toEqual([]); + }); + + it("gives each result a distinct key and caps how many it returns", () => { + const messages = Array.from( + { length: MAX_SEARCH_RESULTS + 1 }, + (_, index) => message("match", index), + ); + + const results = searchConversations( + [conversation("2", { messages })], + "match", + ); + + expect(results).toHaveLength(MAX_SEARCH_RESULTS); + expect(new Set(results.map((result) => result.key)).size).toBe( + MAX_SEARCH_RESULTS, + ); + }); +}); diff --git a/ts/packages/web-console/test/use-intro.unit.test.tsx b/ts/packages/web-console/test/use-intro.unit.test.tsx new file mode 100644 index 00000000..575be04b --- /dev/null +++ b/ts/packages/web-console/test/use-intro.unit.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { useIntro } from "../src/hooks/use-intro.js"; +import { + createPreferencesStore, + type PreferencesStore, +} from "../src/preferences-store.js"; + +const STORAGE_FAILURE = "the origin's storage quota is exhausted"; + +function store( + overrides: Readonly> = {}, +): PreferencesStore { + return { ...createPreferencesStore(createMemoryStorage()), ...overrides }; +} + +describe("useIntro", () => { + afterEach(() => { + cleanup(); + }); + + it("opens on a device that has never dismissed it, with nothing to report", async () => { + const { result } = renderHook(() => useIntro(store())); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + }); + expect(result.current.error).toBeUndefined(); + }); + + it("reports why the stored answer could not be read, and shows the intro as it would on a first run", async () => { + const failing = store({ + introDismissed: async () => Promise.reject(new Error(STORAGE_FAILURE)), + }); + const { result } = renderHook(() => useIntro(failing)); + + await waitFor(() => { + expect(result.current.error).toBe(STORAGE_FAILURE); + }); + expect(result.current.visible).toBe(true); + expect(result.current.ready).toBe(true); + }); + + it("reports a dismissal that could not be saved while still closing the intro for this session", async () => { + const failing = store({ + setIntroDismissed: async () => Promise.reject(new Error(STORAGE_FAILURE)), + }); + const { result } = renderHook(() => useIntro(failing)); + await waitFor(() => { + expect(result.current.visible).toBe(true); + }); + + act(() => { + result.current.dismiss(); + }); + + await waitFor(() => { + expect(result.current.error).toBe(STORAGE_FAILURE); + }); + expect(result.current.visible).toBe(false); + }); + + it("clears the report once a later save succeeds", async () => { + let failing = true; + const flaky = store({ + setIntroDismissed: async () => + failing + ? Promise.reject(new Error(STORAGE_FAILURE)) + : Promise.resolve(), + }); + const { result } = renderHook(() => useIntro(flaky)); + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + act(() => { + result.current.dismiss(); + }); + await waitFor(() => { + expect(result.current.error).toBe(STORAGE_FAILURE); + }); + + failing = false; + act(() => { + result.current.show(); + }); + + await waitFor(() => { + expect(result.current.error).toBeUndefined(); + }); + expect(result.current.visible).toBe(true); + }); +});