@remit/web-client 0.0.203 → 0.0.205

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.203",
3
+ "version": "0.0.205",
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": {
@@ -7,22 +7,52 @@
7
7
  * an image whose notice points at a `LICENSE` nobody staged. That is the one
8
8
  * failure mode a reader cannot see and a distributor is answerable for.
9
9
  *
10
- * The dictionary is shadowed rather than the real one damaged: node resolves
11
- * from the importer outwards, so a package planted in this directory's own
12
- * `node_modules` answers for `dictionary-en-gb` here and nowhere else. That
13
- * makes this file its own process — node's test runner gives each file one —
14
- * because module resolution is cached per process and a sibling test resolving
15
- * the real package first would decide which one this sees.
10
+ * The dictionary is shadowed inside this test's own temporary tree rather than
11
+ * anywhere on the shared resolution path: the plugin sources are copied in
12
+ * beside a private `node_modules`, so `require.resolve` from that copy answers
13
+ * for the shadow and nowhere else. Planting into `spellcheck/node_modules`
14
+ * instead would be visible to every other test file — node's runner isolates
15
+ * the module cache per process, never the filesystem, and runs files
16
+ * concurrently.
16
17
  */
17
18
 
18
19
  import assert from "node:assert/strict";
19
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
+ import {
21
+ copyFileSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readdirSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { createRequire } from "node:module";
20
29
  import { tmpdir } from "node:os";
21
- import { join } from "node:path";
30
+ import { dirname, join } from "node:path";
22
31
  import { after, describe, it } from "node:test";
32
+ import { pathToFileURL } from "node:url";
23
33
 
24
- const shadowRoot = join(import.meta.dirname, "node_modules");
25
- const shadow = join(shadowRoot, "dictionary-en-gb");
34
+ const here = import.meta.dirname;
35
+ const tmpRoot = mkdtempSync(join(tmpdir(), "spellcheck-licence-"));
36
+ after(() => {
37
+ rmSync(tmpRoot, { recursive: true, force: true });
38
+ });
39
+
40
+ // The plugin under test, imported from inside the temporary tree so its
41
+ // package resolution starts at the tree's own `node_modules`.
42
+ const src = join(tmpRoot, "spellcheck");
43
+ const engineDir = join(tmpRoot, "engine");
44
+ mkdirSync(src, { recursive: true });
45
+ // Every local import of vite-plugin.ts must be listed here.
46
+ for (const name of ["vite-plugin.ts", "languages.ts"]) {
47
+ copyFileSync(join(here, name), join(src, name));
48
+ }
49
+
50
+ // A licence-less shadow of `dictionary-en-gb`, plus the real `dictionary-en`
51
+ // copied across for the case that does stage a licence.
52
+ const modules = join(tmpRoot, "node_modules");
53
+ const real = (name: string) =>
54
+ dirname(createRequire(import.meta.url).resolve(name));
55
+ const shadow = join(modules, "dictionary-en-gb");
26
56
  mkdirSync(shadow, { recursive: true });
27
57
  writeFileSync(
28
58
  join(shadow, "package.json"),
@@ -31,8 +61,26 @@ writeFileSync(
31
61
  writeFileSync(join(shadow, "index.js"), "");
32
62
  writeFileSync(join(shadow, "index.aff"), "SET UTF-8\n");
33
63
  writeFileSync(join(shadow, "index.dic"), "1\nword\n");
64
+ const realEn = real("dictionary-en");
65
+ const stagedEn = join(modules, "dictionary-en");
66
+ mkdirSync(stagedEn, { recursive: true });
67
+ for (const entry of [
68
+ "package.json",
69
+ "index.js",
70
+ "index.aff",
71
+ "index.dic",
72
+ ] as const) {
73
+ copyFileSync(join(realEn, entry), join(stagedEn, entry));
74
+ }
75
+ // The licence text under whatever name upstream ships it.
76
+ const licenceFile = readdirSync(realEn).find((entry) =>
77
+ /^licen[cs]e/i.test(entry),
78
+ );
79
+ assert.ok(licenceFile);
80
+ copyFileSync(join(realEn, licenceFile), join(stagedEn, licenceFile));
34
81
 
35
- const engineDir = mkdtempSync(join(tmpdir(), "spellcheck-engine-"));
82
+ // Pins read from beside the engine; the stub carries its own.
83
+ mkdirSync(engineDir, { recursive: true });
36
84
  for (const name of [
37
85
  "hunspell.wasm",
38
86
  "hunspell.mjs",
@@ -41,15 +89,15 @@ for (const name of [
41
89
  ]) {
42
90
  writeFileSync(join(engineDir, name), "stub");
43
91
  }
92
+ writeFileSync(
93
+ join(engineDir, "pin.env"),
94
+ ["HUNSPELL_VERSION=0.0.0-test", "HUNSPELL_SHA256=test"].join("\n"),
95
+ );
44
96
  process.env.REMIT_SPELLCHECK_ENGINE_DIR = engineDir;
45
97
 
46
- const { stageSpellcheck } = await import("./vite-plugin.ts");
47
-
48
- after(() => {
49
- rmSync(shadow, { recursive: true, force: true });
50
- rmSync(shadowRoot, { recursive: true, force: true });
51
- rmSync(engineDir, { recursive: true, force: true });
52
- });
98
+ const { stageSpellcheck }: typeof import("./vite-plugin.ts") = await import(
99
+ pathToFileURL(join(src, "vite-plugin.ts")).href
100
+ );
53
101
 
54
102
  describe("a dictionary that ships no licence text", () => {
55
103
  it("stops the build rather than staging it", () => {
@@ -441,6 +441,32 @@ export const ComposeForm = ({
441
441
  * the subject the reader edited.
442
442
  */
443
443
  const resumedDraftRef = useRef(outboxMessageId !== undefined);
444
+ /**
445
+ * The mode the fields on screen were last written for. A draft that is
446
+ * resumed holds what its reader saved and must not be overwritten when it
447
+ * mounts — but a switch of mode over the same message asks for different
448
+ * recipients, which is how Reply All reaches the Cc field (#796).
449
+ *
450
+ * Taken at mount rather than on the first run that has a source: the source
451
+ * is fetched fresh after a reload, so a reader who presses Reply All before
452
+ * it lands would otherwise have that press recorded as the mode the draft
453
+ * opened under, and Reply All would stay dead.
454
+ */
455
+ const seededModeRef = useRef<ComposeMode | undefined>(
456
+ outboxMessageId !== undefined ? mode : undefined,
457
+ );
458
+ /**
459
+ * The reader's own address the recipients were last written against, absent
460
+ * until something has been written. The account resolves from the source's
461
+ * mailbox, so it lands after the source: a seed made before it arrived kept
462
+ * the reader in their own Cc (#819), and is redone once it does.
463
+ */
464
+ const seededMyEmailRef = useRef<{ myEmail: string | undefined } | undefined>(
465
+ undefined,
466
+ );
467
+ // Read where a mode change must not itself be the trigger.
468
+ const modeRef = useRef(mode);
469
+ modeRef.current = mode;
444
470
  /**
445
471
  * The identity this form has already taken from the surface that mounted it.
446
472
  * A reply learns which account the message reached only once the mailbox it
@@ -480,6 +506,9 @@ export const ComposeForm = ({
480
506
  prevOutboxMessageIdRef.current = outboxMessageId;
481
507
  if (previous === undefined) return;
482
508
  resumedDraftRef.current = outboxMessageId !== undefined;
509
+ seededModeRef.current =
510
+ outboxMessageId !== undefined ? modeRef.current : undefined;
511
+ seededMyEmailRef.current = undefined;
483
512
  // A draft brings its own body along in a moment; a new message opens on
484
513
  // the signature, the same document a fresh mount would have started on.
485
514
  const opening = outboxMessageId
@@ -593,8 +622,18 @@ export const ComposeForm = ({
593
622
  }, [draftData, draftLoaded]);
594
623
 
595
624
  useEffect(() => {
596
- if (resumedDraftRef.current) return;
597
625
  if (!sourceMessage) return;
626
+ // A draft the reader came back to holds what they saved, so mounting over
627
+ // it seeds nothing — but a switch of mode over the same message asks for
628
+ // a different answer, and rewrites the fields for it (#796). A rewrite
629
+ // made before the account resolved is redone when its address arrives,
630
+ // which is what takes the reader back out of their own Cc (#819).
631
+ if (resumedDraftRef.current && seededModeRef.current === mode) {
632
+ const seeded = seededMyEmailRef.current;
633
+ if (!seeded || seeded.myEmail === account?.email) return;
634
+ }
635
+ seededModeRef.current = mode;
636
+ seededMyEmailRef.current = { myEmail: account?.email };
598
637
 
599
638
  // The fields are being rewritten for a different answer, so what was typed
600
639
  // into one and left there belongs to the answer being left behind. Without
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Issue #796: Reply All does nothing on a reply reopened after a reload.
3
+ *
4
+ * The composer mounts holding a draft id, so `resumedDraftRef` starts true —
5
+ * the guard that keeps a saved draft's recipients from being overwritten when
6
+ * it reopens. But that same guard then swallowed every later run of the
7
+ * seeding effect, so switching the mode to Reply All over the same message
8
+ * rewrote nothing: no addresses in Cc, nothing changed on screen. A dead
9
+ * control — the reader cannot tell whether Reply All is broken, misconfigured,
10
+ * or their own mistake.
11
+ *
12
+ * The guarantees here: a resumed reply still opens untouched, but pressing
13
+ * Reply All puts the source message's other recipients in Cc — including when
14
+ * the press lands before the source message or the account has resolved, which
15
+ * after a reload is the ordinary case rather than the exception.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, describe, it } from "node:test";
20
+ import type {
21
+ RemitImapAccountResponse,
22
+ RemitImapDescribeMessageResponse,
23
+ } from "@remit/api-http-client/types.gen.ts";
24
+ import {
25
+ type AnyRouter,
26
+ createMemoryHistory,
27
+ createRootRoute,
28
+ createRoute,
29
+ createRouter,
30
+ RouterContextProvider,
31
+ } from "@tanstack/react-router";
32
+ import { createElement, useState } from "react";
33
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
34
+ import { type HttpMock, mockFetch } from "../../test-support/http";
35
+ import { ComposeForm } from "./ComposeForm";
36
+ import { ComposeProvider } from "./ComposeProvider";
37
+
38
+ const ACCOUNT_ID = "acc-1";
39
+ const DRAFT_ID = "ob-796";
40
+
41
+ const account = {
42
+ accountId: ACCOUNT_ID,
43
+ email: "me@example.com",
44
+ smtpEnabled: true,
45
+ } as unknown as RemitImapAccountResponse;
46
+
47
+ /** The reader is among the message's recipients, as they are on their own mail. */
48
+ const sourceMessage = {
49
+ message: { messageId: "msg-1", mailboxId: "mbx-1" },
50
+ envelope: {
51
+ subject: "Lunch",
52
+ messageIdValue: "<m1@example.com>",
53
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
54
+ replyTo: [],
55
+ to: [
56
+ { normalizedEmail: "other@example.com", displayName: "Other" },
57
+ { normalizedEmail: account.email, displayName: "Me" },
58
+ ],
59
+ cc: [{ normalizedEmail: "copied@example.com", displayName: "Copied" }],
60
+ },
61
+ references: [],
62
+ bodyParts: [],
63
+ } as unknown as RemitImapDescribeMessageResponse;
64
+
65
+ /** The draft as it was saved under Reply: only To holds the sender. */
66
+ const draft = {
67
+ outboxMessageId: DRAFT_ID,
68
+ accountId: ACCOUNT_ID,
69
+ fromAddress: account.email,
70
+ toAddresses: ["them@example.com"],
71
+ ccAddresses: [],
72
+ bccAddresses: [],
73
+ references: [],
74
+ subject: "Re: Lunch",
75
+ textBody: "Does one o'clock work?",
76
+ status: "draft",
77
+ };
78
+
79
+ let harness: DomHarness | undefined;
80
+ let http: HttpMock | undefined;
81
+
82
+ afterEach(() => {
83
+ harness?.close();
84
+ harness = undefined;
85
+ http?.restore();
86
+ http = undefined;
87
+ });
88
+
89
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
90
+
91
+ const rootRoute = createRootRoute();
92
+ const mailboxRoute = createRoute({
93
+ getParentRoute: () => rootRoute,
94
+ path: "/mail/$mailboxId",
95
+ validateSearch: (search: Record<string, unknown>) => search,
96
+ });
97
+
98
+ const testRouter = (): AnyRouter =>
99
+ createRouter({
100
+ routeTree: rootRoute.addChildren([mailboxRoute]),
101
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
102
+ }) as unknown as AnyRouter;
103
+
104
+ interface ReopenedProps {
105
+ /** A reload refetches the source, which lands after the form is on screen. */
106
+ sourceAtMount: boolean;
107
+ /** The account is looked up from the source's mailbox, so it lands later still. */
108
+ accountAtMount: boolean;
109
+ }
110
+
111
+ /**
112
+ * The composer reopened on its saved draft, with a button standing in for the
113
+ * mode control — which switches Reply over to Reply All without remounting the
114
+ * form or changing the draft segment. The two other buttons stand in for the
115
+ * fetches a reload leaves outstanding, so a test can put the press before them.
116
+ */
117
+ const Reopened = ({ sourceAtMount, accountAtMount }: ReopenedProps) => {
118
+ const [mode, setMode] = useState<"reply" | "reply-all">("reply");
119
+ const [source, setSource] = useState(
120
+ sourceAtMount ? sourceMessage : undefined,
121
+ );
122
+ const [resolved, setResolved] = useState(
123
+ accountAtMount ? account : undefined,
124
+ );
125
+
126
+ return createElement(
127
+ "div",
128
+ null,
129
+ createElement(
130
+ "button",
131
+ { type: "button", onClick: () => setMode("reply-all") },
132
+ "Reply All",
133
+ ),
134
+ createElement(
135
+ "button",
136
+ { type: "button", onClick: () => setSource(sourceMessage) },
137
+ "Deliver source",
138
+ ),
139
+ createElement(
140
+ "button",
141
+ { type: "button", onClick: () => setResolved(account) },
142
+ "Resolve account",
143
+ ),
144
+ createElement(ComposeForm, {
145
+ mode,
146
+ account: resolved,
147
+ sourceMessage: source,
148
+ outboxMessageId: DRAFT_ID,
149
+ onDraftCreated: () => undefined,
150
+ onClose: () => undefined,
151
+ }),
152
+ );
153
+ };
154
+
155
+ const mount = async (props: ReopenedProps): Promise<DomHarness> => {
156
+ http = mockFetch(async (call) => {
157
+ if (call.path.endsWith("/config")) return { accounts: [account] };
158
+ return draft;
159
+ });
160
+
161
+ const created = createDomHarness();
162
+ harness = created;
163
+ created.renderApp(
164
+ createElement(RouterContextProvider, {
165
+ router: testRouter(),
166
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
167
+ children: createElement(
168
+ ComposeProvider,
169
+ null,
170
+ createElement(Reopened, props),
171
+ ),
172
+ }),
173
+ );
174
+ await created.flush();
175
+ await created.wait(50);
176
+ return created;
177
+ };
178
+
179
+ const press = async (mounted: DomHarness, label: string): Promise<void> => {
180
+ mounted.click(mounted.byText("button", label));
181
+ await mounted.flush();
182
+ await mounted.wait(20);
183
+ };
184
+
185
+ /**
186
+ * What one address field holds, read off the chips rather than off the form's
187
+ * text: every recipient is one chip, and each carries its address whether or
188
+ * not the envelope gave it a display name.
189
+ */
190
+ const addressesIn = (mounted: DomHarness, label: string): string[] =>
191
+ mounted
192
+ .queryAll(`[data-address-field="${label}"] [aria-label^="Remove "]`)
193
+ .map((chip) =>
194
+ (chip.getAttribute("aria-label") ?? "").replace("Remove ", ""),
195
+ );
196
+
197
+ describe("Reply All on a resumed reply", () => {
198
+ it("keeps the saved draft's fields while it merely reopens", async () => {
199
+ const mounted = await mount({ sourceAtMount: true, accountAtMount: true });
200
+
201
+ assert.deepEqual(addressesIn(mounted, "To"), ["them@example.com"]);
202
+ assert.deepEqual(
203
+ addressesIn(mounted, "Cc"),
204
+ [],
205
+ "reopening must not rewrite what the reader saved",
206
+ );
207
+ });
208
+
209
+ it("puts the source message's other recipients in Cc", async () => {
210
+ const mounted = await mount({ sourceAtMount: true, accountAtMount: true });
211
+
212
+ await press(mounted, "Reply All");
213
+
214
+ assert.deepEqual(addressesIn(mounted, "Cc"), [
215
+ "other@example.com",
216
+ "copied@example.com",
217
+ ]);
218
+ });
219
+
220
+ // After a reload the source is fetched fresh, so the press ordinarily comes
221
+ // first. Reading the mode off that run recorded "reply-all" as the mode the
222
+ // draft opened under, and Cc stayed empty — the original dead control.
223
+ it("seeds Cc when the press lands before the source message", async () => {
224
+ const mounted = await mount({ sourceAtMount: false, accountAtMount: true });
225
+
226
+ await press(mounted, "Reply All");
227
+ await press(mounted, "Deliver source");
228
+
229
+ assert.deepEqual(addressesIn(mounted, "Cc"), [
230
+ "other@example.com",
231
+ "copied@example.com",
232
+ ]);
233
+ });
234
+
235
+ // The account is looked up from the source's mailbox, so it resolves after
236
+ // the source. Its address is the only thing that keeps the reader out of the
237
+ // Cc of their own reply (#819), so a seed made without it is made again.
238
+ it("takes the reader out of Cc when the account resolves after the press", async () => {
239
+ const mounted = await mount({ sourceAtMount: true, accountAtMount: false });
240
+
241
+ await press(mounted, "Reply All");
242
+ await press(mounted, "Resolve account");
243
+
244
+ assert.deepEqual(addressesIn(mounted, "Cc"), [
245
+ "other@example.com",
246
+ "copied@example.com",
247
+ ]);
248
+ });
249
+ });