@remit/web-client 0.0.200 → 0.0.202

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.200",
3
+ "version": "0.0.202",
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": {
@@ -1,41 +1,82 @@
1
- import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
-
7
1
  /**
8
2
  * A filter made from a search belongs to the account the search ran over (#524).
9
- * The surface hands its account to the host on the props; a search entry that
10
- * reads past it writes the rule against whichever account happens to be first in
11
- * the list, and the mail the user was looking at is never filtered.
12
3
  *
13
- * Which account wins is decided — and tested — in
14
- * `../../lib/organize/search-to-rule.test.ts`. What is read off the source here
15
- * is that the host asks that question with the surface's account in hand, which
16
- * is the wiring the bug lived in and which no unit of the resolver can prove.
17
- * The host wires routing, history and several data hooks together, so it is read
18
- * rather than rendered, as this package's other component-level rules are (see
19
- * `SelectionWizardHost.run-exit.test.ts`).
4
+ * The surface hands its account to the host on the props; a search entry that
5
+ * reaches past it writes the rule against whichever account happens to be first
6
+ * in the list, and the mail the user was looking at is never filtered. Which
7
+ * account wins between the query and the surface is
8
+ * `../../lib/organize/search-to-rule.test.ts`; what is asserted here is the set
9
+ * of props a search entry replaces, the account among them.
20
10
  */
21
11
 
