tina4-nodejs 3.0.0-rc.2

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.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Tina4 DevMailbox — File-backed development mailbox, zero dependencies.
3
+ *
4
+ * Captures emails to the filesystem instead of sending them via SMTP.
5
+ * Perfect for local development — no email server needed.
6
+ *
7
+ * import { DevMailbox, createMessenger } from "@tina4/core";
8
+ *
9
+ * const mailbox = new DevMailbox();
10
+ * mailbox.capture({ to: "alice@test.com", subject: "Hello", body: "Hi!" });
11
+ * const messages = mailbox.inbox();
12
+ */
13
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+
17
+ import type { SendResult, EmailMessage } from "./messenger.js";
18
+ import { Messenger } from "./messenger.js";
19
+ import { isTruthy } from "./dotenv.js";
20
+
21
+ // ── DevMailbox ───────────────────────────────────────────────
22
+
23
+ export class DevMailbox {
24
+ private mailboxDir: string;
25
+
26
+ constructor(mailboxDir?: string) {
27
+ this.mailboxDir = mailboxDir ?? process.env.TINA4_MAILBOX_DIR ?? "data/mailbox";
28
+ }
29
+
30
+ /**
31
+ * Ensure a folder directory exists.
32
+ */
33
+ private ensureFolder(folder: string): string {
34
+ const dir = join(this.mailboxDir, folder);
35
+ mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+
39
+ /**
40
+ * Capture an email to the dev mailbox instead of sending it.
41
+ */
42
+ capture(options: {
43
+ to: string | string[];
44
+ subject: string;
45
+ body: string;
46
+ html?: boolean;
47
+ cc?: string[];
48
+ bcc?: string[];
49
+ replyTo?: string;
50
+ attachments?: string[];
51
+ from?: string;
52
+ }): SendResult {
53
+ const id = randomUUID();
54
+ const toList = Array.isArray(options.to) ? options.to : [options.to];
55
+ const now = new Date().toISOString();
56
+
57
+ const message: EmailMessage = {
58
+ id,
59
+ type: "outbox",
60
+ from: options.from ?? process.env.SMTP_FROM ?? "dev@localhost",
61
+ to: toList,
62
+ cc: options.cc ?? [],
63
+ bcc: options.bcc ?? [],
64
+ reply_to: options.replyTo,
65
+ subject: options.subject,
66
+ body: options.body,
67
+ html: options.html ?? false,
68
+ attachments: options.attachments ?? [],
69
+ date: now,
70
+ read: false,
71
+ };
72
+
73
+ // Save to outbox
74
+ const outboxDir = this.ensureFolder("outbox");
75
+ writeFileSync(join(outboxDir, `${id}.json`), JSON.stringify(message, null, 2));
76
+
77
+ // Also save a copy to each recipient's inbox
78
+ const inboxDir = this.ensureFolder("inbox");
79
+ const inboxMessage: EmailMessage = { ...message, type: "inbox" };
80
+ writeFileSync(join(inboxDir, `${id}.json`), JSON.stringify(inboxMessage, null, 2));
81
+
82
+ return { success: true, message: "Email captured to dev mailbox", id };
83
+ }
84
+
85
+ /**
86
+ * List messages from a folder (default: inbox).
87
+ */
88
+ inbox(limit: number = 50, offset: number = 0, folder: string = "inbox"): EmailMessage[] {
89
+ const dir = this.ensureFolder(folder);
90
+ const results: EmailMessage[] = [];
91
+
92
+ let files: string[];
93
+ try {
94
+ files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().reverse();
95
+ } catch {
96
+ return [];
97
+ }
98
+
99
+ const sliced = files.slice(offset, offset + limit);
100
+
101
+ for (const file of sliced) {
102
+ try {
103
+ const msg: EmailMessage = JSON.parse(readFileSync(join(dir, file), "utf-8"));
104
+ results.push(msg);
105
+ } catch {
106
+ // skip corrupt files
107
+ }
108
+ }
109
+
110
+ return results;
111
+ }
112
+
113
+ /**
114
+ * Read a single message by ID. Searches all folders.
115
+ */
116
+ read(msgId: string): EmailMessage | null {
117
+ const folders = ["inbox", "outbox"];
118
+
119
+ for (const folder of folders) {
120
+ const filePath = join(this.mailboxDir, folder, `${msgId}.json`);
121
+ if (existsSync(filePath)) {
122
+ try {
123
+ const msg: EmailMessage = JSON.parse(readFileSync(filePath, "utf-8"));
124
+ // Mark as read
125
+ msg.read = true;
126
+ writeFileSync(filePath, JSON.stringify(msg, null, 2));
127
+ return msg;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ }
133
+
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Count unread messages in the inbox.
139
+ */
140
+ unreadCount(): number {
141
+ const dir = this.ensureFolder("inbox");
142
+ let count = 0;
143
+
144
+ try {
145
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
146
+ for (const file of files) {
147
+ try {
148
+ const msg: EmailMessage = JSON.parse(readFileSync(join(dir, file), "utf-8"));
149
+ if (!msg.read) count++;
150
+ } catch {
151
+ // skip corrupt files
152
+ }
153
+ }
154
+ } catch {
155
+ // directory might not exist
156
+ }
157
+
158
+ return count;
159
+ }
160
+
161
+ /**
162
+ * Delete a message by ID. Removes from all folders.
163
+ */
164
+ delete(msgId: string): boolean {
165
+ let deleted = false;
166
+ const folders = ["inbox", "outbox"];
167
+
168
+ for (const folder of folders) {
169
+ const filePath = join(this.mailboxDir, folder, `${msgId}.json`);
170
+ if (existsSync(filePath)) {
171
+ try {
172
+ unlinkSync(filePath);
173
+ deleted = true;
174
+ } catch {
175
+ // ignore
176
+ }
177
+ }
178
+ }
179
+
180
+ return deleted;
181
+ }
182
+
183
+ /**
184
+ * Clear all messages from a folder, or all folders if none specified.
185
+ */
186
+ clear(folder?: string): void {
187
+ const folders = folder ? [folder] : ["inbox", "outbox"];
188
+
189
+ for (const f of folders) {
190
+ const dir = join(this.mailboxDir, f);
191
+ try {
192
+ if (existsSync(dir)) {
193
+ const files = readdirSync(dir).filter((file) => file.endsWith(".json"));
194
+ for (const file of files) {
195
+ unlinkSync(join(dir, file));
196
+ }
197
+ }
198
+ } catch {
199
+ // ignore
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Seed the mailbox with sample messages for development.
206
+ */
207
+ seed(count: number = 5): void {
208
+ const subjects = [
209
+ "Welcome to Tina4",
210
+ "Your account has been created",
211
+ "Weekly digest",
212
+ "Action required: Verify your email",
213
+ "New feature announcement",
214
+ "Security alert: New login detected",
215
+ "Invoice #1234",
216
+ "Meeting reminder",
217
+ "Password reset request",
218
+ "Feedback requested",
219
+ ];
220
+
221
+ const senders = [
222
+ "noreply@tina4.com",
223
+ "admin@example.com",
224
+ "support@company.com",
225
+ "billing@service.io",
226
+ "alerts@monitoring.dev",
227
+ ];
228
+
229
+ for (let i = 0; i < count; i++) {
230
+ const subject = subjects[i % subjects.length];
231
+ const from = senders[i % senders.length];
232
+ const date = new Date(Date.now() - i * 3600000).toISOString();
233
+ const id = randomUUID();
234
+
235
+ const message: EmailMessage = {
236
+ id,
237
+ type: "inbox",
238
+ from,
239
+ to: ["dev@localhost"],
240
+ cc: [],
241
+ bcc: [],
242
+ subject,
243
+ body: `This is sample email #${i + 1}.\n\nGenerated by DevMailbox.seed() for development purposes.`,
244
+ html: false,
245
+ attachments: [],
246
+ date,
247
+ read: i > 1, // first two are unread
248
+ };
249
+
250
+ const dir = this.ensureFolder("inbox");
251
+ writeFileSync(join(dir, `${id}.json`), JSON.stringify(message, null, 2));
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Count messages in a folder, or all folders if none specified.
257
+ */
258
+ count(folder?: string): { inbox: number; outbox: number; total: number } {
259
+ const countDir = (f: string): number => {
260
+ const dir = join(this.mailboxDir, f);
261
+ try {
262
+ if (existsSync(dir)) {
263
+ return readdirSync(dir).filter((file) => file.endsWith(".json")).length;
264
+ }
265
+ } catch {
266
+ // ignore
267
+ }
268
+ return 0;
269
+ };
270
+
271
+ if (folder) {
272
+ const c = countDir(folder);
273
+ return { inbox: folder === "inbox" ? c : 0, outbox: folder === "outbox" ? c : 0, total: c };
274
+ }
275
+
276
+ const inbox = countDir("inbox");
277
+ const outbox = countDir("outbox");
278
+ return { inbox, outbox, total: inbox + outbox };
279
+ }
280
+ }
281
+
282
+ // ── Factory ──────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Create a Messenger or DevMailbox based on the environment.
286
+ *
287
+ * Returns DevMailbox when:
288
+ * - TINA4_DEBUG is "true", OR
289
+ * - No SMTP_HOST is configured
290
+ *
291
+ * Returns a real Messenger otherwise (SMTP configured + not debug mode).
292
+ *
293
+ * This follows the factory pattern from PHP's MessengerFactory.
294
+ */
295
+ export function createMessenger(): Messenger | DevMailbox {
296
+ const debug = process.env.TINA4_DEBUG;
297
+ const smtpHost = process.env.SMTP_HOST;
298
+
299
+ // Force dev mode when TINA4_DEBUG is truthy
300
+ if (isTruthy(debug)) {
301
+ return new DevMailbox();
302
+ }
303
+
304
+ // No SMTP configured — must use dev mailbox
305
+ if (!smtpHost) {
306
+ return new DevMailbox();
307
+ }
308
+
309
+ // Non-production environment — use dev mailbox
310
+ if (!isProd) {
311
+ return new DevMailbox();
312
+ }
313
+
314
+ // Production with SMTP configured — use real Messenger
315
+ return new Messenger();
316
+ }
@@ -0,0 +1,172 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /**
5
+ * Parse a .env file content string into key-value pairs.
6
+ * Supports:
7
+ * - KEY=value
8
+ * - KEY="double quoted"
9
+ * - KEY='single quoted'
10
+ * - export KEY=value
11
+ * - # comments
12
+ * - Empty lines
13
+ * - Multi-line with trailing backslash \
14
+ */
15
+ function parseEnvContent(content: string): Record<string, string> {
16
+ const result: Record<string, string> = {};
17
+ const lines = content.split("\n");
18
+ let i = 0;
19
+
20
+ while (i < lines.length) {
21
+ let line = lines[i].trim();
22
+ i++;
23
+
24
+ // Skip empty lines and comments
25
+ if (line === "" || line.startsWith("#")) {
26
+ continue;
27
+ }
28
+
29
+ // Strip "export " prefix
30
+ if (line.startsWith("export ")) {
31
+ line = line.slice(7).trim();
32
+ }
33
+
34
+ // Find the first = sign
35
+ const eqIndex = line.indexOf("=");
36
+ if (eqIndex === -1) {
37
+ continue;
38
+ }
39
+
40
+ const key = line.slice(0, eqIndex).trim();
41
+ let value = line.slice(eqIndex + 1).trim();
42
+
43
+ // Handle quoted values
44
+ if (value.startsWith('"') && value.endsWith('"')) {
45
+ value = value.slice(1, -1);
46
+ // Process escape sequences in double-quoted values
47
+ value = value
48
+ .replace(/\\n/g, "\n")
49
+ .replace(/\\r/g, "\r")
50
+ .replace(/\\t/g, "\t")
51
+ .replace(/\\"/g, '"')
52
+ .replace(/\\\\/g, "\\");
53
+ } else if (value.startsWith("'") && value.endsWith("'")) {
54
+ // Single-quoted: literal, no escape processing
55
+ value = value.slice(1, -1);
56
+ } else {
57
+ // Unquoted: handle multi-line with trailing backslash
58
+ while (value.endsWith("\\") && i < lines.length) {
59
+ value = value.slice(0, -1) + lines[i].trim();
60
+ i++;
61
+ }
62
+ // Strip inline comments (only for unquoted values)
63
+ const commentIndex = value.indexOf(" #");
64
+ if (commentIndex !== -1) {
65
+ value = value.slice(0, commentIndex).trim();
66
+ }
67
+ }
68
+
69
+ result[key] = value;
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Load environment variables from a .env file into process.env.
77
+ * Does not override existing process.env values unless they are undefined.
78
+ *
79
+ * @param path - Path to the .env file. Defaults to ".env" in the current working directory.
80
+ * @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
81
+ */
82
+ export function loadEnv(path?: string): Record<string, string> {
83
+ const envPath = resolve(path ?? ".env");
84
+
85
+ if (!existsSync(envPath)) {
86
+ return {};
87
+ }
88
+
89
+ const content = readFileSync(envPath, "utf-8");
90
+ const parsed = parseEnvContent(content);
91
+
92
+ for (const [key, value] of Object.entries(parsed)) {
93
+ if (process.env[key] === undefined) {
94
+ process.env[key] = value;
95
+ _loadedKeys.push(key);
96
+ }
97
+ }
98
+
99
+ return parsed;
100
+ }
101
+
102
+ /**
103
+ * Get an environment variable value with an optional default.
104
+ *
105
+ * @param key - The environment variable name.
106
+ * @param defaultValue - Value to return if the variable is not set.
107
+ * @returns The environment variable value, or the default.
108
+ */
109
+ export function getEnv(key: string, defaultValue?: string): string | undefined {
110
+ return process.env[key] ?? defaultValue;
111
+ }
112
+
113
+ /**
114
+ * Get a required environment variable. Throws if not set.
115
+ *
116
+ * @param key - The environment variable name.
117
+ * @returns The environment variable value.
118
+ * @throws Error if the variable is not set.
119
+ */
120
+ export function requireEnv(key: string): string {
121
+ const value = process.env[key];
122
+ if (value === undefined) {
123
+ throw new Error(`Required environment variable "${key}" is not set.`);
124
+ }
125
+ return value;
126
+ }
127
+
128
+ /**
129
+ * Check if an environment variable exists (is defined in process.env).
130
+ *
131
+ * @param key - The environment variable name.
132
+ * @returns true if the variable is set, false otherwise.
133
+ */
134
+ export function hasEnv(key: string): boolean {
135
+ return process.env[key] !== undefined;
136
+ }
137
+
138
+ /**
139
+ * Return all currently loaded environment variables.
140
+ *
141
+ * @returns A shallow copy of process.env as a record.
142
+ */
143
+ export function allEnv(): Record<string, string | undefined> {
144
+ return { ...process.env };
145
+ }
146
+
147
+ /**
148
+ * Check if a value is truthy for env boolean checks.
149
+ *
150
+ * Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
151
+ * Everything else is falsy (including empty string, undefined, not set).
152
+ *
153
+ * Mirrors Python's `is_truthy()` in `tina4_python.dotenv`.
154
+ */
155
+ export function isTruthy(val: string | undefined | null): boolean {
156
+ if (val == null) return false;
157
+ return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
158
+ }
159
+
160
+ /** Keys loaded by loadEnv, tracked for resetEnv(). */
161
+ const _loadedKeys: string[] = [];
162
+
163
+ /**
164
+ * Remove all environment variables that were loaded by loadEnv().
165
+ * Useful for testing. Only removes keys set by loadEnv(), not pre-existing system env vars.
166
+ */
167
+ export function resetEnv(): void {
168
+ for (const key of _loadedKeys) {
169
+ delete process.env[key];
170
+ }
171
+ _loadedKeys.length = 0;
172
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tests for errorOverlay module.
3
+ */
4
+
5
+ import { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
6
+
7
+ let passed = 0;
8
+ let failed = 0;
9
+
10
+ function assert(condition: boolean, message: string): void {
11
+ if (condition) {
12
+ passed++;
13
+ } else {
14
+ failed++;
15
+ console.error(` FAIL: ${message}`);
16
+ }
17
+ }
18
+
19
+ function assertIncludes(html: string, needle: string, label: string): void {
20
+ assert(html.includes(needle), `${label}: expected HTML to include "${needle}"`);
21
+ }
22
+
23
+ function assertNotIncludes(html: string, needle: string, label: string): void {
24
+ assert(!html.includes(needle), `${label}: expected HTML NOT to include "${needle}"`);
25
+ }
26
+
27
+ function makeError(): Error {
28
+ try {
29
+ throw new TypeError("something broke");
30
+ } catch (e) {
31
+ return e as Error;
32
+ }
33
+ }
34
+
35
+ // ── renderErrorOverlay ──
36
+
37
+ const err = makeError();
38
+ const html = renderErrorOverlay(err);
39
+
40
+ assert(typeof html === "string", "returns a string");
41
+ assert(html.startsWith("<!DOCTYPE html>"), "starts with DOCTYPE");
42
+ assertIncludes(html, "TypeError", "contains exception type");
43
+ assertIncludes(html, "something broke", "contains exception message");
44
+ assertIncludes(html, "errorOverlay.test", "contains file path");
45
+ assertIncludes(html, "&#x25b6;", "contains error line marker");
46
+ assertIncludes(html, "Stack Trace", "contains stack trace section");
47
+ assertIncludes(html, "<details", "uses details element");
48
+ assertIncludes(html, "open", "stack trace open by default");
49
+ assertIncludes(html, "Environment", "contains environment section");
50
+ assertIncludes(html, "Tina4 Node.js", "contains framework name");
51
+ assertIncludes(html, "TINA4_DEBUG", "contains debug reference");
52
+
53
+ // With request
54
+ const htmlWithReq = renderErrorOverlay(err, {
55
+ method: "GET",
56
+ url: "/api/users",
57
+ headers: { host: "localhost" },
58
+ });
59
+ assertIncludes(htmlWithReq, "GET", "request method shown");
60
+ assertIncludes(htmlWithReq, "/api/users", "request URL shown");
61
+ assertIncludes(htmlWithReq, "localhost", "request header shown");
62
+ assertIncludes(htmlWithReq, "Request Details", "request section present");
63
+
64
+ // Without request
65
+ assertNotIncludes(html, "Request Details", "no request section when no request");
66
+
67
+ // XSS escaping
68
+ const xssErr = new Error('<script>alert("xss")</script>');
69
+ const xssHtml = renderErrorOverlay(xssErr);
70
+ assertNotIncludes(xssHtml, "<script>", "escapes script tags");
71
+ assertIncludes(xssHtml, "&lt;script&gt;", "HTML-encodes script tags");
72
+
73
+ // ── renderProductionError ──
74
+
75
+ const prodHtml = renderProductionError();
76
+ assert(prodHtml.startsWith("<!DOCTYPE html>"), "production: starts with DOCTYPE");
77
+ assertIncludes(prodHtml, "500", "production: contains 500");
78
+ assertIncludes(prodHtml, "Internal Server Error", "production: default message");
79
+ assertNotIncludes(prodHtml, "Stack Trace", "production: no stack trace");
80
+
81
+ const prod404 = renderProductionError(404, "Not Found");
82
+ assertIncludes(prod404, "404", "production 404: contains code");
83
+ assertIncludes(prod404, "Not Found", "production 404: contains message");
84
+
85
+ // ── isDebugMode ──
86
+
87
+ const origDebug = process.env.TINA4_DEBUG;
88
+
89
+ process.env.TINA4_DEBUG = "true";
90
+ assert(isDebugMode() === true, "isDebugMode: true => true");
91
+
92
+ process.env.TINA4_DEBUG = "false";
93
+ assert(isDebugMode() === false, "isDebugMode: false => false");
94
+
95
+ process.env.TINA4_DEBUG = "TRUE";
96
+ assert(isDebugMode() === true, "isDebugMode: TRUE (uppercase) => true (case insensitive)");
97
+
98
+ process.env.TINA4_DEBUG = "1";
99
+ assert(isDebugMode() === true, "isDebugMode: 1 => true");
100
+
101
+ process.env.TINA4_DEBUG = "yes";
102
+ assert(isDebugMode() === true, "isDebugMode: yes => true");
103
+
104
+ process.env.TINA4_DEBUG = "on";
105
+ assert(isDebugMode() === true, "isDebugMode: on => true");
106
+
107
+ process.env.TINA4_DEBUG = "0";
108
+ assert(isDebugMode() === false, "isDebugMode: 0 => false");
109
+
110
+ delete process.env.TINA4_DEBUG;
111
+ assert(isDebugMode() === false, "isDebugMode: unset => false");
112
+
113
+ // Restore
114
+ if (origDebug !== undefined) {
115
+ process.env.TINA4_DEBUG = origDebug;
116
+ }
117
+
118
+ // ── Summary ──
119
+ console.log(`\nerrorOverlay tests: ${passed} passed, ${failed} failed`);
120
+ if (failed > 0) {
121
+ process.exit(1);
122
+ }