jsgui3-server 0.0.151 → 0.0.152

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 (77) hide show
  1. package/README.md +21 -0
  2. package/admin-ui/v1/controls/admin_shell.js +33 -0
  3. package/admin-ui/v1/server.js +14 -1
  4. package/docs/api-reference.md +120 -2
  5. package/docs/books/website-design/01-introduction.md +73 -0
  6. package/docs/books/website-design/02-current-state.md +195 -0
  7. package/docs/books/website-design/03-base-class.md +181 -0
  8. package/docs/books/website-design/04-webpage.md +307 -0
  9. package/docs/books/website-design/05-website.md +456 -0
  10. package/docs/books/website-design/06-pages-storage.md +170 -0
  11. package/docs/books/website-design/07-api-layer.md +285 -0
  12. package/docs/books/website-design/08-server-integration.md +271 -0
  13. package/docs/books/website-design/09-cross-agent-review.md +190 -0
  14. package/docs/books/website-design/10-open-questions.md +196 -0
  15. package/docs/books/website-design/11-converged-recommendation.md +205 -0
  16. package/docs/books/website-design/12-content-model.md +395 -0
  17. package/docs/books/website-design/13-webpage-module-spec.md +404 -0
  18. package/docs/books/website-design/14-website-module-spec.md +541 -0
  19. package/docs/books/website-design/15-multi-repo-plan.md +275 -0
  20. package/docs/books/website-design/16-minimal-first.md +203 -0
  21. package/docs/books/website-design/17-implementation-report-codex.md +81 -0
  22. package/docs/books/website-design/README.md +43 -0
  23. package/docs/configuration-reference.md +54 -0
  24. package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
  25. package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
  26. package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
  27. package/docs/swagger.md +316 -0
  28. package/examples/controls/1) window/server.js +6 -1
  29. package/examples/controls/21) mvvm and declarative api/check.js +94 -0
  30. package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
  31. package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
  32. package/examples/controls/21) mvvm and declarative api/client.js +241 -0
  33. declarative api/e2e-screenshot-1-name-change.png +0 -0
  34. declarative api/e2e-screenshot-2-toggled.png +0 -0
  35. declarative api/e2e-screenshot-3-final.png +0 -0
  36. declarative api/e2e-screenshot-final.png +0 -0
  37. package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
  38. package/examples/controls/21) mvvm and declarative api/out.html +1 -0
  39. package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
  40. package/examples/controls/21) mvvm and declarative api/server.js +18 -0
  41. package/examples/data-views/01) query-endpoint/server.js +61 -0
  42. package/labs/website-design/001-base-class-overhead/check.js +162 -0
  43. package/labs/website-design/002-pages-storage/check.js +244 -0
  44. package/labs/website-design/002-pages-storage/results.txt +0 -0
  45. package/labs/website-design/003-type-detection/check.js +193 -0
  46. package/labs/website-design/003-type-detection/results.txt +0 -0
  47. package/labs/website-design/004-two-stage-validation/check.js +314 -0
  48. package/labs/website-design/004-two-stage-validation/results.txt +0 -0
  49. package/labs/website-design/005-normalize-input/check.js +303 -0
  50. package/labs/website-design/006-serve-website-spike/check.js +290 -0
  51. package/labs/website-design/README.md +34 -0
  52. package/labs/website-design/manifest.json +68 -0
  53. package/labs/website-design/run-all.js +60 -0
  54. package/middleware/json-body.js +126 -0
  55. package/openapi.js +474 -0
  56. package/package.json +11 -8
  57. package/publishers/Publishers.js +6 -5
  58. package/publishers/http-function-publisher.js +135 -126
  59. package/publishers/http-webpage-publisher.js +89 -11
  60. package/publishers/query-publisher.js +116 -0
  61. package/publishers/swagger-publisher.js +203 -0
  62. package/publishers/swagger-ui.js +578 -0
  63. package/resources/adapters/array-adapter.js +143 -0
  64. package/resources/query-resource.js +131 -0
  65. package/serve-factory.js +728 -18
  66. package/server.js +421 -103
  67. package/tests/README.md +23 -1
  68. package/tests/admin-ui-jsgui-controls.test.js +16 -1
  69. package/tests/helpers/playwright-e2e-harness.js +326 -0
  70. package/tests/openapi.test.js +319 -0
  71. package/tests/playwright-smoke.test.js +134 -0
  72. package/tests/publish-enhancements.test.js +673 -0
  73. package/tests/query-publisher.test.js +430 -0
  74. package/tests/quick-json-body-test.js +169 -0
  75. package/tests/serve.test.js +425 -122
  76. package/tests/swagger-publisher.test.js +1076 -0
  77. package/tests/test-runner.js +1 -0
