@remit/web-client 0.0.158 → 0.0.160

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.158",
3
+ "version": "0.0.160",
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": {
@@ -30,7 +30,7 @@
30
30
  "build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
31
31
  "preview": "vite preview",
32
32
  "test:typecheck": "npm run generate:routes && tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
33
- "test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node $NODE_TEST_FLAGS --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
33
+ "test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node $NODE_TEST_FLAGS --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts' 'spellcheck/**/*.test.ts'",
34
34
  "test": "npm run test:typecheck && npm run test:run"
35
35
  },
36
36
  "peerDependencies": {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The self-hoster's opt-out, proven by building it.
3
+ *
4
+ * `REMIT_SPELLCHECK_LANGUAGES=` is the documented way to build an image with no
5
+ * spellchecker — a small box, or an operator who does not want to carry
6
+ * somebody else's licence obligations. What it has to produce is a *clean*
7
+ * build: a web client that works, with no `spellcheck/` tree to serve, and none
8
+ * of the worker's code shipped to browsers that can never start it. Nothing
9
+ * short of a real build can tell those apart from a broken one, so this runs
10
+ * vite twice and reads the output.
11
+ *
12
+ * The second build is the control. Asserting only that an empty list emits no
13
+ * worker chunk would pass just as well if the chunk were renamed, if the
14
+ * composer stopped reaching the worker at all, or if the build quietly produced
15
+ * nothing — so the same assertions are made against a build that must carry it.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { spawnSync } from "node:child_process";
20
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join, relative, resolve, sep } from "node:path";
23
+ import { after, before, describe, it } from "node:test";
24
+
25
+ const packageRoot = resolve(import.meta.dirname, "..");
26
+
27
+ const BUILD_TIMEOUT_MS = 600_000;
28
+
29
+ const outputs: string[] = [];
30
+
31
+ // A child process, because the language set is read out of the environment when
32
+ // the plugin configures itself and two builds in one process would share it.
33
+ const build = (languages: string): readonly string[] => {
34
+ const outDir = mkdtempSync(join(tmpdir(), "spellcheck-build-"));
35
+ outputs.push(outDir);
36
+ const result = spawnSync(
37
+ process.execPath,
38
+ [
39
+ "--input-type=module",
40
+ "-e",
41
+ `import { build } from "vite";
42
+ await build({
43
+ logLevel: "warn",
44
+ build: { outDir: ${JSON.stringify(outDir)}, emptyOutDir: true },
45
+ });`,
46
+ ],
47
+ {
48
+ cwd: packageRoot,
49
+ encoding: "utf8",
50
+ env: { ...process.env, REMIT_SPELLCHECK_LANGUAGES: languages },
51
+ },
52
+ );
53
+ assert.equal(
54
+ result.status,
55
+ 0,
56
+ `vite build with REMIT_SPELLCHECK_LANGUAGES="${languages}" failed:\n${result.stdout}\n${result.stderr}`,
57
+ );
58
+ return emitted(outDir);
59
+ };
60
+
61
+ const emitted = (root: string): readonly string[] => {
62
+ const found: string[] = [];
63
+ const walk = (current: string): void => {
64
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
65
+ const full = join(current, entry.name);
66
+ if (entry.isDirectory()) {
67
+ walk(full);
68
+ continue;
69
+ }
70
+ found.push(relative(root, full).split(sep).join("/"));
71
+ }
72
+ };
73
+ walk(root);
74
+ return found.sort();
75
+ };
76
+
77
+ /**
78
+ * The worker, the port it speaks over, and the bundle vite emits for the `new
79
+ * Worker(new URL(...))` that reaches it. Vite emits that bundle while
80
+ * transforming the module rather than while writing the output, so it survives
81
+ * tree-shaking: its absence means the specifier never resolved there at all.
82
+ */
83
+ const workerChunks = (files: readonly string[]): readonly string[] =>
84
+ files.filter((file) => /rich-text-spellcheck-/.test(file));
85
+
86
+ const spellcheckAssets = (files: readonly string[]): readonly string[] =>
87
+ files.filter((file) => file.startsWith("spellcheck/"));
88
+
89
+ describe("a build that carries no dictionaries", () => {
90
+ let files: readonly string[] = [];
91
+
92
+ before(
93
+ () => {
94
+ files = build("");
95
+ },
96
+ { timeout: BUILD_TIMEOUT_MS },
97
+ );
98
+
99
+ after(() => {
100
+ for (const outDir of outputs)
101
+ rmSync(outDir, { recursive: true, force: true });
102
+ });
103
+
104
+ it("is still a web client", () => {
105
+ assert.ok(files.includes("index.html"), "the build emitted no index.html");
106
+ assert.ok(
107
+ files.some((file) => file.startsWith("assets/") && file.endsWith(".js")),
108
+ "the build emitted no JavaScript",
109
+ );
110
+ });
111
+
112
+ it("serves no spellcheck tree", () => {
113
+ assert.deepEqual(spellcheckAssets(files), []);
114
+ });
115
+
116
+ it("ships no worker to browsers that could never start it", () => {
117
+ assert.deepEqual(workerChunks(files), []);
118
+ });
119
+ });
120
+
121
+ describe("a build that carries one", () => {
122
+ let files: readonly string[] = [];
123
+
124
+ before(
125
+ () => {
126
+ files = build("nl");
127
+ },
128
+ { timeout: BUILD_TIMEOUT_MS },
129
+ );
130
+
131
+ it("stages the engine, the dictionary and the notice", () => {
132
+ // The staged directory is named after a digest of its own contents, so
133
+ // what is asserted is the tree under it rather than the whole path.
134
+ const staged = spellcheckAssets(files).map((file) =>
135
+ file.replace(/^spellcheck\/[0-9a-f]+\//, ""),
136
+ );
137
+ // English rides along whether or not it was asked for: it is the language
138
+ // every account is guaranteed to offer.
139
+ assert.deepEqual(staged.sort(), [
140
+ "LICENSE",
141
+ "NOTICE.txt",
142
+ "dictionaries/en/LICENSE",
143
+ "dictionaries/en/index.aff",
144
+ "dictionaries/en/index.dic",
145
+ "dictionaries/nl/LICENSE",
146
+ "dictionaries/nl/index.aff",
147
+ "dictionaries/nl/index.dic",
148
+ "hunspell.mjs",
149
+ "hunspell.wasm",
150
+ "license.hunspell",
151
+ "manifest.json",
152
+ ]);
153
+ });
154
+
155
+ // The control for the two assertions above: both names are real, and a build
156
+ // that should carry them does.
157
+ it("ships the worker the empty build must not", () => {
158
+ assert.notDeepEqual(workerChunks(files), []);
159
+ });
160
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The switch, at the point where a self-hoster's `REMIT_SPELLCHECK_LANGUAGES`
3
+ * becomes the set the image carries. Everything downstream — the staged files,
4
+ * the manifest, NOTICE.txt, and what the composer believes it can check — is
5
+ * that set and nothing else, so a wrong answer here is wrong everywhere at once.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import {
11
+ DEFAULT_SPELLCHECK_LANGUAGES,
12
+ DICTIONARY_SOURCES,
13
+ resolveLanguages,
14
+ } from "./languages.ts";
15
+
16
+ const tagsOf = (requested: string | undefined): readonly string[] =>
17
+ resolveLanguages(requested).map((source) => source.tag);
18
+
19
+ describe("the language set an image is built with", () => {
20
+ it("carries the published default when nothing is asked for", () => {
21
+ assert.deepEqual(
22
+ tagsOf(undefined),
23
+ DEFAULT_SPELLCHECK_LANGUAGES.split(","),
24
+ );
25
+ });
26
+
27
+ // `defaultComposeLanguages` appends `en` to every account, so an image
28
+ // without it would leave the one language every account offers as the one
29
+ // language it cannot check.
30
+ it("carries English whether or not it was asked for", () => {
31
+ assert.deepEqual(tagsOf("nl"), ["en", "nl"]);
32
+ assert.equal(tagsOf("en,nl")[0], "en");
33
+ });
34
+
35
+ it("reads a tag the same however it was typed, and carries it once", () => {
36
+ assert.deepEqual(tagsOf(" nl , EN-gb ,nl, en "), ["en", "nl", "en-GB"]);
37
+ });
38
+
39
+ // The self-hoster's opt-out. An empty list is a legitimate answer and not a
40
+ // typo to be corrected into the default, so it does not acquire English.
41
+ it("carries nothing at all when the list is empty", () => {
42
+ assert.deepEqual(tagsOf(""), []);
43
+ assert.deepEqual(tagsOf(" "), []);
44
+ assert.deepEqual(tagsOf(",, ,"), []);
45
+ });
46
+
47
+ it("hands back the whole row, not just the tag", () => {
48
+ const [english] = resolveLanguages("en");
49
+ assert.equal(english.package, "dictionary-en");
50
+ assert.ok(english.licence.length > 0);
51
+ assert.ok(english.source.startsWith("https://"));
52
+ });
53
+
54
+ // A tag nobody can ship must stop the build rather than quietly produce an
55
+ // image whose NOTICE.txt is a list of what the operator asked for.
56
+ it("stops on a tag no dictionary covers, and says which one", () => {
57
+ assert.throws(
58
+ () => resolveLanguages("en,nl,fi"),
59
+ (failed: Error) => {
60
+ assert.match(failed.message, /"fi"/);
61
+ assert.match(
62
+ failed.message,
63
+ /packages\/web-client\/spellcheck\/languages/,
64
+ );
65
+ // The remedy is picking a known tag, so the message lists them.
66
+ for (const source of DICTIONARY_SOURCES) {
67
+ assert.ok(
68
+ failed.message.includes(source.tag),
69
+ `the failure does not offer ${source.tag}`,
70
+ );
71
+ }
72
+ return true;
73
+ },
74
+ );
75
+ });
76
+
77
+ it("stops on an unknown tag even when every other tag is known", () => {
78
+ assert.throws(() => resolveLanguages("nl,de"), /"de"/);
79
+ });
80
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A dictionary whose licence text cannot travel with it must stop the build.
3
+ *
4
+ * Every licence in the table obliges the text to ship alongside the files, and
5
+ * the build generates NOTICE.txt from the same rows — so a package that lost its
6
+ * licence file (an upstream repackage, a pruned install) would otherwise produce
7
+ * an image whose notice points at a `LICENSE` nobody staged. That is the one
8
+ * failure mode a reader cannot see and a distributor is answerable for.
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.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { after, describe, it } from "node:test";
23
+
24
+ const shadowRoot = join(import.meta.dirname, "node_modules");
25
+ const shadow = join(shadowRoot, "dictionary-en-gb");
26
+ mkdirSync(shadow, { recursive: true });
27
+ writeFileSync(
28
+ join(shadow, "package.json"),
29
+ JSON.stringify({ name: "dictionary-en-gb", version: "0.0.0-shadow" }),
30
+ );
31
+ writeFileSync(join(shadow, "index.js"), "");
32
+ writeFileSync(join(shadow, "index.aff"), "SET UTF-8\n");
33
+ writeFileSync(join(shadow, "index.dic"), "1\nword\n");
34
+
35
+ const engineDir = mkdtempSync(join(tmpdir(), "spellcheck-engine-"));
36
+ for (const name of [
37
+ "hunspell.wasm",
38
+ "hunspell.mjs",
39
+ "LICENSE",
40
+ "license.hunspell",
41
+ ]) {
42
+ writeFileSync(join(engineDir, name), "stub");
43
+ }
44
+ process.env.REMIT_SPELLCHECK_ENGINE_DIR = engineDir;
45
+
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
+ });
53
+
54
+ describe("a dictionary that ships no licence text", () => {
55
+ it("stops the build rather than staging it", () => {
56
+ assert.throws(() => stageSpellcheck("en-GB", "/"));
57
+ });
58
+
59
+ // The remedy is the whole message: which directory, and why a missing
60
+ // licence file is fatal rather than something the notice can paper over.
61
+ it("names the directory and what is wrong with it", () => {
62
+ assert.throws(
63
+ () => stageSpellcheck("en-GB", "/"),
64
+ (failed: Error) => {
65
+ assert.ok(
66
+ failed.message.includes(shadow),
67
+ `the failure does not name the dictionary directory: ${failed.message}`,
68
+ );
69
+ assert.match(failed.message, /licence/i);
70
+ assert.match(failed.message, /NOTICE\.txt/);
71
+ return true;
72
+ },
73
+ );
74
+ });
75
+
76
+ // The languages before the broken one must not reach the output either: a
77
+ // half-staged tree with a complete-looking notice is worse than no build.
78
+ it("takes the whole build with it, not just that language", () => {
79
+ assert.throws(() => stageSpellcheck("en,en-GB", "/"));
80
+ });
81
+ });
82
+
83
+ describe("a dictionary that does ship one", () => {
84
+ it("stages the licence text beside the files it covers", () => {
85
+ const build = stageSpellcheck("en", "/");
86
+ const licence = build.files.find(
87
+ (file) => file.path === "dictionaries/en/LICENSE",
88
+ );
89
+ assert.ok(licence);
90
+ assert.ok(licence.source.byteLength > 0);
91
+ });
92
+ });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * What the build stages, and whether NOTICE.txt describes it.
3
+ *
4
+ * The licence obligation this repo took on is that the notice matches the image
5
+ * exactly and that the served dictionary is the upstream file — which is the
6
+ * whole reason the language set is fixed at build time rather than chosen at
7
+ * run time. A notice that names a dictionary the image does not carry, or omits
8
+ * one it does, is a licence failure and not a cosmetic one, so the chain from
9
+ * the resolved set through the manifest to the notice is asserted end to end
10
+ * rather than at either end.
11
+ *
12
+ * This runs in the checkout, against the dictionary packages actually
13
+ * installed, which is what makes the byte-for-byte and licence-text assertions
14
+ * mean anything. `npm-scripts/lib/spellcheck-staging.test.mjs` is the other
15
+ * half: the same function against a synthetic tree shaped like the published
16
+ * package, where the question is which file gets read and what a distributor is
17
+ * told when one is missing. Base, `servePath` and the digest belong to that
18
+ * one.
19
+ *
20
+ * The engine is stubbed: what it weighs is gated where it is built
21
+ * (npm-scripts/build-hunspell.mjs), and staging it here would make every one of
22
+ * these tests wait on a WebAssembly toolchain.
23
+ */
24
+
25
+ import assert from "node:assert/strict";
26
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { createRequire } from "node:module";
28
+ import { tmpdir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { describe, it } from "node:test";
31
+
32
+ const require = createRequire(import.meta.url);
33
+
34
+ const ENGINE_FILES = {
35
+ "hunspell.wasm": "\0asm stub",
36
+ "hunspell.mjs": "export default () => {};",
37
+ LICENSE: "Mozilla Public Licence 1.1, stub",
38
+ "license.hunspell": "Hunspell licence, stub",
39
+ };
40
+
41
+ const engineDir = mkdtempSync(join(tmpdir(), "spellcheck-engine-"));
42
+ for (const [name, body] of Object.entries(ENGINE_FILES)) {
43
+ writeFileSync(join(engineDir, name), body);
44
+ }
45
+
46
+ process.env.REMIT_SPELLCHECK_ENGINE_DIR = engineDir;
47
+ const { stageSpellcheck } = await import("./vite-plugin.ts");
48
+
49
+ const staged = (requested: string) => stageSpellcheck(requested, "/");
50
+
51
+ const fileNamed = (
52
+ build: ReturnType<typeof staged>,
53
+ path: string,
54
+ ): Buffer | undefined => build.files.find((file) => file.path === path)?.source;
55
+
56
+ const textOf = (build: ReturnType<typeof staged>, path: string): string => {
57
+ const found = fileNamed(build, path);
58
+ if (!found) throw new Error(`${path} was not staged`);
59
+ return found.toString("utf8");
60
+ };
61
+
62
+ /** The tags NOTICE.txt claims this image carries, read back off the notice. */
63
+ const tagsNoticed = (notice: string): readonly string[] => {
64
+ const dictionaries = notice.split("\nDictionaries\n")[1];
65
+ if (dictionaries === undefined) {
66
+ throw new Error("NOTICE.txt has no Dictionaries section");
67
+ }
68
+ return dictionaries
69
+ .split("\n")
70
+ .map((line) => /^(\S+) — /.exec(line)?.[1])
71
+ .filter((tag): tag is string => tag !== undefined);
72
+ };
73
+
74
+ /** The tags the image actually carries a dictionary for. */
75
+ const tagsStaged = (build: ReturnType<typeof staged>): readonly string[] => [
76
+ ...new Set(
77
+ build.files
78
+ .map((file) => /^dictionaries\/([^/]+)\//.exec(file.path)?.[1])
79
+ .filter((tag): tag is string => tag !== undefined),
80
+ ),
81
+ ];
82
+
83
+ describe("what a build stages", () => {
84
+ it("stages the engine, both dictionary files and a licence per language", () => {
85
+ const build = staged("en,nl");
86
+ const paths = build.files.map((file) => file.path).sort();
87
+ assert.deepEqual(paths, [
88
+ "LICENSE",
89
+ "NOTICE.txt",
90
+ "dictionaries/en/LICENSE",
91
+ "dictionaries/en/index.aff",
92
+ "dictionaries/en/index.dic",
93
+ "dictionaries/nl/LICENSE",
94
+ "dictionaries/nl/index.aff",
95
+ "dictionaries/nl/index.dic",
96
+ "hunspell.mjs",
97
+ "hunspell.wasm",
98
+ "license.hunspell",
99
+ "manifest.json",
100
+ ]);
101
+ });
102
+
103
+ // GPL and MPL correspondence is discharged by the served file being the
104
+ // upstream one. Anything that rewrote a dictionary on the way through —
105
+ // a normaliser, a re-encode, a line-ending fix — would break that quietly.
106
+ it("serves the upstream dictionary byte for byte", () => {
107
+ const build = staged("nl");
108
+ const upstream = dirname(require.resolve("dictionary-nl"));
109
+ for (const name of ["index.aff", "index.dic"]) {
110
+ assert.deepEqual(
111
+ fileNamed(build, `dictionaries/nl/${name}`),
112
+ readFileSync(join(upstream, name)),
113
+ `dictionaries/nl/${name} is not the upstream file`,
114
+ );
115
+ }
116
+ });
117
+ });
118
+
119
+ describe("NOTICE.txt", () => {
120
+ it("names every language the image carries", () => {
121
+ const build = staged("en,en-GB,nl");
122
+ const noticed = tagsNoticed(textOf(build, "NOTICE.txt"));
123
+ assert.deepEqual([...noticed].sort(), ["en", "en-GB", "nl"]);
124
+ });
125
+
126
+ // Over-claiming is the failure that matters: a notice listing a dictionary
127
+ // the image does not carry offers a licence text nobody can find, and one
128
+ // missing a dictionary it does carry ships that dictionary with no licence
129
+ // at all. Both are the same defect from opposite sides, so the three lists
130
+ // the build produces are compared to each other rather than to a fixture.
131
+ it("names only the languages the image carries", () => {
132
+ for (const requested of ["en", "nl", "en,en-GB,nl"]) {
133
+ const build = staged(requested);
134
+ const notice = textOf(build, "NOTICE.txt");
135
+ const carried = tagsStaged(build);
136
+ const manifest = JSON.parse(textOf(build, "manifest.json"));
137
+
138
+ assert.deepEqual(
139
+ [...tagsNoticed(notice)].sort(),
140
+ [...carried].sort(),
141
+ `NOTICE.txt and the staged dictionaries disagree for "${requested}"`,
142
+ );
143
+ assert.deepEqual(
144
+ manifest.languages.map((language: { tag: string }) => language.tag),
145
+ build.languages.map((language) => language.tag),
146
+ );
147
+ assert.deepEqual(
148
+ [...carried].sort(),
149
+ build.languages.map((language) => language.tag).sort(),
150
+ );
151
+ }
152
+ });
153
+
154
+ it("points every language at a licence text the image ships", () => {
155
+ const build = staged("en,en-GB,nl");
156
+ const notice = textOf(build, "NOTICE.txt");
157
+ const referenced = [...notice.matchAll(/Licence text: (\S+?),/g)]
158
+ .map((match) => match[1])
159
+ .sort();
160
+ assert.deepEqual(referenced, [
161
+ "LICENSE",
162
+ "dictionaries/en-GB/LICENSE",
163
+ "dictionaries/en/LICENSE",
164
+ "dictionaries/nl/LICENSE",
165
+ ]);
166
+ for (const path of referenced) {
167
+ const licence = fileNamed(build, path);
168
+ assert.ok(licence, `NOTICE.txt names ${path}, which is not staged`);
169
+ assert.ok(licence.byteLength > 0, `${path} is empty`);
170
+ }
171
+ });
172
+
173
+ it("states each licence, its author and the version that was staged", () => {
174
+ const build = staged("nl");
175
+ const notice = textOf(build, "NOTICE.txt");
176
+ const [dutch] = build.languages.filter((language) => language.tag === "nl");
177
+ assert.match(notice, /nl — OpenTaal/);
178
+ assert.ok(notice.includes(`Licence: ${dutch.licence}`));
179
+ assert.ok(notice.includes(`Authors: ${dutch.authors}`));
180
+ assert.ok(notice.includes(`dictionary-nl@${dutch.version}`));
181
+ assert.match(dutch.version, /^\d+\.\d+\.\d+/);
182
+ });
183
+
184
+ it("says the served files are the source where a licence requires it", () => {
185
+ const gpl = textOf(staged("nl,en"), "NOTICE.txt");
186
+ // Neither English nor Dutch is under a correspondence licence here, so
187
+ // the sentence must not appear; German would bring it.
188
+ assert.ok(!gpl.includes("unmodified upstream source"));
189
+ });
190
+
191
+ it("carries the engine's own provenance, checksum included", () => {
192
+ const notice = textOf(staged("en"), "NOTICE.txt");
193
+ const pins = readFileSync(
194
+ join(
195
+ import.meta.dirname,
196
+ "..",
197
+ "..",
198
+ "..",
199
+ "docker",
200
+ "hunspell",
201
+ "pin.env",
202
+ ),
203
+ "utf8",
204
+ );
205
+ const version = /HUNSPELL_VERSION=(\S+)/.exec(pins)?.[1];
206
+ const sha = /HUNSPELL_SHA256=(\S+)/.exec(pins)?.[1];
207
+ assert.ok(version && sha);
208
+ assert.ok(notice.includes(`Hunspell ${version}`));
209
+ assert.ok(notice.includes(sha));
210
+ });
211
+ });
212
+
213
+ describe("the manifest the app reads", () => {
214
+ it("describes the engine and every language it staged", () => {
215
+ const build = staged("en,nl");
216
+ const manifest = JSON.parse(textOf(build, "manifest.json"));
217
+ assert.equal(manifest.engine.project, "Hunspell");
218
+ assert.equal(manifest.engine.licence, "MPL-1.1");
219
+ assert.deepEqual(
220
+ manifest.languages.map(
221
+ (language: { licenceFile: string }) => language.licenceFile,
222
+ ),
223
+ ["dictionaries/en/LICENSE", "dictionaries/nl/LICENSE"],
224
+ );
225
+ for (const language of manifest.languages) {
226
+ assert.ok(fileNamed(build, language.licenceFile));
227
+ assert.match(language.package, /@\d+\.\d+\.\d+/);
228
+ }
229
+ });
230
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Keeps the spellchecking worker out of a build that staged no dictionaries.
3
+ *
4
+ * Vite emits a worker bundle for whatever module graph reaches a `new
5
+ * Worker(new URL(...))`, and it emits it while transforming that module —
6
+ * before tree-shaking, and whether or not anything in the output still imports
7
+ * it. So a dead branch around the dynamic import is not enough on its own: the
8
+ * bundle is already a file by the time the branch is dropped. The only way the
9
+ * worker never becomes an asset is for the specifier never to resolve to it.
10
+ *
11
+ * The decision is `resolveLanguages`, the same function the staging plugin
12
+ * asks, so there is no second answer to what this build carries.
13
+ */
14
+
15
+ import type { Plugin } from "vite";
16
+ import { resolveLanguages } from "./languages.ts";
17
+
18
+ const WORKER_MODULE = "@remit/ui/spellcheck-worker";
19
+ const STUB = "\0remit-spellcheck-absent";
20
+
21
+ export const spellcheckWorkerStub = (): Plugin => ({
22
+ name: "remit-spellcheck-worker-stub",
23
+ enforce: "pre",
24
+ resolveId(source) {
25
+ if (source !== WORKER_MODULE) return null;
26
+ const staged = resolveLanguages(process.env.REMIT_SPELLCHECK_LANGUAGES);
27
+ return staged.length === 0 ? STUB : null;
28
+ },
29
+ load(id) {
30
+ // The provider's own contract for a language it cannot check: answer null,
31
+ // start no worker, and leave the browser's checking switched on.
32
+ return id === STUB
33
+ ? "export const openSpellcheckWorker = async () => null;\n"
34
+ : null;
35
+ },
36
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The pane in Settings › Advanced that is the only place a quarantined message
3
+ * is ever mentioned. Every read state has to say something: an empty list is
4
+ * the normal one, and a failed read is never a blank panel.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "node:test";
8
+ import { type QuarantineEntry, quarantineDemoEntries } from "@remit/ui";
9
+ import React, { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { QuarantinePanelView } from "./QuarantinePanel";
12
+
13
+ // See MessageToolbar.render.test.ts: the SSR test loader transpiles remit-ui
14
+ // `.tsx` with the classic JSX runtime, which needs a global `React`.
15
+ (globalThis as { React?: typeof React }).React = React;
16
+
17
+ const render = (props: {
18
+ entries: readonly QuarantineEntry[];
19
+ isPending: boolean;
20
+ error: Error | null;
21
+ }): string =>
22
+ renderToString(createElement(QuarantinePanelView, props) as never);
23
+
24
+ describe("QuarantinePanelView", () => {
25
+ it("says nothing was set aside rather than rendering nothing", () => {
26
+ const html = render({ entries: [], isPending: false, error: null });
27
+ assert.match(html, /Messages set aside/);
28
+ assert.match(html, /Nothing is set aside/);
29
+ });
30
+
31
+ it("lists a set-aside message with a way to report it", () => {
32
+ const html = render({
33
+ entries: quarantineDemoEntries.slice(0, 1),
34
+ isPending: false,
35
+ error: null,
36
+ });
37
+ assert.match(html, /Cut a bug/);
38
+ assert.match(html, /could not read/i);
39
+ });
40
+
41
+ it("calls more than one set aside a pattern, not a list", () => {
42
+ const html = render({
43
+ entries: quarantineDemoEntries,
44
+ isPending: false,
45
+ error: null,
46
+ });
47
+ assert.match(
48
+ html,
49
+ new RegExp(`${quarantineDemoEntries.length} messages could not be read`),
50
+ );
51
+ });
52
+
53
+ it("reports a failed read in the pane, with a way to file it", () => {
54
+ const html = render({
55
+ entries: [],
56
+ isPending: false,
57
+ error: new Error("Failed to fetch"),
58
+ });
59
+ assert.match(html, /role="alert"/);
60
+ assert.match(html, /could not be read/);
61
+ assert.match(html, /Failed to fetch/);
62
+ assert.match(html, /Report this/);
63
+ assert.doesNotMatch(html, /Nothing is set aside/);
64
+ });
65
+
66
+ it("says it is still reading rather than claiming an empty list", () => {
67
+ const html = render({ entries: [], isPending: true, error: null });
68
+ assert.match(html, /Checking for messages set aside/);
69
+ assert.doesNotMatch(html, /Nothing is set aside/);
70
+ });
71
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Settings › Advanced — the messages sync set aside.
3
+ *
4
+ * A quarantined message exists on the server and nowhere else in the app, so
5
+ * this pane is the only place it is ever mentioned. Three states are all
6
+ * visible: the list, the reassurance that there is nothing in it, and the
7
+ * failure to read it at all. None of them is an empty panel.
8
+ */
9
+ import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import {
11
+ Button,
12
+ QuarantineBugDialog,
13
+ type QuarantineEntry,
14
+ QuarantineSection,
15
+ quarantineIssueTitle,
16
+ quarantineReportSections,
17
+ } from "@remit/ui";
18
+ import { useQuery } from "@tanstack/react-query";
19
+ import { Bug } from "lucide-react";
20
+ import { useState } from "react";
21
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
22
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
23
+ import { toQuarantineEntry } from "@/lib/quarantine-entries";
24
+
25
+ function openIssue(url: string): void {
26
+ window.open(url, "_blank", "noopener,noreferrer");
27
+ }
28
+
29
+ function issueUrlFor(entry: QuarantineEntry): string {
30
+ return buildGitHubIssueUrl(
31
+ buildBugReportContext({
32
+ quarantine: quarantineReportSections(entry),
33
+ title: quarantineIssueTitle(entry),
34
+ }),
35
+ );
36
+ }
37
+
38
+ function QuarantineReadFailure({ error }: { error: Error }) {
39
+ return (
40
+ <section role="alert" className="space-y-3">
41
+ <h2 className="text-sm font-semibold text-fg">Messages set aside</h2>
42
+ <div className="space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-row-inset py-3">
43
+ <p className="text-sm font-medium text-danger">
44
+ The list of set-aside messages could not be read.
45
+ </p>
46
+ <p className="break-words text-sm text-fg-muted">
47
+ {formatErrorMessage(error)}
48
+ </p>
49
+ <p className="text-xs text-fg-muted">
50
+ Mail may still have been set aside — this page cannot say either way
51
+ until it can read the list. Reporting this gets it fixed.
52
+ </p>
53
+ <Button
54
+ variant="secondary"
55
+ size="sm"
56
+ icon={<Bug className="size-3.5" />}
57
+ onClick={() =>
58
+ openIssue(buildGitHubIssueUrl(buildBugReportContext()))
59
+ }
60
+ >
61
+ Report this
62
+ </Button>
63
+ </div>
64
+ </section>
65
+ );
66
+ }
67
+
68
+ export interface QuarantinePanelViewProps {
69
+ entries: readonly QuarantineEntry[];
70
+ isPending: boolean;
71
+ error: Error | null;
72
+ }
73
+
74
+ export function QuarantinePanelView({
75
+ entries,
76
+ isPending,
77
+ error,
78
+ }: QuarantinePanelViewProps) {
79
+ const [reporting, setReporting] = useState<QuarantineEntry | null>(null);
80
+ const [copyFailed, setCopyFailed] = useState(false);
81
+
82
+ if (isPending) {
83
+ return (
84
+ <p className="animate-pulse text-sm text-fg-subtle">
85
+ Checking for messages set aside…
86
+ </p>
87
+ );
88
+ }
89
+
90
+ if (error) return <QuarantineReadFailure error={error} />;
91
+
92
+ const handleCopy = (report: string) => {
93
+ navigator.clipboard.writeText(report).then(
94
+ () => {
95
+ setCopyFailed(false);
96
+ setReporting(null);
97
+ },
98
+ () => {
99
+ setCopyFailed(true);
100
+ setReporting(null);
101
+ },
102
+ );
103
+ };
104
+
105
+ return (
106
+ <>
107
+ {copyFailed && (
108
+ <p role="alert" className="mb-3 text-sm text-danger">
109
+ The report was not copied — this browser refused clipboard access. Cut
110
+ the bug again and use Open on GitHub, which needs no clipboard.
111
+ </p>
112
+ )}
113
+ <QuarantineSection entries={entries} onCutBug={setReporting} />
114
+ <QuarantineBugDialog
115
+ entry={reporting}
116
+ issueUrl={reporting ? issueUrlFor(reporting) : ""}
117
+ onClose={() => setReporting(null)}
118
+ onCopy={handleCopy}
119
+ />
120
+ </>
121
+ );
122
+ }
123
+
124
+ export function QuarantinePanel() {
125
+ const { data, isPending, error } = useQuery(
126
+ meOperationsListQuarantineOptions(),
127
+ );
128
+
129
+ return (
130
+ <QuarantinePanelView
131
+ entries={(data?.entries ?? []).map(toQuarantineEntry)}
132
+ isPending={isPending}
133
+ error={error}
134
+ />
135
+ );
136
+ }
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { toQuarantineEntry } from "./quarantine-entries";
5
+
6
+ const wire: RemitImapQuarantineResponse = {
7
+ quarantineId: "8b1e0c2a-0000-4000-8000-000000000001",
8
+ accountConfigId: "8b1e0c2a-0000-4000-8000-000000000002",
9
+ accountId: "8b1e0c2a-0000-4000-8000-000000000003",
10
+ mailboxId: "8b1e0c2a-0000-4000-8000-000000000004",
11
+ uidValidity: 1717171717,
12
+ uid: 4211,
13
+ mailboxRole: "Junk",
14
+ mailboxPath: "INBOX/Spam",
15
+ quarantinedAt: 1_750_000_000_000,
16
+ attempts: 3,
17
+ failureStage: "BodyParse",
18
+ failureCode: "UnknownCharset",
19
+ failureMessage: 'unknown charset "x-mac-roman"',
20
+ failurePartPath: "1.2",
21
+ workerVersion: "imap-worker@1.4.0",
22
+ contentType: "multipart/mixed",
23
+ transferEncoding: "base64",
24
+ charset: "x-mac-roman",
25
+ sizeBytes: 91_204,
26
+ structure: [
27
+ { depth: 0, contentType: "multipart/mixed" },
28
+ { depth: 1, contentType: "text/plain" },
29
+ ],
30
+ messageIdHash: "sha256:abc",
31
+ createdAt: 1_750_000_000_000,
32
+ updatedAt: 1_750_000_000_000,
33
+ };
34
+
35
+ describe("toQuarantineEntry", () => {
36
+ it("spells the folder role the way the kit does", () => {
37
+ assert.equal(toQuarantineEntry(wire).mailboxRole, "junk");
38
+ });
39
+
40
+ it("leaves the role absent when the folder has none appointed", () => {
41
+ const { mailboxRole: _dropped, ...roleless } = wire;
42
+ assert.equal(toQuarantineEntry(roleless).mailboxRole, undefined);
43
+ });
44
+
45
+ it("carries every field the report and the row are built from", () => {
46
+ const entry = toQuarantineEntry(wire);
47
+ assert.equal(entry.quarantineId, wire.quarantineId);
48
+ assert.equal(entry.uid, wire.uid);
49
+ assert.equal(entry.uidValidity, wire.uidValidity);
50
+ assert.equal(entry.mailboxPath, wire.mailboxPath);
51
+ assert.equal(entry.failureCode, wire.failureCode);
52
+ assert.equal(entry.failureStage, wire.failureStage);
53
+ assert.equal(entry.failureMessage, wire.failureMessage);
54
+ assert.equal(entry.failurePartPath, wire.failurePartPath);
55
+ assert.equal(entry.attempts, wire.attempts);
56
+ assert.equal(entry.quarantinedAt, wire.quarantinedAt);
57
+ assert.equal(entry.workerVersion, wire.workerVersion);
58
+ assert.equal(entry.contentType, wire.contentType);
59
+ assert.equal(entry.transferEncoding, wire.transferEncoding);
60
+ assert.equal(entry.charset, wire.charset);
61
+ assert.equal(entry.sizeBytes, wire.sizeBytes);
62
+ assert.equal(entry.messageIdHash, wire.messageIdHash);
63
+ assert.deepEqual(entry.structure, wire.structure);
64
+ });
65
+
66
+ it("drops the fields the settings surface has no use for", () => {
67
+ const entry = toQuarantineEntry(wire);
68
+ assert.equal("accountConfigId" in entry, false);
69
+ assert.equal("createdAt" in entry, false);
70
+ assert.equal("updatedAt" in entry, false);
71
+ });
72
+ });
@@ -0,0 +1,38 @@
1
+ import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import type { QuarantineEntry } from "@remit/ui";
3
+ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
4
+
5
+ /**
6
+ * The wire record carries `accountConfigId`, `createdAt` and `updatedAt`, which
7
+ * the settings surface has no use for, and spells the folder role in the
8
+ * canonical PascalCase the API uses everywhere. Both differences are resolved
9
+ * here rather than by widening the kit's entry type.
10
+ */
11
+ export function toQuarantineEntry(
12
+ wire: RemitImapQuarantineResponse,
13
+ ): QuarantineEntry {
14
+ return {
15
+ quarantineId: wire.quarantineId,
16
+ accountId: wire.accountId,
17
+ mailboxId: wire.mailboxId,
18
+ uidValidity: wire.uidValidity,
19
+ uid: wire.uid,
20
+ mailboxRole: wire.mailboxRole
21
+ ? CANONICAL_TO_NAV_ROLE[wire.mailboxRole]
22
+ : undefined,
23
+ mailboxPath: wire.mailboxPath,
24
+ failureStage: wire.failureStage,
25
+ failureCode: wire.failureCode,
26
+ failureMessage: wire.failureMessage,
27
+ failurePartPath: wire.failurePartPath,
28
+ quarantinedAt: wire.quarantinedAt,
29
+ attempts: wire.attempts,
30
+ sizeBytes: wire.sizeBytes,
31
+ contentType: wire.contentType,
32
+ transferEncoding: wire.transferEncoding,
33
+ charset: wire.charset,
34
+ structure: wire.structure,
35
+ messageIdHash: wire.messageIdHash,
36
+ workerVersion: wire.workerVersion,
37
+ };
38
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Advanced settings. Updates live here — and only here — with a full pane in
3
- * Settings › Advanced. Notification rules, export, and diagnostics are future
4
- * scope.
3
+ * Settings › Advanced, alongside the messages sync could not read. Notification
4
+ * rules and export are future scope.
5
5
  */
6
6
  import { SettingsShell } from "@remit/ui";
7
7
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
8
8
  import { useState } from "react";
9
+ import { QuarantinePanel } from "@/components/settings/QuarantinePanel";
9
10
  import { SelfUpdatePanel } from "@/components/settings/SelfUpdatePanel";
10
11
  import { TlsRootCaDownload } from "@/components/settings/TlsRootCaDownload";
11
12
  import { AppVersion } from "@/components/ui/AppVersion";
@@ -18,8 +19,14 @@ export const Route = createFileRoute("/settings/advanced")({
18
19
  const advancedHelp = (
19
20
  <div className="space-y-3">
20
21
  <p>
21
- <strong className="text-fg">Notification rules</strong>, data export, and
22
- per-account diagnostics are coming in a future release.
22
+ <strong className="text-fg">Notification rules</strong> and data export
23
+ are coming in a future release.
24
+ </p>
25
+ <p>
26
+ A message Remit cannot read is{" "}
27
+ <strong className="text-fg">set aside</strong> rather than skipped, and
28
+ the folder keeps syncing. Setting one aside is a defect in Remit, so every
29
+ entry can be reported with the diagnostics already attached.
23
30
  </p>
24
31
  </div>
25
32
  );
@@ -46,10 +53,12 @@ function AdvancedSettings() {
46
53
  onBackToMail={() => void navigate({ to: "/mail" })}
47
54
  >
48
55
  <SelfUpdatePanel />
49
- <p className="text-sm text-fg-muted">
50
- Notification rules, data export, and raw sync diagnostics are coming in
51
- a future release.
52
- </p>
56
+ <QuarantinePanel />
57
+ <div className="mt-6 border-t border-line pt-4">
58
+ <p className="text-sm text-fg-muted">
59
+ Notification rules and data export are coming in a future release.
60
+ </p>
61
+ </div>
53
62
  <TlsRootCaDownload />
54
63
  <div className="border-t border-line pt-4 mt-4">
55
64
  <p className="text-sm font-medium text-fg mb-1">About</p>
package/vite.base.ts CHANGED
@@ -5,6 +5,7 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite";
5
5
  import react from "@vitejs/plugin-react";
6
6
  import type { AliasOptions, BuildOptions, PluginOption } from "vite";
7
7
  import { spellcheckPlugin } from "./spellcheck/vite-plugin.ts";
8
+ import { spellcheckWorkerStub } from "./spellcheck/worker-stub.ts";
8
9
 
9
10
  /**
10
11
  * The one place the web-client build toolchain is described. Both the dev
@@ -56,4 +57,5 @@ export const webClientPlugins = (routes: RouterPaths = {}): PluginOption[] => [
56
57
  react(),
57
58
  tailwindcss(),
58
59
  spellcheckPlugin(),
60
+ spellcheckWorkerStub(),
59
61
  ];