toiljs 0.0.92 → 0.0.94
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/CHANGELOG.md +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +7 -3
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +3 -0
- package/build/compiler/config.js +13 -1
- package/build/compiler/index.js +14 -3
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/email/console.d.ts +7 -0
- package/build/devserver/email/console.js +96 -0
- package/build/devserver/runtime/host.js +9 -0
- package/package.json +1 -1
- package/src/cli/index.ts +15 -3
- package/src/compiler/config.ts +30 -3
- package/src/compiler/index.ts +21 -3
- package/src/devserver/email/console.ts +134 -0
- package/src/devserver/runtime/host.ts +13 -1
- package/test/config-host-port.test.ts +59 -0
- package/test/email-console.test.ts +56 -0
package/src/compiler/config.ts
CHANGED
|
@@ -51,8 +51,13 @@ export interface ClientConfig {
|
|
|
51
51
|
readonly outDir?: string;
|
|
52
52
|
/** Public base path. Default `/`. */
|
|
53
53
|
readonly base?: string;
|
|
54
|
-
/**
|
|
54
|
+
/** Port the dev server AND the self-host (`toiljs start`) listen on. Default `3000`.
|
|
55
|
+
* Overridden by the `--port` CLI flag and the `PORT` env var (in that precedence). */
|
|
55
56
|
readonly port?: number;
|
|
57
|
+
/** Bind host for the dev server AND the self-host. Default `127.0.0.1` (loopback only).
|
|
58
|
+
* Set to `0.0.0.0` (or a specific interface) to accept LAN / public connections.
|
|
59
|
+
* Overridden by the `--host` CLI flag and the `HOST` env var (in that precedence). */
|
|
60
|
+
readonly host?: string;
|
|
56
61
|
/**
|
|
57
62
|
* Optimize imported images at build time (resize/convert via `vite-imagetools` + sharp): an
|
|
58
63
|
* import like `logo.png?w=400;800&format=webp&as=srcset` emits resized, compressed variants.
|
|
@@ -194,6 +199,8 @@ export interface ResolvedToilConfig {
|
|
|
194
199
|
readonly outDir: string;
|
|
195
200
|
readonly base: string;
|
|
196
201
|
readonly port: number;
|
|
202
|
+
/** Bind host for the dev server + self-host (`toiljs start`). Default `127.0.0.1`. */
|
|
203
|
+
readonly host: string;
|
|
197
204
|
/** Whether build-time image optimization (`vite-imagetools`) is enabled. */
|
|
198
205
|
readonly images: boolean;
|
|
199
206
|
/** Whether build-time font preloading is enabled. */
|
|
@@ -246,7 +253,7 @@ function resolveRuntimePath(): string {
|
|
|
246
253
|
|
|
247
254
|
/** Finds and loads `toil.config.*` or `toiljs.config.*` from `root`, then resolves defaults. */
|
|
248
255
|
export async function loadConfig(
|
|
249
|
-
opts: { root?: string; port?: number } = {},
|
|
256
|
+
opts: { root?: string; port?: number; host?: string } = {},
|
|
250
257
|
): Promise<ResolvedToilConfig> {
|
|
251
258
|
const root = path.resolve(opts.root ?? process.cwd());
|
|
252
259
|
|
|
@@ -278,7 +285,12 @@ export async function loadConfig(
|
|
|
278
285
|
toilDir: path.join(root, '.toil'),
|
|
279
286
|
outDir: client.outDir ?? 'build/client',
|
|
280
287
|
base: client.base ?? '/',
|
|
281
|
-
|
|
288
|
+
// Port + bind host for the dev server and the self-host, resolved with the
|
|
289
|
+
// precedence: CLI flag (opts) > env (PORT/HOST) > config (client.*) > default.
|
|
290
|
+
// `PORT`/`HOST` are the conventional PaaS env vars; a platform that injects
|
|
291
|
+
// `PORT` (Heroku, Render, Fly, containers) works with zero config.
|
|
292
|
+
port: opts.port ?? envPort() ?? client.port ?? 3000,
|
|
293
|
+
host: opts.host ?? envHost() ?? client.host ?? '127.0.0.1',
|
|
282
294
|
images: client.images ?? true,
|
|
283
295
|
fonts: client.fonts ?? true,
|
|
284
296
|
viewTransitions: client.viewTransitions ?? false,
|
|
@@ -299,6 +311,21 @@ export async function loadConfig(
|
|
|
299
311
|
};
|
|
300
312
|
}
|
|
301
313
|
|
|
314
|
+
/** The `PORT` env var as a positive integer, or `undefined` when unset/invalid
|
|
315
|
+
* (so it falls through to the config value / default rather than binding to 0). */
|
|
316
|
+
function envPort(): number | undefined {
|
|
317
|
+
const v = process.env.PORT;
|
|
318
|
+
if (v == null || v.trim() === '') return undefined;
|
|
319
|
+
const n = Number(v);
|
|
320
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The `HOST` env var (trimmed), or `undefined` when unset/blank. */
|
|
324
|
+
function envHost(): string | undefined {
|
|
325
|
+
const v = process.env.HOST;
|
|
326
|
+
return v != null && v.trim() !== '' ? v.trim() : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
302
329
|
const DEV_NODE_MODES: readonly DevNodeMode[] = ['hot', 'regional', 'continental', 'daemon', 'all'];
|
|
303
330
|
|
|
304
331
|
/** A `nodeMode` outside the enum falls back to `all` with a warning (fail-soft:
|
package/src/compiler/index.ts
CHANGED
|
@@ -845,6 +845,17 @@ async function collectDevCss(server: ViteDevServer): Promise<string> {
|
|
|
845
845
|
return css;
|
|
846
846
|
}
|
|
847
847
|
|
|
848
|
+
/** The host to show in a clickable URL: a wildcard bind (`0.0.0.0`/`::`/empty) is
|
|
849
|
+
* not universally openable, so present `localhost` for those; otherwise the host. */
|
|
850
|
+
function displayHost(host: string): string {
|
|
851
|
+
return host === '0.0.0.0' || host === '::' || host === '' ? 'localhost' : host;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Whether the bind host exposes the server beyond loopback (worth flagging to the user). */
|
|
855
|
+
function isExposedHost(host: string): boolean {
|
|
856
|
+
return host !== '127.0.0.1' && host !== 'localhost' && host !== '::1';
|
|
857
|
+
}
|
|
858
|
+
|
|
848
859
|
export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer> {
|
|
849
860
|
const cfg = await loadConfig(opts);
|
|
850
861
|
// Server first: build it (regenerating shared/server.ts) before the client dev server starts.
|
|
@@ -921,6 +932,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
921
932
|
const front = await startDevServer({
|
|
922
933
|
root: cfg.root,
|
|
923
934
|
port: cfg.port,
|
|
935
|
+
host: cfg.host,
|
|
924
936
|
wasmFile: serverWasmFile(cfg.root),
|
|
925
937
|
// The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
|
|
926
938
|
// project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
|
|
@@ -938,17 +950,21 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
938
950
|
server.httpServer?.once('close', () => {
|
|
939
951
|
void front.close();
|
|
940
952
|
});
|
|
953
|
+
const shownHost = displayHost(front.host);
|
|
941
954
|
process.stdout.write(
|
|
942
955
|
'\n ' +
|
|
943
956
|
pc.green('➜') +
|
|
944
957
|
' ' +
|
|
945
958
|
pc.bold('Local') +
|
|
946
959
|
': ' +
|
|
947
|
-
pc.cyan(`http
|
|
960
|
+
pc.cyan(`http://${shownHost}:${pc.bold(String(front.port))}/`) +
|
|
948
961
|
pc.dim(' (wasm server + vite)') +
|
|
962
|
+
(isExposedHost(front.host)
|
|
963
|
+
? pc.yellow(`\n ⚠ exposed on ${front.host}:${String(front.port)} (not just loopback)`)
|
|
964
|
+
: '') +
|
|
949
965
|
'\n',
|
|
950
966
|
);
|
|
951
|
-
printEmailsUrl(cfg, `http
|
|
967
|
+
printEmailsUrl(cfg, `http://${shownHost}:${String(front.port)}/`);
|
|
952
968
|
|
|
953
969
|
// Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
|
|
954
970
|
// and the dev server hot-swaps the recompiled wasm module.
|
|
@@ -1044,7 +1060,9 @@ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBacke
|
|
|
1044
1060
|
daemon: cfg.daemon,
|
|
1045
1061
|
threads: opts.threads ?? cfg.threads,
|
|
1046
1062
|
port: cfg.port,
|
|
1047
|
-
host
|
|
1063
|
+
// `cfg.host` already folds in the `--host` flag, `HOST` env, and `client.host`
|
|
1064
|
+
// config (CLI > env > config > 127.0.0.1); see loadConfig.
|
|
1065
|
+
host: cfg.host,
|
|
1048
1066
|
email: cfg.email ?? undefined,
|
|
1049
1067
|
});
|
|
1050
1068
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEV EMAIL EMULATOR: renders an outgoing email as an ASCII-drawn box in the
|
|
3
|
+
* terminal so confirm / reset / 2FA flows are testable locally WITHOUT a real
|
|
4
|
+
* inbox (the whole point of dev). It converts the HTML body to readable text,
|
|
5
|
+
* lays it out under a left-barred box, and highlights the ACTION (the one-time
|
|
6
|
+
* link, which carries its token in the `#token=` fragment and so is directly
|
|
7
|
+
* clickable, or the numeric 2FA code). Dev-only; the production edge never renders
|
|
8
|
+
* or logs token material.
|
|
9
|
+
*
|
|
10
|
+
* The box uses a LEFT bar plus full top/bottom rules (no right border), so colored
|
|
11
|
+
* spans never break column alignment and long URLs stay on one unbroken, clickable
|
|
12
|
+
* line.
|
|
13
|
+
*/
|
|
14
|
+
import pc from 'picocolors';
|
|
15
|
+
|
|
16
|
+
import { type ParsedEmail } from './wire.js';
|
|
17
|
+
|
|
18
|
+
const WIDTH = 68; // wrap column for body text (URLs are left un-wrapped)
|
|
19
|
+
|
|
20
|
+
/** Decode the handful of HTML entities our built-in templates can emit. */
|
|
21
|
+
function decodeEntities(s: string): string {
|
|
22
|
+
return s
|
|
23
|
+
.replace(/ /g, ' ')
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
.replace(/�*39;|'/g, "'")
|
|
29
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h: string) => String.fromCodePoint(parseInt(h, 16)))
|
|
30
|
+
.replace(/&#(\d+);/g, (_m, d: string) => String.fromCodePoint(parseInt(d, 10)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A plain-text rendering of an HTML email body: anchors become `text (url)` so
|
|
35
|
+
* the URL stays visible + clickable, block elements become line breaks, remaining
|
|
36
|
+
* tags are stripped, entities decoded, and whitespace collapsed.
|
|
37
|
+
*/
|
|
38
|
+
export function htmlToText(html: string): string {
|
|
39
|
+
let s = html;
|
|
40
|
+
// <a href="URL">TEXT</a> -> "TEXT (URL)" (or just URL when text == url / empty)
|
|
41
|
+
s = s.replace(
|
|
42
|
+
/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
|
|
43
|
+
(_m, href: string, inner: string) => {
|
|
44
|
+
const t = inner.replace(/<[^>]+>/g, '').trim();
|
|
45
|
+
return t.length > 0 && t !== href ? `${t} (${href})` : href;
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
// block-level tags -> newlines (open OR close), <br> -> newline
|
|
49
|
+
s = s.replace(/<br\s*\/?>/gi, '\n');
|
|
50
|
+
s = s.replace(/<\/?(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|section|header|footer)\b[^>]*>/gi, '\n');
|
|
51
|
+
// strip whatever tags remain
|
|
52
|
+
s = s.replace(/<[^>]+>/g, '');
|
|
53
|
+
s = decodeEntities(s);
|
|
54
|
+
// collapse intra-line whitespace, trim each line, cap blank runs at one
|
|
55
|
+
s = s.replace(/[ \t]+/g, ' ');
|
|
56
|
+
s = s
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((l) => l.trim())
|
|
59
|
+
.join('\n')
|
|
60
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
61
|
+
.trim();
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The actionable bit of an email: the first link, else a 4-8 digit code, else null. */
|
|
66
|
+
export function emailAction(parsed: ParsedEmail): { kind: 'link' | 'code'; value: string } | null {
|
|
67
|
+
const body = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
|
|
68
|
+
const link = /https?:\/\/[^\s"'<>)]+/.exec(body);
|
|
69
|
+
if (link !== null) return { kind: 'link', value: link[0] };
|
|
70
|
+
const code = /\b(\d{4,8})\b/.exec(body);
|
|
71
|
+
if (code !== null) return { kind: 'code', value: code[1] };
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Word-wrap to `width`; words longer than `width` (URLs, tokens) overflow rather
|
|
76
|
+
* than being hard-split, so they stay intact and clickable. */
|
|
77
|
+
function wrap(text: string, width: number): string[] {
|
|
78
|
+
const out: string[] = [];
|
|
79
|
+
for (const para of text.split('\n')) {
|
|
80
|
+
if (para.length === 0) {
|
|
81
|
+
out.push('');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
let line = '';
|
|
85
|
+
for (const word of para.split(' ')) {
|
|
86
|
+
if (line.length === 0) line = word;
|
|
87
|
+
else if (line.length + 1 + word.length <= width) line += ' ' + word;
|
|
88
|
+
else {
|
|
89
|
+
out.push(line);
|
|
90
|
+
line = word;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
out.push(line);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The full ASCII box for one dev email. `status` is a short label for the header
|
|
100
|
+
* (e.g. `not sent - no provider`, `sent`, `deduped`).
|
|
101
|
+
*/
|
|
102
|
+
export function renderEmailConsole(parsed: ParsedEmail, status: string): string {
|
|
103
|
+
const bar = pc.dim(' │ ');
|
|
104
|
+
const rule = (label: string): string => {
|
|
105
|
+
const head = label.length > 0 ? `─ ${label} ` : '─';
|
|
106
|
+
const fill = '─'.repeat(Math.max(0, WIDTH + 2 - head.length));
|
|
107
|
+
return pc.dim(` ├${head}${fill}`);
|
|
108
|
+
};
|
|
109
|
+
const purpose = parsed.purpose.length > 0 ? ` · ${parsed.purpose}` : '';
|
|
110
|
+
const header = `─ ✉ ${pc.bold('Email')} ${pc.dim(`(dev${purpose} · ${status})`)} `;
|
|
111
|
+
const topFill = '─'.repeat(Math.max(0, WIDTH + 2 - `─ ✉ Email (dev${purpose} · ${status}) `.length));
|
|
112
|
+
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
lines.push(pc.dim(` ┌${header}${topFill}`));
|
|
115
|
+
lines.push(bar + pc.dim('To: ') + parsed.to);
|
|
116
|
+
lines.push(bar + pc.dim('Subject: ') + parsed.subject);
|
|
117
|
+
lines.push(pc.dim(' │'));
|
|
118
|
+
|
|
119
|
+
const bodyText = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
|
|
120
|
+
for (const l of wrap(bodyText, WIDTH)) lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
|
|
121
|
+
|
|
122
|
+
const action = emailAction(parsed);
|
|
123
|
+
if (action !== null) {
|
|
124
|
+
if (action.kind === 'link') {
|
|
125
|
+
lines.push(rule('🔗 click to continue'));
|
|
126
|
+
lines.push(bar + pc.cyan(pc.underline(action.value)));
|
|
127
|
+
} else {
|
|
128
|
+
lines.push(rule('🔢 code'));
|
|
129
|
+
lines.push(bar + pc.bold(pc.yellow(action.value)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
lines.push(pc.dim(` └${'─'.repeat(WIDTH + 2)}`));
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
@@ -22,7 +22,8 @@ import { ratelimitCheck } from '../config/ratelimit.js';
|
|
|
22
22
|
import { buildAnalyticsImports } from '../analytics/index.js';
|
|
23
23
|
import { buildDatabaseImports, type DbDevState, freshDbState } from '../db/index.js';
|
|
24
24
|
import { EmailStatus, getEmailService, recordSentEmail } from '../email/index.js';
|
|
25
|
-
import {
|
|
25
|
+
import { renderEmailConsole } from '../email/console.js';
|
|
26
|
+
import { type ParsedEmail, parseEmailBlob } from '../email/wire.js';
|
|
26
27
|
import { buildCryptoImports, type CryptoState, freshCryptoState } from './crypto.js';
|
|
27
28
|
|
|
28
29
|
/** Limits identical to the edge's `set_header` / `respond_file` bounds. */
|
|
@@ -204,6 +205,14 @@ function envLookup(
|
|
|
204
205
|
* confirm/reset tests find their tokens whichever import the guest used. Mirrors
|
|
205
206
|
* the edge's `email_send_import.rs`.
|
|
206
207
|
*/
|
|
208
|
+
/** Render the dev EMAIL EMULATOR box (from the HTML) so confirm/reset/2FA flows are
|
|
209
|
+
* testable without a real inbox. Skipped under vitest (tests read the
|
|
210
|
+
* `__sentEmails` seam), where a box per email would just be noise. */
|
|
211
|
+
function previewDevEmail(parsed: ParsedEmail, status: string): void {
|
|
212
|
+
if (process.env.VITEST) return;
|
|
213
|
+
process.stdout.write(renderEmailConsole(parsed, status) + '\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
207
216
|
function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
|
|
208
217
|
const raw = readBytes(ref, reqPtr, reqLen);
|
|
209
218
|
const svc = getEmailService();
|
|
@@ -212,6 +221,8 @@ function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
|
|
|
212
221
|
if (parsed !== null) recordSentEmail(parsed); // dev test seam
|
|
213
222
|
const to = parsed?.to ?? '<unparsed>';
|
|
214
223
|
process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
|
|
224
|
+
// Draw the email so the flow is testable with no mailer configured.
|
|
225
|
+
if (parsed !== null) previewDevEmail(parsed, 'not sent · no provider');
|
|
215
226
|
return EmailStatus.Sent;
|
|
216
227
|
}
|
|
217
228
|
const { status, parsed } = svc.prepare(raw);
|
|
@@ -220,6 +231,7 @@ function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
|
|
|
220
231
|
return status;
|
|
221
232
|
}
|
|
222
233
|
recordSentEmail(parsed); // dev test seam
|
|
234
|
+
previewDevEmail(parsed, 'delivering via provider'); // shown even with a real provider
|
|
223
235
|
void svc
|
|
224
236
|
.deliver(parsed)
|
|
225
237
|
.then((s) => {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadConfig host/port resolution: the dev server AND the self-host (`toiljs start`)
|
|
3
|
+
* bind to a host+port resolved with the precedence CLI flag > env (PORT/HOST) >
|
|
4
|
+
* config (client.host/port) > default (127.0.0.1:3000). These lock that precedence
|
|
5
|
+
* and the env parsing (a blank/garbage PORT must fall through, never bind to 0).
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { loadConfig } from '../src/compiler/config.js';
|
|
14
|
+
|
|
15
|
+
// An empty dir with no toil.config so loadConfig exercises the env/opts/default path.
|
|
16
|
+
const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-cfg-'));
|
|
17
|
+
|
|
18
|
+
describe('loadConfig host/port precedence', () => {
|
|
19
|
+
const savedPort = process.env.PORT;
|
|
20
|
+
const savedHost = process.env.HOST;
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (savedPort === undefined) delete process.env.PORT;
|
|
23
|
+
else process.env.PORT = savedPort;
|
|
24
|
+
if (savedHost === undefined) delete process.env.HOST;
|
|
25
|
+
else process.env.HOST = savedHost;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('defaults to 127.0.0.1:3000 with no flag / env / config', async () => {
|
|
29
|
+
delete process.env.PORT;
|
|
30
|
+
delete process.env.HOST;
|
|
31
|
+
const cfg = await loadConfig({ root: emptyRoot });
|
|
32
|
+
expect(cfg.port).toBe(3000);
|
|
33
|
+
expect(cfg.host).toBe('127.0.0.1');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('reads the PORT / HOST env vars', async () => {
|
|
37
|
+
process.env.PORT = '8080';
|
|
38
|
+
process.env.HOST = '0.0.0.0';
|
|
39
|
+
const cfg = await loadConfig({ root: emptyRoot });
|
|
40
|
+
expect(cfg.port).toBe(8080);
|
|
41
|
+
expect(cfg.host).toBe('0.0.0.0');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('CLI opts win over env (CLI > env)', async () => {
|
|
45
|
+
process.env.PORT = '8080';
|
|
46
|
+
process.env.HOST = '0.0.0.0';
|
|
47
|
+
const cfg = await loadConfig({ root: emptyRoot, port: 4321, host: '192.168.1.5' });
|
|
48
|
+
expect(cfg.port).toBe(4321);
|
|
49
|
+
expect(cfg.host).toBe('192.168.1.5');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('a blank / non-numeric PORT env falls through to the default (never binds to 0)', async () => {
|
|
53
|
+
process.env.PORT = '';
|
|
54
|
+
delete process.env.HOST;
|
|
55
|
+
expect((await loadConfig({ root: emptyRoot })).port).toBe(3000);
|
|
56
|
+
process.env.PORT = 'not-a-number';
|
|
57
|
+
expect((await loadConfig({ root: emptyRoot })).port).toBe(3000);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev email emulator (src/devserver/email/console.ts): the ASCII-drawn box + the
|
|
3
|
+
* HTML->text conversion + action (link/code) extraction that make confirm/reset/2FA
|
|
4
|
+
* flows testable in `toiljs dev` without a real inbox.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { emailAction, htmlToText, renderEmailConsole } from '../src/devserver/email/console.js';
|
|
9
|
+
|
|
10
|
+
const confirmEmail = {
|
|
11
|
+
to: 'bob@gmail.com',
|
|
12
|
+
subject: 'Confirm your account',
|
|
13
|
+
purpose: 'verify',
|
|
14
|
+
body: 'Confirm your account by opening this link:\nhttp://localhost:3000/confirm#token=abc123\n',
|
|
15
|
+
html:
|
|
16
|
+
'<p>Confirm your account by clicking the link below:</p>' +
|
|
17
|
+
'<p><a href="http://localhost:3000/confirm#token=abc123">Confirm my account</a></p>',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** picocolors may or may not emit ANSI depending on TTY; strip it for assertions. */
|
|
21
|
+
const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '');
|
|
22
|
+
|
|
23
|
+
describe('dev email emulator', () => {
|
|
24
|
+
it('htmlToText: anchors become "text (url)", blocks become newlines, entities decode', () => {
|
|
25
|
+
const t = htmlToText('<p>Hi & welcome</p><p><a href="https://x.com/a?b=1">Open</a></p>');
|
|
26
|
+
expect(t).toContain('Hi & welcome');
|
|
27
|
+
expect(t).toContain('Open (https://x.com/a?b=1)');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('emailAction extracts the link from a confirm email', () => {
|
|
31
|
+
expect(emailAction(confirmEmail)).toEqual({
|
|
32
|
+
kind: 'link',
|
|
33
|
+
value: 'http://localhost:3000/confirm#token=abc123',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('emailAction extracts a 2FA code when there is no link', () => {
|
|
38
|
+
const twofa = { to: 'x', subject: 's', purpose: '2fa', body: 'Your code is 481920.', html: '' };
|
|
39
|
+
expect(emailAction(twofa)).toEqual({ kind: 'code', value: '481920' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('emailAction falls back to the HTML body when the text body is empty', () => {
|
|
43
|
+
expect(emailAction({ ...confirmEmail, body: '' })?.value).toBe(
|
|
44
|
+
'http://localhost:3000/confirm#token=abc123',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renderEmailConsole draws a box with recipient, subject, and the clickable link', () => {
|
|
49
|
+
const box = plain(renderEmailConsole(confirmEmail, 'not sent'));
|
|
50
|
+
expect(box).toContain('bob@gmail.com');
|
|
51
|
+
expect(box).toContain('Confirm your account');
|
|
52
|
+
expect(box).toContain('http://localhost:3000/confirm#token=abc123');
|
|
53
|
+
expect(box).toContain('┌');
|
|
54
|
+
expect(box).toContain('└');
|
|
55
|
+
});
|
|
56
|
+
});
|