@remit/web-client 0.0.229 → 0.0.231
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/calendar/CalendarComposePane.stories.tsx +25 -0
- package/src/components/calendar/CalendarComposePane.tsx +34 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
- package/src/components/calendar/CalendarEventPane.tsx +67 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
- package/src/components/calendar/CalendarWorkspace.tsx +162 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -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/mail/MailSidebarAdapter.tsx +17 -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/hooks/useCalendarData.ts +72 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-fixtures.ts +207 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +183 -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/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +220 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +100 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -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/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +139 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/routing/sections.ts +3 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
|
|
2
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
3
|
+
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The event the address names, resolved.
|
|
7
|
+
*
|
|
8
|
+
* The two event routes — the series and one of its occurrences — differ only in
|
|
9
|
+
* what the address says, so the lookup and the way back live here once rather
|
|
10
|
+
* than in each of them.
|
|
11
|
+
*/
|
|
12
|
+
export interface OpenCalendarEventProps {
|
|
13
|
+
calendarObjectId: string;
|
|
14
|
+
/** One occurrence of a series, absent on the series itself. */
|
|
15
|
+
recurrenceId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function OpenCalendarEvent({
|
|
19
|
+
calendarObjectId,
|
|
20
|
+
recurrenceId,
|
|
21
|
+
}: OpenCalendarEventProps) {
|
|
22
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
23
|
+
const { events, calendars } = useCalendarData({ view, date, calendarIds });
|
|
24
|
+
const { closeEvent } = useCalendarNavigation();
|
|
25
|
+
|
|
26
|
+
const event = events.find((candidate) => candidate.id === calendarObjectId);
|
|
27
|
+
const calendar = calendars.find(
|
|
28
|
+
(candidate) => candidate.id === event?.calendarId,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<CalendarEventPane
|
|
33
|
+
event={event}
|
|
34
|
+
calendar={calendar}
|
|
35
|
+
isOccurrence={recurrenceId !== undefined}
|
|
36
|
+
onClose={closeEvent}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each zoom puts on screen.
|
|
3
|
+
*
|
|
4
|
+
* The route is one route for all five views, so "the view mounts its own
|
|
5
|
+
* component" is a decision this pane makes and the place to assert it. A zoom
|
|
6
|
+
* that is not drawn yet says so out loud: an empty surface would be
|
|
7
|
+
* indistinguishable from a week with nothing in it, which is the dead route
|
|
8
|
+
* this stage exists to avoid.
|
|
9
|
+
*/
|
|
10
|
+
import "@remit/test-dom";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type { CalendarColorId, CalendarEventData } from "@remit/ui";
|
|
14
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
15
|
+
import { createElement, type ReactElement } from "react";
|
|
16
|
+
import { renderToString } from "react-dom/server";
|
|
17
|
+
import {
|
|
18
|
+
CalendarWorkspace,
|
|
19
|
+
type CalendarWorkspaceProps,
|
|
20
|
+
} from "./CalendarWorkspace.js";
|
|
21
|
+
|
|
22
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
23
|
+
const DATE = "2026-06-10";
|
|
24
|
+
const NOW = `${DATE}T09:30:00+02:00`;
|
|
25
|
+
const CALENDAR = "cal_work";
|
|
26
|
+
|
|
27
|
+
const colorByCalendarId: Record<string, CalendarColorId> = {
|
|
28
|
+
[CALENDAR]: "cal-1",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const roadmap: CalendarEventData = {
|
|
32
|
+
id: "roadmap",
|
|
33
|
+
calendarId: CALENDAR,
|
|
34
|
+
title: "Roadmap review",
|
|
35
|
+
start: `${DATE}T10:00:00+02:00`,
|
|
36
|
+
end: `${DATE}T11:00:00+02:00`,
|
|
37
|
+
allDay: false,
|
|
38
|
+
location: "",
|
|
39
|
+
notes: "",
|
|
40
|
+
attendees: [],
|
|
41
|
+
myRsvp: "accepted",
|
|
42
|
+
threadId: "",
|
|
43
|
+
threadSubject: "",
|
|
44
|
+
timeZone: TIME_ZONE,
|
|
45
|
+
zoneCertainty: "explicit",
|
|
46
|
+
recurrenceRule: "",
|
|
47
|
+
seriesId: "",
|
|
48
|
+
seriesException: false,
|
|
49
|
+
status: "confirmed",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const noop = () => {};
|
|
53
|
+
|
|
54
|
+
const workspaceProps = (
|
|
55
|
+
view: "year" | "month" | "week" | "day" | "agenda",
|
|
56
|
+
events: CalendarEventData[] = [roadmap],
|
|
57
|
+
): CalendarWorkspaceProps => ({
|
|
58
|
+
view,
|
|
59
|
+
date: DATE,
|
|
60
|
+
events,
|
|
61
|
+
colorByCalendarId,
|
|
62
|
+
density: "comfortable",
|
|
63
|
+
selectedEventId: "",
|
|
64
|
+
timeZone: TIME_ZONE,
|
|
65
|
+
now: NOW,
|
|
66
|
+
onChangeView: noop,
|
|
67
|
+
onToday: noop,
|
|
68
|
+
onStep: noop,
|
|
69
|
+
onChangeDensity: noop,
|
|
70
|
+
onSelectEvent: noop,
|
|
71
|
+
onPickSlot: noop,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The calendar engine schedules browser timers while it renders. Nothing mounts
|
|
76
|
+
* here, so nothing ever clears them and the suite would sit on a live event
|
|
77
|
+
* loop after its last assertion. Take them back.
|
|
78
|
+
*/
|
|
79
|
+
function renderQuietly(element: ReactElement): string {
|
|
80
|
+
const scheduled: ReturnType<typeof setTimeout>[] = [];
|
|
81
|
+
const native = globalThis.setTimeout;
|
|
82
|
+
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
|
|
83
|
+
const handle = native(...args);
|
|
84
|
+
scheduled.push(handle);
|
|
85
|
+
return handle;
|
|
86
|
+
}) as typeof globalThis.setTimeout;
|
|
87
|
+
try {
|
|
88
|
+
return renderToString(element);
|
|
89
|
+
} finally {
|
|
90
|
+
globalThis.setTimeout = native;
|
|
91
|
+
for (const handle of scheduled) clearTimeout(handle);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const render = (
|
|
96
|
+
view: "year" | "month" | "week" | "day" | "agenda",
|
|
97
|
+
events: CalendarEventData[] = [roadmap],
|
|
98
|
+
): string =>
|
|
99
|
+
renderQuietly(createElement(CalendarWorkspace, workspaceProps(view, events)));
|
|
100
|
+
|
|
101
|
+
describe("the zoom the address names", () => {
|
|
102
|
+
it("draws the grid for the week and the day", () => {
|
|
103
|
+
for (const view of ["week", "day"] as const) {
|
|
104
|
+
const html = render(view);
|
|
105
|
+
assert.ok(
|
|
106
|
+
html.includes("Roadmap review"),
|
|
107
|
+
`${view} did not draw the events it was given`,
|
|
108
|
+
);
|
|
109
|
+
assert.equal(html.includes("Not built yet"), false);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("says so for a zoom that is not drawn yet", () => {
|
|
114
|
+
for (const view of ["year", "month", "agenda"] as const) {
|
|
115
|
+
const html = render(view);
|
|
116
|
+
assert.ok(
|
|
117
|
+
html.includes(`calendar-placeholder-${view}`),
|
|
118
|
+
`${view} rendered no placeholder`,
|
|
119
|
+
);
|
|
120
|
+
assert.ok(html.includes("Not built yet"));
|
|
121
|
+
assert.ok(html.includes("Week and Day work now."));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("names the agenda's own stage rather than a generic one", () => {
|
|
126
|
+
assert.ok(render("agenda").includes("scrolling day strip"));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("the toolbar", () => {
|
|
131
|
+
it("offers every zoom, whichever one is on", () => {
|
|
132
|
+
const html = render("year");
|
|
133
|
+
for (const label of ["Year", "Month", "Week", "Day", "Agenda"]) {
|
|
134
|
+
assert.ok(html.includes(`>${label}<`), `no way to reach ${label}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("offers back, forward and today at a zoom that draws no grid", () => {
|
|
139
|
+
const html = render("month");
|
|
140
|
+
assert.ok(html.includes('aria-label="Previous"'));
|
|
141
|
+
assert.ok(html.includes('aria-label="Next"'));
|
|
142
|
+
assert.ok(html.includes(">Today<"));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("names the range before the grid has measured one", () => {
|
|
146
|
+
// The words are the device's locale's; the year is the part that is this
|
|
147
|
+
// component's answer rather than `Intl`'s.
|
|
148
|
+
assert.ok(render("month").includes("2026"));
|
|
149
|
+
assert.ok(render("year").includes("2026"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("carries the density control, which the address never names", () => {
|
|
153
|
+
const html = render("week");
|
|
154
|
+
assert.ok(html.includes("Calendar density"));
|
|
155
|
+
assert.ok(html.includes(">Detail<"));
|
|
156
|
+
assert.ok(html.includes(">Glance<"));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("a week with nothing in it", () => {
|
|
161
|
+
it("draws the grid rather than an empty surface", () => {
|
|
162
|
+
const html = render("week", []);
|
|
163
|
+
assert.equal(html.includes("Not built yet"), false);
|
|
164
|
+
assert.ok(html.includes('aria-label="Previous"'));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The way back to the mail, at a width where the nav is not a pane.
|
|
170
|
+
*
|
|
171
|
+
* The shell renders the sidebar as a slide-over below 1024px and nothing opens
|
|
172
|
+
* a slide-over on its own, so a surface that is the whole width of the app has
|
|
173
|
+
* to carry the control itself — mail's own list headers do, and the calendar is
|
|
174
|
+
* reached from the same sidebar it would otherwise strand a reader away from.
|
|
175
|
+
*/
|
|
176
|
+
describe("the folders control", () => {
|
|
177
|
+
const shell = (width: number): string =>
|
|
178
|
+
renderQuietly(
|
|
179
|
+
createElement(AppShellSlotted, {
|
|
180
|
+
initialWidth: width,
|
|
181
|
+
nav: createElement("nav", null, "Folders"),
|
|
182
|
+
list: createElement(CalendarWorkspace, workspaceProps("week")),
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it("is in the calendar's header where the nav is a slide-over", () => {
|
|
187
|
+
assert.match(shell(900), /aria-label="Open folders"/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("is absent where the nav is a pane of its own", () => {
|
|
191
|
+
assert.doesNotMatch(shell(1440), /aria-label="Open folders"/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -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
|
+
});
|