@remit/web-client 0.0.300 → 0.0.302
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 -1
- package/src/auth/amplify-config.ts +2 -3
- package/src/auth/cognito-sign-in.stories.tsx +90 -0
- package/src/auth/email-sign-in.stories.tsx +128 -0
- package/src/components/compose/ComposeForm.tsx +14 -0
- package/src/components/compose/mail-sync-off-refusal.ts +7 -0
- package/src/components/compose/send-from-mail-off-account.render.test.ts +173 -0
- package/src/components/onboarding/onboarding.stories.tsx +460 -0
- package/src/components/settings/AccountServices.render.test.ts +254 -0
- package/src/components/settings/AccountServices.tsx +181 -0
- package/src/components/settings/AccountServicesCard.stories.tsx +71 -0
- package/src/components/settings/AccountServicesCard.tsx +76 -0
- package/src/components/settings/accounts.stories.tsx +363 -0
- package/src/components/settings/advanced.stories.tsx +162 -0
- package/src/components/settings/appearance.stories.tsx +33 -0
- package/src/components/settings/folders.stories.tsx +89 -0
- package/src/components/settings/self-update.stories.tsx +270 -0
- package/src/components/settings/senders.stories.tsx +155 -0
- package/src/mocks/story-frame/AppStory.tsx +21 -6
- package/src/mocks/story-frame/mail-handlers.ts +69 -7
- package/src/mocks/story-frame/mail-world.ts +22 -4
- package/src/mocks/story-frame/onboarding-handlers.ts +97 -0
- package/src/mocks/story-frame/session.tsx +19 -0
- package/src/routes/settings/-accounts.test.ts +1 -0
- package/src/routes/settings/accounts.tsx +28 -20
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
|
+
import {
|
|
4
|
+
REDIRECT_STALL_MESSAGE,
|
|
5
|
+
REDIRECT_STALL_MS,
|
|
6
|
+
} from "@/hooks/useRedirectEnded";
|
|
7
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
8
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
9
|
+
import { mailWorld } from "@/mocks/story-frame/mail-world";
|
|
10
|
+
import {
|
|
11
|
+
NEW_ACCOUNT_ID,
|
|
12
|
+
type OnboardingHandlerOptions,
|
|
13
|
+
onboardingHandlers,
|
|
14
|
+
} from "@/mocks/story-frame/onboarding-handlers";
|
|
15
|
+
import { makeAccount } from "@/test-support/fixtures";
|
|
16
|
+
|
|
17
|
+
const EMAIL = "alice@northwind.example";
|
|
18
|
+
|
|
19
|
+
const firstRun = mailWorld({ accounts: [], threads: [], outbox: [] });
|
|
20
|
+
|
|
21
|
+
const handlers = (
|
|
22
|
+
options: OnboardingHandlerOptions = {},
|
|
23
|
+
world = firstRun,
|
|
24
|
+
) => ({
|
|
25
|
+
msw: { handlers: [...onboardingHandlers(options), ...mailHandlers(world)] },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: "Playground/Shipped/Onboarding/Wizard",
|
|
30
|
+
component: AppStory,
|
|
31
|
+
args: { url: "/onboarding" },
|
|
32
|
+
parameters: { layout: "fullscreen", ...handlers() },
|
|
33
|
+
} satisfies Meta<typeof AppStory>;
|
|
34
|
+
export default meta;
|
|
35
|
+
|
|
36
|
+
type Story = StoryObj<typeof meta>;
|
|
37
|
+
|
|
38
|
+
type Canvas = ReturnType<typeof within>;
|
|
39
|
+
|
|
40
|
+
const phone = { viewport: { value: "mobile", isRotated: false } };
|
|
41
|
+
|
|
42
|
+
const press = async (canvas: Canvas, name: string | RegExp) =>
|
|
43
|
+
userEvent.click(await canvas.findByRole("button", { name }));
|
|
44
|
+
|
|
45
|
+
const toConnector = (canvas: Canvas) => press(canvas, "Add your first account");
|
|
46
|
+
|
|
47
|
+
const toAddress = async (canvas: Canvas) => {
|
|
48
|
+
await toConnector(canvas);
|
|
49
|
+
await press(canvas, "Continue with IMAP");
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const toServers = async (canvas: Canvas, email = EMAIL) => {
|
|
53
|
+
await toAddress(canvas);
|
|
54
|
+
await userEvent.type(await canvas.findByLabelText("Email address"), email);
|
|
55
|
+
await press(canvas, "Continue");
|
|
56
|
+
await canvas.findByText("Confirm server settings");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const toCredentials = async (canvas: Canvas) => {
|
|
60
|
+
await toServers(canvas);
|
|
61
|
+
await press(canvas, "Continue");
|
|
62
|
+
await canvas.findByText("Sign in to northwind.example");
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const toTest = async (canvas: Canvas) => {
|
|
66
|
+
await toCredentials(canvas);
|
|
67
|
+
await userEvent.type(canvas.getByLabelText(/Password/), "app-password");
|
|
68
|
+
await press(canvas, "Test connection");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const toMicrosoft = async (canvas: Canvas) => {
|
|
72
|
+
await toConnector(canvas);
|
|
73
|
+
await userEvent.click(await canvas.findByText("Outlook / Microsoft 365"));
|
|
74
|
+
await press(canvas, "Continue with Microsoft");
|
|
75
|
+
return canvas.findByRole("button", { name: "Sign in with Microsoft" });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const Welcome: Story = {
|
|
79
|
+
play: async ({ canvasElement }) => {
|
|
80
|
+
const canvas = within(canvasElement);
|
|
81
|
+
await expect(await canvas.findByText("Welcome to Remit")).toBeVisible();
|
|
82
|
+
await expect(
|
|
83
|
+
canvas.getByRole("button", { name: "Import a config file" }),
|
|
84
|
+
).toBeVisible();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const Walkthrough: Story = {
|
|
89
|
+
play: async ({ canvasElement }) => {
|
|
90
|
+
const canvas = within(canvasElement);
|
|
91
|
+
await toTest(canvas);
|
|
92
|
+
await expect(
|
|
93
|
+
await canvas.findByText(`Syncing ${EMAIL}`, undefined, {
|
|
94
|
+
timeout: 4000,
|
|
95
|
+
}),
|
|
96
|
+
).toBeVisible();
|
|
97
|
+
await expect(
|
|
98
|
+
await canvas.findByRole("button", { name: "Go to inbox" }),
|
|
99
|
+
).toBeEnabled();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const ConnectorPicker: Story = {
|
|
104
|
+
play: async ({ canvasElement }) => {
|
|
105
|
+
const canvas = within(canvasElement);
|
|
106
|
+
await toConnector(canvas);
|
|
107
|
+
await expect(
|
|
108
|
+
await canvas.findByText("How does this account connect?"),
|
|
109
|
+
).toBeVisible();
|
|
110
|
+
await expect(
|
|
111
|
+
canvas.getByRole("button", { name: "Continue with IMAP" }),
|
|
112
|
+
).toBeVisible();
|
|
113
|
+
await expect(canvas.queryByText(/Google/)).toBeNull();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const ConnectorPickerMicrosoft: Story = {
|
|
118
|
+
play: async ({ canvasElement }) => {
|
|
119
|
+
const canvas = within(canvasElement);
|
|
120
|
+
await toConnector(canvas);
|
|
121
|
+
await userEvent.click(await canvas.findByText("Outlook / Microsoft 365"));
|
|
122
|
+
await expect(
|
|
123
|
+
canvas.getByRole("button", { name: "Continue with Microsoft" }),
|
|
124
|
+
).toBeVisible();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const ConnectorPickerInSettings: Story = {
|
|
129
|
+
args: { url: "/settings/accounts" },
|
|
130
|
+
parameters: handlers({}, mailWorld()),
|
|
131
|
+
play: async ({ canvasElement }) => {
|
|
132
|
+
const canvas = within(canvasElement);
|
|
133
|
+
await press(canvas, "Add account");
|
|
134
|
+
await expect(
|
|
135
|
+
await canvas.findByText("How does this account connect?"),
|
|
136
|
+
).toBeVisible();
|
|
137
|
+
await expect(canvas.queryByRole("button", { name: "Back" })).toBeNull();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const ConnectorPickerPhone: Story = {
|
|
142
|
+
globals: phone,
|
|
143
|
+
play: async ({ canvasElement }) => {
|
|
144
|
+
const canvas = within(canvasElement);
|
|
145
|
+
await toConnector(canvas);
|
|
146
|
+
await expect(
|
|
147
|
+
await canvas.findByRole("button", { name: "Continue with IMAP" }),
|
|
148
|
+
).toBeVisible();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const ConnectorPickerMicrosoftPhone: Story = {
|
|
153
|
+
globals: phone,
|
|
154
|
+
play: async ({ canvasElement }) => {
|
|
155
|
+
const canvas = within(canvasElement);
|
|
156
|
+
await toConnector(canvas);
|
|
157
|
+
await userEvent.click(await canvas.findByText("Outlook / Microsoft 365"));
|
|
158
|
+
await expect(
|
|
159
|
+
canvas.getByRole("button", { name: "Continue with Microsoft" }),
|
|
160
|
+
).toBeVisible();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const MicrosoftEmail: Story = {
|
|
165
|
+
play: async ({ canvasElement }) => {
|
|
166
|
+
const canvas = within(canvasElement);
|
|
167
|
+
await expect(await toMicrosoft(canvas)).toBeEnabled();
|
|
168
|
+
await expect(
|
|
169
|
+
canvas.getByLabelText("Email address (optional)"),
|
|
170
|
+
).toBeVisible();
|
|
171
|
+
await expect(canvas.getByRole("button", { name: "Back" })).toBeVisible();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const MicrosoftEmailInSettings: Story = {
|
|
176
|
+
args: { url: "/settings/accounts" },
|
|
177
|
+
parameters: handlers({}, mailWorld()),
|
|
178
|
+
play: async ({ canvasElement }) => {
|
|
179
|
+
const canvas = within(canvasElement);
|
|
180
|
+
await press(canvas, "Add account");
|
|
181
|
+
await userEvent.click(await canvas.findByText("Outlook / Microsoft 365"));
|
|
182
|
+
await press(canvas, "Continue with Microsoft");
|
|
183
|
+
await expect(
|
|
184
|
+
await canvas.findByRole("button", { name: "Sign in with Microsoft" }),
|
|
185
|
+
).toBeVisible();
|
|
186
|
+
await expect(canvas.getByRole("button", { name: "Back" })).toBeVisible();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const realSetTimeout = window.setTimeout;
|
|
191
|
+
|
|
192
|
+
const stallAtOnce: Window["setTimeout"] = (handler, delay, ...rest) =>
|
|
193
|
+
realSetTimeout(handler, delay === REDIRECT_STALL_MS ? 0 : delay, ...rest);
|
|
194
|
+
|
|
195
|
+
export const MicrosoftRedirectStalled: Story = {
|
|
196
|
+
beforeEach: () => () => {
|
|
197
|
+
Reflect.set(window, "setTimeout", realSetTimeout);
|
|
198
|
+
},
|
|
199
|
+
play: async ({ canvasElement }) => {
|
|
200
|
+
const canvas = within(canvasElement);
|
|
201
|
+
const signIn = await toMicrosoft(canvas);
|
|
202
|
+
Reflect.set(window, "setTimeout", stallAtOnce);
|
|
203
|
+
await userEvent.click(signIn);
|
|
204
|
+
await expect(await canvas.findByText(REDIRECT_STALL_MESSAGE)).toBeVisible();
|
|
205
|
+
await expect(
|
|
206
|
+
canvas.getByRole("button", { name: "Sign in with Microsoft" }),
|
|
207
|
+
).toBeEnabled();
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const AddressAutodiscovery: Story = {
|
|
212
|
+
parameters: handlers({ autoconfig: "slow" }),
|
|
213
|
+
play: async ({ canvasElement }) => {
|
|
214
|
+
const canvas = within(canvasElement);
|
|
215
|
+
await toAddress(canvas);
|
|
216
|
+
await userEvent.type(await canvas.findByLabelText("Email address"), EMAIL);
|
|
217
|
+
await press(canvas, "Continue");
|
|
218
|
+
await expect(
|
|
219
|
+
await canvas.findByText("Looking up settings for northwind.example…"),
|
|
220
|
+
).toBeVisible();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const AddressInvalid: Story = {
|
|
225
|
+
play: async ({ canvasElement }) => {
|
|
226
|
+
const canvas = within(canvasElement);
|
|
227
|
+
await toAddress(canvas);
|
|
228
|
+
await userEvent.type(
|
|
229
|
+
await canvas.findByLabelText("Email address"),
|
|
230
|
+
"alice",
|
|
231
|
+
);
|
|
232
|
+
await press(canvas, "Continue");
|
|
233
|
+
await expect(
|
|
234
|
+
await canvas.findByText("Enter a valid email address."),
|
|
235
|
+
).toBeVisible();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export const ServerConfirm: Story = {
|
|
240
|
+
play: async ({ canvasElement }) => {
|
|
241
|
+
const canvas = within(canvasElement);
|
|
242
|
+
await toServers(canvas);
|
|
243
|
+
await expect(canvas.getByText(/Found via autodiscovery/)).toBeVisible();
|
|
244
|
+
await expect(canvas.getAllByText("detected")).toHaveLength(2);
|
|
245
|
+
await expect(
|
|
246
|
+
canvas.getAllByDisplayValue("mail.northwind.example"),
|
|
247
|
+
).toHaveLength(2);
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const ServerConfirmPhone: Story = {
|
|
252
|
+
globals: phone,
|
|
253
|
+
play: async ({ canvasElement }) => {
|
|
254
|
+
const canvas = within(canvasElement);
|
|
255
|
+
await toServers(canvas);
|
|
256
|
+
for (const security of canvas.getAllByLabelText("Security")) {
|
|
257
|
+
await expect(security).toBeEnabled();
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const ServerManualFallback: Story = {
|
|
263
|
+
parameters: handlers({ autoconfig: "missing" }),
|
|
264
|
+
play: async ({ canvasElement }) => {
|
|
265
|
+
const canvas = within(canvasElement);
|
|
266
|
+
await toServers(canvas);
|
|
267
|
+
await expect(
|
|
268
|
+
canvas.getByText(/couldn't detect settings for northwind.example/),
|
|
269
|
+
).toBeVisible();
|
|
270
|
+
await expect(
|
|
271
|
+
canvas.getByDisplayValue("imap.northwind.example"),
|
|
272
|
+
).toBeVisible();
|
|
273
|
+
await expect(canvas.queryByText("detected")).toBeNull();
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const ServerMissingHost: Story = {
|
|
278
|
+
parameters: handlers({ autoconfig: "missing" }),
|
|
279
|
+
play: async ({ canvasElement }) => {
|
|
280
|
+
const canvas = within(canvasElement);
|
|
281
|
+
await toServers(canvas);
|
|
282
|
+
await userEvent.clear(canvas.getByDisplayValue("imap.northwind.example"));
|
|
283
|
+
await press(canvas, "Continue");
|
|
284
|
+
await expect(await canvas.findByText("Enter the IMAP host.")).toBeVisible();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export const ServerProviderPreset: Story = {
|
|
289
|
+
play: async ({ canvasElement }) => {
|
|
290
|
+
const canvas = within(canvasElement);
|
|
291
|
+
await toServers(canvas, "alice@icloud.com");
|
|
292
|
+
await expect(canvas.getByLabelText("Provider")).toHaveValue("icloud");
|
|
293
|
+
await expect(
|
|
294
|
+
canvas.getByText(/pre-filled for iCloud and locked/),
|
|
295
|
+
).toBeVisible();
|
|
296
|
+
await expect(
|
|
297
|
+
canvas.getByRole("link", { name: "Get an app password" }),
|
|
298
|
+
).toBeVisible();
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export const Credentials: Story = {
|
|
303
|
+
play: async ({ canvasElement }) => {
|
|
304
|
+
const canvas = within(canvasElement);
|
|
305
|
+
await toCredentials(canvas);
|
|
306
|
+
await expect(canvas.getByLabelText("Username")).toHaveValue(EMAIL);
|
|
307
|
+
await press(canvas, "Test connection");
|
|
308
|
+
await expect(
|
|
309
|
+
await canvas.findByText("Enter your username and password."),
|
|
310
|
+
).toBeVisible();
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export const TestConnectionSuccess: Story = {
|
|
315
|
+
play: async ({ canvasElement }) => {
|
|
316
|
+
const canvas = within(canvasElement);
|
|
317
|
+
await toTest(canvas);
|
|
318
|
+
await expect(await canvas.findByText("Connection verified")).toBeVisible();
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const TestConnectionFailure: Story = {
|
|
323
|
+
parameters: handlers({
|
|
324
|
+
connection: {
|
|
325
|
+
imapSuccess: true,
|
|
326
|
+
smtpSuccess: false,
|
|
327
|
+
smtpError: "535 5.7.8 Authentication credentials invalid",
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
play: async ({ canvasElement }) => {
|
|
331
|
+
const canvas = within(canvasElement);
|
|
332
|
+
await toTest(canvas);
|
|
333
|
+
await expect(
|
|
334
|
+
await canvas.findByText(/many providers require an app password/),
|
|
335
|
+
).toBeVisible();
|
|
336
|
+
await expect(
|
|
337
|
+
canvas.getByRole("button", { name: "Back to credentials" }),
|
|
338
|
+
).toBeVisible();
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export const TestConnectionNetworkFailure: Story = {
|
|
343
|
+
parameters: handlers({
|
|
344
|
+
connection: {
|
|
345
|
+
imapSuccess: false,
|
|
346
|
+
imapError: "connect ECONNREFUSED 203.0.113.7:993",
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
play: async ({ canvasElement }) => {
|
|
350
|
+
const canvas = within(canvasElement);
|
|
351
|
+
await toTest(canvas);
|
|
352
|
+
await expect(
|
|
353
|
+
await canvas.findByRole("button", { name: "Back to servers" }),
|
|
354
|
+
).toBeVisible();
|
|
355
|
+
await expect(canvas.getAllByText(/ECONNREFUSED/).length).toBeGreaterThan(0);
|
|
356
|
+
await expect(
|
|
357
|
+
canvas.queryByText(/many providers require an app password/),
|
|
358
|
+
).toBeNull();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export const SyncProgress: Story = {
|
|
363
|
+
parameters: handlers({
|
|
364
|
+
sync: {
|
|
365
|
+
syncPhase: "syncing_inbox",
|
|
366
|
+
mailboxCountTotal: 6,
|
|
367
|
+
mailboxCountSynced: 0,
|
|
368
|
+
mailboxes: [
|
|
369
|
+
{
|
|
370
|
+
mailboxId: "mbx-new-inbox",
|
|
371
|
+
fullPath: "INBOX",
|
|
372
|
+
phase: "syncing",
|
|
373
|
+
messagesTotal: 800,
|
|
374
|
+
messagesSynced: 120,
|
|
375
|
+
highWaterMarkUid: 0,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
play: async ({ canvasElement }) => {
|
|
381
|
+
const canvas = within(canvasElement);
|
|
382
|
+
await toTest(canvas);
|
|
383
|
+
await expect(
|
|
384
|
+
await canvas.findByText("120 / 800 messages", undefined, {
|
|
385
|
+
timeout: 4000,
|
|
386
|
+
}),
|
|
387
|
+
).toBeVisible();
|
|
388
|
+
await expect(
|
|
389
|
+
canvas.getByRole("button", { name: "Go to inbox" }),
|
|
390
|
+
).toBeEnabled();
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const waitingForMail = handlers({
|
|
395
|
+
sync: { syncPhase: "discovering_mailboxes", mailboxes: [] },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
export const SyncWaitingForFirstMail: Story = {
|
|
399
|
+
parameters: waitingForMail,
|
|
400
|
+
play: async ({ canvasElement }) => {
|
|
401
|
+
const canvas = within(canvasElement);
|
|
402
|
+
await toTest(canvas);
|
|
403
|
+
await expect(
|
|
404
|
+
await canvas.findByRole(
|
|
405
|
+
"button",
|
|
406
|
+
{ name: "Getting your mail…" },
|
|
407
|
+
{ timeout: 4000 },
|
|
408
|
+
),
|
|
409
|
+
).toBeDisabled();
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const SyncWaitingForFirstMailPhone: Story = {
|
|
414
|
+
globals: phone,
|
|
415
|
+
parameters: waitingForMail,
|
|
416
|
+
play: SyncWaitingForFirstMail.play,
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
export const SyncCreateError: Story = {
|
|
420
|
+
parameters: handlers({ createFails: true }),
|
|
421
|
+
play: async ({ canvasElement }) => {
|
|
422
|
+
const canvas = within(canvasElement);
|
|
423
|
+
await toTest(canvas);
|
|
424
|
+
await expect(
|
|
425
|
+
await canvas.findByText("Couldn't create account", undefined, {
|
|
426
|
+
timeout: 4000,
|
|
427
|
+
}),
|
|
428
|
+
).toBeVisible();
|
|
429
|
+
await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible();
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
export const SyncStalled: Story = {
|
|
434
|
+
parameters: handlers(
|
|
435
|
+
{ sync: { syncPhase: "error", mailboxes: [] } },
|
|
436
|
+
mailWorld({
|
|
437
|
+
accounts: [
|
|
438
|
+
makeAccount({
|
|
439
|
+
accountId: NEW_ACCOUNT_ID,
|
|
440
|
+
email: EMAIL,
|
|
441
|
+
lastError: "IMAP IDLE dropped: connection reset by peer",
|
|
442
|
+
}),
|
|
443
|
+
],
|
|
444
|
+
threads: [],
|
|
445
|
+
outbox: [],
|
|
446
|
+
}),
|
|
447
|
+
),
|
|
448
|
+
play: async ({ canvasElement }) => {
|
|
449
|
+
const canvas = within(canvasElement);
|
|
450
|
+
await toTest(canvas);
|
|
451
|
+
await expect(
|
|
452
|
+
await canvas.findByText(/Sync stalled/, undefined, { timeout: 4000 }),
|
|
453
|
+
).toBeVisible();
|
|
454
|
+
await waitFor(() =>
|
|
455
|
+
expect(
|
|
456
|
+
canvas.getByText("IMAP IDLE dropped: connection reset by peer"),
|
|
457
|
+
).toBeVisible(),
|
|
458
|
+
);
|
|
459
|
+
},
|
|
460
|
+
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
4
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import { MICROSOFT_SERVICE_SCOPES } from "@remit/mail-oauth-service";
|
|
6
|
+
import { useQuery } from "@tanstack/react-query";
|
|
7
|
+
import { createElement } from "react";
|
|
8
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
9
|
+
import { makeAccount } from "../../test-support/fixtures";
|
|
10
|
+
import {
|
|
11
|
+
type HttpCall,
|
|
12
|
+
type HttpMock,
|
|
13
|
+
httpError,
|
|
14
|
+
mockFetch,
|
|
15
|
+
} from "../../test-support/http";
|
|
16
|
+
import { AccountServices } from "./AccountServices";
|
|
17
|
+
|
|
18
|
+
const CONSENT_URL = "http://localhost/#microsoft-consent";
|
|
19
|
+
const MAIL_SCOPES = [...MICROSOFT_SERVICE_SCOPES.Mail];
|
|
20
|
+
const BOTH_SCOPES = [...MAIL_SCOPES, ...MICROSOFT_SERVICE_SCOPES.Calendar];
|
|
21
|
+
const REFUSAL = "The mail server refused the change. Try again later.";
|
|
22
|
+
|
|
23
|
+
let harness: DomHarness | undefined;
|
|
24
|
+
let http: HttpMock | undefined;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
harness?.close();
|
|
28
|
+
harness = undefined;
|
|
29
|
+
http?.restore();
|
|
30
|
+
http = undefined;
|
|
31
|
+
window.location.hash = "";
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const outlook = (
|
|
35
|
+
overrides: Partial<RemitImapAccountResponse> = {},
|
|
36
|
+
): RemitImapAccountResponse =>
|
|
37
|
+
makeAccount({
|
|
38
|
+
accountId: "acc-1",
|
|
39
|
+
authType: "oauthMicrosoft",
|
|
40
|
+
syncedServices: ["Mail"],
|
|
41
|
+
grantedScopes: BOTH_SCOPES,
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
type Patch = (call: HttpCall) => unknown;
|
|
46
|
+
|
|
47
|
+
const hang: Patch = () => new Promise(() => {});
|
|
48
|
+
|
|
49
|
+
const Screen = ({ onRemoveAccount }: { onRemoveAccount: () => void }) => {
|
|
50
|
+
const { data } = useQuery(configOperationsGetConfigOptions());
|
|
51
|
+
const account = data?.accounts[0];
|
|
52
|
+
if (!account) return null;
|
|
53
|
+
return createElement(AccountServices, { account, onRemoveAccount });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const mount = async (
|
|
57
|
+
initial: RemitImapAccountResponse,
|
|
58
|
+
patch: Patch = hang,
|
|
59
|
+
onRemoveAccount: () => void = () => {},
|
|
60
|
+
): Promise<DomHarness> => {
|
|
61
|
+
let stored = initial;
|
|
62
|
+
http = mockFetch((call) => {
|
|
63
|
+
if (call.path.endsWith("/config")) return { accounts: [stored] };
|
|
64
|
+
if (call.path.endsWith("/oauth/microsoft/start")) {
|
|
65
|
+
return { authorizationUrl: CONSENT_URL };
|
|
66
|
+
}
|
|
67
|
+
if (call.method === "PATCH") {
|
|
68
|
+
const answer = patch(call);
|
|
69
|
+
if (answer !== undefined) return answer;
|
|
70
|
+
stored = { ...stored, ...call.body };
|
|
71
|
+
return stored;
|
|
72
|
+
}
|
|
73
|
+
return {};
|
|
74
|
+
});
|
|
75
|
+
const mounted = createDomHarness();
|
|
76
|
+
harness = mounted;
|
|
77
|
+
mounted.renderApp(createElement(Screen, { onRemoveAccount }));
|
|
78
|
+
await mounted.waitFor(
|
|
79
|
+
() => (http?.to("/config").length ?? 0) > 0,
|
|
80
|
+
"the account to load",
|
|
81
|
+
);
|
|
82
|
+
await mounted.wait(10);
|
|
83
|
+
return mounted;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const switchFor = (mounted: DomHarness, name: string): HTMLButtonElement =>
|
|
87
|
+
mounted.byLabel(`Sync ${name}`) as HTMLButtonElement;
|
|
88
|
+
|
|
89
|
+
const checked = (mounted: DomHarness, name: string): string | null =>
|
|
90
|
+
switchFor(mounted, name).getAttribute("aria-checked");
|
|
91
|
+
|
|
92
|
+
const press = (mounted: DomHarness, text: string): void =>
|
|
93
|
+
mounted.click(mounted.byText("button", text));
|
|
94
|
+
|
|
95
|
+
const patches = (): HttpCall[] =>
|
|
96
|
+
(http?.calls ?? []).filter((call) => call.method === "PATCH");
|
|
97
|
+
|
|
98
|
+
describe("AccountServices", () => {
|
|
99
|
+
it("renders no switches for an IMAP account", async () => {
|
|
100
|
+
const mounted = await mount(
|
|
101
|
+
outlook({ authType: "password", grantedScopes: [] }),
|
|
102
|
+
);
|
|
103
|
+
assert.equal(mounted.queryAll('[role="switch"]').length, 0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("shows each switch in the state the server holds", async () => {
|
|
107
|
+
const mounted = await mount(outlook());
|
|
108
|
+
assert.equal(checked(mounted, "mail"), "true");
|
|
109
|
+
assert.equal(checked(mounted, "calendar"), "false");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("saves calendar on and settles on the value the server returns", async () => {
|
|
113
|
+
const mounted = await mount(outlook(), () => undefined);
|
|
114
|
+
|
|
115
|
+
mounted.click(switchFor(mounted, "calendar"));
|
|
116
|
+
await mounted.waitFor(
|
|
117
|
+
() => (http?.to("/config").length ?? 0) >= 2,
|
|
118
|
+
"the refetch after the save",
|
|
119
|
+
);
|
|
120
|
+
await mounted.flush();
|
|
121
|
+
|
|
122
|
+
assert.deepEqual(patches()[0]?.body, {
|
|
123
|
+
syncedServices: ["Mail", "Calendar"],
|
|
124
|
+
});
|
|
125
|
+
assert.equal(checked(mounted, "calendar"), "true");
|
|
126
|
+
assert.equal(switchFor(mounted, "calendar").disabled, false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("holds both switches still while a save is in flight", async () => {
|
|
130
|
+
const mounted = await mount(outlook());
|
|
131
|
+
|
|
132
|
+
mounted.click(switchFor(mounted, "calendar"));
|
|
133
|
+
await mounted.waitFor(() => patches().length > 0, "the save to start");
|
|
134
|
+
|
|
135
|
+
assert.equal(checked(mounted, "calendar"), "true");
|
|
136
|
+
assert.equal(switchFor(mounted, "mail").disabled, true);
|
|
137
|
+
assert.equal(switchFor(mounted, "calendar").disabled, true);
|
|
138
|
+
mounted.click(switchFor(mounted, "mail"));
|
|
139
|
+
assert.equal(patches().length, 1);
|
|
140
|
+
assert.doesNotMatch(mounted.text(), /Stop syncing mail/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("states a refused calendar-on in the server's words, offers a report, and turns the switch back", async () => {
|
|
144
|
+
const mounted = await mount(outlook(), () => httpError(400, REFUSAL));
|
|
145
|
+
|
|
146
|
+
mounted.click(switchFor(mounted, "calendar"));
|
|
147
|
+
await mounted.waitFor(
|
|
148
|
+
() => mounted.text().includes(REFUSAL),
|
|
149
|
+
"the refusal to show",
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
assert.match(mounted.text(), /Couldn't turn calendar on/);
|
|
153
|
+
const report = mounted.byText("a", "Report an issue");
|
|
154
|
+
assert.match(
|
|
155
|
+
report.getAttribute("href") ?? "",
|
|
156
|
+
/github\.com\/.+\/issues\/new/,
|
|
157
|
+
);
|
|
158
|
+
assert.equal(checked(mounted, "calendar"), "false");
|
|
159
|
+
assert.equal(switchFor(mounted, "calendar").disabled, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("asks before mail goes off and keeps mail on when the server refuses", async () => {
|
|
163
|
+
const mounted = await mount(
|
|
164
|
+
outlook({ syncedServices: ["Mail", "Calendar"] }),
|
|
165
|
+
() => httpError(400, REFUSAL),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
mounted.click(switchFor(mounted, "mail"));
|
|
169
|
+
assert.match(mounted.text(), /Mail already in Remit stays/);
|
|
170
|
+
assert.equal(patches().length, 0);
|
|
171
|
+
|
|
172
|
+
press(mounted, "Stop syncing mail");
|
|
173
|
+
await mounted.waitFor(
|
|
174
|
+
() => mounted.text().includes(REFUSAL),
|
|
175
|
+
"the refusal to show",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(patches()[0]?.body, { syncedServices: ["Calendar"] });
|
|
179
|
+
assert.match(mounted.text(), /Couldn't turn mail off/);
|
|
180
|
+
assert.equal(checked(mounted, "mail"), "true");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("clears the refusal on the next try", async () => {
|
|
184
|
+
let answer: Patch = () => httpError(400, REFUSAL);
|
|
185
|
+
const mounted = await mount(outlook(), (call) => answer(call));
|
|
186
|
+
|
|
187
|
+
mounted.click(switchFor(mounted, "calendar"));
|
|
188
|
+
await mounted.waitFor(
|
|
189
|
+
() => mounted.text().includes(REFUSAL),
|
|
190
|
+
"the refusal to show",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
answer = hang;
|
|
194
|
+
mounted.click(switchFor(mounted, "calendar"));
|
|
195
|
+
await mounted.waitFor(() => patches().length === 2, "the second save");
|
|
196
|
+
|
|
197
|
+
assert.doesNotMatch(mounted.text(), /Couldn't turn calendar on/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("sends an ungranted calendar through the Microsoft sign-in, saying so first", async () => {
|
|
201
|
+
const mounted = await mount(outlook({ grantedScopes: MAIL_SCOPES }));
|
|
202
|
+
|
|
203
|
+
assert.match(mounted.text(), /signing in with Microsoft again/);
|
|
204
|
+
mounted.click(switchFor(mounted, "calendar"));
|
|
205
|
+
assert.match(mounted.text(), /Sign in with Microsoft to add calendar\?/);
|
|
206
|
+
|
|
207
|
+
press(mounted, "Continue to Microsoft");
|
|
208
|
+
await mounted.waitFor(
|
|
209
|
+
() => window.location.hash === "#microsoft-consent",
|
|
210
|
+
"the redirect to Microsoft",
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const [start] = http?.to("/oauth/microsoft/start") ?? [];
|
|
214
|
+
assert.deepEqual(start?.body, {
|
|
215
|
+
email: "alice@example.com",
|
|
216
|
+
services: ["Mail", "Calendar"],
|
|
217
|
+
});
|
|
218
|
+
assert.equal(patches().length, 0);
|
|
219
|
+
assert.equal(switchFor(mounted, "calendar").disabled, true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("reflects the granted scopes once the sign-in returns", async () => {
|
|
223
|
+
const mounted = await mount(
|
|
224
|
+
outlook({ syncedServices: ["Mail", "Calendar"] }),
|
|
225
|
+
);
|
|
226
|
+
assert.equal(checked(mounted, "calendar"), "true");
|
|
227
|
+
assert.doesNotMatch(mounted.text(), /signing in with Microsoft again/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("leaves mail on when the question is cancelled", async () => {
|
|
231
|
+
const mounted = await mount(
|
|
232
|
+
outlook({ syncedServices: ["Mail", "Calendar"] }),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
mounted.click(switchFor(mounted, "mail"));
|
|
236
|
+
press(mounted, "Cancel");
|
|
237
|
+
|
|
238
|
+
assert.equal(patches().length, 0);
|
|
239
|
+
assert.equal(checked(mounted, "mail"), "true");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("routes switching off the last service to removing the account", async () => {
|
|
243
|
+
let removals = 0;
|
|
244
|
+
const mounted = await mount(outlook(), hang, () => {
|
|
245
|
+
removals += 1;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
mounted.click(switchFor(mounted, "mail"));
|
|
249
|
+
press(mounted, "Remove account");
|
|
250
|
+
|
|
251
|
+
assert.equal(removals, 1);
|
|
252
|
+
assert.equal(patches().length, 0);
|
|
253
|
+
});
|
|
254
|
+
});
|