tina4-nodejs 3.12.10 → 3.13.1

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.
@@ -190,6 +190,28 @@ export class Router {
190
190
  return this.addRoute({ method: "DELETE", pattern: path, handler, middlewares, meta });
191
191
  }
192
192
 
193
+ /**
194
+ * Register an explicit HEAD route. By default the framework auto-handles
195
+ * HEAD by falling back to the GET route and stripping the body
196
+ * (RFC 9110 §9.3.2). Use this only when you need a HEAD handler that
197
+ * does something different from GET — e.g. cheaper existence-check
198
+ * logic, custom validator headers without the cost of building the body.
199
+ * The framework still strips the response body for you on the way out.
200
+ */
201
+ head(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
202
+ return this.addRoute({ method: "HEAD", pattern: path, handler, middlewares, meta });
203
+ }
204
+
205
+ /**
206
+ * Register an explicit OPTIONS route. By default the framework auto-
207
+ * handles OPTIONS by building an Allow header from every method
208
+ * registered for the path and returning 204 (RFC 9110 §9.3.7). Use
209
+ * this to take over that behaviour.
210
+ */
211
+ options(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
212
+ return this.addRoute({ method: "OPTIONS", pattern: path, handler, middlewares, meta });
213
+ }
214
+
193
215
  /**
194
216
  * Register a route that matches ANY HTTP method.
195
217
  */