22
- const here = dirname(fileURLToPath(import.meta.url));
23
- const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+ import type { SearchConversion } from "@remit/ui";
15
+ import { searchEntryOverrides } from "./SelectionWizardHost";
16
+
17
+ const conversion = (
18
+ over: Partial<SearchConversion> = {},
19
+ ): SearchConversion => ({
20
+ clauses: [],
21
+ matchOperator: "all",
22
+ droppedFacets: [],
23
+ keptTerms: false,
24
+ droppedSemantic: false,
25
+ ...over,
26
+ });
27
+
28
+ const accounts = [{ accountId: "acc-first" }, { accountId: "acc-surface" }];
29
+
30
+ describe("the account a search entry writes its rule against", () => {
31
+ it("is the one the surface handed over", () => {
32
+ assert.equal(
33
+ searchEntryOverrides(conversion(), { accountId: "acc-surface" }, accounts)
34
+ .accountId,
35
+ "acc-surface",
36
+ );
37
+ });
24
38
 
25
- const host = source.match(
26
- /export function SelectionWizardHost\(([\s\S]*)$/,
27
- )?.[1];
39
+ it("is the one the query names when it names one", () => {
40
+ assert.equal(
41
+ searchEntryOverrides(
42
+ conversion({ targetAccountId: "acc-typed" }),
43
+ { accountId: "acc-surface" },
44
+ accounts,
45
+ ).accountId,
46
+ "acc-typed",
47
+ );
48
+ });
49
+
50
+ // The first configured account is the last resort, never the answer while the
51
+ // surface has one of its own.
52
+ it("falls back to the first configured account only when the surface has none", () => {
53
+ assert.equal(
54
+ searchEntryOverrides(conversion(), { accountId: undefined }, accounts)
55
+ .accountId,
56
+ "acc-first",
57
+ );
58
+ });
59
+ });
28
60
 
29
- describe("the search entry's account", () => {
30
- it("is resolved with the account the surface handed over", () => {
31
- assert.ok(host, "the host is gone");
32
- const call = host.match(/searchRuleAccountId\(([\s\S]*?)\)/)?.[1];
33
- assert.ok(call, "the search entry resolves no account of its own");
34
- assert.match(call, /\bprops\.accountId\b/);
61
+ describe("what else a search entry replaces", () => {
62
+ it("ticks nothing, carries no restriction and drops any escalation", () => {
63
+ const overrides = searchEntryOverrides(
64
+ conversion(),
65
+ { accountId: "acc-surface" },
66
+ accounts,
67
+ );
68
+ assert.equal(overrides.verb, "organize");
69
+ assert.deepEqual(overrides.selection, []);
70
+ assert.equal(overrides.selectionRestriction, undefined);
71
+ assert.equal(overrides.escalated, undefined);
35
72
  });
36
73
 
37
- it("never reaches past it for the first configured account", () => {
38
- assert.ok(host, "the host is gone");
39
- assert.doesNotMatch(host, /accounts\[0\]/);
74
+ it("hands the conversion on so the properties step can seed its clauses", () => {
75
+ const seeded = conversion({ keptTerms: true });
76
+ assert.equal(
77
+ searchEntryOverrides(seeded, { accountId: "acc-surface" }, accounts)
78
+ .searchConversion,
79
+ seeded,
80
+ );
40
81
  });
41
82
  });
@@ -1,4 +1,5 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
3
  import {
3
4
  type ClauseDraft,
4
5
  type ClauseEditState,
@@ -72,6 +73,12 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
72
73
  import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
73
74
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
74
75
  import { organizeRunState } from "./organize-run-state";
76
+ import {
77
+ retryIntent,
78
+ runIsInFlight,
79
+ stopRunner,
80
+ wizardExit,
81
+ } from "./selection-wizard-run-controls";
75
82
 
76
83
  const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
77
84
 
@@ -171,6 +178,34 @@ const NO_CONVERSION: SearchConversion = {
171
178
  /** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
172
179
  const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
173
180
 
181
+ /**
182
+ * What a search entry overrides on the props the surface handed over. Its rule
183
+ * belongs to the account the surface named, and to the one the query names when
184
+ * it names one (#524) — reaching past both for the first configured account
185
+ * writes the rule against whichever account happens to be first in the list, and
186
+ * the mail the user was looking at is never filtered.
187
+ */
188
+ export const searchEntryOverrides = (
189
+ conversion: SearchConversion,
190
+ props: Pick<SelectionWizardHostProps, "accountId">,
191
+ accounts: readonly Pick<RemitImapAccountResponse, "accountId">[],
192
+ ): Pick<
193
+ SelectionWizardSessionProps,
194
+ | "verb"
195
+ | "accountId"
196
+ | "selection"
197
+ | "selectionRestriction"
198
+ | "escalated"
199
+ | "searchConversion"
200
+ > => ({
201
+ verb: "organize",
202
+ accountId: searchRuleAccountId(conversion, props.accountId, accounts),
203
+ selection: EMPTY_SELECTION,
204
+ selectionRestriction: undefined,
205
+ escalated: undefined,
206
+ searchConversion: conversion,
207
+ });
208
+
174
209
  /** How far a commit has got, whichever of the three ways it took. */
175
210
  interface RunSnapshot {
176
211
  state: RunState;
@@ -851,42 +886,39 @@ function SelectionWizardSession({
851
886
  // walking back to Review, which would push an entry the wizard does not own
852
887
  // and leave Cancel rewinding to a step instead of out.
853
888
  const retry = (): void => {
854
- // A status that could not be read is a job still running on the server, so
855
- // the screen looks at that job again rather than queuing a second pass over
856
- // the same mail (#526).
857
- if (run.state === "statusUnknown") {
858
- organizeJob.refreshStatus();
859
- return;
860
- }
861
- // The predicate is re-resolved, not resumed: every verb it carries is
862
- // idempotent, so the messages the first pass already reached are a no-op.
863
- if (escalated) {
864
- void runEscalated();
865
- return;
866
- }
867
- if (committedScope === "standing" || committedScope === "temporary") {
868
- if (createFilter.isError) {
889
+ const intent = retryIntent({
890
+ runState: run.state,
891
+ isEscalated: escalated !== undefined,
892
+ committedScope,
893
+ createFilterFailed: createFilter.isError,
894
+ backApplyPending: backApplyDraft !== undefined,
895
+ widenRunsAsJob: widenedRunsAsJob(verb),
896
+ failedIds: bulkRun?.outcome?.failedIds ?? [],
897
+ sent: bulkRun?.sent ?? bulkTargets,
898
+ });
899
+ switch (intent.kind) {
900
+ case "refreshStatus":
901
+ organizeJob.refreshStatus();
902
+ return;
903
+ case "rerunEscalated":
904
+ void runEscalated();
905
+ return;
906
+ case "resetAndResend":
869
907
  createFilter.reset();
870
908
  sendCommit();
871
909
  return;
872
- }
873
- if (backApplyDraft) startJob(backApplyDraft);
874
- return;
875
- }
876
- if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
877
- sendCommit();
878
- return;
910
+ case "resend":
911
+ sendCommit();
912
+ return;
913
+ case "startBackApply":
914
+ if (backApplyDraft) startJob(backApplyDraft);
915
+ return;
916
+ case "waitOnJob":
917
+ return;
918
+ case "rerunBulk":
919
+ void runBulk(intent.targets);
920
+ return;
879
921
  }
880
- const outstanding = new Set(bulkRun?.outcome?.failedIds ?? []);
881
- // A run hands back ids, and each one's account came from the batch it was
882
- // sent in — so a retry re-sends the targets the run was given, filtered to
883
- // what it never reached.
884
- const sent = bulkRun?.sent ?? bulkTargets;
885
- void runBulk(
886
- outstanding.size > 0
887
- ? sent.filter((target) => outstanding.has(target.id))
888
- : sent,
889
- );
890
922
  };
891
923
 
892
924
  // Cancel rewinds the entries the wizard owns and leaves the selection where
@@ -905,21 +937,9 @@ function SelectionWizardSession({
905
937
  onFinished();
906
938
  }, [closeWizard, steps, current, onFinished]);
907
939
 
908
- // Ends the run rather than leaving it. The escalated predicate is run by the
909
- // list's runner and a bounded selection by this wizard's own, so the stop
910
- // follows whichever one is paging.
911
940
  const { stop: stopBulk } = bulk;
912
- // A commit that never started has nothing paging and nothing to end.
913
- const runInFlight =
914
- bulkRun !== undefined &&
915
- bulkRun.outcome === undefined &&
916
- bulkRun.failureReason === undefined;
917
941
  const stopRun = useCallback(() => {
918
- if (escalated) {
919
- escalated.stop();
920
- return;
921
- }
922
- stopBulk();
942
+ stopRunner(escalated, stopBulk);
923
943
  }, [escalated, stopBulk]);
924
944
 
925
945
  // Hardware back on the run screen leaves the wizard, the movement the header
@@ -971,7 +991,7 @@ function SelectionWizardSession({
971
991
  // Every exit from the run screen is the same movement: the header's X,
972
992
  // the header arrow, the footer and hardware Back all leave a run that
973
993
  // has already been sent, and all of them drop the selection with it.
974
- onExit={current === "run" ? dismiss : cancel}
994
+ onExit={wizardExit(current, { dismiss, cancel })}
975
995
  onContinue={advance}
976
996
  onCommit={commit}
977
997
  match={{
@@ -1051,7 +1071,7 @@ function SelectionWizardSession({
1051
1071
  failureReason: run.failureReason,
1052
1072
  onRetry: retry,
1053
1073
  onDismiss: dismiss,
1054
- onCancelRun: runInFlight ? stopRun : undefined,
1074
+ onCancelRun: runIsInFlight(bulkRun) ? stopRun : undefined,
1055
1075
  }}
1056
1076
  />
1057
1077
  );
@@ -1084,20 +1104,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
1084
1104
  return (
1085
1105
  <SelectionWizardSession
1086
1106
  {...props}
1087
- {...(conversion
1088
- ? {
1089
- verb: "organize" as const,
1090
- accountId: searchRuleAccountId(
1091
- conversion,
1092
- props.accountId,
1093
- accounts,
1094
- ),
1095
- selection: EMPTY_SELECTION,
1096
- selectionRestriction: undefined,
1097
- escalated: undefined,
1098
- searchConversion: conversion,
1099
- }
1100
- : {})}
1107
+ {...(conversion ? searchEntryOverrides(conversion, props, accounts) : {})}
1101
1108
  step={step}
1102
1109
  goToStep={goToStep}
1103
1110
  goBack={goBack}
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The roll-call no run can take: which surfaces exist, and which function each
3
+ * one's verbs name.
4
+ *
5
+ * Everything a selection surface *does* — the review screen naming what a verb
6
+ * covers, a run reaching the mail server — is driven end to end by
7
+ * `packages/e2e`. What no run observes is a surface that was never wired: a
8
+ * fourth pane growing its own direct handler while every other route was moved,
9
+ * a fourth list rendering the make-filter row with no wizard behind it, or a
10
+ * verb on a bar nobody presses in a spec quietly keeping a direct call. The
11
+ * suite has locators for Delete, Move and Organize and none for Junk or
12
+ * Mark-read, so three of the five are reachable only from here.
13
+ *
14
+ * So this reads the source, and reads it for the census: who raises a bar, what
15
+ * each of its verbs is wired to, who declares a keyboard verb over a selection,
16
+ * who renders the make-filter row. Every census is discovered from the
17
+ * directory rather than listed, so a fourth surface is a failure here until it
18
+ * is wired like the other three.
19
+ */
20
+
21
+ import assert from "node:assert/strict";
22
+ import { readdirSync, readFileSync } from "node:fs";
23
+ import { dirname, resolve } from "node:path";
24
+ import { describe, it } from "node:test";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const read = (file: string): string =>
29
+ readFileSync(resolve(here, file), "utf8");
30
+
31
+ const sourceFiles = (): string[] =>
32
+ readdirSync(here).filter((file) => file.endsWith(".tsx"));
33
+
34
+ const BAR_VERB_PROPS = [
35
+ "onDelete",
36
+ "onMove",
37
+ "onOrganize",
38
+ "onJunk",
39
+ "onMarkRead",
40
+ ] as const;
41
+
42
+ /**
43
+ * How a surface may answer a verb prop. `startWizard`/`wizard.start`/
44
+ * `startSelectionVerb` open the wizard on the verb; `organizeSelection` and
45
+ * `startFromSearch` open it on the search entry, which is the same wizard
46
+ * reached by its other door (#477 1.8). Anything else routes around the review
47
+ * screen that names what the verb covers (#477 1.4).
48
+ */
49
+ const OPENS_THE_WIZARD =
50
+ /^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
51
+
52
+ const bracedFrom = (source: string, open: number): string => {
53
+ let depth = 0;
54
+ for (let i = open; i < source.length; i++) {
55
+ if (source[i] === "{") depth++;
56
+ else if (source[i] === "}") {
57
+ depth--;
58
+ if (depth === 0) return source.slice(open, i + 1);
59
+ }
60
+ }
61
+ return source.slice(open);
62
+ };
63
+
64
+ const namedCallbackBody = (source: string, name: string): string => {
65
+ const at = source.indexOf(`const ${name} = useCallback(`);
66
+ if (at === -1) return "";
67
+ const open = source.indexOf("{", at);
68
+ return open === -1 ? "" : bracedFrom(source, open);
69
+ };
70
+
71
+ /**
72
+ * The bar element as written, to its own closing tag. Found by matching tags
73
+ * rather than by stopping at the first `/>` on a line: a nested self-closing
74
+ * element matches that too, so the scan would end early and silently skip
75
+ * whatever a surface happened to write after it.
76
+ */
77
+ const barMarkup = (source: string): string => {
78
+ const start = source.indexOf("<SelectionTopBar");
79
+ if (start === -1) return "";
80
+ let depth = 0;
81
+ for (let i = start; i < source.length; i++) {
82
+ if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
83
+ else if (source.startsWith("/>", i)) {
84
+ depth--;
85
+ if (depth === 0) return source.slice(start, i + 2);
86
+ i++;
87
+ } else if (source.startsWith("</", i)) depth--;
88
+ }
89
+ return "";
90
+ };
91
+
92
+ /**
93
+ * What a verb prop is wired to, with the whitespace, the guard a verb is
94
+ * offered behind, and the arrow taken off — what is left is the call itself.
95
+ */
96
+ const verbHandler = (bar: string, prop: string): string | undefined => {
97
+ const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
98
+ if (assigned === undefined) return undefined;
99
+ return assigned
100
+ .replace(/\s+/g, " ")
101
+ .replace(/^.*? \? /, "")
102
+ .replace(/ : undefined$/, "")
103
+ .replace(/^\(\)\s*=>\s*/, "")
104
+ .trim();
105
+ };
106
+
107
+ const declaredHandlers = (source: string): string[] => {
108
+ const at = source.indexOf("handlers: {");
109
+ if (at === -1) return [];
110
+ return Array.from(
111
+ bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
112
+ (match) => match[1],
113
+ );
114
+ };
115
+
116
+ /**
117
+ * What a pane does for one keyboard verb: the handler it wired into the triage
118
+ * layer, plus any callback that handler defers to for its target. A handler
119
+ * that offers the list the press and then falls through to a helper picking the
120
+ * selection is exactly as wrong as one picking it inline, so both are read.
121
+ */
122
+ const paneVerbBody = (source: string, handler: string): string => {
123
+ const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
124
+ if (at === -1) return "";
125
+ const value =
126
+ source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
127
+ const body = value.startsWith("(")
128
+ ? bracedFrom(source, source.indexOf("{", at))
129
+ : namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
130
+ const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
131
+ namedCallbackBody(source, match[1]),
132
+ );
133
+ return [body, ...helpers].join("\n");
134
+ };
135
+
136
+ describe("the surfaces that raise a selection bar", () => {
137
+ const surfaces = (): string[] =>
138
+ sourceFiles()
139
+ .filter((file) => /<SelectionTopBar/.test(read(file)))
140
+ .sort();
141
+
142
+ it("are these three and no others", () => {
143
+ assert.deepEqual(surfaces(), [
144
+ "DailyBrief.tsx",
145
+ "MessageList.tsx",
146
+ "ThreadListInteraction.tsx",
147
+ ]);
148
+ });
149
+
150
+ for (const file of surfaces()) {
151
+ it(`${file} routes every verb it offers through the wizard`, () => {
152
+ const bar = barMarkup(read(file));
153
+ assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
154
+ const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
155
+ assert.ok(offered.length > 0, `${file} offers at least one verb`);
156
+ for (const prop of offered) {
157
+ const handler = verbHandler(bar, prop);
158
+ assert.ok(handler, `${file}'s ${prop} is wired to something`);
159
+ assert.match(
160
+ handler,
161
+ OPENS_THE_WIZARD,
162
+ `${file}'s ${prop} must open the wizard, not run the verb`,
163
+ );
164
+ }
165
+ });
166
+ }
167
+
168
+ for (const file of ["DailyBrief.tsx", "MessageList.tsx"]) {
169
+ it(`${file} drops none of the five verbs from the bar it raises`, () => {
170
+ const bar = barMarkup(read(file));
171
+ for (const prop of BAR_VERB_PROPS) {
172
+ assert.match(bar, new RegExp(`${prop}=`), `${file} offers ${prop}`);
173
+ }
174
+ });
175
+ }
176
+ });
177
+
178
+ // A pane that runs a verb over `selectedIds` without offering the list the
179
+ // press first is the hole no run can see: the pane's fallback and the list's
180
+ // claim are indistinguishable from outside until a selection outlives the
181
+ // list's commands. That the list claims the press is rendered in
182
+ // `./ThreadListInteraction.test.ts`.
183
+ describe("the panes that answer a keyboard verb over a selection", () => {
184
+ const PANE_VERB_ROUTE: Record<string, string> = {
185
+ delete: "delete",
186
+ toggleRead: "markRead",
187
+ markJunk: "junk",
188
+ };
189
+
190
+ const panes = (): string[] =>
191
+ sourceFiles()
192
+ .filter((file) => read(file).includes("handlers: {"))
193
+ .sort();
194
+
195
+ const routed = (): string[] =>
196
+ panes()
197
+ .flatMap((file) =>
198
+ declaredHandlers(read(file))
199
+ .filter((handler) => handler in PANE_VERB_ROUTE)
200
+ .map((handler) => `${file}:${handler}`),
201
+ )
202
+ .sort();
203
+
204
+ it("are these three panes and no others", () => {
205
+ assert.deepEqual(panes(), [
206
+ "BriefPane.tsx",
207
+ "FlaggedPane.tsx",
208
+ "MailboxPane.tsx",
209
+ ]);
210
+ });
211
+
212
+ it("declare these seven routed handlers and no others", () => {
213
+ assert.deepEqual(routed(), [
214
+ "BriefPane.tsx:delete",
215
+ "BriefPane.tsx:toggleRead",
216
+ "FlaggedPane.tsx:delete",
217
+ "FlaggedPane.tsx:toggleRead",
218
+ "MailboxPane.tsx:delete",
219
+ "MailboxPane.tsx:markJunk",
220
+ "MailboxPane.tsx:toggleRead",
221
+ ]);
222
+ });
223
+
224
+ for (const entry of routed()) {
225
+ const [file, handler] = entry.split(":");
226
+ const verb = PANE_VERB_ROUTE[handler];
227
+
228
+ it(`${entry} offers the list ${verb} before running anything`, () => {
229
+ const body = paneVerbBody(read(file), handler);
230
+ assert.notEqual(body, "", `${handler}'s body was found`);
231
+ assert.match(body, new RegExp(`requestVerb\\("${verb}"\\)`));
232
+ });
233
+
234
+ it(`${entry} falls back to the focused row, never to a selection`, () => {
235
+ const body = paneVerbBody(read(file), handler);
236
+ assert.notEqual(body, "", `${handler}'s body was found`);
237
+ assert.doesNotMatch(body, /selectedIds/i);
238
+ });
239
+ }
240
+ });
241
+
242
+ // The row is built once by `MailListHeader` and handed down as `makeFilterSlot`,
243
+ // but the wizard answering the step it pushes is mounted per surface. A surface
244
+ // that renders the row without one leaves the press on a URL nothing answers.
245
+ describe("the surfaces that render the make-filter row", () => {
246
+ const ANSWERED_BY: Record<string, string> = {
247
+ "DailyBrief.tsx": "DailyBrief.tsx",
248
+ "MessageList.tsx": "MessageList.tsx",
249
+ "ThreadListInteraction.tsx": "FlaggedList.tsx",
250
+ };
251
+
252
+ it("are these three and no others", () => {
253
+ assert.deepEqual(
254
+ sourceFiles()
255
+ .filter((file) => /\.makeFilterSlot/.test(read(file)))
256
+ .sort(),
257
+ Object.keys(ANSWERED_BY).sort(),
258
+ );
259
+ });
260
+
261
+ it("each name a file that mounts a wizard to answer the step", () => {
262
+ for (const [surface, host] of Object.entries(ANSWERED_BY)) {
263
+ assert.match(
264
+ read(host),
265
+ /<SelectionWizardHost/,
266
+ `${surface} is answered by ${host}`,
267
+ );
268
+ }
269
+ });
270
+ });