@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.
- package/package.json +2 -2
- package/src/components/mail/MoveToTrigger.render.test.ts +153 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +145 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
|
|
30
30
|
"preview": "vite preview",
|
|
31
31
|
"test:typecheck": "npm run generate:routes && tsgo --noEmit",
|
|
32
|
-
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=
|
|
32
|
+
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
|
|
33
33
|
"test": "npm run test:typecheck && npm run test:run"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move-to-folder picker. It only fetches folders once it is opened, it
|
|
3
|
+
* marks the folder the messages are already in rather than offering it as a
|
|
4
|
+
* destination, and on desktop Escape or a click outside puts it away.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, describe, it } from "node:test";
|
|
9
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
+
import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
13
|
+
import { makeMailbox } from "../../test-support/fixtures";
|
|
14
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
15
|
+
|
|
16
|
+
let harness: DomHarness | undefined;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
harness?.close();
|
|
20
|
+
harness = undefined;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ACCOUNT_ID = "acc-1";
|
|
24
|
+
|
|
25
|
+
const mount = (
|
|
26
|
+
options: {
|
|
27
|
+
mailboxes?: RemitImapMailboxResponse[];
|
|
28
|
+
currentMailboxId?: string;
|
|
29
|
+
disabledHint?: string;
|
|
30
|
+
onMove?: (destinationMailboxId: string) => void;
|
|
31
|
+
viewportWidth?: number;
|
|
32
|
+
} = {},
|
|
33
|
+
): DomHarness => {
|
|
34
|
+
harness = createDomHarness({ viewportWidth: options.viewportWidth });
|
|
35
|
+
if (options.mailboxes) {
|
|
36
|
+
harness.queryClient.setQueryData(
|
|
37
|
+
mailboxOperationsListMailboxesQueryKey({
|
|
38
|
+
path: { accountId: ACCOUNT_ID },
|
|
39
|
+
}),
|
|
40
|
+
{ items: options.mailboxes },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
harness.renderApp(
|
|
44
|
+
createElement(MoveToTrigger, {
|
|
45
|
+
accountId: ACCOUNT_ID,
|
|
46
|
+
currentMailboxId: options.currentMailboxId ?? "mbx-inbox",
|
|
47
|
+
onMove: options.onMove ?? (() => undefined),
|
|
48
|
+
disabledHint: options.disabledHint,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
return harness;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const FOLDERS = [
|
|
55
|
+
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
56
|
+
makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" }),
|
|
57
|
+
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
describe("MoveToTrigger", () => {
|
|
61
|
+
it("reports itself as collapsed until it is opened", () => {
|
|
62
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
63
|
+
const trigger = dom.byLabel("Move to folder");
|
|
64
|
+
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
65
|
+
assert.equal(trigger.getAttribute("aria-controls"), null);
|
|
66
|
+
assert.equal(dom.query('[role="listbox"], input'), null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("opens a listbox of destinations and marks the folder we are in", () => {
|
|
70
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
71
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
72
|
+
|
|
73
|
+
assert.equal(
|
|
74
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
75
|
+
"true",
|
|
76
|
+
);
|
|
77
|
+
const labels = dom
|
|
78
|
+
.queryAll("[role=option]")
|
|
79
|
+
.map((option) => option.textContent ?? "");
|
|
80
|
+
assert.ok(labels.some((label) => label.includes("Work")));
|
|
81
|
+
assert.ok(labels.some((label) => label.includes("Receipts")));
|
|
82
|
+
|
|
83
|
+
// The folder the messages are already in stays in the list, marked as
|
|
84
|
+
// where they are now rather than presented as somewhere to move them.
|
|
85
|
+
const current = labels.find((label) => label.includes("current"));
|
|
86
|
+
assert.ok(current, "the source folder is marked as the current one");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("moves to the folder the user picks and closes itself", () => {
|
|
90
|
+
const moved: string[] = [];
|
|
91
|
+
const dom = mount({
|
|
92
|
+
mailboxes: FOLDERS,
|
|
93
|
+
onMove: (id) => moved.push(id),
|
|
94
|
+
});
|
|
95
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
96
|
+
dom.click(dom.byText("[role=option]", "Work"));
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(moved, ["mbx-work"]);
|
|
99
|
+
assert.equal(
|
|
100
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
101
|
+
"false",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("closes on Escape and on a click outside it", () => {
|
|
106
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
107
|
+
const isOpen = () =>
|
|
108
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded") === "true";
|
|
109
|
+
|
|
110
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
111
|
+
assert.equal(isOpen(), true);
|
|
112
|
+
dom.dispatch(
|
|
113
|
+
dom.window.document,
|
|
114
|
+
new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
|
|
115
|
+
);
|
|
116
|
+
assert.equal(isOpen(), false);
|
|
117
|
+
|
|
118
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
119
|
+
assert.equal(isOpen(), true);
|
|
120
|
+
dom.dispatch(
|
|
121
|
+
dom.window.document.body,
|
|
122
|
+
new dom.window.MouseEvent("mousedown", { bubbles: true }),
|
|
123
|
+
);
|
|
124
|
+
assert.equal(isOpen(), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("refuses to open, and says why, when the selection spans accounts", () => {
|
|
128
|
+
const hint = "Select messages from one account to move them";
|
|
129
|
+
const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
|
|
130
|
+
const trigger = dom.byLabel("Move to folder");
|
|
131
|
+
assert.equal(trigger.getAttribute("title"), hint);
|
|
132
|
+
|
|
133
|
+
dom.click(trigger);
|
|
134
|
+
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("opens a dialog rather than a popover on a phone", () => {
|
|
138
|
+
const dom = mount({ mailboxes: FOLDERS, viewportWidth: 390 });
|
|
139
|
+
assert.equal(
|
|
140
|
+
dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
|
|
141
|
+
"dialog",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("asks for the folder list only once the picker is opened", () => {
|
|
146
|
+
const dom = mount();
|
|
147
|
+
assert.equal(dom.queryAll("[role=option]").length, 0);
|
|
148
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
149
|
+
// No cached mailboxes and no network in the test harness: the picker
|
|
150
|
+
// shows its loading state rather than an empty list of destinations.
|
|
151
|
+
assert.match(dom.text(), /Loading folders/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bulk-action bar. Two rules govern it: actions are never disabled (they
|
|
3
|
+
* no-op and explain — `doc/rules/ux.md`), and Move/Organize only appear when
|
|
4
|
+
* the selection is inside a single account, because neither works across
|
|
5
|
+
* accounts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
12
|
+
import { SelectionToolbar } from "./SelectionToolbar";
|
|
13
|
+
|
|
14
|
+
let harness: DomHarness | undefined;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
harness?.close();
|
|
18
|
+
harness = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type ToolbarProps = Parameters<typeof SelectionToolbar>[0];
|
|
22
|
+
|
|
23
|
+
const mount = (props: Partial<ToolbarProps> = {}): DomHarness => {
|
|
24
|
+
harness = createDomHarness();
|
|
25
|
+
harness.renderApp(
|
|
26
|
+
createElement(SelectionToolbar, {
|
|
27
|
+
selectedCount: 2,
|
|
28
|
+
onDelete: () => undefined,
|
|
29
|
+
onClearSelection: () => undefined,
|
|
30
|
+
...props,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
return harness;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("SelectionToolbar", () => {
|
|
37
|
+
it("renders nothing while nothing is selected", () => {
|
|
38
|
+
const dom = mount({ selectedCount: 0 });
|
|
39
|
+
assert.equal(dom.html(), "");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("counts the selection in words the user reads", () => {
|
|
43
|
+
assert.match(mount({ selectedCount: 1 }).text(), /1 message selected/);
|
|
44
|
+
harness?.close();
|
|
45
|
+
harness = undefined;
|
|
46
|
+
assert.match(mount({ selectedCount: 4 }).text(), /4 messages selected/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("hides Move until both an account and a source mailbox are known", () => {
|
|
50
|
+
const dom = mount({ onMove: () => undefined, accountId: "acc-1" });
|
|
51
|
+
assert.equal(dom.query('[aria-label="Move selected messages"]'), null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("offers Move and Organize once the selection sits in one account", () => {
|
|
55
|
+
const dom = mount({
|
|
56
|
+
onMove: () => undefined,
|
|
57
|
+
onOrganize: () => undefined,
|
|
58
|
+
accountId: "acc-1",
|
|
59
|
+
currentMailboxId: "mbx-inbox",
|
|
60
|
+
});
|
|
61
|
+
assert.ok(dom.query('[aria-label="Move selected messages"]'));
|
|
62
|
+
assert.ok(dom.query('[aria-label="Organize similar messages"]'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("withdraws Organize and explains why when the selection spans accounts", () => {
|
|
66
|
+
const dom = mount({
|
|
67
|
+
onMove: () => undefined,
|
|
68
|
+
onOrganize: () => undefined,
|
|
69
|
+
accountId: "acc-1",
|
|
70
|
+
currentMailboxId: "mbx-inbox",
|
|
71
|
+
moveDisabledHint: "Select messages from one account to move them",
|
|
72
|
+
});
|
|
73
|
+
assert.equal(dom.query('[aria-label="Organize similar messages"]'), null);
|
|
74
|
+
assert.match(dom.text(), /Select messages from one account to move them/);
|
|
75
|
+
assert.match(
|
|
76
|
+
dom.query('[role="status"]')?.textContent ?? "",
|
|
77
|
+
/one account/,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps every action pressable while a mutation is in flight (ux.md)", () => {
|
|
82
|
+
const dom = mount({ isDeleting: true, onMarkAsRead: () => undefined });
|
|
83
|
+
for (const button of dom.queryAll<HTMLButtonElement>("button")) {
|
|
84
|
+
assert.equal(button.disabled, false);
|
|
85
|
+
}
|
|
86
|
+
assert.equal(
|
|
87
|
+
dom.byLabel("Delete selected messages").getAttribute("aria-busy"),
|
|
88
|
+
"true",
|
|
89
|
+
);
|
|
90
|
+
assert.match(dom.text(), /Deleting\.\.\./);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("no-ops rather than firing a second time while busy", () => {
|
|
94
|
+
let deletes = 0;
|
|
95
|
+
let marks = 0;
|
|
96
|
+
const dom = mount({
|
|
97
|
+
isMoving: true,
|
|
98
|
+
onDelete: () => {
|
|
99
|
+
deletes += 1;
|
|
100
|
+
},
|
|
101
|
+
onMarkAsRead: () => {
|
|
102
|
+
marks += 1;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
dom.click(dom.byLabel("Delete selected messages"));
|
|
106
|
+
dom.click(dom.byLabel("Mark as read"));
|
|
107
|
+
assert.equal(deletes, 0);
|
|
108
|
+
assert.equal(marks, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fires delete, mark-read and clear when idle", () => {
|
|
112
|
+
let cleared = 0;
|
|
113
|
+
let deletes = 0;
|
|
114
|
+
let marks = 0;
|
|
115
|
+
const dom = mount({
|
|
116
|
+
onDelete: () => {
|
|
117
|
+
deletes += 1;
|
|
118
|
+
},
|
|
119
|
+
onClearSelection: () => {
|
|
120
|
+
cleared += 1;
|
|
121
|
+
},
|
|
122
|
+
onMarkAsRead: () => {
|
|
123
|
+
marks += 1;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
dom.click(dom.byLabel("Delete selected messages"));
|
|
127
|
+
dom.click(dom.byLabel("Mark as read"));
|
|
128
|
+
dom.click(dom.byLabel("Clear selection"));
|
|
129
|
+
assert.deepEqual([deletes, marks, cleared], [1, 1, 1]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("opens the organize flow for the current selection", () => {
|
|
133
|
+
let organized = 0;
|
|
134
|
+
const dom = mount({
|
|
135
|
+
onMove: () => undefined,
|
|
136
|
+
accountId: "acc-1",
|
|
137
|
+
currentMailboxId: "mbx-inbox",
|
|
138
|
+
onOrganize: () => {
|
|
139
|
+
organized += 1;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
dom.click(dom.byLabel("Organize similar messages"));
|
|
143
|
+
assert.equal(organized, 1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -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
|
+
});
|