tina4-nodejs 3.12.9 → 3.13.0
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/CLAUDE.md +17 -17
- package/package.json +5 -1
- package/packages/cli/src/commands/migrate.ts +14 -4
- package/packages/cli/src/commands/migrateRollback.ts +12 -4
- package/packages/cli/src/commands/migrateStatus.ts +10 -4
- package/packages/core/src/__feedback/widget.js +96 -0
- package/packages/core/src/auth.ts +15 -8
- package/packages/core/src/devAdmin.ts +228 -10
- package/packages/core/src/errorOverlay.ts +41 -3
- package/packages/core/src/feedback.ts +277 -0
- package/packages/core/src/index.ts +14 -1
- package/packages/core/src/mcp.test.ts +301 -0
- package/packages/core/src/mcp.ts +302 -7
- package/packages/core/src/plan.ts +56 -15
- package/packages/core/src/request.ts +17 -1
- package/packages/core/src/routeDiscovery.ts +69 -1
- package/packages/core/src/server.ts +42 -3
- package/packages/core/src/static.ts +9 -2
- package/packages/core/src/test.ts +246 -0
- package/packages/core/src/types.ts +18 -0
- package/packages/orm/src/database.ts +14 -0
|
@@ -27,8 +27,15 @@ export function tryServeStatic(
|
|
|
27
27
|
req: Tina4Request,
|
|
28
28
|
res: Tina4Response
|
|
29
29
|
): boolean {
|
|
30
|
-
|
|
31
|
-
|
|
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[]>;
|
|
@@ -869,6 +869,20 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
|
869
869
|
const rawType = config?.type ?? "sqlite";
|
|
870
870
|
const type = rawType === "sqlserver" ? "mssql" : rawType;
|
|
871
871
|
|
|
872
|
+
// Loud warning when we hit the default SQLite path because nothing was
|
|
873
|
+
// configured. Silent fallback was the cause of "my migrations went to the
|
|
874
|
+
// wrong DB" — the developer thought their .env was being honoured.
|
|
875
|
+
// Only warn when the caller passed no config AND no env var was set; an
|
|
876
|
+
// explicit `{ type: "sqlite" }` call is intentional and stays silent.
|
|
877
|
+
if (config === undefined && !process.env.TINA4_DATABASE_URL) {
|
|
878
|
+
const path = "./data/tina4.db";
|
|
879
|
+
console.warn(
|
|
880
|
+
`[tina4] No TINA4_DATABASE_URL set — falling back to SQLite at ${path}. ` +
|
|
881
|
+
`If you meant to use Postgres/MySQL/etc., set TINA4_DATABASE_URL in your .env ` +
|
|
882
|
+
`and re-run. (Was the .env loaded? CLI commands must call loadEnv() first.)`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
872
886
|
switch (type) {
|
|
873
887
|
case "sqlite": {
|
|
874
888
|
const { SQLiteAdapter } = await import("./adapters/sqlite.js");
|