@@ -222,28 +244,65 @@ export class Router {
222
244
 
223
245
  // Try exact method first, then ANY routes are already registered under each method
224
246
  const routes = this.routes.get(upperMethod);
225
- if (!routes) return null;
226
-
227
- const direct = this.matchRoute(routes, path);
228
- if (direct) return direct;
229
-
230
- // Trailing-slash redirect strip a single trailing "/" and retry.
231
- // Root "/" is intentionally excluded (it's its own route).
232
- if (
233
- isTrailingSlashRedirectEnabled() &&
234
- path.length > 1 &&
235
- path.endsWith("/")
236
- ) {
237
- const stripped = path.replace(/\/+$/, "");
238
- if (stripped.length > 0) {
239
- const retry = this.matchRoute(routes, stripped);
240
- if (retry) return retry;
247
+ if (routes) {
248
+ const direct = this.matchRoute(routes, path);
249
+ if (direct) return direct;
250
+
251
+ // Trailing-slash redirect — strip a single trailing "/" and retry.
252
+ // Root "/" is intentionally excluded (it's its own route).
253
+ if (
254
+ isTrailingSlashRedirectEnabled() &&
255
+ path.length > 1 &&
256
+ path.endsWith("/")
257
+ ) {
258
+ const stripped = path.replace(/\/+$/, "");
259
+ if (stripped.length > 0) {
260
+ const retry = this.matchRoute(routes, stripped);
261
+ if (retry) return retry;
262
+ }
263
+ }
264
+ }
265
+
266
+ // RFC 9110 §9.3.2: HEAD is identical to GET except for the absence
267
+ // of a response body. If no explicit HEAD route matched, fall back
268
+ // to the GET route — the dispatcher strips the body on the way out.
269
+ if (upperMethod === "HEAD") {
270
+ const getRoutes = this.routes.get("GET");
271
+ if (getRoutes) {
272
+ return this.matchRoute(getRoutes, path);
241
273
  }
242
274
  }
243
275
 
244
276
  return null;
245
277
  }
246
278
 
279
+ /**
280
+ * Return the list of HTTP methods registered for ``path``, in canonical
281
+ * order GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by the
282
+ * dispatcher to build the ``Allow:`` header on 405 / OPTIONS responses
283
+ * (RFC 9110 §10.2.1, §9.3.7).
284
+ *
285
+ * If GET is registered, HEAD is appended implicitly (HEAD auto-fallback).
286
+ * OPTIONS is appended whenever any method exists for the path (the
287
+ * framework auto-handles OPTIONS).
288
+ */
289
+ methodsAllowedForPath(path: string): string[] {
290
+ const order = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
291
+ const seen = new Set<string>();
292
+ for (const m of order) {
293
+ const routes = this.routes.get(m);
294
+ if (!routes) continue;
295
+ if (this.matchRoute(routes, path)) {
296
+ seen.add(m);
297
+ }
298
+ }
299
+ if (seen.size > 0) {
300
+ if (seen.has("GET")) seen.add("HEAD");
301
+ seen.add("OPTIONS");
302
+ }
303
+ return order.filter((m) => seen.has(m));
304
+ }
305
+
247
306
  /** Inner match against a list of compiled routes, no trailing-slash logic. */
248
307
  private matchRoute(routes: CompiledRoute[], path: string): MatchResult | null {
249
308
  for (const route of routes) {
@@ -19,6 +19,7 @@ import { createHealthRoutes } from "./health.js";
19
19
  import { rateLimiter } from "./rateLimiter.js";
20
20
  import { Log } from "./logger.js";
21
21
  import { DevAdmin, RequestInspector } from "./devAdmin.js";
22
+ import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
22
23
  import { I18n } from "./i18n.js";
23
24
  import { stopAllBackgroundTasks } from "./background.js";
24
25
 
@@ -913,6 +914,36 @@ ${reset}
913
914
  const req = createRequest(rawReq);
914
915
  const res = createResponse(rawRes);
915
916
 
917
+ // RFC 9110 §9.3.2: the server MUST NOT send content in a HEAD response.
918
+ // Intercept rawRes.write / rawRes.end so every code path — explicit
919
+ // Router.head() handler, GET auto-fallback, 405 / 404 responses — drops
920
+ // its body. Content-Length is preserved when present, so cache
921
+ // validators / link checkers / monitoring probes still see the size
922
+ // the equivalent GET would have sent.
923
+ if ((rawReq.method ?? "GET").toUpperCase() === "HEAD") {
924
+ const origEnd = rawRes.end.bind(rawRes);
925
+ const origWrite = rawRes.write.bind(rawRes);
926
+ let accumulated = 0;
927
+ rawRes.write = ((chunk?: any, _enc?: any, cb?: any): boolean => {
928
+ if (chunk != null) {
929
+ accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
930
+ }
931
+ if (typeof cb === "function") cb();
932
+ return true;
933
+ }) as typeof rawRes.write;
934
+ rawRes.end = ((chunk?: any, _enc?: any, cb?: any): any => {
935
+ if (chunk != null && typeof chunk !== "function") {
936
+ accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
937
+ }
938
+ if (accumulated > 0 && !rawRes.headersSent && !rawRes.hasHeader("Content-Length")) {
939
+ rawRes.setHeader("Content-Length", String(accumulated));
940
+ }
941
+ const realCb = typeof chunk === "function" ? chunk : cb;
942
+ return origEnd(undefined, undefined, realCb);
943
+ void origWrite; // referenced to keep tsc happy
944
+ }) as typeof rawRes.end;
945
+ }
946
+
916
947
  // Auto-start session — read cookie, create session, save + set cookie on response end
917
948
  {
918
949
  const { Session, buildSessionCookie } = await import("./session.js");
@@ -954,7 +985,7 @@ ${reset}
954
985
  // Parse request body
955
986
  await req.parseBody();
956
987
 
957
- const pathname = (req.url ?? "/").split("?")[0];
988
+ const pathname = req.path;
958
989
 
959
990
  // Track request start time for dev inspector
960
991
  const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
@@ -1001,9 +1032,10 @@ ${reset}
1001
1032
  };
1002
1033
  if (typeof chunk === "string") {
1003
1034
  chunk = injectDevToolbar(chunk, toolbarCtx);
1035
+ chunk = injectFeedbackWidget(req, chunk as string);
1004
1036
  } else if (Buffer.isBuffer(chunk)) {
1005
1037
  const html = chunk.toString("utf-8");
1006
- chunk = injectDevToolbar(html, toolbarCtx);
1038
+ chunk = injectFeedbackWidget(req, injectDevToolbar(html, toolbarCtx));
1007
1039
  }
1008
1040
  // Remove content-length since toolbar injection changes body size
1009
1041
  if (!res.raw.headersSent) {
@@ -1019,6 +1051,43 @@ ${reset}
1019
1051
  return originalEnd(chunk, cb);
1020
1052
  };
1021
1053
  res.raw.end = wrappedEnd;
1054
+ } else if (
1055
+ feedbackEnabled() &&
1056
+ !pathname.startsWith("/__dev") &&
1057
+ !pathname.startsWith("/__feedback")
1058
+ ) {
1059
+ // Production / non-dev path: still inject the feedback widget for
1060
+ // whitelisted users. The injector itself re-checks the whitelist,
1061
+ // path, and html marker so the wrapper is cheap when it no-ops.
1062
+ const originalEnd = res.raw.end.bind(res.raw);
1063
+ const wrappedEnd: typeof res.raw.end = function (
1064
+ chunk?: unknown,
1065
+ encodingOrCb?: BufferEncoding | (() => void),
1066
+ cb?: () => void,
1067
+ ) {
1068
+ const contentType = res.raw.getHeader("content-type");
1069
+ if (
1070
+ typeof contentType === "string" &&
1071
+ contentType.includes("text/html")
1072
+ ) {
1073
+ if (typeof chunk === "string") {
1074
+ chunk = injectFeedbackWidget(req, chunk);
1075
+ } else if (Buffer.isBuffer(chunk)) {
1076
+ chunk = injectFeedbackWidget(req, chunk.toString("utf-8"));
1077
+ }
1078
+ if (!res.raw.headersSent) {
1079
+ res.raw.removeHeader("content-length");
1080
+ }
1081
+ }
1082
+ if (typeof encodingOrCb === "function") {
1083
+ return originalEnd(chunk, encodingOrCb);
1084
+ }
1085
+ if (encodingOrCb !== undefined) {
1086
+ return originalEnd(chunk, encodingOrCb, cb);
1087
+ }
1088
+ return originalEnd(chunk, cb);
1089
+ };
1090
+ res.raw.end = wrappedEnd;
1022
1091
  }
1023
1092
 
1024
1093
  // Try static files first (project public dir, src/public dir, then framework built-in)
@@ -1170,6 +1239,36 @@ ${reset}
1170
1239
  }
1171
1240
  }
1172
1241
 
1242
+ // RFC 9110 conformance — before falling through to 404, check whether
1243
+ // the PATH is registered under any OTHER method.
1244
+ // - OPTIONS request → 204 with Allow header (§9.3.7)
1245
+ // - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
1246
+ // → 405 with Allow header (§15.5.6 + §10.2.1)
1247
+ const allowedMethods = router.methodsAllowedForPath(pathname);
1248
+ if (allowedMethods.length > 0) {
1249
+ const allowHeader = allowedMethods.join(", ");
1250
+ const requestMethod = (req.method ?? "GET").toUpperCase();
1251
+ if (requestMethod === "OPTIONS") {
1252
+ res.raw.writeHead(204, undefined, { Allow: allowHeader, "Content-Length": "0" });
1253
+ res.raw.end();
1254
+ return;
1255
+ }
1256
+ const body = JSON.stringify({
1257
+ error: "Method Not Allowed",
1258
+ path: pathname,
1259
+ method: requestMethod,
1260
+ allow: allowedMethods,
1261
+ statusCode: 405,
1262
+ });
1263
+ res.raw.writeHead(405, httpReason(405), {
1264
+ Allow: allowHeader,
1265
+ "Content-Type": "application/json",
1266
+ "Content-Length": String(Buffer.byteLength(body)),
1267
+ });
1268
+ res.raw.end(body);
1269
+ return;
1270
+ }
1271
+
1173
1272
  // 404 — pass canonical reason phrase so the status line is well-formed
1174
1273
  const html404 = await renderErrorPage(404, { path: pathname }, templatesDir);
1175
1274
  if (html404) {
@@ -1192,7 +1291,7 @@ ${reset}
1192
1291
  const html500 = await renderErrorPage(500, {
1193
1292
  error_message: errorMessage,
1194
1293
  request_id: `${Date.now().toString(36)}`,
1195
- path: (req.url ?? "/").split("?")[0],
1294
+ path: req.path,
1196
1295
  }, templatesDir);
1197
1296
  if (html500) {
1198
1297
  res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
@@ -161,6 +161,64 @@ function startDaemonService(svc: RegisteredService): void {
161
161
  executeHandler(svc);
162
162
  }
163
163
 
164
+ // ─── Tina4Service base class (3.13.1) ───────────────────────────────────────
165
+ //
166
+ // Class-based background service pattern. Cross-framework parity with
167
+ // Python tina4_python.service (when shipped), PHP Tina4\Service, and Ruby
168
+ // Tina4::Service. The documentation has long taught:
169
+ //
170
+ // class EmailQueueWorker extends Tina4Service {
171
+ // async run() {
172
+ // while (!this.shouldStop()) {
173
+ // // process work
174
+ // }
175
+ // }
176
+ // }
177
+ //
178
+ // ServiceRunner.registerService("emails", new EmailQueueWorker());
179
+ // await ServiceRunner.start();
180
+ //
181
+ // Subclasses MUST override `run()`. Optionally override `stop()` for
182
+ // custom shutdown; always call `super.stop()` so the internal flag
183
+ // gets set — `shouldStop()` reads from it.
184
+
185
+ export abstract class Tina4Service {
186
+ private _running = true;
187
+
188
+ /** Main work loop — subclasses MUST override. */
189
+ abstract run(): Promise<void> | void;
190
+
191
+ /**
192
+ * Signal this service to stop. The next `shouldStop()` check returns true.
193
+ * Override for custom shutdown behaviour but always call `super.stop()`.
194
+ */
195
+ stop(): void {
196
+ this._running = false;
197
+ }
198
+
199
+ /**
200
+ * Returns true once `stop()` has been called. Use inside `run()` loops
201
+ * as the exit condition:
202
+ *
203
+ * async run() {
204
+ * while (!this.shouldStop()) { ... }
205
+ * }
206
+ */
207
+ shouldStop(): boolean {
208
+ return !this._running;
209
+ }
210
+
211
+ /**
212
+ * Return a callable that ServiceRunner can register. Used by
213
+ * ServiceRunner.registerService under the hood.
214
+ */
215
+ asHandler(): ServiceHandler {
216
+ return async () => {
217
+ await this.run();
218
+ };
219
+ }
220
+ }
221
+
164
222
  // ─── ServiceRunner ───────────────────────────────────────────────────────────
165
223
 
166
224
  export class ServiceRunner {
@@ -187,6 +245,35 @@ export class ServiceRunner {
187
245
  });
188
246
  }
189
247
 
248
+ /**
249
+ * Register a class-based service (subclass of {@link Tina4Service}) by name.
250
+ *
251
+ * Wraps the service's `run()` method as the runner's handler. Defaults
252
+ * to `daemon: true` because Tina4Service subclasses manage their own
253
+ * loop inside `run()`. Override via `options`.
254
+ *
255
+ * class EmailWorker extends Tina4Service { async run() { ... } }
256
+ * ServiceRunner.registerService("emails", new EmailWorker());
257
+ * await ServiceRunner.start();
258
+ *
259
+ * Cross-framework parity with PHP `ServiceRunner::registerService` and
260
+ * Ruby `Tina4::ServiceRunner.register_service`.
261
+ */
262
+ static registerService(
263
+ name: string,
264
+ service: Tina4Service,
265
+ options: ServiceOptions = {},
266
+ ): void {
267
+ const merged: ServiceOptions = { daemon: true, ...options };
268
+ this.register(name, service.asHandler(), merged);
269
+ // Stash the instance on the registry entry so future stop() calls
270
+ // can route to service.stop().
271
+ const entry = registry.get(name);
272
+ if (entry) {
273
+ (entry as unknown as Record<string, unknown>).instance = service;
274
+ }
275
+ }
276
+
190
277
  /**
191
278
  * Discover services from a directory. Each file should export
192
279
  * { name, handler, timing?, interval?, daemon?, maxRetries? }.
@@ -27,8 +27,15 @@ export function tryServeStatic(
27
27
  req: Tina4Request,
28
28
  res: Tina4Response
29
29
  ): boolean {
30
- const url = req.url ?? "/";
31
- const pathname = url.split("?")[0];
30
+ // Prefer req.path (always path-only, set by createRequest). Fall back to
31
+ // parsing req.url for hand-rolled request objects in unit tests.
32
+ let pathname = req.path;
33
+ if (!pathname) {
34
+ const raw = req.url ?? "/";
35
+ pathname = raw.startsWith("http")
36
+ ? new URL(raw).pathname
37
+ : raw.split("?")[0];
38
+ }
32
39
 
33
40
  // Try exact file match, then index.html for directory requests
34
41
  const candidates = [
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Tina4 — The Intelligent Native Application 4ramework
3
+ * Copyright 2007 - current Tina4
4
+ * License: MIT https://opensource.org/licenses/MIT
5
+ *
6
+ * Tina4 xUnit-style Test base class.
7
+ *
8
+ * Chapter 18 of the documentation has long shown:
9
+ *
10
+ * class UserApiTest extends Tina4Test {
11
+ * async testHealth() {
12
+ * const resp = await this.get("/health");
13
+ * this.assertEqual(resp.status, 200);
14
+ * }
15
+ * }
16
+ *
17
+ * Until 3.13.0 this class did not exist — examples crashed with
18
+ * "ReferenceError: Tina4Test is not defined". This is the Node.js
19
+ * parity of the Python `tina4_python.test.Test`, PHP `Tina4\Test`,
20
+ * and Ruby `Tina4::Test` classes shipped at the same time.
21
+ *
22
+ * The class has a built-in runner (`Tina4Test.runAll`) so the docs'
23
+ * `npx tina4nodejs test` flow can discover every subclass without
24
+ * an external test framework. HTTP helpers (get/post/put/patch/delete)
25
+ * delegate to a lazy TestClient. Positional assertions match the
26
+ * cross-framework (actual, expected, message) shape.
27
+ */
28
+
29
+ import { TestClient, type TestResponse, type RequestOptions } from "./testClient.js";
30
+
31
+ // Class-level registry so runAll() can discover every subclass.
32
+ const _subclasses: Array<typeof Tina4Test> = [];
33
+
34
+ /** Raised by Tina4Test assertion helpers when an assertion fails. */
35
+ export class AssertionError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "AssertionError";
39
+ }
40
+ }
41
+
42
+ /** Result of running a Tina4Test subclass (or all subclasses). */
43
+ export interface TestRunResults {
44
+ passed: number;
45
+ failed: number;
46
+ errors: number;
47
+ details: Array<{
48
+ suite: string;
49
+ test: string;
50
+ status: "passed" | "failed" | "error";
51
+ message?: string;
52
+ }>;
53
+ }
54
+
55
+ /**
56
+ * Tina4 xUnit-style test base class — class-based suites with HTTP
57
+ * helpers and positional assertions, zero external deps.
58
+ *
59
+ * Subclass and define `test*` methods:
60
+ *
61
+ * class BasicTest extends Tina4Test {
62
+ * async testAddition() {
63
+ * this.assertEqual(2 + 2, 4, "addition works");
64
+ * }
65
+ * async testHttpHealth() {
66
+ * const resp = await this.get("/health");
67
+ * this.assertEqual(resp.status, 200);
68
+ * }
69
+ * }
70
+ *
71
+ * const results = await Tina4Test.runAll();
72
+ * // → { passed, failed, errors, details }
73
+ */
74
+ export class Tina4Test {
75
+ private _client: TestClient | null = null;
76
+
77
+ // Auto-register every subclass for the runAll() discovery API.
78
+ // (The static block runs once when the class file loads.)
79
+ static {
80
+ // Skip the base class itself
81
+ }
82
+
83
+ /** snake_case lifecycle hook — runs before each test. Override in subclasses. */
84
+ async setUp(): Promise<void> {}
85
+
86
+ /** snake_case lifecycle hook — runs after each test. Override in subclasses. */
87
+ async tearDown(): Promise<void> {}
88
+
89
+ // ── HTTP test client (lazy) ─────────────────────────────────────────
90
+
91
+ /** The lazily-created TestClient instance shared by this suite's tests. */
92
+ protected get client(): TestClient {
93
+ if (this._client === null) this._client = new TestClient();
94
+ return this._client;
95
+ }
96
+
97
+ async get(path: string, options?: RequestOptions): Promise<TestResponse> {
98
+ return this.client.get(path, options);
99
+ }
100
+
101
+ async post(path: string, options?: RequestOptions): Promise<TestResponse> {
102
+ return this.client.post(path, options);
103
+ }
104
+
105
+ async put(path: string, options?: RequestOptions): Promise<TestResponse> {
106
+ return this.client.put(path, options);
107
+ }
108
+
109
+ async patch(path: string, options?: RequestOptions): Promise<TestResponse> {
110
+ return this.client.patch(path, options);
111
+ }
112
+
113
+ async delete(path: string, options?: RequestOptions): Promise<TestResponse> {
114
+ return this.client.delete(path, options);
115
+ }
116
+
117
+ // ── Positional assertions — (actual, expected, message) shape ────────
118
+
119
+ assertEqual(actual: unknown, expected: unknown, message?: string): void {
120
+ // Deep equality via JSON for objects, strict for primitives — matches
121
+ // how the Python/PHP/Ruby ports compare.
122
+ const eq =
123
+ actual === expected ||
124
+ (typeof actual === "object" && typeof expected === "object" &&
125
+ JSON.stringify(actual) === JSON.stringify(expected));
126
+ if (!eq) {
127
+ throw new AssertionError(
128
+ message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
129
+ );
130
+ }
131
+ }
132
+
133
+ assertNotEqual(actual: unknown, expected: unknown, message?: string): void {
134
+ const eq =
135
+ actual === expected ||
136
+ (typeof actual === "object" && typeof expected === "object" &&
137
+ JSON.stringify(actual) === JSON.stringify(expected));
138
+ if (eq) {
139
+ throw new AssertionError(
140
+ message || `Expected ${JSON.stringify(actual)} != ${JSON.stringify(expected)}, but they are equal`
141
+ );
142
+ }
143
+ }
144
+
145
+ assertTrue(value: unknown, message?: string): void {
146
+ if (!value) throw new AssertionError(message || `Expected truthy, got ${JSON.stringify(value)}`);
147
+ }
148
+
149
+ assertFalse(value: unknown, message?: string): void {
150
+ if (value) throw new AssertionError(message || `Expected falsy, got ${JSON.stringify(value)}`);
151
+ }
152
+
153
+ assertNull(value: unknown, message?: string): void {
154
+ if (value !== null) throw new AssertionError(message || `Expected null, got ${JSON.stringify(value)}`);
155
+ }
156
+
157
+ assertNotNull(value: unknown, message?: string): void {
158
+ if (value === null) throw new AssertionError(message || "Expected non-null, got null");
159
+ }
160
+
161
+ async assertRaises(
162
+ expectedClass: new (...args: never[]) => Error,
163
+ fn: () => unknown | Promise<unknown>,
164
+ message?: string
165
+ ): Promise<void> {
166
+ try {
167
+ await fn();
168
+ } catch (err) {
169
+ if (err instanceof expectedClass) return;
170
+ throw new AssertionError(
171
+ message || `Expected ${expectedClass.name}, got ${(err as Error)?.constructor?.name}: ${String(err)}`
172
+ );
173
+ }
174
+ throw new AssertionError(message || `Expected ${expectedClass.name} to be raised, but nothing was`);
175
+ }
176
+
177
+ // ── Runner ───────────────────────────────────────────────────────────
178
+
179
+ /** Register a subclass for discovery. Called automatically via `extends Tina4Test`. */
180
+ static register(klass: typeof Tina4Test): void {
181
+ if (!_subclasses.includes(klass)) _subclasses.push(klass);
182
+ }
183
+
184
+ /** Run every `test*` method on this class. Returns counts and per-test details. */
185
+ static async run(this: typeof Tina4Test): Promise<TestRunResults> {
186
+ const results: TestRunResults = { passed: 0, failed: 0, errors: 0, details: [] };
187
+ const proto = this.prototype as unknown as Record<string, unknown>;
188
+ const methods = Object.getOwnPropertyNames(this.prototype)
189
+ .filter((m) => m.startsWith("test") && typeof proto[m] === "function")
190
+ .sort();
191
+
192
+ for (const method of methods) {
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ const suite = new (this as any)();
195
+ try {
196
+ await suite.setUp();
197
+ await suite[method]();
198
+ await suite.tearDown();
199
+ results.passed++;
200
+ results.details.push({ suite: this.name, test: method, status: "passed" });
201
+ } catch (err) {
202
+ if (err instanceof AssertionError) {
203
+ results.failed++;
204
+ results.details.push({ suite: this.name, test: method, status: "failed", message: err.message });
205
+ } else {
206
+ results.errors++;
207
+ results.details.push({
208
+ suite: this.name,
209
+ test: method,
210
+ status: "error",
211
+ message: `${(err as Error)?.constructor?.name || "Error"}: ${String(err)}`,
212
+ });
213
+ }
214
+ }
215
+ }
216
+ return results;
217
+ }
218
+
219
+ /** Run every Tina4Test subclass discovered via auto-registration. */
220
+ static async runAll(options: { quiet?: boolean } = {}): Promise<TestRunResults> {
221
+ const aggregate: TestRunResults = { passed: 0, failed: 0, errors: 0, details: [] };
222
+ for (const klass of _subclasses) {
223
+ const out = await klass.run();
224
+ aggregate.passed += out.passed;
225
+ aggregate.failed += out.failed;
226
+ aggregate.errors += out.errors;
227
+ aggregate.details.push(...out.details);
228
+ }
229
+ if (!options.quiet) {
230
+ // eslint-disable-next-line no-console
231
+ console.log(
232
+ `Tina4 Test results: ${aggregate.passed} passed, ${aggregate.failed} failed, ${aggregate.errors} errors`
233
+ );
234
+ }
235
+ return aggregate;
236
+ }
237
+
238
+ /** Subclasses array — read-only view used by tests. */
239
+ static get subclasses(): ReadonlyArray<typeof Tina4Test> {
240
+ return _subclasses;
241
+ }
242
+ }
243
+
244
+ // Auto-register subclasses by overriding the `extends` hook. Because JS
245
+ // doesn't have a Ruby/Python-style `inherited` callback, expose `register`
246
+ // as the explicit handshake and document it; tests can call directly.
@@ -20,6 +20,24 @@ export interface Tina4Session {
20
20
  export interface Tina4Request extends IncomingMessage {
21
21
  params: Record<string, string>;
22
22
  query: Record<string, string>;
23
+ /**
24
+ * Request path only — no query string. Matches `request.path` in
25
+ * Python/PHP/Ruby. Example: `/users/42`.
26
+ */
27
+ path: string;
28
+ /**
29
+ * Raw query string with no leading "?". Matches `request.query_string`
30
+ * (Python/Ruby) and `request.queryString` (PHP). Example: `"page=2"`.
31
+ */
32
+ queryString: string;
33
+ /**
34
+ * Full absolute URL — `scheme://host[:port]/path[?query]`.
35
+ * Honours X-Forwarded-Proto / X-Forwarded-Host. Matches PHP/Ruby/Python parity.
36
+ *
37
+ * Note: this overrides Node's native `IncomingMessage.url` (which contains
38
+ * only path+query). Inside Tina4 handlers, `req.url` is always the full URL.
39
+ */
40
+ url: string;
23
41
  body: unknown;
24
42
  ip: string;
25
43
  files: Record<string, UploadedFile | UploadedFile[]>;