@@ -0,0 +1,1076 @@
1
+ /**
2
+ * Comprehensive E2E tests for the Swagger Publisher system.
3
+ *
4
+ * Covers:
5
+ * - Swagger_Publisher lifecycle (construction, ready event, type, caching)
6
+ * - handle_http routing (spec, docs, 404, 405, query strings)
7
+ * - OpenAPI spec richness with realistic example APIs
8
+ * - Schema edge cases (nested, arrays, enums, required, defaults)
9
+ * - Tag auto-detection
10
+ * - Multi-method endpoints
11
+ * - Publisher registry integration
12
+ * - Full integration tests with a real HTTP server
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const assert = require('assert');
18
+ const http = require('http');
19
+ const net = require('net');
20
+ const { describe, it, before, after } = require('node:test');
21
+
22
+ // ── Helpers ──────────────────────────────────────────────────
23
+
24
+ const get_free_port = () => new Promise((resolve, reject) => {
25
+ const srv = net.createServer();
26
+ srv.listen(0, '127.0.0.1', () => {
27
+ const port = srv.address().port;
28
+ srv.close(() => resolve(port));
29
+ });
30
+ srv.on('error', reject);
31
+ });
32
+
33
+ const http_get = (port, path) => new Promise((resolve, reject) => {
34
+ const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => {
35
+ let body = '';
36
+ res.on('data', (chunk) => body += chunk);
37
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
38
+ });
39
+ req.on('error', reject);
40
+ });
41
+
42
+ const http_request = (port, path, method) => new Promise((resolve, reject) => {
43
+ const req = http.request({
44
+ hostname: '127.0.0.1',
45
+ port,
46
+ path,
47
+ method
48
+ }, (res) => {
49
+ let body = '';
50
+ res.on('data', (chunk) => body += chunk);
51
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
52
+ });
53
+ req.on('error', reject);
54
+ req.end();
55
+ });
56
+
57
+ // ── Utility module imports ───────────────────────────────────
58
+
59
+ const { generate_openapi_spec, collect_api_entries, simple_schema_to_openapi } = require('../openapi');
60
+ const { generate_swagger_html } = require('../publishers/swagger-ui');
61
+
62
+ // ══════════════════════════════════════════════════════════════
63
+ // 1. Swagger_Publisher Unit Tests
64
+ // ══════════════════════════════════════════════════════════════
65
+
66
+ describe('Swagger_Publisher unit', () => {
67
+ const Swagger_Publisher = require('../publishers/swagger-publisher');
68
+
69
+ it('should be a constructor extending HTTP_Publisher', () => {
70
+ assert.strictEqual(typeof Swagger_Publisher, 'function');
71
+ const HTTP_Publisher = require('../publishers/http-publisher');
72
+ assert.ok(Swagger_Publisher.prototype instanceof HTTP_Publisher);
73
+ });
74
+
75
+ it('should set type to "swagger"', () => {
76
+ const pub = new Swagger_Publisher({
77
+ server: { _api_registry: [], get_listening_endpoints: () => [] }
78
+ });
79
+ assert.strictEqual(pub.type, 'swagger');
80
+ });
81
+
82
+ it('should store server reference', () => {
83
+ const fake_server = { _api_registry: [], get_listening_endpoints: () => [] };
84
+ const pub = new Swagger_Publisher({ server: fake_server });
85
+ assert.strictEqual(pub.server, fake_server);
86
+ });
87
+
88
+ it('should use default routes when not specified', () => {
89
+ const pub = new Swagger_Publisher({
90
+ server: { _api_registry: [], get_listening_endpoints: () => [] }
91
+ });
92
+ assert.strictEqual(pub.spec_route, '/api/openapi.json');
93
+ assert.strictEqual(pub.docs_route, '/api/docs');
94
+ });
95
+
96
+ it('should accept custom routes', () => {
97
+ const pub = new Swagger_Publisher({
98
+ server: { _api_registry: [], get_listening_endpoints: () => [] },
99
+ spec_route: '/v2/spec.json',
100
+ docs_route: '/v2/docs'
101
+ });
102
+ assert.strictEqual(pub.spec_route, '/v2/spec.json');
103
+ assert.strictEqual(pub.docs_route, '/v2/docs');
104
+ });
105
+
106
+ it('should cache HTML buffer on construction', () => {
107
+ const pub = new Swagger_Publisher({
108
+ server: { _api_registry: [], get_listening_endpoints: () => [] }
109
+ });
110
+ assert.ok(Buffer.isBuffer(pub._html_buffer));
111
+ assert.ok(pub._html_buffer.length > 100);
112
+ const html = pub._html_buffer.toString('utf8');
113
+ assert.ok(html.includes('swagger-ui'));
114
+ });
115
+
116
+ it('should store spec_options from constructor args', () => {
117
+ const pub = new Swagger_Publisher({
118
+ server: { _api_registry: [], get_listening_endpoints: () => [] },
119
+ title: 'My API',
120
+ version: '3.0.0',
121
+ description: 'Custom desc'
122
+ });
123
+ assert.strictEqual(pub.spec_options.title, 'My API');
124
+ assert.strictEqual(pub.spec_options.version, '3.0.0');
125
+ assert.strictEqual(pub.spec_options.description, 'Custom desc');
126
+ });
127
+
128
+ it('should emit ready event', (_, done) => {
129
+ const pub = new Swagger_Publisher({
130
+ server: { _api_registry: [], get_listening_endpoints: () => [] }
131
+ });
132
+ pub.on('ready', () => {
133
+ done();
134
+ });
135
+ });
136
+ });
137
+
138
+ // ══════════════════════════════════════════════════════════════
139
+ // 2. handle_http Routing Tests
140
+ // ══════════════════════════════════════════════════════════════
141
+
142
+ describe('Swagger_Publisher handle_http', () => {
143
+ const Swagger_Publisher = require('../publishers/swagger-publisher');
144
+
145
+ const mock_server = {
146
+ name: 'Test API',
147
+ _api_registry: [
148
+ { path: '/api/hello', method: 'GET', meta: { summary: 'Hello' }, schema: {} }
149
+ ],
150
+ get_listening_endpoints: () => []
151
+ };
152
+
153
+ let pub;
154
+ before(() => {
155
+ pub = new Swagger_Publisher({ server: mock_server });
156
+ });
157
+
158
+ const mock_response = () => {
159
+ const res = {
160
+ _status: null,
161
+ _headers: {},
162
+ _body: '',
163
+ writeHead(status, headers) { res._status = status; Object.assign(res._headers, headers || {}); },
164
+ end(body) { res._body = body || ''; }
165
+ };
166
+ return res;
167
+ };
168
+
169
+ it('should serve OpenAPI JSON on spec route', () => {
170
+ const res = mock_response();
171
+ pub.handle_http({ method: 'GET', url: '/api/openapi.json' }, res);
172
+ assert.strictEqual(res._status, 200);
173
+ assert.ok(res._headers['Content-Type'].includes('application/json'));
174
+ const spec = JSON.parse(res._body);
175
+ assert.strictEqual(spec.openapi, '3.0.3');
176
+ assert.ok(spec.paths['/api/hello']);
177
+ });
178
+
179
+ it('should serve Swagger UI HTML on docs route', () => {
180
+ const res = mock_response();
181
+ pub.handle_http({ method: 'GET', url: '/api/docs' }, res);
182
+ assert.strictEqual(res._status, 200);
183
+ assert.ok(res._headers['Content-Type'].includes('text/html'));
184
+ assert.ok(res._body.toString().includes('swagger-ui'));
185
+ });
186
+
187
+ it('should return 404 for unknown routes', () => {
188
+ const res = mock_response();
189
+ pub.handle_http({ method: 'GET', url: '/api/unknown' }, res);
190
+ assert.strictEqual(res._status, 404);
191
+ });
192
+
193
+ it('should return 405 for POST on spec route', () => {
194
+ const res = mock_response();
195
+ pub.handle_http({ method: 'POST', url: '/api/openapi.json' }, res);
196
+ assert.strictEqual(res._status, 405);
197
+ assert.strictEqual(res._headers['Allow'], 'GET');
198
+ });
199
+
200
+ it('should return 405 for DELETE on docs route', () => {
201
+ const res = mock_response();
202
+ pub.handle_http({ method: 'DELETE', url: '/api/docs' }, res);
203
+ assert.strictEqual(res._status, 405);
204
+ });
205
+
206
+ it('should handle HEAD requests (same as GET)', () => {
207
+ const res = mock_response();
208
+ pub.handle_http({ method: 'HEAD', url: '/api/openapi.json' }, res);
209
+ assert.strictEqual(res._status, 200);
210
+ });
211
+
212
+ it('should strip query strings for route matching', () => {
213
+ const res = mock_response();
214
+ pub.handle_http({ method: 'GET', url: '/api/openapi.json?t=123' }, res);
215
+ assert.strictEqual(res._status, 200);
216
+ const spec = JSON.parse(res._body);
217
+ assert.strictEqual(spec.openapi, '3.0.3');
218
+ });
219
+
220
+ it('should use spec_options for title/version', () => {
221
+ const custom_pub = new Swagger_Publisher({
222
+ server: mock_server,
223
+ title: 'Custom Title',
224
+ version: '5.0.0'
225
+ });
226
+ const res = mock_response();
227
+ custom_pub.handle_http({ method: 'GET', url: '/api/openapi.json' }, res);
228
+ const spec = JSON.parse(res._body);
229
+ assert.strictEqual(spec.info.title, 'Custom Title');
230
+ assert.strictEqual(spec.info.version, '5.0.0');
231
+ });
232
+ });
233
+
234
+ // ══════════════════════════════════════════════════════════════
235
+ // 3. Rich Example APIs — Spec Accuracy
236
+ // ══════════════════════════════════════════════════════════════
237
+
238
+ describe('OpenAPI spec with rich example APIs', () => {
239
+
240
+ const rich_server = {
241
+ name: 'E-Commerce API',
242
+ _api_registry: [
243
+ // ── Products ──
244
+ {
245
+ path: '/api/products/search',
246
+ method: 'POST',
247
+ meta: {
248
+ summary: 'Search products',
249
+ description: 'Full-text search across the product catalog with pagination.',
250
+ tags: ['Products'],
251
+ operationId: 'searchProducts',
252
+ params: {
253
+ query: { type: 'string', description: 'Search query', required: true },
254
+ page: { type: 'integer', description: 'Page number', default: 1 },
255
+ page_size: { type: 'integer', description: 'Results per page', default: 25 },
256
+ sort: { type: 'string', description: 'Sort field', enum: ['name', 'price', 'date'] },
257
+ category: { type: 'string', description: 'Filter by category' }
258
+ },
259
+ returns: {
260
+ rows: { type: 'array', items: { type: 'object' } },
261
+ total_count: { type: 'integer', description: 'Total matching results' },
262
+ page: { type: 'integer' },
263
+ has_more: { type: 'boolean' }
264
+ }
265
+ }
266
+ },
267
+ {
268
+ path: '/api/products/create',
269
+ method: 'POST',
270
+ meta: {
271
+ summary: 'Create product',
272
+ tags: ['Products'],
273
+ params: {
274
+ name: { type: 'string', required: true, description: 'Product name' },
275
+ price: { type: 'number', required: true, description: 'Price in USD' },
276
+ description: { type: 'string', description: 'Product description' },
277
+ category: { type: 'string', description: 'Category' },
278
+ in_stock: { type: 'boolean', default: true }
279
+ },
280
+ returns: {
281
+ id: { type: 'integer' },
282
+ name: { type: 'string' }
283
+ }
284
+ }
285
+ },
286
+ {
287
+ path: '/api/products',
288
+ method: 'GET',
289
+ meta: {
290
+ summary: 'List all products',
291
+ tags: ['Products'],
292
+ returns: {
293
+ rows: { type: 'array', items: { type: 'object' } }
294
+ }
295
+ }
296
+ },
297
+ {
298
+ path: '/api/products/delete',
299
+ method: 'DELETE',
300
+ meta: {
301
+ summary: 'Delete a product (deprecated)',
302
+ tags: ['Products'],
303
+ deprecated: true,
304
+ params: {
305
+ id: { type: 'integer', required: true }
306
+ }
307
+ }
308
+ },
309
+ // ── Users ──
310
+ {
311
+ path: '/api/users/list',
312
+ method: 'POST',
313
+ meta: {
314
+ summary: 'List users',
315
+ description: 'Returns paginated user list with role filtering.',
316
+ tags: ['Users'],
317
+ params: {
318
+ role: { type: 'string', enum: ['admin', 'editor', 'viewer'] },
319
+ active: { type: 'boolean', default: true }
320
+ },
321
+ returns: {
322
+ users: { type: 'array', items: { type: 'object' } },
323
+ count: { type: 'integer' }
324
+ }
325
+ }
326
+ },
327
+ {
328
+ path: '/api/users/profile',
329
+ method: 'GET',
330
+ meta: {
331
+ summary: 'Get user profile',
332
+ tags: ['Users'],
333
+ returns: {
334
+ id: { type: 'integer' },
335
+ name: { type: 'string' },
336
+ email: { type: 'string' },
337
+ role: { type: 'string' },
338
+ created: { type: 'string', description: 'ISO 8601 date' }
339
+ }
340
+ }
341
+ },
342
+ // ── System ──
343
+ {
344
+ path: '/api/health',
345
+ method: 'GET',
346
+ meta: {
347
+ summary: 'Health check',
348
+ tags: ['System'],
349
+ response_description: 'Server health status',
350
+ returns: {
351
+ status: { type: 'string', enum: ['ok', 'degraded', 'down'] },
352
+ uptime: { type: 'number', description: 'Uptime in seconds' },
353
+ memory: { type: 'object' }
354
+ }
355
+ }
356
+ },
357
+ {
358
+ path: '/api/version',
359
+ method: 'GET',
360
+ meta: {
361
+ summary: 'API version',
362
+ tags: ['System']
363
+ }
364
+ }
365
+ ],
366
+ get_listening_endpoints: () => [
367
+ { url: 'http://localhost:3000', protocol: 'http', host: 'localhost', port: 3000 }
368
+ ]
369
+ };
370
+
371
+ it('should include all 8 endpoints in the spec', () => {
372
+ const spec = generate_openapi_spec(rich_server);
373
+ const paths = Object.keys(spec.paths);
374
+ assert.strictEqual(paths.length, 8);
375
+ });
376
+
377
+ it('should use correct HTTP methods for each endpoint', () => {
378
+ const spec = generate_openapi_spec(rich_server);
379
+ assert.ok(spec.paths['/api/products/search'].post);
380
+ assert.ok(spec.paths['/api/products/create'].post);
381
+ assert.ok(spec.paths['/api/products'].get);
382
+ assert.ok(spec.paths['/api/products/delete'].delete);
383
+ assert.ok(spec.paths['/api/users/list'].post);
384
+ assert.ok(spec.paths['/api/users/profile'].get);
385
+ assert.ok(spec.paths['/api/health'].get);
386
+ assert.ok(spec.paths['/api/version'].get);
387
+ });
388
+
389
+ it('should preserve summaries and descriptions', () => {
390
+ const spec = generate_openapi_spec(rich_server);
391
+ assert.strictEqual(spec.paths['/api/products/search'].post.summary, 'Search products');
392
+ assert.ok(spec.paths['/api/products/search'].post.description.includes('Full-text'));
393
+ assert.strictEqual(spec.paths['/api/users/list'].post.summary, 'List users');
394
+ });
395
+
396
+ it('should set custom operationId', () => {
397
+ const spec = generate_openapi_spec(rich_server);
398
+ assert.strictEqual(spec.paths['/api/products/search'].post.operationId, 'searchProducts');
399
+ });
400
+
401
+ it('should mark deprecated endpoints', () => {
402
+ const spec = generate_openapi_spec(rich_server);
403
+ assert.strictEqual(spec.paths['/api/products/delete'].delete.deprecated, true);
404
+ assert.strictEqual(spec.paths['/api/products/search'].post.deprecated, undefined);
405
+ });
406
+
407
+ it('should collect all tags alphabetically', () => {
408
+ const spec = generate_openapi_spec(rich_server);
409
+ const tag_names = spec.tags.map(t => t.name);
410
+ assert.deepStrictEqual(tag_names, ['Products', 'System', 'Users']);
411
+ });
412
+
413
+ it('should include server URLs', () => {
414
+ const spec = generate_openapi_spec(rich_server);
415
+ assert.strictEqual(spec.servers.length, 1);
416
+ assert.strictEqual(spec.servers[0].url, 'http://localhost:3000');
417
+ });
418
+
419
+ it('should use custom response_description', () => {
420
+ const spec = generate_openapi_spec(rich_server);
421
+ assert.strictEqual(
422
+ spec.paths['/api/health'].get.responses['200'].description,
423
+ 'Server health status'
424
+ );
425
+ });
426
+
427
+ it('should generate requestBody for POST methods with params', () => {
428
+ const spec = generate_openapi_spec(rich_server);
429
+ const search = spec.paths['/api/products/search'].post;
430
+ assert.ok(search.requestBody);
431
+ const schema = search.requestBody.content['application/json'].schema;
432
+ assert.strictEqual(schema.type, 'object');
433
+ assert.ok(schema.properties.query);
434
+ assert.ok(schema.properties.page);
435
+ assert.ok(schema.properties.sort);
436
+ });
437
+
438
+ it('should NOT generate requestBody for GET methods without params', () => {
439
+ const spec = generate_openapi_spec(rich_server);
440
+ const version = spec.paths['/api/version'].get;
441
+ assert.strictEqual(version.requestBody, undefined);
442
+ });
443
+
444
+ it('should generate response schema from returns metadata', () => {
445
+ const spec = generate_openapi_spec(rich_server);
446
+ const profile = spec.paths['/api/users/profile'].get;
447
+ const schema = profile.responses['200'].content['application/json'].schema;
448
+ assert.strictEqual(schema.type, 'object');
449
+ assert.ok(schema.properties.id);
450
+ assert.ok(schema.properties.name);
451
+ assert.ok(schema.properties.email);
452
+ });
453
+
454
+ it('should include 500 error response for all operations', () => {
455
+ const spec = generate_openapi_spec(rich_server);
456
+ for (const [, methods] of Object.entries(spec.paths)) {
457
+ for (const [, op] of Object.entries(methods)) {
458
+ assert.ok(op.responses['500'], 'Missing 500 response');
459
+ assert.ok(op.responses['500'].content['application/json'].schema.properties.error);
460
+ }
461
+ }
462
+ });
463
+ });
464
+
465
+ // ══════════════════════════════════════════════════════════════
466
+ // 4. Schema Edge Cases
467
+ // ══════════════════════════════════════════════════════════════
468
+
469
+ describe('Schema edge cases', () => {
470
+ it('should handle enum fields', () => {
471
+ const schema = simple_schema_to_openapi({
472
+ status: { type: 'string', enum: ['active', 'inactive', 'pending'] }
473
+ });
474
+ assert.deepStrictEqual(schema.properties.status.enum, ['active', 'inactive', 'pending']);
475
+ });
476
+
477
+ it('should handle required fields from multiple properties', () => {
478
+ const schema = simple_schema_to_openapi({
479
+ name: { type: 'string', required: true },
480
+ email: { type: 'string', required: true },
481
+ bio: { type: 'string' }
482
+ });
483
+ assert.deepStrictEqual(schema.required.sort(), ['email', 'name']);
484
+ });
485
+
486
+ it('should handle default values of different types', () => {
487
+ const schema = simple_schema_to_openapi({
488
+ count: { type: 'integer', default: 0 },
489
+ enabled: { type: 'boolean', default: false },
490
+ name: { type: 'string', default: '' }
491
+ });
492
+ assert.strictEqual(schema.properties.count.default, 0);
493
+ assert.strictEqual(schema.properties.enabled.default, false);
494
+ assert.strictEqual(schema.properties.name.default, '');
495
+ });
496
+
497
+ it('should handle array type with items', () => {
498
+ const schema = simple_schema_to_openapi({
499
+ tags: { type: 'array', items: { type: 'string' } }
500
+ });
501
+ assert.strictEqual(schema.properties.tags.type, 'array');
502
+ assert.deepStrictEqual(schema.properties.tags.items, { type: 'string' });
503
+ });
504
+
505
+ it('should handle nested object type', () => {
506
+ const schema = simple_schema_to_openapi({
507
+ address: {
508
+ type: 'object',
509
+ properties: {
510
+ street: { type: 'string' },
511
+ city: { type: 'string' }
512
+ }
513
+ }
514
+ });
515
+ assert.strictEqual(schema.properties.address.type, 'object');
516
+ });
517
+
518
+ it('should normalise unknown types to string', () => {
519
+ const schema = simple_schema_to_openapi({
520
+ field: { type: 'datetime' }
521
+ });
522
+ assert.strictEqual(schema.properties.field.type, 'string');
523
+ });
524
+
525
+ it('should handle bare values as string type', () => {
526
+ const schema = simple_schema_to_openapi({
527
+ name: 'just a value'
528
+ });
529
+ assert.strictEqual(schema.properties.name.type, 'string');
530
+ });
531
+
532
+ it('should handle pass-through for raw OpenAPI array schema', () => {
533
+ const schema = simple_schema_to_openapi({
534
+ type: 'array',
535
+ items: { type: 'object' }
536
+ });
537
+ assert.strictEqual(schema.type, 'array');
538
+ assert.deepStrictEqual(schema.items, { type: 'object' });
539
+ });
540
+
541
+ it('should handle empty object schema', () => {
542
+ const schema = simple_schema_to_openapi({});
543
+ assert.strictEqual(schema.type, 'object');
544
+ assert.deepStrictEqual(schema.properties, {});
545
+ });
546
+ });
547
+
548
+ // ══════════════════════════════════════════════════════════════
549
+ // 5. Tag Auto-Detection
550
+ // ══════════════════════════════════════════════════════════════
551
+
552
+ describe('Tag auto-detection', () => {
553
+ it('should derive tag from first path segment after /api/', () => {
554
+ const server = {
555
+ _api_registry: [
556
+ { path: '/api/users/list', method: 'GET', meta: {} },
557
+ { path: '/api/products/search', method: 'GET', meta: {} }
558
+ ],
559
+ get_listening_endpoints: () => []
560
+ };
561
+ const spec = generate_openapi_spec(server);
562
+ assert.deepStrictEqual(spec.paths['/api/users/list'].get.tags, ['users']);
563
+ assert.deepStrictEqual(spec.paths['/api/products/search'].get.tags, ['products']);
564
+ });
565
+
566
+ it('should use explicit tags over auto-detected ones', () => {
567
+ const server = {
568
+ _api_registry: [
569
+ { path: '/api/users/list', method: 'GET', meta: { tags: ['User Management'] } }
570
+ ],
571
+ get_listening_endpoints: () => []
572
+ };
573
+ const spec = generate_openapi_spec(server);
574
+ assert.deepStrictEqual(spec.paths['/api/users/list'].get.tags, ['User Management']);
575
+ });
576
+
577
+ it('should handle root-level paths', () => {
578
+ const server = {
579
+ _api_registry: [
580
+ { path: '/health', method: 'GET', meta: {} }
581
+ ],
582
+ get_listening_endpoints: () => []
583
+ };
584
+ const spec = generate_openapi_spec(server);
585
+ assert.deepStrictEqual(spec.paths['/health'].get.tags, ['health']);
586
+ });
587
+ });
588
+
589
+ // ══════════════════════════════════════════════════════════════
590
+ // 6. Multi-Method Endpoints
591
+ // ══════════════════════════════════════════════════════════════
592
+
593
+ describe('Multi-method endpoints', () => {
594
+ it('should support GET and POST on the same path', () => {
595
+ const server = {
596
+ _api_registry: [
597
+ { path: '/api/items', method: 'GET', meta: { summary: 'List items' } },
598
+ { path: '/api/items', method: 'POST', meta: { summary: 'Create item' } }
599
+ ],
600
+ get_listening_endpoints: () => []
601
+ };
602
+ const spec = generate_openapi_spec(server);
603
+ assert.ok(spec.paths['/api/items'].get);
604
+ assert.ok(spec.paths['/api/items'].post);
605
+ assert.strictEqual(spec.paths['/api/items'].get.summary, 'List items');
606
+ assert.strictEqual(spec.paths['/api/items'].post.summary, 'Create item');
607
+ });
608
+
609
+ it('should support PUT and DELETE on the same path', () => {
610
+ const server = {
611
+ _api_registry: [
612
+ { path: '/api/items', method: 'PUT', meta: { summary: 'Update item' } },
613
+ { path: '/api/items', method: 'DELETE', meta: { summary: 'Delete item' } }
614
+ ],
615
+ get_listening_endpoints: () => []
616
+ };
617
+ const spec = generate_openapi_spec(server);
618
+ assert.ok(spec.paths['/api/items'].put);
619
+ assert.ok(spec.paths['/api/items'].delete);
620
+ });
621
+ });
622
+
623
+ // ══════════════════════════════════════════════════════════════
624
+ // 7. Publisher Registry
625
+ // ══════════════════════════════════════════════════════════════
626
+
627
+ describe('Publisher registry integration', () => {
628
+ it('should be registered as "swagger" in Publishers', () => {
629
+ const Publishers = require('../publishers/Publishers');
630
+ assert.ok(Publishers.swagger);
631
+ assert.strictEqual(typeof Publishers.swagger, 'function');
632
+ });
633
+
634
+ it('should create a working instance from the registry', () => {
635
+ const Publishers = require('../publishers/Publishers');
636
+ const pub = new Publishers.swagger({
637
+ server: { _api_registry: [], get_listening_endpoints: () => [] }
638
+ });
639
+ assert.strictEqual(pub.type, 'swagger');
640
+ });
641
+ });
642
+
643
+ // ══════════════════════════════════════════════════════════════
644
+ // 8. No-Metadata Fallback
645
+ // ══════════════════════════════════════════════════════════════
646
+
647
+ describe('No-metadata fallback', () => {
648
+ it('should produce valid entries for endpoints with empty meta', () => {
649
+ const server = {
650
+ _api_registry: [
651
+ { path: '/api/bare1', method: 'POST', meta: {}, schema: {} },
652
+ { path: '/api/bare2', method: 'GET', meta: {} }
653
+ ],
654
+ get_listening_endpoints: () => []
655
+ };
656
+ const spec = generate_openapi_spec(server);
657
+ assert.ok(spec.paths['/api/bare1'].post);
658
+ assert.ok(spec.paths['/api/bare2'].get);
659
+ assert.ok(spec.paths['/api/bare1'].post.responses['200']);
660
+ assert.ok(spec.paths['/api/bare2'].get.responses['200']);
661
+ });
662
+
663
+ it('should auto-generate operationId from path', () => {
664
+ const server = {
665
+ _api_registry: [
666
+ { path: '/api/users/list', method: 'GET', meta: {} }
667
+ ],
668
+ get_listening_endpoints: () => []
669
+ };
670
+ const spec = generate_openapi_spec(server);
671
+ assert.ok(spec.paths['/api/users/list'].get.operationId);
672
+ assert.ok(typeof spec.paths['/api/users/list'].get.operationId === 'string');
673
+ assert.ok(spec.paths['/api/users/list'].get.operationId.length > 0);
674
+ });
675
+ });
676
+
677
+ // ══════════════════════════════════════════════════════════════
678
+ // 9. Swagger HTML Theme Tests
679
+ // ══════════════════════════════════════════════════════════════
680
+
681
+ describe('Swagger HTML generation', () => {
682
+ it('should include dark theme CSS variables', () => {
683
+ const html = generate_swagger_html();
684
+ assert.ok(html.includes('--swagger-bg'));
685
+ assert.ok(html.includes('--swagger-surface'));
686
+ assert.ok(html.includes('--swagger-accent'));
687
+ });
688
+
689
+ it('should include default spec URL', () => {
690
+ const html = generate_swagger_html();
691
+ assert.ok(html.includes('/api/openapi.json'));
692
+ });
693
+
694
+ it('should include CDN links for swagger-ui', () => {
695
+ const html = generate_swagger_html();
696
+ assert.ok(html.includes('unpkg.com/swagger-ui-dist'));
697
+ assert.ok(html.includes('swagger-ui-bundle.js'));
698
+ assert.ok(html.includes('swagger-ui.css'));
699
+ });
700
+
701
+ it('should HTML-escape the title', () => {
702
+ const html = generate_swagger_html({ title: 'Test <script>alert(1)</script>' });
703
+ assert.ok(!html.includes('<script>alert(1)</script>'));
704
+ assert.ok(html.includes('&lt;script&gt;'));
705
+ });
706
+
707
+ it('should accept custom spec_url', () => {
708
+ const html = generate_swagger_html({ spec_url: '/v2/api.json' });
709
+ assert.ok(html.includes('/v2/api.json'));
710
+ });
711
+
712
+ // ── Theme-specific tests ──
713
+
714
+ it('should include all 5 built-in theme data-theme selectors', () => {
715
+ const html = generate_swagger_html();
716
+ assert.ok(html.includes('[data-theme="wlilo"]'));
717
+ assert.ok(html.includes('[data-theme="midnight"]'));
718
+ assert.ok(html.includes('[data-theme="light"]'));
719
+ assert.ok(html.includes('[data-theme="nord"]'));
720
+ assert.ok(html.includes('[data-theme="high-contrast"]'));
721
+ });
722
+
723
+ it('should set wlilo as default data-theme on html element', () => {
724
+ const html = generate_swagger_html();
725
+ assert.ok(html.includes('data-theme="wlilo"'));
726
+ });
727
+
728
+ it('should set custom default_theme on html element', () => {
729
+ const html = generate_swagger_html({ default_theme: 'light' });
730
+ assert.ok(html.includes('<html lang="en" data-theme="light">'));
731
+ });
732
+
733
+ it('should include theme-selector widget JS', () => {
734
+ const html = generate_swagger_html();
735
+ assert.ok(html.includes('theme-selector'));
736
+ assert.ok(html.includes('theme-select'));
737
+ assert.ok(html.includes('swagger-theme')); // localStorage key
738
+ });
739
+
740
+ it('should include localStorage persistence code', () => {
741
+ const html = generate_swagger_html();
742
+ assert.ok(html.includes('localStorage'));
743
+ assert.ok(html.includes('STORAGE_KEY'));
744
+ });
745
+
746
+ it('should include all theme labels in selector options', () => {
747
+ const html = generate_swagger_html();
748
+ assert.ok(html.includes('WLILO Dark'));
749
+ assert.ok(html.includes('Midnight'));
750
+ assert.ok(html.includes('Light'));
751
+ assert.ok(html.includes('Nord'));
752
+ assert.ok(html.includes('High Contrast'));
753
+ });
754
+
755
+ it('should include CSS transitions for smooth theme switching', () => {
756
+ const html = generate_swagger_html();
757
+ assert.ok(html.includes('transition'));
758
+ });
759
+
760
+ it('should accept custom themes via options', () => {
761
+ const html = generate_swagger_html({
762
+ themes: {
763
+ ocean: {
764
+ label: 'Ocean Blue',
765
+ vars: {
766
+ '--swagger-bg': '#0a192f',
767
+ '--swagger-accent': '#64ffda'
768
+ }
769
+ }
770
+ }
771
+ });
772
+ assert.ok(html.includes('[data-theme="ocean"]'));
773
+ assert.ok(html.includes('#0a192f'));
774
+ assert.ok(html.includes('#64ffda'));
775
+ assert.ok(html.includes('Ocean Blue'));
776
+ });
777
+
778
+ it('should include unique colours for each theme', () => {
779
+ const html = generate_swagger_html();
780
+ // WLILO gold accent
781
+ assert.ok(html.includes('#c9a84c'));
782
+ // Midnight blue accent
783
+ assert.ok(html.includes('#4facfe'));
784
+ // Light mode white background
785
+ assert.ok(html.includes('#f8f9fa'));
786
+ // Nord accent
787
+ assert.ok(html.includes('#88c0d0'));
788
+ // High contrast yellow accent
789
+ assert.ok(html.includes('#ffff00'));
790
+ });
791
+
792
+ it('should include aria-label on theme select for accessibility', () => {
793
+ const html = generate_swagger_html();
794
+ assert.ok(html.includes('aria-label'));
795
+ assert.ok(html.includes('Select theme'));
796
+ });
797
+
798
+ it('should include --swagger-danger and --swagger-success variables', () => {
799
+ const html = generate_swagger_html();
800
+ assert.ok(html.includes('--swagger-danger'));
801
+ assert.ok(html.includes('--swagger-success'));
802
+ });
803
+
804
+ it('BUILTIN_THEMES export should contain all 5 themes', () => {
805
+ const { BUILTIN_THEMES } = require('../publishers/swagger-ui');
806
+ const names = Object.keys(BUILTIN_THEMES);
807
+ assert.deepStrictEqual(names.sort(), ['high-contrast', 'light', 'midnight', 'nord', 'wlilo']);
808
+ // Each theme should have a label and vars
809
+ for (const [, theme] of Object.entries(BUILTIN_THEMES)) {
810
+ assert.ok(theme.label);
811
+ assert.ok(theme.vars);
812
+ assert.ok(theme.vars['--swagger-bg']);
813
+ assert.ok(theme.vars['--swagger-accent']);
814
+ }
815
+ });
816
+ });
817
+
818
+ // ══════════════════════════════════════════════════════════════
819
+ // 10. Full Integration Test (Real HTTP Server)
820
+ // ══════════════════════════════════════════════════════════════
821
+
822
+ describe('Full integration (real HTTP server)', function () {
823
+ let server_instance;
824
+ let port;
825
+
826
+ // FakePublisher to avoid jsgui3-html heavy loading
827
+ const fake_webpage_publisher_path = require.resolve('../publishers/http-webpage-publisher');
828
+ const fake_website_publisher_path = require.resolve('../publishers/http-webpageorsite-publisher');
829
+ const original_webpage = require.cache[fake_webpage_publisher_path];
830
+ const original_website = require.cache[fake_website_publisher_path];
831
+ const { Evented_Class } = require('lang-tools');
832
+
833
+ class FakePublisher extends Evented_Class {
834
+ constructor() {
835
+ super();
836
+ const self = this;
837
+ setImmediate(() => self.raise('ready', { _arr: [] }));
838
+ }
839
+ handle_http(req, res) {
840
+ res.writeHead(200, { 'Content-Type': 'text/html' });
841
+ res.end('<html><body>fake</body></html>');
842
+ }
843
+ meets_requirements() { return true; }
844
+ start(cb) { cb && cb(null); }
845
+ stop(cb) { cb && cb(null); }
846
+ }
847
+
848
+ require.cache[fake_webpage_publisher_path] = { exports: FakePublisher };
849
+ require.cache[fake_website_publisher_path] = { exports: FakePublisher };
850
+
851
+ const Server = require('../server');
852
+
853
+ before(async () => {
854
+ port = await get_free_port();
855
+ server_instance = await Server.serve({
856
+ host: '127.0.0.1',
857
+ port,
858
+ swagger: true,
859
+ website: false,
860
+ api: {
861
+ 'products/search': {
862
+ handler: (input) => ({
863
+ rows: [{ id: 1, name: 'Widget' }],
864
+ total_count: 1,
865
+ page: input?.page || 1
866
+ }),
867
+ method: 'POST',
868
+ summary: 'Search products',
869
+ description: 'Full-text product search with pagination.',
870
+ tags: ['Products'],
871
+ params: {
872
+ query: { type: 'string', required: true },
873
+ page: { type: 'integer', default: 1 },
874
+ page_size: { type: 'integer', default: 25 }
875
+ },
876
+ returns: {
877
+ rows: { type: 'array', items: { type: 'object' } },
878
+ total_count: { type: 'integer' },
879
+ page: { type: 'integer' }
880
+ }
881
+ },
882
+ 'products/create': {
883
+ handler: (input) => ({ id: 42, name: input?.name }),
884
+ method: 'POST',
885
+ summary: 'Create a product',
886
+ tags: ['Products'],
887
+ params: {
888
+ name: { type: 'string', required: true },
889
+ price: { type: 'number', required: true }
890
+ },
891
+ returns: {
892
+ id: { type: 'integer' },
893
+ name: { type: 'string' }
894
+ }
895
+ },
896
+ 'users/list': {
897
+ handler: () => ({ users: [], count: 0 }),
898
+ method: 'POST',
899
+ summary: 'List users',
900
+ tags: ['Users'],
901
+ params: {
902
+ role: { type: 'string', enum: ['admin', 'editor', 'viewer'] },
903
+ active: { type: 'boolean', default: true }
904
+ },
905
+ returns: {
906
+ users: { type: 'array', items: { type: 'object' } },
907
+ count: { type: 'integer' }
908
+ }
909
+ },
910
+ 'users/profile': {
911
+ handler: () => ({ id: 1, name: 'Alice', email: 'alice@example.com' }),
912
+ method: 'GET',
913
+ summary: 'Get user profile',
914
+ tags: ['Users'],
915
+ returns: {
916
+ id: { type: 'integer' },
917
+ name: { type: 'string' },
918
+ email: { type: 'string' }
919
+ }
920
+ },
921
+ 'health': {
922
+ handler: () => ({ status: 'ok', uptime: process.uptime() }),
923
+ method: 'GET',
924
+ summary: 'Health check',
925
+ tags: ['System'],
926
+ returns: {
927
+ status: { type: 'string', enum: ['ok', 'degraded', 'down'] },
928
+ uptime: { type: 'number' }
929
+ }
930
+ },
931
+ 'version': {
932
+ handler: () => ({ version: '1.0.0' }),
933
+ method: 'GET',
934
+ summary: 'API version',
935
+ tags: ['System']
936
+ }
937
+ }
938
+ });
939
+ });
940
+
941
+ after(async () => {
942
+ if (original_webpage) require.cache[fake_webpage_publisher_path] = original_webpage;
943
+ if (original_website) require.cache[fake_website_publisher_path] = original_website;
944
+ if (server_instance && typeof server_instance.close === 'function') {
945
+ await new Promise(r => server_instance.close(r));
946
+ }
947
+ });
948
+
949
+ // ── Spec tests ──
950
+
951
+ it('GET /api/openapi.json returns 200 with JSON', async () => {
952
+ const { status, headers } = await http_get(port, '/api/openapi.json');
953
+ assert.strictEqual(status, 200);
954
+ assert.ok(headers['content-type'].includes('application/json'));
955
+ });
956
+
957
+ it('spec contains all 6 example API paths', async () => {
958
+ const { body } = await http_get(port, '/api/openapi.json');
959
+ const spec = JSON.parse(body);
960
+ assert.ok(spec.paths['/api/products/search']);
961
+ assert.ok(spec.paths['/api/products/create']);
962
+ assert.ok(spec.paths['/api/users/list']);
963
+ assert.ok(spec.paths['/api/users/profile']);
964
+ assert.ok(spec.paths['/api/health']);
965
+ assert.ok(spec.paths['/api/version']);
966
+ });
967
+
968
+ it('spec excludes swagger own routes', async () => {
969
+ const { body } = await http_get(port, '/api/openapi.json');
970
+ const spec = JSON.parse(body);
971
+ assert.strictEqual(spec.paths['/api/openapi.json'], undefined);
972
+ assert.strictEqual(spec.paths['/api/docs'], undefined);
973
+ });
974
+
975
+ it('spec has correct tags', async () => {
976
+ const { body } = await http_get(port, '/api/openapi.json');
977
+ const spec = JSON.parse(body);
978
+ const tag_names = spec.tags.map(t => t.name);
979
+ assert.ok(tag_names.includes('Products'));
980
+ assert.ok(tag_names.includes('Users'));
981
+ assert.ok(tag_names.includes('System'));
982
+ });
983
+
984
+ it('spec includes requestBody schemas', async () => {
985
+ const { body } = await http_get(port, '/api/openapi.json');
986
+ const spec = JSON.parse(body);
987
+ const search = spec.paths['/api/products/search'].post;
988
+ assert.ok(search.requestBody);
989
+ const schema = search.requestBody.content['application/json'].schema;
990
+ assert.strictEqual(schema.properties.query.type, 'string');
991
+ assert.strictEqual(schema.properties.page.type, 'integer');
992
+ assert.strictEqual(schema.properties.page.default, 1);
993
+ });
994
+
995
+ it('spec includes required fields', async () => {
996
+ const { body } = await http_get(port, '/api/openapi.json');
997
+ const spec = JSON.parse(body);
998
+ const schema = spec.paths['/api/products/search'].post.requestBody.content['application/json'].schema;
999
+ assert.ok(schema.required.includes('query'));
1000
+ });
1001
+
1002
+ it('spec includes enum fields', async () => {
1003
+ const { body } = await http_get(port, '/api/openapi.json');
1004
+ const spec = JSON.parse(body);
1005
+ const schema = spec.paths['/api/users/list'].post.requestBody.content['application/json'].schema;
1006
+ assert.deepStrictEqual(schema.properties.role.enum, ['admin', 'editor', 'viewer']);
1007
+ });
1008
+
1009
+ it('spec includes response schemas', async () => {
1010
+ const { body } = await http_get(port, '/api/openapi.json');
1011
+ const spec = JSON.parse(body);
1012
+ const profile = spec.paths['/api/users/profile'].get;
1013
+ const schema = profile.responses['200'].content['application/json'].schema;
1014
+ assert.strictEqual(schema.properties.id.type, 'integer');
1015
+ assert.strictEqual(schema.properties.name.type, 'string');
1016
+ });
1017
+
1018
+ // ── Docs tests ──
1019
+
1020
+ it('GET /api/docs returns 200 with HTML', async () => {
1021
+ const { status, headers } = await http_get(port, '/api/docs');
1022
+ assert.strictEqual(status, 200);
1023
+ assert.ok(headers['content-type'].includes('text/html'));
1024
+ });
1025
+
1026
+ it('docs HTML includes swagger-ui CDN link', async () => {
1027
+ const { body } = await http_get(port, '/api/docs');
1028
+ assert.ok(body.includes('swagger-ui-bundle.js'));
1029
+ assert.ok(body.includes('swagger-ui.css'));
1030
+ });
1031
+
1032
+ it('docs HTML references /api/openapi.json', async () => {
1033
+ const { body } = await http_get(port, '/api/docs');
1034
+ assert.ok(body.includes('/api/openapi.json'));
1035
+ });
1036
+
1037
+ // ── Method enforcement ──
1038
+
1039
+ it('POST /api/openapi.json returns 405', async () => {
1040
+ const { status } = await http_request(port, '/api/openapi.json', 'POST');
1041
+ assert.strictEqual(status, 405);
1042
+ });
1043
+
1044
+ it('DELETE /api/docs returns 405', async () => {
1045
+ const { status } = await http_request(port, '/api/docs', 'DELETE');
1046
+ assert.strictEqual(status, 405);
1047
+ });
1048
+
1049
+ // ── Cache-Control ──
1050
+
1051
+ it('spec has no-cache header', async () => {
1052
+ const { headers } = await http_get(port, '/api/openapi.json');
1053
+ assert.ok(headers['cache-control'].includes('no-cache'));
1054
+ });
1055
+
1056
+ it('docs has no-cache header', async () => {
1057
+ const { headers } = await http_get(port, '/api/docs');
1058
+ assert.ok(headers['cache-control'].includes('no-cache'));
1059
+ });
1060
+
1061
+ // ── Swagger_Publisher on server ──
1062
+
1063
+ it('server._swagger_publisher is a Swagger_Publisher instance', () => {
1064
+ assert.ok(server_instance._swagger_publisher);
1065
+ assert.strictEqual(server_instance._swagger_publisher.type, 'swagger');
1066
+ });
1067
+
1068
+ // ── API endpoints still work ──
1069
+
1070
+ it('actual API endpoints still respond (GET /api/health)', async () => {
1071
+ const { status, body } = await http_get(port, '/api/health');
1072
+ assert.strictEqual(status, 200);
1073
+ const data = JSON.parse(body);
1074
+ assert.strictEqual(data.status, 'ok');
1075
+ });
1076
+ });