@remit/web-client 0.0.52 → 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.
- package/package.json +2 -2
- package/src/components/mail/BriefPane.selection.test.ts +52 -0
- package/src/components/mail/BriefPane.tsx +7 -9
- package/src/components/mail/MessageList.selection.test.ts +24 -0
- package/src/components/mail/MessageList.tsx +21 -6
- package/src/components/mail/MoveToTrigger.render.test.ts +153 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +145 -0
- package/src/components/mail/ThreadListInteraction.test.ts +81 -1
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +292 -0
- package/src/components/settings/AccountFormPanel.render.test.ts +246 -0
- package/src/components/ui/ErrorBanner.render.test.ts +104 -0
- package/src/components/ui/ErrorState.render.test.ts +82 -0
- package/src/hooks/mutation-optimism.test.ts +278 -0
- package/src/test-support/dom.ts +180 -0
- package/src/test-support/fixtures.ts +83 -0
- package/src/test-support/http.ts +72 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking the onboarding wizard end to end. The flow is the product here: a
|
|
3
|
+
* first-run user gets from "welcome" to a syncing account without ever seeing
|
|
4
|
+
* a server setting they have to know, and when the connection test fails the
|
|
5
|
+
* wizard sends them back to the step that can fix it — credentials for a
|
|
6
|
+
* rejected password, servers for an unreachable host.
|
|
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 { type HttpMock, mockFetch } from "../../test-support/http";
|
|
14
|
+
import { OnboardingWizard } from "./OnboardingWizard";
|
|
15
|
+
|
|
16
|
+
let harness: DomHarness | undefined;
|
|
17
|
+
let http: HttpMock;
|
|
18
|
+
|
|
19
|
+
interface TestConnectionResult {
|
|
20
|
+
imapSuccess: boolean;
|
|
21
|
+
smtpSuccess: boolean;
|
|
22
|
+
imapError?: string;
|
|
23
|
+
smtpError?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const OK: TestConnectionResult = { imapSuccess: true, smtpSuccess: true };
|
|
27
|
+
|
|
28
|
+
interface Backend {
|
|
29
|
+
test?: TestConnectionResult;
|
|
30
|
+
syncPhase?: string;
|
|
31
|
+
lastError?: string;
|
|
32
|
+
mailboxCountTotal?: number;
|
|
33
|
+
mailboxCountSynced?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const completed: string[] = [];
|
|
37
|
+
const cancelled: string[] = [];
|
|
38
|
+
|
|
39
|
+
const start = (backend: Backend = {}, skipWelcome = false): DomHarness => {
|
|
40
|
+
http = mockFetch((call) => {
|
|
41
|
+
if (call.path === "/accounts/test-connection") return backend.test ?? OK;
|
|
42
|
+
if (call.path === "/accounts") return { accountId: "acc-new" };
|
|
43
|
+
if (call.path === "/config") {
|
|
44
|
+
return {
|
|
45
|
+
accounts: [{ accountId: "acc-new", lastError: backend.lastError }],
|
|
46
|
+
mailboxes: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (call.path.endsWith("/sync/status")) {
|
|
50
|
+
return {
|
|
51
|
+
accountId: "acc-new",
|
|
52
|
+
syncPhase: backend.syncPhase ?? "complete",
|
|
53
|
+
mailboxCountTotal: backend.mailboxCountTotal ?? 4,
|
|
54
|
+
mailboxCountSynced: backend.mailboxCountSynced ?? 4,
|
|
55
|
+
mailboxes: [
|
|
56
|
+
{
|
|
57
|
+
mailboxId: "mbx-inbox",
|
|
58
|
+
fullPath: "INBOX",
|
|
59
|
+
messagesTotal: 120,
|
|
60
|
+
messagesSynced: 120,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {};
|
|
66
|
+
});
|
|
67
|
+
harness = createDomHarness();
|
|
68
|
+
harness.renderApp(
|
|
69
|
+
createElement(OnboardingWizard, {
|
|
70
|
+
skipWelcome,
|
|
71
|
+
onComplete: (accountId: string) => completed.push(accountId),
|
|
72
|
+
onCancel: () => cancelled.push("cancelled"),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
return harness;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
completed.length = 0;
|
|
80
|
+
cancelled.length = 0;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
harness?.close();
|
|
85
|
+
harness = undefined;
|
|
86
|
+
http.restore();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** The wizard's server fields carry generated ids; the placeholder is stable. */
|
|
90
|
+
const imapHostField = (dom: DomHarness): HTMLInputElement => {
|
|
91
|
+
const input = dom.query<HTMLInputElement>(
|
|
92
|
+
'input[placeholder="imap.example.com"]',
|
|
93
|
+
);
|
|
94
|
+
assert.ok(input, "expected the IMAP host field");
|
|
95
|
+
return input;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const clickText = (dom: DomHarness, text: string): void => {
|
|
99
|
+
dom.click(dom.byText("button", text));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Welcome → Connector → Address, with an address the provider table knows. */
|
|
103
|
+
const walkToServers = async (
|
|
104
|
+
dom: DomHarness,
|
|
105
|
+
email = "alice@fastmail.com",
|
|
106
|
+
): Promise<void> => {
|
|
107
|
+
clickText(dom, "Add your first account");
|
|
108
|
+
clickText(dom, "Continue");
|
|
109
|
+
const emailField = dom.query<HTMLInputElement>("#onboarding-email");
|
|
110
|
+
assert.ok(emailField);
|
|
111
|
+
dom.type(emailField, email);
|
|
112
|
+
clickText(dom, "Continue");
|
|
113
|
+
await dom.flush();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const walkToTest = async (dom: DomHarness): Promise<void> => {
|
|
117
|
+
await walkToServers(dom);
|
|
118
|
+
clickText(dom, "Continue");
|
|
119
|
+
const password = dom.query<HTMLInputElement>("#credentials-password");
|
|
120
|
+
assert.ok(password);
|
|
121
|
+
dom.type(password, "app-password");
|
|
122
|
+
clickText(dom, "Test connection");
|
|
123
|
+
await dom.flush();
|
|
124
|
+
// The test step stages IMAP then SMTP on a short delay before it settles.
|
|
125
|
+
await dom.wait(500);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** A verified connection carries on to the sync step on its own, after a beat. */
|
|
129
|
+
const walkToSync = async (dom: DomHarness): Promise<void> => {
|
|
130
|
+
await walkToTest(dom);
|
|
131
|
+
await dom.wait(900);
|
|
132
|
+
// Creation resolves, then the sync-status poll behind it.
|
|
133
|
+
for (let round = 0; round < 4; round += 1) {
|
|
134
|
+
await dom.flush();
|
|
135
|
+
await dom.wait(20);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
describe("OnboardingWizard — the first-run path", () => {
|
|
140
|
+
it("opens on the welcome step with no server settings in sight", () => {
|
|
141
|
+
const dom = start();
|
|
142
|
+
assert.match(dom.text(), /Welcome to Remit/);
|
|
143
|
+
assert.equal(dom.query("#onboarding-email"), null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("starts at the connector picker when it is opened from Settings", () => {
|
|
147
|
+
const dom = start({}, true);
|
|
148
|
+
assert.match(dom.text(), /How does this account connect/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("fills the server settings from the address alone", async () => {
|
|
152
|
+
const dom = start();
|
|
153
|
+
await walkToServers(dom);
|
|
154
|
+
|
|
155
|
+
assert.match(dom.text(), /Confirm server settings/);
|
|
156
|
+
assert.equal(imapHostField(dom).value, "imap.fastmail.com");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("falls back to a guess for a domain nobody has heard of", async () => {
|
|
160
|
+
const dom = start();
|
|
161
|
+
await walkToServers(dom, "alice@unheard-of.example");
|
|
162
|
+
|
|
163
|
+
assert.equal(imapHostField(dom).value, "imap.unheard-of.example");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("refuses an address that is not one, without leaving the step", async () => {
|
|
167
|
+
const dom = start();
|
|
168
|
+
clickText(dom, "Add your first account");
|
|
169
|
+
clickText(dom, "Continue");
|
|
170
|
+
const emailField = dom.query<HTMLInputElement>("#onboarding-email");
|
|
171
|
+
assert.ok(emailField);
|
|
172
|
+
dom.type(emailField, "not-an-address");
|
|
173
|
+
clickText(dom, "Continue");
|
|
174
|
+
await dom.flush();
|
|
175
|
+
|
|
176
|
+
assert.match(dom.text(), /Enter a valid email address/);
|
|
177
|
+
assert.ok(dom.query("#onboarding-email"));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("tests the connection with what the user entered", async () => {
|
|
181
|
+
const dom = start();
|
|
182
|
+
await walkToTest(dom);
|
|
183
|
+
|
|
184
|
+
const [tested] = http.to("/accounts/test-connection");
|
|
185
|
+
assert.ok(tested);
|
|
186
|
+
assert.equal(tested.body?.imapHost, "imap.fastmail.com");
|
|
187
|
+
assert.equal(tested.body?.password, "app-password");
|
|
188
|
+
assert.equal(tested.body?.username, "alice@fastmail.com");
|
|
189
|
+
assert.match(dom.text(), /Connection verified/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("creates the account from what the wizard collected", async () => {
|
|
193
|
+
const dom = start();
|
|
194
|
+
await walkToSync(dom);
|
|
195
|
+
|
|
196
|
+
const [created] = http.calls.filter(
|
|
197
|
+
(call) => call.path === "/accounts" && call.method === "POST",
|
|
198
|
+
);
|
|
199
|
+
assert.ok(created, "a verified connection carries on to creation");
|
|
200
|
+
assert.equal(created.body?.email, "alice@fastmail.com");
|
|
201
|
+
assert.equal(created.body?.imapHost, "imap.fastmail.com");
|
|
202
|
+
assert.equal(created.body?.password, "app-password");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("reports sync progress and hands the new account to the caller", async () => {
|
|
206
|
+
const dom = start();
|
|
207
|
+
await walkToSync(dom);
|
|
208
|
+
|
|
209
|
+
assert.match(dom.text(), /INBOX/);
|
|
210
|
+
assert.match(dom.text(), /4 mailboxes found/);
|
|
211
|
+
clickText(dom, "Go to inbox");
|
|
212
|
+
assert.deepEqual(completed, ["acc-new"]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("OnboardingWizard — a connection that does not work", () => {
|
|
217
|
+
it("sends a rejected password back to credentials, not to servers", async () => {
|
|
218
|
+
const dom = start({
|
|
219
|
+
test: {
|
|
220
|
+
imapSuccess: false,
|
|
221
|
+
smtpSuccess: false,
|
|
222
|
+
imapError: "Authentication failed",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
await walkToTest(dom);
|
|
226
|
+
|
|
227
|
+
assert.match(dom.text(), /Authentication failed/);
|
|
228
|
+
assert.match(dom.text(), /app password/i);
|
|
229
|
+
clickText(dom, "Back to credentials");
|
|
230
|
+
assert.ok(dom.query("#credentials-password"));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("sends an unreachable host back to servers", async () => {
|
|
234
|
+
const dom = start({
|
|
235
|
+
test: {
|
|
236
|
+
imapSuccess: false,
|
|
237
|
+
smtpSuccess: false,
|
|
238
|
+
imapError: "ECONNREFUSED",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
await walkToTest(dom);
|
|
242
|
+
|
|
243
|
+
assert.match(dom.text(), /ECONNREFUSED/);
|
|
244
|
+
clickText(dom, "Back to servers");
|
|
245
|
+
assert.equal(imapHostField(dom).value, "imap.fastmail.com");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("offers a retry that runs the test again rather than a dead end", async () => {
|
|
249
|
+
const dom = start({
|
|
250
|
+
test: {
|
|
251
|
+
imapSuccess: false,
|
|
252
|
+
smtpSuccess: false,
|
|
253
|
+
imapError: "ECONNREFUSED",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
await walkToTest(dom);
|
|
257
|
+
|
|
258
|
+
const before = http.to("/accounts/test-connection").length;
|
|
259
|
+
clickText(dom, "Retry");
|
|
260
|
+
await dom.flush();
|
|
261
|
+
assert.equal(http.to("/accounts/test-connection").length, before + 1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("never reaches account creation while the connection is broken", async () => {
|
|
265
|
+
const dom = start({
|
|
266
|
+
test: { imapSuccess: false, smtpSuccess: false, imapError: "no route" },
|
|
267
|
+
});
|
|
268
|
+
await walkToTest(dom);
|
|
269
|
+
await dom.wait(50);
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(
|
|
272
|
+
http.calls.filter(
|
|
273
|
+
(call) => call.path === "/accounts" && call.method === "POST",
|
|
274
|
+
),
|
|
275
|
+
[],
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("OnboardingWizard — a sync that stalls", () => {
|
|
281
|
+
it("says the account is still there and offers a retry", async () => {
|
|
282
|
+
const dom = start({
|
|
283
|
+
syncPhase: "error",
|
|
284
|
+
lastError: "IMAP LOGIN failed",
|
|
285
|
+
});
|
|
286
|
+
await walkToSync(dom);
|
|
287
|
+
|
|
288
|
+
assert.match(dom.text(), /Sync stalled/);
|
|
289
|
+
assert.match(dom.text(), /still active/);
|
|
290
|
+
assert.match(dom.text(), /IMAP LOGIN failed/);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -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'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
|
+
});
|