@remit/web-client 0.0.228 → 0.0.230
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 +1 -1
- package/src/components/compose/ComposeForm.tsx +96 -61
- package/src/components/compose/quote-leaves-with-the-send.render.test.ts +234 -0
- package/src/components/compose/quoted-message.test.ts +158 -0
- package/src/components/compose/quoted-message.ts +216 -0
- package/src/components/config-import/ConfigImportWizard.flow.test.ts +338 -0
- package/src/components/config-import/ConfigImportWizard.tsx +720 -0
- package/src/components/config-import/config-import.fixtures.ts +229 -0
- package/src/components/config-import/config-import.stories.tsx +449 -0
- package/src/components/config-import/steps.tsx +772 -0
- package/src/components/onboarding/OnboardingWizard.tsx +64 -390
- package/src/components/onboarding/credential-steps.tsx +441 -0
- package/src/components/settings/ConfigExportCard.tsx +114 -0
- package/src/components/settings/ConfigTransferPanel.tsx +72 -0
- package/src/lib/config-import.test.ts +306 -0
- package/src/lib/config-import.ts +492 -0
- package/src/lib/json.ts +11 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/import.tsx +51 -0
- package/src/routes/onboarding.tsx +9 -2
- package/src/routes/settings/accounts.tsx +30 -8
- package/src/routes/settings/advanced.tsx +5 -1
- package/src/routing/sections.ts +3 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { RemitImapDescribeMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { type RichTextValue, wrapWithLanguage } from "@remit/ui";
|
|
3
|
+
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
4
|
+
import { formatDate } from "../../lib/format";
|
|
5
|
+
|
|
6
|
+
type Envelope = RemitImapDescribeMessageResponse["envelope"];
|
|
7
|
+
type EnvelopeAddress = Envelope["from"][number];
|
|
8
|
+
|
|
9
|
+
/** Whether the original is being answered or passed on. */
|
|
10
|
+
export type QuoteKind = "reply" | "forward";
|
|
11
|
+
|
|
12
|
+
/** The message being quoted, in both the representations a send carries. */
|
|
13
|
+
export interface QuotedBlock {
|
|
14
|
+
text: string;
|
|
15
|
+
html: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The original's body as the content route hands it over: one part, either
|
|
20
|
+
* HTML or plain text, never both. The other representation is derived here,
|
|
21
|
+
* because a message goes out with both and HTML markup dropped into the
|
|
22
|
+
* `text/plain` part arrives as visible tags.
|
|
23
|
+
*/
|
|
24
|
+
export type QuotedSourceBody =
|
|
25
|
+
| { kind: "text"; content: string }
|
|
26
|
+
| { kind: "html"; content: string };
|
|
27
|
+
|
|
28
|
+
export const escapeHtml = (text: string): string =>
|
|
29
|
+
text
|
|
30
|
+
.replace(/&/g, "&")
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, """);
|
|
34
|
+
|
|
35
|
+
export const textToHtml = (text: string): string =>
|
|
36
|
+
text
|
|
37
|
+
.split("\n")
|
|
38
|
+
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
|
39
|
+
.join("");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Elements that end the line they are on. Everything else is inline and runs
|
|
43
|
+
* into its neighbours, which is what separates a plain-text rendering of a
|
|
44
|
+
* message from its `textContent` — that returns one long line with the words
|
|
45
|
+
* of adjacent paragraphs joined together.
|
|
46
|
+
*/
|
|
47
|
+
const LINE_BREAKING_TAGS = new Set([
|
|
48
|
+
"ADDRESS",
|
|
49
|
+
"ARTICLE",
|
|
50
|
+
"BLOCKQUOTE",
|
|
51
|
+
"BR",
|
|
52
|
+
"DIV",
|
|
53
|
+
"H1",
|
|
54
|
+
"H2",
|
|
55
|
+
"H3",
|
|
56
|
+
"H4",
|
|
57
|
+
"H5",
|
|
58
|
+
"H6",
|
|
59
|
+
"HR",
|
|
60
|
+
"LI",
|
|
61
|
+
"OL",
|
|
62
|
+
"P",
|
|
63
|
+
"PRE",
|
|
64
|
+
"SECTION",
|
|
65
|
+
"TABLE",
|
|
66
|
+
"TR",
|
|
67
|
+
"UL",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const htmlToPlainText = (html: string): string => {
|
|
71
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
let line = "";
|
|
74
|
+
|
|
75
|
+
const endLine = (): void => {
|
|
76
|
+
lines.push(line.trim());
|
|
77
|
+
line = "";
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const visit = (node: Node): void => {
|
|
81
|
+
if (node.nodeType === node.TEXT_NODE) {
|
|
82
|
+
line += (node.textContent ?? "").replace(/\s+/g, " ");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (node.nodeType !== node.ELEMENT_NODE) return;
|
|
86
|
+
const breaks = LINE_BREAKING_TAGS.has((node as Element).tagName);
|
|
87
|
+
if (breaks && line !== "") endLine();
|
|
88
|
+
for (const child of node.childNodes) visit(child);
|
|
89
|
+
if (breaks) endLine();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
visit(parsed.body);
|
|
93
|
+
if (line !== "") endLine();
|
|
94
|
+
return lines
|
|
95
|
+
.join("\n")
|
|
96
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
97
|
+
.trim();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const addressLabel = (address: EnvelopeAddress): string =>
|
|
101
|
+
address.displayName
|
|
102
|
+
? `${address.displayName} <${address.normalizedEmail}>`
|
|
103
|
+
: address.normalizedEmail;
|
|
104
|
+
|
|
105
|
+
const addressList = (addresses: readonly EnvelopeAddress[]): string =>
|
|
106
|
+
addresses.map(addressLabel).join(", ");
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An absolute date, because the attribution outlives the moment it was written
|
|
110
|
+
* — the relative form the message list uses would reach the recipient reading
|
|
111
|
+
* "Yesterday" about a day that is no longer yesterday.
|
|
112
|
+
*/
|
|
113
|
+
const quotedDate = (date: Envelope["date"]): string =>
|
|
114
|
+
formatDate(date, {
|
|
115
|
+
year: "numeric",
|
|
116
|
+
month: "short",
|
|
117
|
+
day: "numeric",
|
|
118
|
+
hour: "numeric",
|
|
119
|
+
minute: "numeric",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** The form `snippet.ts` and the search chunker both recognise as a quote. */
|
|
123
|
+
const attributionLine = (envelope: Envelope): string => {
|
|
124
|
+
const who = addressList(envelope.from) || "the sender";
|
|
125
|
+
const when = quotedDate(envelope.date);
|
|
126
|
+
if (!when) return `${who} wrote:`;
|
|
127
|
+
return `On ${when}, ${who} wrote:`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const FORWARD_RULE = "---------- Forwarded message ----------";
|
|
131
|
+
|
|
132
|
+
const forwardHeader = (envelope: Envelope): string[] => {
|
|
133
|
+
const lines = [
|
|
134
|
+
FORWARD_RULE,
|
|
135
|
+
`From: ${addressList(envelope.from)}`,
|
|
136
|
+
`Date: ${quotedDate(envelope.date)}`,
|
|
137
|
+
`Subject: ${envelope.subject ?? ""}`,
|
|
138
|
+
`To: ${addressList(envelope.to)}`,
|
|
139
|
+
];
|
|
140
|
+
if (envelope.cc.length > 0) lines.push(`Cc: ${addressList(envelope.cc)}`);
|
|
141
|
+
return lines;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const quotePrefixed = (text: string): string[] =>
|
|
145
|
+
text.split("\n").map((line) => (line === "" ? ">" : `> ${line}`));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The original, as the message being written will carry it. One value, so what
|
|
149
|
+
* the composer shows under the editor is the same block that leaves for the
|
|
150
|
+
* API rather than a second rendering of the same source (#845.5).
|
|
151
|
+
*
|
|
152
|
+
* A reply indents the original behind an attribution line; a forward passes it
|
|
153
|
+
* on whole under the header block, because a forward is the original message
|
|
154
|
+
* rather than a passage cited from it.
|
|
155
|
+
*/
|
|
156
|
+
export const buildQuotedBlock = (
|
|
157
|
+
kind: QuoteKind,
|
|
158
|
+
envelope: Envelope,
|
|
159
|
+
body: QuotedSourceBody,
|
|
160
|
+
): QuotedBlock => {
|
|
161
|
+
const bodyText =
|
|
162
|
+
body.kind === "text" ? body.content.trim() : htmlToPlainText(body.content);
|
|
163
|
+
const bodyHtml =
|
|
164
|
+
body.kind === "html" ? body.content : textToHtml(body.content.trim());
|
|
165
|
+
|
|
166
|
+
if (kind === "forward") {
|
|
167
|
+
const header = forwardHeader(envelope);
|
|
168
|
+
return {
|
|
169
|
+
text: [...header, "", bodyText].join("\n"),
|
|
170
|
+
html: `${textToHtml(header.join("\n"))}${bodyHtml}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const attribution = attributionLine(envelope);
|
|
175
|
+
return {
|
|
176
|
+
text: [attribution, ...quotePrefixed(bodyText)].join("\n"),
|
|
177
|
+
html: `<p>${escapeHtml(attribution)}</p><blockquote type="cite">${bodyHtml}</blockquote>`,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const joinText = (written: string, quoted: string): string =>
|
|
182
|
+
written === "" ? quoted : `${written}\n\n${quoted}`;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What the two body columns carry for this mode.
|
|
186
|
+
*
|
|
187
|
+
* Plain mode writes the empty string rather than omitting `htmlBody`: absent
|
|
188
|
+
* means "leave alone" at every layer below, so a plain draft that omitted it
|
|
189
|
+
* would send the HTML it was written as before the switch. The empty string is
|
|
190
|
+
* defined, so the repository's update guard clears the column, and nodemailer
|
|
191
|
+
* branches on the value being truthy — an empty one builds no HTML alternative
|
|
192
|
+
* and the message leaves as a single `text/plain` part. It is also why the
|
|
193
|
+
* quote reaches a plain message through `textBody` alone: putting its markup in
|
|
194
|
+
* `htmlBody` would reopen the draft as rich and send the recipient an HTML
|
|
195
|
+
* alternative of a message written as plain text.
|
|
196
|
+
*
|
|
197
|
+
* Rich mode leaves `htmlBody` alone while it has nothing to say, so the moments
|
|
198
|
+
* before the lazily-loaded editor reports its document cannot write a draft
|
|
199
|
+
* back as plain. A quote is something to say, so a send pressed in those
|
|
200
|
+
* moments still carries the message it quotes.
|
|
201
|
+
*/
|
|
202
|
+
export const outgoingBody = (
|
|
203
|
+
bodyMode: ComposeBodyMode,
|
|
204
|
+
body: RichTextValue,
|
|
205
|
+
language: string,
|
|
206
|
+
quoted: QuotedBlock | undefined,
|
|
207
|
+
): { textBody: string | undefined; htmlBody: string | undefined } => {
|
|
208
|
+
const text = quoted ? joinText(body.text, quoted.text) : body.text;
|
|
209
|
+
if (bodyMode === "plain")
|
|
210
|
+
return { textBody: text || undefined, htmlBody: "" };
|
|
211
|
+
const html = quoted ? `${body.html}${quoted.html}` : body.html;
|
|
212
|
+
return {
|
|
213
|
+
textBody: text || undefined,
|
|
214
|
+
htmlBody: html ? wrapWithLanguage(html, language) : undefined,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking the config import wizard against a mocked instance.
|
|
3
|
+
*
|
|
4
|
+
* Every state here is one the components could already render in Storybook; the
|
|
5
|
+
* question this file answers is whether the app can ever reach them. Each of
|
|
6
|
+
* these was unreachable at one point: the 409 came back with its code on the
|
|
7
|
+
* thrown wrapper's body rather than on the error, an account whose password had
|
|
8
|
+
* just been verified stayed red because `connectionState` is written later by a
|
|
9
|
+
* worker, and "Check the file" was live before there was a document to send.
|
|
10
|
+
*
|
|
11
|
+
* Held with the real caches wired to `lib/query-error-handler.ts`, because
|
|
12
|
+
* "does the fatal overlay swallow this" is a question about what those two
|
|
13
|
+
* handlers pass, and nothing below them can see it. The overlay itself is not
|
|
14
|
+
* mounted — `getCurrentFatalError` is the same signal without a second app's
|
|
15
|
+
* worth of chrome in the tree.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, describe, it } from "node:test";
|
|
20
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
21
|
+
import { createElement } from "react";
|
|
22
|
+
import { ApiError } from "../../lib/api";
|
|
23
|
+
import "../../lib/client";
|
|
24
|
+
import { shouldEscalate, softErrorStatuses } from "../../lib/error-classifier";
|
|
25
|
+
import { __resetFatalError, getCurrentFatalError } from "../../lib/fatal-error";
|
|
26
|
+
import {
|
|
27
|
+
handleMutationCacheError,
|
|
28
|
+
handleQueryCacheError,
|
|
29
|
+
} from "../../lib/query-error-handler";
|
|
30
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
31
|
+
import {
|
|
32
|
+
type HttpCall,
|
|
33
|
+
type HttpMock,
|
|
34
|
+
mockFetch,
|
|
35
|
+
} from "../../test-support/http";
|
|
36
|
+
import { StepWelcome } from "../onboarding/OnboardingWizard";
|
|
37
|
+
import { ConfigImportWizard } from "./ConfigImportWizard";
|
|
38
|
+
|
|
39
|
+
let harness: DomHarness | undefined;
|
|
40
|
+
let http: HttpMock | undefined;
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
harness?.close();
|
|
44
|
+
harness = undefined;
|
|
45
|
+
http?.restore();
|
|
46
|
+
http = undefined;
|
|
47
|
+
__resetFatalError();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const ACCOUNT_ID = "acct-imported";
|
|
51
|
+
|
|
52
|
+
const escalatingClient = (): QueryClient =>
|
|
53
|
+
new QueryClient({
|
|
54
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
55
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
56
|
+
defaultOptions: {
|
|
57
|
+
queries: { retry: false },
|
|
58
|
+
mutations: { retry: false },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The flat body the API emits, at the status it emits it with. */
|
|
63
|
+
const refusal = (status: number, body: unknown): Response =>
|
|
64
|
+
new Response(JSON.stringify(body), {
|
|
65
|
+
status,
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const report = (overrides: Record<string, unknown> = {}) => ({
|
|
70
|
+
valid: true,
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
applied: false,
|
|
73
|
+
items: [{ section: "accounts", key: "a@example.test", verdict: "created" }],
|
|
74
|
+
errors: [],
|
|
75
|
+
warnings: [],
|
|
76
|
+
accountsNeedingCredentials: [],
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const importedAccount = (): Record<string, unknown> => ({
|
|
81
|
+
accountId: ACCOUNT_ID,
|
|
82
|
+
accountConfigId: "cfg-1",
|
|
83
|
+
username: "a@example.test",
|
|
84
|
+
email: "a@example.test",
|
|
85
|
+
authType: "password",
|
|
86
|
+
imapHost: "imap.example.test",
|
|
87
|
+
imapPort: 993,
|
|
88
|
+
imapTls: true,
|
|
89
|
+
imapStartTls: false,
|
|
90
|
+
smtpEnabled: false,
|
|
91
|
+
smtpHost: "",
|
|
92
|
+
smtpPort: 587,
|
|
93
|
+
smtpTls: false,
|
|
94
|
+
smtpStartTls: false,
|
|
95
|
+
smtpUsername: "",
|
|
96
|
+
isActive: false,
|
|
97
|
+
// The server keeps saying this: only the imap worker clears it, on a
|
|
98
|
+
// connection that has not happened yet.
|
|
99
|
+
connectionState: "credentials_missing",
|
|
100
|
+
createdAt: 0,
|
|
101
|
+
updatedAt: 0,
|
|
102
|
+
folderAppointments: [],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
interface Backend {
|
|
106
|
+
onImport?: (call: HttpCall) => unknown;
|
|
107
|
+
accounts?: Array<Record<string, unknown>>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const done: string[] = [];
|
|
111
|
+
|
|
112
|
+
const start = (backend: Backend = {}): DomHarness => {
|
|
113
|
+
done.length = 0;
|
|
114
|
+
http = mockFetch((call) => {
|
|
115
|
+
if (call.path === "/config/import") {
|
|
116
|
+
return backend.onImport?.(call) ?? report();
|
|
117
|
+
}
|
|
118
|
+
if (call.path === "/config") {
|
|
119
|
+
return { accounts: backend.accounts ?? [], mailboxes: [] };
|
|
120
|
+
}
|
|
121
|
+
if (call.path === "/accounts/test-connection") {
|
|
122
|
+
return { imapSuccess: true, smtpSuccess: true };
|
|
123
|
+
}
|
|
124
|
+
return {};
|
|
125
|
+
});
|
|
126
|
+
harness = createDomHarness({ queryClient: escalatingClient() });
|
|
127
|
+
harness.renderApp(
|
|
128
|
+
createElement(ConfigImportWizard, {
|
|
129
|
+
onDone: (outcome: string) => done.push(outcome),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
return harness;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Hand the picker a file. jsdom has no `DataTransfer`, so the drop path cannot
|
|
137
|
+
* be driven from here; the input behind "Choose file" is the same entry point
|
|
138
|
+
* and takes a `files` list that only needs `item`.
|
|
139
|
+
*/
|
|
140
|
+
const pickFile = async (dom: DomHarness, name: string, text: string) => {
|
|
141
|
+
const input = dom.query<HTMLInputElement>('input[type="file"]');
|
|
142
|
+
assert.ok(input, "expected the file input");
|
|
143
|
+
const file = new File([text], name, { type: "application/json" });
|
|
144
|
+
Object.defineProperty(input, "files", {
|
|
145
|
+
configurable: true,
|
|
146
|
+
value: { 0: file, length: 1, item: (i: number) => (i === 0 ? file : null) },
|
|
147
|
+
});
|
|
148
|
+
dom.dispatch(input, new dom.window.Event("change", { bubbles: true }));
|
|
149
|
+
await dom.flush();
|
|
150
|
+
await dom.wait(10);
|
|
151
|
+
await dom.flush();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const CONFIG = JSON.stringify({ kind: "reader.config", schemaVersion: 1 });
|
|
155
|
+
|
|
156
|
+
const checkButton = (dom: DomHarness): HTMLButtonElement =>
|
|
157
|
+
dom.byText("button", "Check the file") as HTMLButtonElement;
|
|
158
|
+
|
|
159
|
+
describe("ConfigImportWizard — a file that cannot be sent", () => {
|
|
160
|
+
it("keeps the check disabled and says why, for a file that is not JSON", async () => {
|
|
161
|
+
const dom = start();
|
|
162
|
+
await pickFile(dom, "photos.zip", "PKnot json");
|
|
163
|
+
|
|
164
|
+
assert.match(dom.text(), /That file is not JSON/);
|
|
165
|
+
assert.equal(checkButton(dom).disabled, true);
|
|
166
|
+
// Nothing left the browser: the refusal happened before the request.
|
|
167
|
+
assert.equal(http?.to("/config/import").length, 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("arms the check only once a document has parsed", async () => {
|
|
171
|
+
const dom = start();
|
|
172
|
+
assert.equal(checkButton(dom).disabled, true);
|
|
173
|
+
|
|
174
|
+
await pickFile(dom, "config.json", CONFIG);
|
|
175
|
+
assert.equal(checkButton(dom).disabled, false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("ConfigImportWizard — an instance that is not empty", () => {
|
|
180
|
+
const conflicted = () =>
|
|
181
|
+
refusal(409, {
|
|
182
|
+
code: "config_not_empty",
|
|
183
|
+
message: "already holds configuration",
|
|
184
|
+
details: { accounts: "2", filters: "14" },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("offers abort or merge on the 409 rather than a connection error", async () => {
|
|
188
|
+
const dom = start({ onImport: conflicted });
|
|
189
|
+
await pickFile(dom, "config.json", CONFIG);
|
|
190
|
+
dom.click(checkButton(dom));
|
|
191
|
+
await dom.flush();
|
|
192
|
+
|
|
193
|
+
assert.match(dom.text(), /This instance already has configuration/);
|
|
194
|
+
assert.match(dom.text(), /2 accounts, 14 rules are set up here already/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The other half of reaching that screen: the refusal must not also take the
|
|
199
|
+
* app to the fatal page over it. That decision is `shouldEscalate` reading
|
|
200
|
+
* the mutation's meta, and it is pinned here rather than through the wizard
|
|
201
|
+
* because under this loader `lib/client.ts` and the generated SDK resolve
|
|
202
|
+
* `client.gen` through two specifiers (`…/client.gen.ts` and `./client.gen.js`)
|
|
203
|
+
* and so hold two client instances — the error interceptor registers on one
|
|
204
|
+
* and the request goes through the other, which never happens under Vite,
|
|
205
|
+
* where both specifiers resolve to the same module.
|
|
206
|
+
*/
|
|
207
|
+
it("keeps the 409 off the fatal page, and still escalates a 401", () => {
|
|
208
|
+
const meta = softErrorStatuses(409);
|
|
209
|
+
assert.equal(
|
|
210
|
+
shouldEscalate(new ApiError("not empty", 409, {}), meta, "user"),
|
|
211
|
+
false,
|
|
212
|
+
);
|
|
213
|
+
assert.equal(
|
|
214
|
+
shouldEscalate(new ApiError("signed out", 401, {}), meta, "user"),
|
|
215
|
+
true,
|
|
216
|
+
);
|
|
217
|
+
assert.equal(
|
|
218
|
+
shouldEscalate(new ApiError("broken", 500, {}), meta, "user"),
|
|
219
|
+
true,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not record an import when the reader aborts the 409", async () => {
|
|
224
|
+
const dom = start({ onImport: conflicted });
|
|
225
|
+
await pickFile(dom, "config.json", CONFIG);
|
|
226
|
+
dom.click(checkButton(dom));
|
|
227
|
+
await dom.flush();
|
|
228
|
+
|
|
229
|
+
dom.click(dom.byText("button", "Stop, keep this instance"));
|
|
230
|
+
await dom.flush();
|
|
231
|
+
dom.click(dom.byText("button", "Continue"));
|
|
232
|
+
await dom.flush();
|
|
233
|
+
|
|
234
|
+
assert.deepEqual(done, ["abandoned"]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("ConfigImportWizard — a session that has lapsed", () => {
|
|
239
|
+
it("escalates a 401 rather than keeping it in the wizard", async () => {
|
|
240
|
+
const dom = start({
|
|
241
|
+
onImport: () => refusal(401, { message: "signed out" }),
|
|
242
|
+
});
|
|
243
|
+
await pickFile(dom, "config.json", CONFIG);
|
|
244
|
+
dom.click(checkButton(dom));
|
|
245
|
+
await dom.flush();
|
|
246
|
+
|
|
247
|
+
assert.ok(
|
|
248
|
+
getCurrentFatalError(),
|
|
249
|
+
"expected the 401 to escalate rather than stay in the wizard",
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("ConfigImportWizard — the credentials the file does not carry", () => {
|
|
255
|
+
const applied = () => {
|
|
256
|
+
let call = 0;
|
|
257
|
+
return () => {
|
|
258
|
+
call += 1;
|
|
259
|
+
return call === 1
|
|
260
|
+
? report({ accountsNeedingCredentials: [ACCOUNT_ID] })
|
|
261
|
+
: report({
|
|
262
|
+
applied: true,
|
|
263
|
+
accountsNeedingCredentials: [ACCOUNT_ID],
|
|
264
|
+
importId: "imp-1",
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
it("marks the account ready once its password is verified and stored", async () => {
|
|
270
|
+
const dom = start({
|
|
271
|
+
onImport: applied(),
|
|
272
|
+
accounts: [importedAccount()],
|
|
273
|
+
});
|
|
274
|
+
await pickFile(dom, "config.json", CONFIG);
|
|
275
|
+
dom.click(checkButton(dom));
|
|
276
|
+
await dom.flush();
|
|
277
|
+
dom.click(dom.byText("button", "Import 1 changes"));
|
|
278
|
+
await dom.flush();
|
|
279
|
+
await dom.wait(20);
|
|
280
|
+
await dom.flush();
|
|
281
|
+
|
|
282
|
+
assert.match(dom.text(), /These accounts need credentials/);
|
|
283
|
+
assert.match(dom.text(), /credentials needed/);
|
|
284
|
+
|
|
285
|
+
dom.click(dom.byText("button", "Enter password"));
|
|
286
|
+
await dom.flush();
|
|
287
|
+
const password = dom.query<HTMLInputElement>("#credentials-password");
|
|
288
|
+
assert.ok(password, "expected the password field");
|
|
289
|
+
dom.type(password, "mailbox-password");
|
|
290
|
+
dom.click(dom.byText("button", "Test and continue"));
|
|
291
|
+
await dom.flush();
|
|
292
|
+
// The test step stages IMAP then SMTP before it settles, then hands over.
|
|
293
|
+
await dom.wait(500);
|
|
294
|
+
await dom.flush();
|
|
295
|
+
await dom.wait(900);
|
|
296
|
+
await dom.flush();
|
|
297
|
+
|
|
298
|
+
const [stored] = http?.to(`/accounts/${ACCOUNT_ID}`) ?? [];
|
|
299
|
+
assert.ok(stored, "expected the password to be stored on the account");
|
|
300
|
+
assert.equal(stored.body?.password, "mailbox-password");
|
|
301
|
+
|
|
302
|
+
// GET /config still says `credentials_missing`, and the row is green
|
|
303
|
+
// anyway: this wizard verified the credential itself.
|
|
304
|
+
assert.match(dom.text(), /These accounts need credentials/);
|
|
305
|
+
assert.match(dom.text(), /ready/);
|
|
306
|
+
assert.doesNotMatch(dom.text(), /credentials needed/);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("The onboarding welcome step", () => {
|
|
311
|
+
it("offers the import beside adding an account, where /onboarding mounts it", () => {
|
|
312
|
+
harness = createDomHarness();
|
|
313
|
+
const imported: string[] = [];
|
|
314
|
+
harness.renderApp(
|
|
315
|
+
createElement(StepWelcome, {
|
|
316
|
+
onStart: () => undefined,
|
|
317
|
+
onImportConfig: () => imported.push("import"),
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
assert.match(harness.text(), /Add your first account/);
|
|
322
|
+
harness.click(harness.byText("button", "Import a config file"));
|
|
323
|
+
assert.deepEqual(imported, ["import"]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("offers no import where the wizard is embedded to add an account", () => {
|
|
327
|
+
harness = createDomHarness();
|
|
328
|
+
harness.renderApp(createElement(StepWelcome, { onStart: () => undefined }));
|
|
329
|
+
|
|
330
|
+
assert.match(harness.text(), /Add your first account/);
|
|
331
|
+
assert.equal(
|
|
332
|
+
harness
|
|
333
|
+
.queryAll("button")
|
|
334
|
+
.some((button) => button.textContent === "Import a config file"),
|
|
335
|
+
false,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
});
|