@remit/web-client 0.0.244 → 0.0.246
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/config-import/ConfigImportWizard.tsx +1 -0
- package/src/components/config-import/config-import.fixtures.ts +26 -0
- package/src/components/config-import/config-import.stories.tsx +19 -0
- package/src/components/config-import/steps.tsx +19 -8
- package/src/components/mail/IntelligencePane.render.test.ts +4 -21
- package/src/components/mail/intelligence-semantic-off.render.test.ts +3 -20
- package/src/components/settings/QuarantinePanel.delimiter.test.ts +41 -11
- package/src/lib/config-import.test.ts +69 -0
- package/src/lib/config-import.ts +38 -13
- package/src/test-support/dom.ts +28 -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.246",
|
|
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": {
|
|
@@ -565,6 +565,7 @@ export function ConfigImportWizard({ onDone }: ConfigImportWizardProps) {
|
|
|
565
565
|
results={sectionResults(report)}
|
|
566
566
|
message={failed?.message ?? "The import stopped before it finished."}
|
|
567
567
|
raw={`${failed?.code ?? WRITE_FAILURE_CODE}: ${failed?.message ?? "no message"}`}
|
|
568
|
+
nothingLanded={!report.applied}
|
|
568
569
|
onBack={goToFile}
|
|
569
570
|
onNext={() => runImport("apply", "merge")}
|
|
570
571
|
/>
|
|
@@ -181,6 +181,32 @@ export const partialImportReport: RemitImapConfigImportReport = report({
|
|
|
181
181
|
warnings: folderWarnings,
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The same stop with no section written: a store that holds the import in one
|
|
186
|
+
* transaction undoes the earlier writes, so the report carries no landed item
|
|
187
|
+
* and `applied` is false. Warnings go with them.
|
|
188
|
+
*/
|
|
189
|
+
export const nothingLandedImportReport: RemitImapConfigImportReport = report({
|
|
190
|
+
applied: false,
|
|
191
|
+
items: [
|
|
192
|
+
item(
|
|
193
|
+
"filters",
|
|
194
|
+
"Bonnetjes",
|
|
195
|
+
"rejected",
|
|
196
|
+
"the filter store refused the write",
|
|
197
|
+
),
|
|
198
|
+
],
|
|
199
|
+
errors: [
|
|
200
|
+
{
|
|
201
|
+
code: "import_write_failed",
|
|
202
|
+
message:
|
|
203
|
+
"the filter store refused the write Nothing was written; the import stopped here.",
|
|
204
|
+
details: { section: "filters", key: "Bonnetjes" },
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
warnings: [],
|
|
208
|
+
});
|
|
209
|
+
|
|
184
210
|
export const importedAccounts: ImportedAccount[] = [
|
|
185
211
|
{
|
|
186
212
|
accountId: "acct-ischen",
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
cleanRunReport,
|
|
20
20
|
dryRunReport,
|
|
21
21
|
importedAccounts,
|
|
22
|
+
nothingLandedImportReport,
|
|
22
23
|
partialImportReport,
|
|
23
24
|
rejectedReports,
|
|
24
25
|
} from "./config-import.fixtures";
|
|
@@ -416,6 +417,7 @@ export const PartiallyLandedImportUnnamed: Story = {
|
|
|
416
417
|
results={sectionResults(report)}
|
|
417
418
|
message={writeFailure(report)?.message ?? ""}
|
|
418
419
|
raw="import_write_failed: the store refused the write"
|
|
420
|
+
nothingLanded={!report.applied}
|
|
419
421
|
/>
|
|
420
422
|
);
|
|
421
423
|
},
|
|
@@ -428,6 +430,23 @@ export const PartiallyLandedImport: Story = {
|
|
|
428
430
|
results={sectionResults(partialImportReport)}
|
|
429
431
|
message={writeFailure(partialImportReport)?.message ?? ""}
|
|
430
432
|
raw={`import_write_failed: ${writeFailure(partialImportReport)?.message ?? ""}`}
|
|
433
|
+
nothingLanded={!partialImportReport.applied}
|
|
434
|
+
/>
|
|
435
|
+
),
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The same stop with no section written — a transaction undid the earlier
|
|
440
|
+
* writes, or the first write was the one that failed. No section may carry a
|
|
441
|
+
* tick, and the screen counts nothing as landed.
|
|
442
|
+
*/
|
|
443
|
+
export const NothingLandedImport: Story = {
|
|
444
|
+
render: () => (
|
|
445
|
+
<StepPartialImport
|
|
446
|
+
results={sectionResults(nothingLandedImportReport)}
|
|
447
|
+
message={writeFailure(nothingLandedImportReport)?.message ?? ""}
|
|
448
|
+
raw={`import_write_failed: ${writeFailure(nothingLandedImportReport)?.message ?? ""}`}
|
|
449
|
+
nothingLanded={!nothingLandedImportReport.applied}
|
|
431
450
|
/>
|
|
432
451
|
),
|
|
433
452
|
};
|
|
@@ -704,9 +704,11 @@ export function StepFileRejected({
|
|
|
704
704
|
}
|
|
705
705
|
|
|
706
706
|
/**
|
|
707
|
-
* A section nobody can vouch for reads as failed, never as pending
|
|
708
|
-
*
|
|
709
|
-
*
|
|
707
|
+
* A section nobody can vouch for reads as failed, never as pending: it is
|
|
708
|
+
* finished, and an outcome the reader has to go and check for themselves is
|
|
709
|
+
* not a quiet empty row. Pending is the empty row — nothing from the file went
|
|
710
|
+
* into this section, whether the import never reached it or reached it and
|
|
711
|
+
* wrote none of it — and it is the state that carries no tick.
|
|
710
712
|
*/
|
|
711
713
|
function resultState(state: SectionResult["state"]) {
|
|
712
714
|
if (state === "landed") return "ok" as const;
|
|
@@ -718,6 +720,7 @@ export function StepPartialImport({
|
|
|
718
720
|
results,
|
|
719
721
|
message,
|
|
720
722
|
raw,
|
|
723
|
+
nothingLanded,
|
|
721
724
|
onBack,
|
|
722
725
|
onNext,
|
|
723
726
|
}: {
|
|
@@ -725,6 +728,8 @@ export function StepPartialImport({
|
|
|
725
728
|
/** The server's own account of what survived, which only it knows. */
|
|
726
729
|
message: string;
|
|
727
730
|
raw: string;
|
|
731
|
+
/** No section of the file was written — not that the instance is unchanged. */
|
|
732
|
+
nothingLanded: boolean;
|
|
728
733
|
} & StepNav) {
|
|
729
734
|
const landed = results.filter((result) => result.state === "landed").length;
|
|
730
735
|
const unknown = results.some((result) => result.state === "unknown");
|
|
@@ -732,11 +737,17 @@ export function StepPartialImport({
|
|
|
732
737
|
<WizardShell
|
|
733
738
|
steps={IMPORT_STEPS}
|
|
734
739
|
activeStep={1}
|
|
735
|
-
title=
|
|
740
|
+
title={
|
|
741
|
+
nothingLanded
|
|
742
|
+
? "The import stopped, and nothing landed"
|
|
743
|
+
: "The import stopped part-way"
|
|
744
|
+
}
|
|
736
745
|
subtitle={
|
|
737
|
-
|
|
738
|
-
? "
|
|
739
|
-
:
|
|
746
|
+
nothingLanded
|
|
747
|
+
? "No section of the file was written."
|
|
748
|
+
: unknown
|
|
749
|
+
? "It did not say how far it got, so check Settings before importing again."
|
|
750
|
+
: `${landed} of ${results.length} sections landed.`
|
|
740
751
|
}
|
|
741
752
|
footer={
|
|
742
753
|
<>
|
|
@@ -744,7 +755,7 @@ export function StepPartialImport({
|
|
|
744
755
|
Choose another file
|
|
745
756
|
</Button>
|
|
746
757
|
<Button variant="primary" onClick={onNext}>
|
|
747
|
-
Retry the rest
|
|
758
|
+
{nothingLanded ? "Try the import again" : "Retry the rest"}
|
|
748
759
|
</Button>
|
|
749
760
|
</>
|
|
750
761
|
}
|
|
@@ -58,23 +58,6 @@ const UndoHarness = () => {
|
|
|
58
58
|
let harness: DomHarness | undefined;
|
|
59
59
|
let http: HttpMock;
|
|
60
60
|
|
|
61
|
-
const waitFor = async (
|
|
62
|
-
predicate: () => boolean,
|
|
63
|
-
timeoutMs = 2000,
|
|
64
|
-
): Promise<void> => {
|
|
65
|
-
if (!harness) throw new Error("nothing mounted");
|
|
66
|
-
const deadline = Date.now() + timeoutMs;
|
|
67
|
-
while (!predicate()) {
|
|
68
|
-
if (Date.now() > deadline) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
await harness.flush();
|
|
74
|
-
await harness.wait(5);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
61
|
afterEach(() => {
|
|
79
62
|
harness?.close();
|
|
80
63
|
harness = undefined;
|
|
@@ -95,14 +78,14 @@ describe("the spam quick action disables itself for the duration of its own requ
|
|
|
95
78
|
|
|
96
79
|
const button = harness.byText("button", "Report spam") as HTMLButtonElement;
|
|
97
80
|
harness.click(button);
|
|
98
|
-
await waitFor(() => button.textContent === "Reporting…");
|
|
81
|
+
await harness.waitFor(() => button.textContent === "Reporting…");
|
|
99
82
|
|
|
100
83
|
assert.equal(button.disabled, true, "a control mid-request must disable");
|
|
101
84
|
harness.click(button);
|
|
102
85
|
|
|
103
86
|
assert.ok(resolveRequest, "the request never reached the mock");
|
|
104
87
|
resolveRequest?.();
|
|
105
|
-
await waitFor(() => button.textContent === "Report spam");
|
|
88
|
+
await harness.waitFor(() => button.textContent === "Report spam");
|
|
106
89
|
|
|
107
90
|
assert.equal(http.to("/messages/report-spam").length, 1);
|
|
108
91
|
});
|
|
@@ -120,14 +103,14 @@ describe("the spam quick action disables itself for the duration of its own requ
|
|
|
120
103
|
|
|
121
104
|
const button = harness.byText("button", "Not spam") as HTMLButtonElement;
|
|
122
105
|
harness.click(button);
|
|
123
|
-
await waitFor(() => button.textContent === "Undoing…");
|
|
106
|
+
await harness.waitFor(() => button.textContent === "Undoing…");
|
|
124
107
|
|
|
125
108
|
assert.equal(button.disabled, true, "a control mid-request must disable");
|
|
126
109
|
harness.click(button);
|
|
127
110
|
|
|
128
111
|
assert.ok(resolveRequest, "the request never reached the mock");
|
|
129
112
|
resolveRequest?.();
|
|
130
|
-
await waitFor(() => button.textContent === "Not spam");
|
|
113
|
+
await harness.waitFor(() => button.textContent === "Not spam");
|
|
131
114
|
|
|
132
115
|
assert.equal(http.to("/messages/not-spam").length, 1);
|
|
133
116
|
});
|
|
@@ -62,23 +62,6 @@ const respond =
|
|
|
62
62
|
let harness: DomHarness | undefined;
|
|
63
63
|
let http: HttpMock;
|
|
64
64
|
|
|
65
|
-
const waitFor = async (
|
|
66
|
-
predicate: () => boolean,
|
|
67
|
-
timeoutMs = 2000,
|
|
68
|
-
): Promise<void> => {
|
|
69
|
-
if (!harness) throw new Error("nothing mounted");
|
|
70
|
-
const deadline = Date.now() + timeoutMs;
|
|
71
|
-
while (!predicate()) {
|
|
72
|
-
if (Date.now() > deadline) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
await harness.flush();
|
|
78
|
-
await harness.wait(5);
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
65
|
afterEach(() => {
|
|
83
66
|
harness?.close();
|
|
84
67
|
harness = undefined;
|
|
@@ -91,7 +74,7 @@ describe("the Similar messages section on an instance with semantic search off",
|
|
|
91
74
|
harness = createDomHarness();
|
|
92
75
|
harness.renderApp(createElement(SimilarHarness));
|
|
93
76
|
|
|
94
|
-
await waitFor(
|
|
77
|
+
await harness.waitFor(
|
|
95
78
|
() => harness?.text().includes("Semantic search is off") === true,
|
|
96
79
|
);
|
|
97
80
|
|
|
@@ -107,7 +90,7 @@ describe("the Similar messages section on an instance with semantic search off",
|
|
|
107
90
|
harness = createDomHarness();
|
|
108
91
|
harness.renderApp(createElement(SimilarHarness));
|
|
109
92
|
|
|
110
|
-
await waitFor(
|
|
93
|
+
await harness.waitFor(
|
|
111
94
|
() => harness?.text().includes("Semantic search is off") === true,
|
|
112
95
|
);
|
|
113
96
|
await harness.wait(30);
|
|
@@ -121,7 +104,7 @@ describe("the Similar messages section on an instance with semantic search off",
|
|
|
121
104
|
harness = createDomHarness();
|
|
122
105
|
harness.renderApp(createElement(SimilarHarness));
|
|
123
106
|
|
|
124
|
-
await waitFor(() => http.to("/search/semantic").length > 0);
|
|
107
|
+
await harness.waitFor(() => http.to("/search/semantic").length > 0);
|
|
125
108
|
|
|
126
109
|
assert.doesNotMatch(harness.text(), /Semantic search is off/);
|
|
127
110
|
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
11
|
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
12
13
|
import { createElement } from "react";
|
|
13
14
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
14
15
|
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
@@ -62,26 +63,47 @@ afterEach(() => {
|
|
|
62
63
|
http = undefined;
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The quarantine list is the read that gives the pane something to paint: until
|
|
68
|
+
* it has landed, an assertion that no path is showing passes because there are
|
|
69
|
+
* no rows at all, which is not what any of these cases claims to test.
|
|
70
|
+
*/
|
|
71
|
+
const quarantineLanded = (view: DomHarness): boolean =>
|
|
72
|
+
view.queryClient.getQueryState(meOperationsListQuarantineOptions().queryKey)
|
|
73
|
+
?.status === "success";
|
|
74
|
+
|
|
75
|
+
const mount = async (
|
|
76
|
+
mailboxes: () => unknown,
|
|
77
|
+
settled: (view: DomHarness) => boolean,
|
|
78
|
+
description: string,
|
|
79
|
+
): Promise<DomHarness> => {
|
|
66
80
|
http = mockFetch((call) => {
|
|
67
81
|
if (call.path.endsWith("/config")) return CONFIG;
|
|
68
82
|
if (call.path.endsWith("/mailboxes")) return mailboxes();
|
|
69
83
|
if (call.path.endsWith("/quarantine")) return QUARANTINE;
|
|
70
84
|
return {};
|
|
71
85
|
});
|
|
72
|
-
|
|
73
|
-
harness
|
|
86
|
+
const view = createDomHarness();
|
|
87
|
+
harness = view;
|
|
88
|
+
view.renderApp(createElement(QuarantinePanel));
|
|
74
89
|
// Two waves: the mailbox-list fan-out is keyed on the accounts `/config`
|
|
75
|
-
// returns, so it cannot even start until that first read has settled.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await
|
|
79
|
-
return
|
|
90
|
+
// returns, so it cannot even start until that first read has settled. How
|
|
91
|
+
// many turns those waves take is the runner's business, so each case names
|
|
92
|
+
// the state it needs instead of counting them.
|
|
93
|
+
await view.waitFor(() => settled(view), description);
|
|
94
|
+
return view;
|
|
80
95
|
};
|
|
81
96
|
|
|
82
97
|
describe("QuarantinePanel folder names", () => {
|
|
83
98
|
it("waits for the account's delimiter rather than cutting on a guess", async () => {
|
|
84
|
-
|
|
99
|
+
// The mailbox read never resolves here, so settled means every read that
|
|
100
|
+
// can land has: the entries are in hand and only the delimiter is missing.
|
|
101
|
+
const view = await mount(
|
|
102
|
+
() => new Promise(() => {}),
|
|
103
|
+
(pane) =>
|
|
104
|
+
quarantineLanded(pane) && (http?.to("/mailboxes").length ?? 0) > 0,
|
|
105
|
+
"the quarantine list to land while the mailbox read hangs",
|
|
106
|
+
);
|
|
85
107
|
|
|
86
108
|
assert.match(view.text(), /Checking for messages set aside/);
|
|
87
109
|
assert.doesNotMatch(
|
|
@@ -91,7 +113,11 @@ describe("QuarantinePanel folder names", () => {
|
|
|
91
113
|
});
|
|
92
114
|
|
|
93
115
|
it("names the folder by its leaf once the delimiter has arrived", async () => {
|
|
94
|
-
const view = await mount(
|
|
116
|
+
const view = await mount(
|
|
117
|
+
() => MAILBOXES,
|
|
118
|
+
(pane) => /Q3/.test(pane.text()),
|
|
119
|
+
"the folder leaf to be named",
|
|
120
|
+
);
|
|
95
121
|
|
|
96
122
|
assert.match(view.text(), /Q3/);
|
|
97
123
|
assert.doesNotMatch(
|
|
@@ -101,7 +127,11 @@ describe("QuarantinePanel folder names", () => {
|
|
|
101
127
|
});
|
|
102
128
|
|
|
103
129
|
it("reports a failed mailbox read instead of naming folders on a guess", async () => {
|
|
104
|
-
const view = await mount(
|
|
130
|
+
const view = await mount(
|
|
131
|
+
() => httpError(500),
|
|
132
|
+
(pane) => /role="alert"/.test(pane.html()),
|
|
133
|
+
"the failed mailbox read to be reported",
|
|
134
|
+
);
|
|
105
135
|
|
|
106
136
|
assert.match(view.html(), /role="alert"/);
|
|
107
137
|
assert.doesNotMatch(
|
|
@@ -151,6 +151,75 @@ test("a stopped import splits the write order into landed, failed and never reac
|
|
|
151
151
|
assert.equal(byId.get("settings")?.state, "not-attempted");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("an import the server says wrote nothing marks no section landed, whatever ran before the stop", () => {
|
|
155
|
+
const results = sectionResults(
|
|
156
|
+
reportOf({
|
|
157
|
+
applied: false,
|
|
158
|
+
items: [
|
|
159
|
+
item("filters", "Receipts", "rejected", "the store refused the write"),
|
|
160
|
+
],
|
|
161
|
+
errors: [
|
|
162
|
+
{
|
|
163
|
+
code: "import_write_failed",
|
|
164
|
+
message:
|
|
165
|
+
"the store refused the write Nothing was written; the import stopped here.",
|
|
166
|
+
details: { section: "filters", key: "Receipts" },
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const byId = new Map(results.map((result) => [result.section, result]));
|
|
173
|
+
assert.equal(byId.get("accounts")?.state, "not-landed");
|
|
174
|
+
assert.match(
|
|
175
|
+
byId.get("accounts")?.detail ?? "",
|
|
176
|
+
/Nothing from this section was written/,
|
|
177
|
+
);
|
|
178
|
+
assert.equal(byId.get("labels")?.state, "not-landed");
|
|
179
|
+
assert.equal(byId.get("filters")?.state, "failed");
|
|
180
|
+
assert.equal(byId.get("addressFlags")?.state, "not-attempted");
|
|
181
|
+
assert.equal(byId.get("settings")?.state, "not-attempted");
|
|
182
|
+
assert.equal(results.filter((result) => result.state === "landed").length, 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a first write that fails on a store with no transaction lands nothing either", () => {
|
|
186
|
+
const results = sectionResults(
|
|
187
|
+
reportOf({
|
|
188
|
+
applied: false,
|
|
189
|
+
items: [
|
|
190
|
+
item("accounts", "a@example.test", "rejected", "the store refused"),
|
|
191
|
+
],
|
|
192
|
+
errors: [
|
|
193
|
+
{
|
|
194
|
+
code: "import_write_failed",
|
|
195
|
+
message:
|
|
196
|
+
"the store refused The items above it were written and remain; the import stopped here.",
|
|
197
|
+
details: { section: "accounts", key: "a@example.test" },
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const byId = new Map(results.map((result) => [result.section, result]));
|
|
204
|
+
assert.equal(byId.get("accounts")?.state, "failed");
|
|
205
|
+
assert.equal(byId.get("labels")?.state, "not-attempted");
|
|
206
|
+
assert.equal(results.filter((result) => result.state === "landed").length, 0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("nothing written and no section named is a known outcome, not an unknown one", () => {
|
|
210
|
+
const results = sectionResults(
|
|
211
|
+
reportOf({
|
|
212
|
+
applied: false,
|
|
213
|
+
errors: [{ code: "import_write_failed", message: "the store refused" }],
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
assert.deepEqual(
|
|
218
|
+
[...new Set(results.map((result) => result.state))],
|
|
219
|
+
["not-landed"],
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
154
223
|
test("a pending folder names the setting waiting on it, and repeats are folded", () => {
|
|
155
224
|
const folders = pendingFolders(
|
|
156
225
|
reportOf({
|
package/src/lib/config-import.ts
CHANGED
|
@@ -285,7 +285,12 @@ export const writeFailure = (
|
|
|
285
285
|
): ApiError | undefined =>
|
|
286
286
|
report.errors.find((it) => it.code === WRITE_FAILURE_CODE);
|
|
287
287
|
|
|
288
|
-
export type SectionOutcome =
|
|
288
|
+
export type SectionOutcome =
|
|
289
|
+
| "landed"
|
|
290
|
+
| "not-landed"
|
|
291
|
+
| "failed"
|
|
292
|
+
| "not-attempted"
|
|
293
|
+
| "unknown";
|
|
289
294
|
|
|
290
295
|
export interface SectionResult {
|
|
291
296
|
section: ImportSection;
|
|
@@ -294,14 +299,21 @@ export interface SectionResult {
|
|
|
294
299
|
detail: string;
|
|
295
300
|
}
|
|
296
301
|
|
|
302
|
+
const NOT_LANDED_DETAIL = "Nothing from this section was written.";
|
|
303
|
+
|
|
297
304
|
/**
|
|
298
|
-
* What a stopped import
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* the
|
|
305
|
+
* What a stopped import wrote, section by section. The write order is fixed, so
|
|
306
|
+
* the section the failure names splits the document in three: what is before it
|
|
307
|
+
* ran, it did not, and what is after it was never reached.
|
|
308
|
+
*
|
|
309
|
+
* `applied` is the server's answer on whether any of it was written, because
|
|
310
|
+
* only the backend knows whether it had a transaction: false means no section
|
|
311
|
+
* of the file was written, however far the write order got. It does not say
|
|
312
|
+
* why — a transaction undid the earlier writes, or the first one failed — and
|
|
313
|
+
* the report carries no flag that does, so this says what the file put in and
|
|
314
|
+
* never what the instance now holds.
|
|
303
315
|
*
|
|
304
|
-
* A failure that names no section leaves
|
|
316
|
+
* A failure that names no section leaves the split undecidable, and every
|
|
305
317
|
* section reads `unknown` rather than `landed`. Telling someone their accounts
|
|
306
318
|
* are in when nothing said so is the one answer this screen must never give:
|
|
307
319
|
* they would stop looking.
|
|
@@ -312,6 +324,7 @@ export const sectionResults = (
|
|
|
312
324
|
const failure = writeFailure(report);
|
|
313
325
|
const named = failure?.details?.section;
|
|
314
326
|
const failedSection = named ? asSection(named) : undefined;
|
|
327
|
+
const nothingLanded = !report.applied;
|
|
315
328
|
const counts = new Map<ImportSection, number>();
|
|
316
329
|
for (const item of report.items) {
|
|
317
330
|
if (asVerdict(item.verdict) === "rejected") continue;
|
|
@@ -320,16 +333,20 @@ export const sectionResults = (
|
|
|
320
333
|
counts.set(section, (counts.get(section) ?? 0) + 1);
|
|
321
334
|
}
|
|
322
335
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
|
|
326
|
-
: "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
|
|
327
|
-
return IMPORT_SECTION_ORDER.map((section) => ({
|
|
336
|
+
const everySection = (state: SectionOutcome, detail: string) =>
|
|
337
|
+
IMPORT_SECTION_ORDER.map((section) => ({
|
|
328
338
|
section,
|
|
329
339
|
title: SECTION_TITLES[section],
|
|
330
|
-
state
|
|
340
|
+
state,
|
|
331
341
|
detail,
|
|
332
342
|
}));
|
|
343
|
+
|
|
344
|
+
if (!failedSection) {
|
|
345
|
+
if (nothingLanded) return everySection("not-landed", NOT_LANDED_DETAIL);
|
|
346
|
+
const detail = named
|
|
347
|
+
? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
|
|
348
|
+
: "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
|
|
349
|
+
return everySection("unknown", detail);
|
|
333
350
|
}
|
|
334
351
|
|
|
335
352
|
const failedAt = IMPORT_SECTION_ORDER.indexOf(failedSection);
|
|
@@ -352,6 +369,14 @@ export const sectionResults = (
|
|
|
352
369
|
detail: "Not attempted — the import stopped before this section.",
|
|
353
370
|
};
|
|
354
371
|
}
|
|
372
|
+
if (nothingLanded) {
|
|
373
|
+
return {
|
|
374
|
+
section,
|
|
375
|
+
title,
|
|
376
|
+
state: "not-landed" as const,
|
|
377
|
+
detail: NOT_LANDED_DETAIL,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
355
380
|
const written = counts.get(section) ?? 0;
|
|
356
381
|
return {
|
|
357
382
|
section,
|
package/src/test-support/dom.ts
CHANGED
|
@@ -39,6 +39,16 @@ export interface DomHarness {
|
|
|
39
39
|
flush: () => Promise<void>;
|
|
40
40
|
/** Let real timers fire — some flows stage their steps on a delay. */
|
|
41
41
|
wait: (ms: number) => Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Pump the render until `predicate` holds, then return with the DOM current.
|
|
44
|
+
* A drain counted in ticks is a guess about how many awaits a read chain
|
|
45
|
+
* takes, and a loaded runner always finds one more; a condition is not.
|
|
46
|
+
*/
|
|
47
|
+
waitFor: (
|
|
48
|
+
predicate: () => boolean,
|
|
49
|
+
description?: string,
|
|
50
|
+
timeoutMs?: number,
|
|
51
|
+
) => Promise<void>;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
export interface DomOptions {
|
|
@@ -186,6 +196,24 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
|
186
196
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
187
197
|
});
|
|
188
198
|
},
|
|
199
|
+
waitFor: async (
|
|
200
|
+
predicate,
|
|
201
|
+
description = "the render to settle",
|
|
202
|
+
timeoutMs = 2_000,
|
|
203
|
+
) => {
|
|
204
|
+
const deadline = Date.now() + timeoutMs;
|
|
205
|
+
for (;;) {
|
|
206
|
+
await harness.flush();
|
|
207
|
+
if (predicate()) return;
|
|
208
|
+
if (Date.now() > deadline) {
|
|
209
|
+
const shown = harness.text().trim() || "(nothing rendered)";
|
|
210
|
+
throw new Error(
|
|
211
|
+
`waited ${timeoutMs}ms for ${description}; the render shows: ${shown.slice(0, 400)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
await harness.wait(1);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
189
217
|
flush: async () => {
|
|
190
218
|
// Enough turns for a chain of awaits — form validation, then the
|
|
191
219
|
// mutation, then its `onSuccess` — to settle.
|