@remit/web-client 0.0.53 → 0.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The add/edit account form. The cases here are the ones where the form does
3
+ * something the user did not type: it refuses a new account without a
4
+ * password, it derives SMTP from IMAP rather than saving an account that
5
+ * cannot send (#196), it keeps a stored password when the field was never
6
+ * touched, and it fills the server fields from a provider preset.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { afterEach, beforeEach, describe, it } from "node:test";
11
+ import { createElement } from "react";
12
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
13
+ import { makeAccount } from "../../test-support/fixtures";
14
+ import {
15
+ type HttpCall,
16
+ type HttpMock,
17
+ mockFetch,
18
+ } from "../../test-support/http";
19
+ import { AccountFormPanel } from "./AccountFormPanel";
20
+
21
+ const account = makeAccount({
22
+ accountId: "acc-1",
23
+ email: "alice@example.com",
24
+ imapHost: "imap.example.com",
25
+ });
26
+
27
+ let harness: DomHarness | undefined;
28
+ let http: HttpMock;
29
+
30
+ beforeEach(() => {
31
+ http = mockFetch((call) =>
32
+ call.path === "/config"
33
+ ? { accounts: [account], mailboxes: [] }
34
+ : { accountId: "acc-new" },
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ harness?.close();
40
+ harness = undefined;
41
+ http.restore();
42
+ });
43
+
44
+ const mount = (
45
+ props: Partial<Parameters<typeof AccountFormPanel>[0]> = {},
46
+ ): DomHarness => {
47
+ harness = createDomHarness();
48
+ harness.renderApp(
49
+ createElement(AccountFormPanel, {
50
+ isOpen: true,
51
+ onClose: () => undefined,
52
+ ...props,
53
+ }),
54
+ );
55
+ return harness;
56
+ };
57
+
58
+ const field = (dom: DomHarness, id: string): HTMLInputElement => {
59
+ const input = dom.query<HTMLInputElement>(`#${id}`);
60
+ if (!input) throw new Error(`no field #${id}`);
61
+ return input;
62
+ };
63
+
64
+ const submit = async (dom: DomHarness): Promise<void> => {
65
+ const form = dom.query<HTMLFormElement>("#account-form");
66
+ if (!form) throw new Error("no account form");
67
+ dom.dispatch(form, new dom.window.Event("submit", { bubbles: true }));
68
+ await dom.flush();
69
+ await dom.flush();
70
+ };
71
+
72
+ const saved = (): HttpCall | undefined =>
73
+ http.calls.find((call) => call.method === "POST" || call.method === "PATCH");
74
+
75
+ const body = (call: HttpCall | undefined): Record<string, unknown> => {
76
+ assert.ok(call, "expected the form to reach the API");
77
+ assert.ok(call.body, "expected a request body");
78
+ return call.body;
79
+ };
80
+
81
+ describe("AccountFormPanel — adding an account", () => {
82
+ it("refuses to save without a password and says so, rather than posting", async () => {
83
+ const dom = mount();
84
+ dom.type(field(dom, "account-email"), "alice@example.com");
85
+ dom.type(field(dom, "imap-host"), "imap.example.com");
86
+
87
+ await submit(dom);
88
+
89
+ assert.match(dom.text(), /Password is required/);
90
+ assert.equal(saved(), undefined);
91
+ });
92
+
93
+ it("reports a missing IMAP host in the same pass as the rest", async () => {
94
+ const dom = mount();
95
+ dom.type(field(dom, "account-email"), "alice@example.com");
96
+ dom.type(field(dom, "account-password"), "hunter2");
97
+
98
+ await submit(dom);
99
+
100
+ assert.match(dom.text(), /IMAP host is required/);
101
+ assert.equal(saved(), undefined);
102
+ });
103
+
104
+ it("derives SMTP from the IMAP host so the account can send (#196)", async () => {
105
+ const dom = mount();
106
+ dom.type(field(dom, "account-email"), "alice@example.com");
107
+ dom.type(field(dom, "account-password"), "hunter2");
108
+ dom.type(field(dom, "imap-host"), "imap.example.com");
109
+
110
+ await submit(dom);
111
+
112
+ const sent = body(saved());
113
+ assert.equal(sent.smtpHost, "smtp.example.com");
114
+ assert.equal(sent.smtpPort, 587);
115
+ assert.equal(sent.smtpStartTls, true);
116
+ assert.equal(sent.smtpTls, false);
117
+ });
118
+
119
+ it("never overrides an SMTP host the user typed", async () => {
120
+ const dom = mount();
121
+ dom.type(field(dom, "account-email"), "alice@example.com");
122
+ dom.type(field(dom, "account-password"), "hunter2");
123
+ dom.type(field(dom, "imap-host"), "imap.example.com");
124
+ dom.type(field(dom, "smtp-host"), "relay.example.net");
125
+
126
+ await submit(dom);
127
+
128
+ assert.equal(body(saved()).smtpHost, "relay.example.net");
129
+ });
130
+
131
+ it("sends no SMTP credentials unless the user asked for separate ones", async () => {
132
+ const dom = mount();
133
+ dom.type(field(dom, "account-email"), "alice@example.com");
134
+ dom.type(field(dom, "account-password"), "hunter2");
135
+ dom.type(field(dom, "imap-host"), "imap.example.com");
136
+
137
+ await submit(dom);
138
+
139
+ const sent = body(saved());
140
+ assert.equal(sent.smtpUsername, undefined);
141
+ assert.equal(sent.smtpPassword, undefined);
142
+ });
143
+
144
+ it("closes itself once the account is created", async () => {
145
+ let closed = 0;
146
+ const dom = mount({
147
+ onClose: () => {
148
+ closed += 1;
149
+ },
150
+ });
151
+ dom.type(field(dom, "account-email"), "alice@example.com");
152
+ dom.type(field(dom, "account-password"), "hunter2");
153
+ dom.type(field(dom, "imap-host"), "imap.example.com");
154
+
155
+ await submit(dom);
156
+
157
+ assert.equal(closed, 1);
158
+ });
159
+ });
160
+
161
+ describe("AccountFormPanel — provider presets", () => {
162
+ const pickFastmail = (dom: DomHarness): void => {
163
+ const select = dom.query<HTMLSelectElement>("#account-provider");
164
+ assert.ok(select);
165
+ dom.select(select, "fastmail");
166
+ };
167
+
168
+ it("fills and locks the server fields the preset owns", () => {
169
+ const dom = mount();
170
+ pickFastmail(dom);
171
+
172
+ assert.equal(field(dom, "imap-host").value, "imap.fastmail.com");
173
+ assert.equal(field(dom, "imap-port").value, "993");
174
+ assert.equal(field(dom, "imap-host").readOnly, true);
175
+ assert.match(dom.text(), /locked/);
176
+ });
177
+
178
+ it("hands the server fields back on Advanced", () => {
179
+ const dom = mount();
180
+ pickFastmail(dom);
181
+
182
+ dom.click(dom.byText("button", "Advanced"));
183
+
184
+ assert.equal(field(dom, "imap-host").readOnly, false);
185
+ assert.match(dom.text(), /Use preset settings/);
186
+ });
187
+
188
+ it("adopts the email as the username when the user left it blank", () => {
189
+ const dom = mount();
190
+ dom.type(field(dom, "account-email"), "alice@fastmail.com");
191
+ pickFastmail(dom);
192
+
193
+ assert.equal(field(dom, "account-username").value, "alice@fastmail.com");
194
+ });
195
+
196
+ it("points at the provider's app-password help", () => {
197
+ const dom = mount();
198
+ pickFastmail(dom);
199
+ const link = dom.byText("a", "Get an app password");
200
+ assert.match(link.getAttribute("href") ?? "", /fastmail/);
201
+ });
202
+ });
203
+
204
+ describe("AccountFormPanel — editing an account", () => {
205
+ it("shows a placeholder for the stored password and keeps it on save", async () => {
206
+ const dom = mount({ account });
207
+ assert.equal(field(dom, "account-password").value, "••••••••••");
208
+
209
+ await submit(dom);
210
+
211
+ const call = saved();
212
+ assert.equal(call?.method, "PATCH");
213
+ assert.equal(call?.path, "/accounts/acc-1");
214
+ assert.equal(body(call).password, undefined);
215
+ });
216
+
217
+ it("sends the new password once the field is touched", async () => {
218
+ const dom = mount({ account });
219
+ dom.type(field(dom, "account-password"), "new-app-password");
220
+
221
+ await submit(dom);
222
+
223
+ assert.equal(body(saved()).password, "new-app-password");
224
+ });
225
+
226
+ it("tests the connection against the stored password rather than the placeholder", async () => {
227
+ const dom = mount({ account });
228
+ dom.click(dom.byText("button", "Test IMAP Connection"));
229
+ await dom.flush();
230
+
231
+ const [test] = http.to("/accounts/test-connection");
232
+ const sent = body(test);
233
+ assert.equal(sent.accountId, "acc-1");
234
+ assert.equal(sent.password, undefined);
235
+ assert.equal(sent.imapHost, "imap.example.com");
236
+ });
237
+
238
+ it("shows a read-only summary for a Microsoft OAuth account — no credential fields", () => {
239
+ const dom = mount({
240
+ account: makeAccount({ accountId: "acc-ms", authType: "oauthMicrosoft" }),
241
+ });
242
+ assert.match(dom.text(), /Microsoft OAuth \(XOAUTH2\)/);
243
+ assert.equal(dom.query("#account-password"), null);
244
+ assert.equal(dom.query("#imap-host"), null);
245
+ });
246
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Banner rendering (#55): a banner sits on top of the toolbar and the message
3
+ * list, so it is opaque, it says which severity it is out loud, and only an
4
+ * error interrupts a screen reader.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import React, { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { ErrorBanner } from "./ErrorBanner";
12
+ import { ErrorBannerStack } from "./ErrorBannerStack";
13
+ import type { ErrorBannerEntry, ErrorBannerSeverity } from "./error-banners.js";
14
+
15
+ (globalThis as { React?: typeof React }).React = React;
16
+
17
+ const renderBanner = (severity: ErrorBannerSeverity, detail?: string): string =>
18
+ renderToString(
19
+ createElement(ErrorBanner, {
20
+ id: "b1",
21
+ severity,
22
+ title: "Couldn't move message",
23
+ detail,
24
+ onDismiss: () => undefined,
25
+ }) as never,
26
+ );
27
+
28
+ describe("ErrorBanner", () => {
29
+ it("interrupts assertively for an error and politely for the rest", () => {
30
+ assert.match(renderBanner("error"), /role="alert"/);
31
+ assert.match(renderBanner("error"), /aria-live="assertive"/);
32
+ for (const severity of ["warning", "info"] as const) {
33
+ assert.match(renderBanner(severity), /role="status"/);
34
+ assert.match(renderBanner(severity), /aria-live="polite"/);
35
+ }
36
+ });
37
+
38
+ it("names the severity in the dismiss label so it is not just an X", () => {
39
+ assert.match(renderBanner("error"), /aria-label="Dismiss error"/);
40
+ assert.match(renderBanner("warning"), /aria-label="Dismiss warning"/);
41
+ assert.match(renderBanner("info"), /aria-label="Dismiss information"/);
42
+ });
43
+
44
+ it("stays opaque — no translucent surface over the message list (#55)", () => {
45
+ const backgrounds: Record<ErrorBannerSeverity, RegExp> = {
46
+ error: /bg-danger-soft/,
47
+ warning: /bg-warning-soft/,
48
+ info: /bg-accent-2-soft/,
49
+ };
50
+ for (const [severity, background] of Object.entries(backgrounds)) {
51
+ const html = renderBanner(severity as ErrorBannerSeverity);
52
+ assert.match(html, background);
53
+ // An alpha-suffixed background (`bg-x/40`) is see-through; text over
54
+ // the message list behind it stops being readable.
55
+ assert.doesNotMatch(html, /class="[^"]*\sbg-[\w-]+\/\d/);
56
+ }
57
+ });
58
+
59
+ it("shows the detail only when there is one", () => {
60
+ assert.match(renderBanner("error", "Connection reset"), /Connection reset/);
61
+ assert.doesNotMatch(renderBanner("error"), /Connection reset/);
62
+ });
63
+ });
64
+
65
+ const entry = (
66
+ id: string,
67
+ severity: ErrorBannerSeverity,
68
+ title: string,
69
+ ): ErrorBannerEntry => ({
70
+ id,
71
+ severity,
72
+ title,
73
+ createdAt: 0,
74
+ });
75
+
76
+ const renderStack = (errors: ErrorBannerEntry[]): string =>
77
+ renderToString(
78
+ createElement(ErrorBannerStack, {
79
+ errors,
80
+ onDismiss: () => undefined,
81
+ }) as never,
82
+ );
83
+
84
+ describe("ErrorBannerStack", () => {
85
+ it("renders nothing at all when there is nothing to say", () => {
86
+ assert.equal(renderStack([]), "");
87
+ });
88
+
89
+ it("stacks every banner it is given under one labelled region", () => {
90
+ const html = renderStack([
91
+ entry("a", "error", "Couldn't delete"),
92
+ entry("b", "info", "Sync finished"),
93
+ ]);
94
+ assert.match(html, /aria-label="Notifications"/);
95
+ assert.match(html, /Couldn&#x27;t delete/);
96
+ assert.match(html, /Sync finished/);
97
+ });
98
+
99
+ it("lets clicks through the region so it never blocks the mail behind it", () => {
100
+ const html = renderStack([entry("a", "error", "Couldn't delete")]);
101
+ assert.match(html, /pointer-events-none/);
102
+ assert.match(html, /pointer-events-auto/);
103
+ });
104
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ErrorState is the shared "this didn't load" surface. Two things matter about
3
+ * it: the message it shows comes from whatever the caller caught (which is
4
+ * rarely an `Error`), and Retry only exists when there is something to retry.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import React, { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { ErrorState, formatErrorMessage } from "./ErrorState";
12
+
13
+ (globalThis as { React?: typeof React }).React = React;
14
+
15
+ const render = (props: Parameters<typeof ErrorState>[0]): string =>
16
+ renderToString(createElement(ErrorState, props) as never);
17
+
18
+ describe("formatErrorMessage", () => {
19
+ it("uses an Error's message", () => {
20
+ assert.equal(
21
+ formatErrorMessage(new Error("imap timed out")),
22
+ "imap timed out",
23
+ );
24
+ });
25
+
26
+ it("passes a bare string through", () => {
27
+ assert.equal(formatErrorMessage("nope"), "nope");
28
+ });
29
+
30
+ it("reads `message` off a plain object — what a rejected fetch body looks like", () => {
31
+ assert.equal(formatErrorMessage({ message: "Bad Gateway" }), "Bad Gateway");
32
+ });
33
+
34
+ it("falls back for a shape it cannot read", () => {
35
+ assert.equal(
36
+ formatErrorMessage({ status: 500 }),
37
+ "An unexpected error occurred",
38
+ );
39
+ assert.equal(formatErrorMessage(null), "An unexpected error occurred");
40
+ assert.equal(
41
+ formatErrorMessage({ message: 42 }),
42
+ "An unexpected error occurred",
43
+ );
44
+ });
45
+ });
46
+
47
+ describe("ErrorState", () => {
48
+ it("announces itself as an alert and shows the caught message", () => {
49
+ const html = render({ error: new Error("mailbox unreachable") });
50
+ assert.match(html, /role="alert"/);
51
+ assert.match(html, /mailbox unreachable/);
52
+ assert.match(html, /Couldn&#x27;t load content/);
53
+ });
54
+
55
+ it("takes a caller-supplied title over the default", () => {
56
+ const html = render({ error: "x", title: "Couldn't load folders" });
57
+ assert.match(html, /Couldn&#x27;t load folders/);
58
+ assert.doesNotMatch(html, /load content/);
59
+ });
60
+
61
+ it("offers no Retry when the caller has no retry to give", () => {
62
+ assert.doesNotMatch(render({ error: "x" }), /Retry/);
63
+ assert.doesNotMatch(render({ error: "x", variant: "inline" }), /Retry/);
64
+ });
65
+
66
+ it("offers Retry in both variants when it can retry", () => {
67
+ const retry = () => undefined;
68
+ assert.match(render({ error: "x", onRetry: retry }), /Retry/);
69
+ assert.match(
70
+ render({ error: "x", onRetry: retry, variant: "inline" }),
71
+ /Retry/,
72
+ );
73
+ });
74
+
75
+ it("renders the inline variant as a single row, not the centred block", () => {
76
+ const inline = render({ error: "x", variant: "inline" });
77
+ const block = render({ error: "x" });
78
+ assert.match(inline, /items-start/);
79
+ assert.match(block, /justify-center/);
80
+ assert.doesNotMatch(inline, /justify-center/);
81
+ });
82
+ });