jsgui3-server 0.0.151 → 0.0.155

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 (109) 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/agi/skills/README.md +23 -0
  5. package/docs/agi/skills/agent-output-control/SKILL.md +56 -0
  6. package/docs/agi/skills/ai-deep-research/SKILL.md +52 -0
  7. package/docs/agi/skills/autonomous-ui-inspection/SKILL.md +102 -0
  8. package/docs/agi/skills/deep-research/SKILL.md +156 -0
  9. package/docs/agi/skills/endurance/SKILL.md +53 -0
  10. package/docs/agi/skills/exploring-other-codebases/SKILL.md +56 -0
  11. package/docs/agi/skills/instruction-adherence/SKILL.md +73 -0
  12. package/docs/agi/skills/jsgui3-activation-debug/SKILL.md +94 -0
  13. package/docs/agi/skills/jsgui3-context-menu-patterns/SKILL.md +94 -0
  14. package/docs/agi/skills/puppeteer-efficient-ui-verification/SKILL.md +65 -0
  15. package/docs/agi/skills/runaway-process-guard/SKILL.md +49 -0
  16. package/docs/agi/skills/session-discipline/SKILL.md +40 -0
  17. package/docs/agi/skills/skill-writing/SKILL.md +211 -0
  18. package/docs/agi/skills/static-analysis/SKILL.md +58 -0
  19. package/docs/agi/skills/targeted-testing/SKILL.md +63 -0
  20. package/docs/agi/skills/understanding-jsgui3/SKILL.md +85 -0
  21. package/docs/api-reference.md +120 -2
  22. package/docs/books/jsgui3-bundling-research-book/06-unused-module-elimination-strategy.md +1 -0
  23. package/docs/books/jsgui3-bundling-research-book/07-jsgui3-html-control-and-mixin-pruning.md +33 -0
  24. package/docs/books/website-design/01-introduction.md +73 -0
  25. package/docs/books/website-design/02-current-state.md +195 -0
  26. package/docs/books/website-design/03-base-class.md +181 -0
  27. package/docs/books/website-design/04-webpage.md +307 -0
  28. package/docs/books/website-design/05-website.md +456 -0
  29. package/docs/books/website-design/06-pages-storage.md +170 -0
  30. package/docs/books/website-design/07-api-layer.md +285 -0
  31. package/docs/books/website-design/08-server-integration.md +271 -0
  32. package/docs/books/website-design/09-cross-agent-review.md +190 -0
  33. package/docs/books/website-design/10-open-questions.md +196 -0
  34. package/docs/books/website-design/11-converged-recommendation.md +205 -0
  35. package/docs/books/website-design/12-content-model.md +395 -0
  36. package/docs/books/website-design/13-webpage-module-spec.md +404 -0
  37. package/docs/books/website-design/14-website-module-spec.md +541 -0
  38. package/docs/books/website-design/15-multi-repo-plan.md +275 -0
  39. package/docs/books/website-design/16-minimal-first.md +203 -0
  40. package/docs/books/website-design/17-implementation-report-codex.md +81 -0
  41. package/docs/books/website-design/README.md +43 -0
  42. package/docs/bundling-system-deep-dive.md +112 -3
  43. package/docs/configuration-reference.md +84 -0
  44. package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
  45. package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
  46. package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
  47. package/docs/swagger.md +316 -0
  48. package/examples/controls/1) window/server.js +6 -1
  49. package/examples/controls/21) mvvm and declarative api/check.js +94 -0
  50. package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
  51. package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
  52. package/examples/controls/21) mvvm and declarative api/client.js +241 -0
  53. declarative api/e2e-screenshot-1-name-change.png +0 -0
  54. declarative api/e2e-screenshot-2-toggled.png +0 -0
  55. declarative api/e2e-screenshot-3-final.png +0 -0
  56. declarative api/e2e-screenshot-final.png +0 -0
  57. package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
  58. package/examples/controls/21) mvvm and declarative api/out.html +1 -0
  59. package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
  60. package/examples/controls/21) mvvm and declarative api/server.js +18 -0
  61. package/examples/data-views/01) query-endpoint/server.js +61 -0
  62. package/labs/website-design/001-base-class-overhead/check.js +162 -0
  63. package/labs/website-design/002-pages-storage/check.js +244 -0
  64. package/labs/website-design/002-pages-storage/results.txt +0 -0
  65. package/labs/website-design/003-type-detection/check.js +193 -0
  66. package/labs/website-design/003-type-detection/results.txt +0 -0
  67. package/labs/website-design/004-two-stage-validation/check.js +314 -0
  68. package/labs/website-design/004-two-stage-validation/results.txt +0 -0
  69. package/labs/website-design/005-normalize-input/check.js +303 -0
  70. package/labs/website-design/006-serve-website-spike/check.js +290 -0
  71. package/labs/website-design/README.md +34 -0
  72. package/labs/website-design/manifest.json +68 -0
  73. package/labs/website-design/run-all.js +60 -0
  74. package/middleware/json-body.js +126 -0
  75. package/openapi.js +474 -0
  76. package/package.json +13 -7
  77. package/publishers/Publishers.js +6 -5
  78. package/publishers/http-function-publisher.js +135 -126
  79. package/publishers/http-webpage-publisher.js +89 -11
  80. package/publishers/query-publisher.js +116 -0
  81. package/publishers/swagger-publisher.js +203 -0
  82. package/publishers/swagger-ui.js +578 -0
  83. package/resources/adapters/array-adapter.js +143 -0
  84. package/resources/processors/bundlers/js/esbuild/Advanced_JS_Bundler_Using_ESBuild.js +90 -22
  85. package/resources/processors/bundlers/js/esbuild/Core_JS_Non_Minifying_Bundler_Using_ESBuild.js +50 -14
  86. package/resources/processors/bundlers/js/esbuild/Core_JS_Single_File_Minifying_Bundler_Using_ESBuild.js +48 -14
  87. package/resources/processors/bundlers/js/esbuild/JSGUI3_HTML_Control_Optimizer.js +396 -44
  88. package/resources/query-resource.js +131 -0
  89. package/serve-factory.js +677 -18
  90. package/server.js +585 -167
  91. package/tests/README.md +86 -2
  92. package/tests/admin-ui-jsgui-controls.test.js +16 -1
  93. package/tests/bundling-default-control-elimination.puppeteer.test.js +32 -1
  94. package/tests/control-elimination-root-feature-pruning.test.js +440 -0
  95. package/tests/control-elimination-static-bracket-access.test.js +245 -0
  96. package/tests/control-scan-manifest-regression.test.js +2 -0
  97. package/tests/end-to-end.test.js +22 -21
  98. package/tests/fixtures/control_scan_manifest_expectations.json +4 -2
  99. package/tests/helpers/playwright-e2e-harness.js +326 -0
  100. package/tests/helpers/puppeteer-e2e-harness.js +62 -1
  101. package/tests/openapi.test.js +319 -0
  102. package/tests/playwright-smoke.test.js +134 -0
  103. package/tests/project-local-controls-bundling.puppeteer.test.js +462 -0
  104. package/tests/publish-enhancements.test.js +673 -0
  105. package/tests/query-publisher.test.js +430 -0
  106. package/tests/quick-json-body-test.js +169 -0
  107. package/tests/serve.test.js +425 -122
  108. package/tests/swagger-publisher.test.js +1076 -0
  109. package/tests/test-runner.js +4 -0
@@ -0,0 +1,673 @@
1
+ /**
2
+ * Tests for the 4 server.publish() enhancements:
3
+ *
4
+ * 1. Raw handler passthrough (meta.raw: true)
5
+ * 2. URL path parameter support
6
+ * 3. Query string parsing for GET handlers
7
+ * 4. OpenAPI query parameter schema for GET endpoints
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const assert = require('assert');
13
+ const http = require('http');
14
+ const net = require('net');
15
+ const { describe, it, before, after } = require('node:test');
16
+ const { generate_openapi_spec, collect_api_entries, simple_schema_to_openapi } = require('../openapi');
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────
19
+
20
+ const get_free_port = () => new Promise((resolve, reject) => {
21
+ const srv = net.createServer();
22
+ srv.listen(0, '127.0.0.1', () => {
23
+ const port = srv.address().port;
24
+ srv.close(() => resolve(port));
25
+ });
26
+ srv.on('error', reject);
27
+ });
28
+
29
+ const http_get = (port, path) => new Promise((resolve, reject) => {
30
+ const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => {
31
+ let body = '';
32
+ res.on('data', (chunk) => body += chunk);
33
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
34
+ });
35
+ req.on('error', reject);
36
+ });
37
+
38
+ const http_request = (port, path, method, body_obj) => new Promise((resolve, reject) => {
39
+ const body_str = body_obj ? JSON.stringify(body_obj) : '';
40
+ const req = http.request({
41
+ hostname: '127.0.0.1',
42
+ port,
43
+ path,
44
+ method,
45
+ headers: body_obj ? {
46
+ 'Content-Type': 'application/json',
47
+ 'Content-Length': Buffer.byteLength(body_str)
48
+ } : {}
49
+ }, (res) => {
50
+ let body = '';
51
+ res.on('data', (chunk) => body += chunk);
52
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
53
+ });
54
+ req.on('error', reject);
55
+ if (body_str) req.write(body_str);
56
+ req.end();
57
+ });
58
+
59
+ // ══════════════════════════════════════════════════════════════
60
+ // 1. Raw Handler Passthrough — Unit Tests
61
+ // ══════════════════════════════════════════════════════════════
62
+
63
+ describe('Enhancement 1: Raw handler passthrough (meta.raw)', () => {
64
+
65
+ describe('openapi spec with raw endpoints', () => {
66
+ const server = {
67
+ _api_registry: [
68
+ {
69
+ path: '/api/events/stream',
70
+ method: 'GET',
71
+ meta: {
72
+ raw: true,
73
+ summary: 'Stream events as NDJSON',
74
+ tags: ['Events'],
75
+ returns: {
76
+ event: { type: 'string' },
77
+ data: { type: 'object' }
78
+ }
79
+ },
80
+ schema: {}
81
+ },
82
+ {
83
+ path: '/api/data/export',
84
+ method: 'GET',
85
+ meta: {
86
+ raw: true,
87
+ summary: 'Export data as gzip',
88
+ tags: ['Data']
89
+ },
90
+ schema: {}
91
+ }
92
+ ],
93
+ get_listening_endpoints: () => []
94
+ };
95
+
96
+ it('should include raw endpoints in the spec', () => {
97
+ const spec = generate_openapi_spec(server);
98
+ assert.ok(spec.paths['/api/events/stream']);
99
+ assert.ok(spec.paths['/api/events/stream'].get);
100
+ assert.strictEqual(spec.paths['/api/events/stream'].get.summary, 'Stream events as NDJSON');
101
+ });
102
+
103
+ it('should include returns schema for raw endpoints', () => {
104
+ const spec = generate_openapi_spec(server);
105
+ const schema = spec.paths['/api/events/stream'].get.responses['200'].content['application/json'].schema;
106
+ assert.ok(schema.properties.event);
107
+ assert.ok(schema.properties.data);
108
+ });
109
+
110
+ it('should collect tags from raw endpoints', () => {
111
+ const spec = generate_openapi_spec(server);
112
+ const tag_names = spec.tags.map(t => t.name);
113
+ assert.ok(tag_names.includes('Events'));
114
+ assert.ok(tag_names.includes('Data'));
115
+ });
116
+ });
117
+ });
118
+
119
+ // ══════════════════════════════════════════════════════════════
120
+ // 2. URL Path Parameters — Unit Tests
121
+ // ══════════════════════════════════════════════════════════════
122
+
123
+ describe('Enhancement 2: URL path parameters', () => {
124
+
125
+ describe('OpenAPI path conversion', () => {
126
+ const server = {
127
+ _api_registry: [
128
+ {
129
+ path: '/api/item/:id/detail',
130
+ method: 'GET',
131
+ meta: {
132
+ summary: 'Get item detail',
133
+ params: {
134
+ id: { type: 'integer', description: 'Item ID' }
135
+ }
136
+ }
137
+ },
138
+ {
139
+ path: '/api/domain/:domain/status',
140
+ method: 'GET',
141
+ meta: {
142
+ summary: 'Get domain status',
143
+ params: {
144
+ domain: { type: 'string', description: 'Domain name' }
145
+ }
146
+ }
147
+ },
148
+ {
149
+ path: '/api/user/:userId/post/:postId',
150
+ method: 'GET',
151
+ meta: {
152
+ summary: 'Get user post',
153
+ tags: ['Posts']
154
+ }
155
+ }
156
+ ],
157
+ get_listening_endpoints: () => []
158
+ };
159
+
160
+ it('should convert :param to {param} in OpenAPI paths', () => {
161
+ const spec = generate_openapi_spec(server);
162
+ assert.ok(spec.paths['/api/item/{id}/detail']);
163
+ assert.ok(spec.paths['/api/domain/{domain}/status']);
164
+ assert.ok(spec.paths['/api/user/{userId}/post/{postId}']);
165
+ // Should NOT have the :param versions
166
+ assert.strictEqual(spec.paths['/api/item/:id/detail'], undefined);
167
+ });
168
+
169
+ it('should emit path parameters with in: "path"', () => {
170
+ const spec = generate_openapi_spec(server);
171
+ const params = spec.paths['/api/item/{id}/detail'].get.parameters;
172
+ assert.ok(params);
173
+ const id_param = params.find(p => p.name === 'id');
174
+ assert.ok(id_param);
175
+ assert.strictEqual(id_param.in, 'path');
176
+ assert.strictEqual(id_param.required, true);
177
+ });
178
+
179
+ it('should use description from meta.params for path parameters', () => {
180
+ const spec = generate_openapi_spec(server);
181
+ const params = spec.paths['/api/item/{id}/detail'].get.parameters;
182
+ const id_param = params.find(p => p.name === 'id');
183
+ assert.strictEqual(id_param.description, 'Item ID');
184
+ });
185
+
186
+ it('should handle multiple path parameters', () => {
187
+ const spec = generate_openapi_spec(server);
188
+ const params = spec.paths['/api/user/{userId}/post/{postId}'].get.parameters;
189
+ assert.ok(params);
190
+ assert.ok(params.find(p => p.name === 'userId'));
191
+ assert.ok(params.find(p => p.name === 'postId'));
192
+ assert.ok(params.every(p => p.in === 'path'));
193
+ });
194
+
195
+ it('should separate path params from query params', () => {
196
+ const spec = generate_openapi_spec(server);
197
+ const params = spec.paths['/api/item/{id}/detail'].get.parameters;
198
+ const path_p = params.filter(p => p.in === 'path');
199
+ const query_p = params.filter(p => p.in === 'query');
200
+ // 'id' is in the path, so it should be a path param not a query param
201
+ assert.strictEqual(path_p.length, 1);
202
+ assert.strictEqual(path_p[0].name, 'id');
203
+ assert.strictEqual(query_p.length, 0);
204
+ });
205
+ });
206
+
207
+ describe('Path + query params combined', () => {
208
+ it('should emit both path and query params for GET with mixed params', () => {
209
+ const server = {
210
+ _api_registry: [{
211
+ path: '/api/users/:userId/posts',
212
+ method: 'GET',
213
+ meta: {
214
+ summary: 'Get user posts',
215
+ params: {
216
+ userId: { type: 'integer', description: 'User ID' },
217
+ page: { type: 'integer', default: 1, description: 'Page number' },
218
+ limit: { type: 'integer', default: 10 }
219
+ }
220
+ }
221
+ }],
222
+ get_listening_endpoints: () => []
223
+ };
224
+ const spec = generate_openapi_spec(server);
225
+ const params = spec.paths['/api/users/{userId}/posts'].get.parameters;
226
+ assert.ok(params);
227
+ const path_p = params.filter(p => p.in === 'path');
228
+ const query_p = params.filter(p => p.in === 'query');
229
+ assert.strictEqual(path_p.length, 1);
230
+ assert.strictEqual(path_p[0].name, 'userId');
231
+ assert.strictEqual(query_p.length, 2);
232
+ assert.ok(query_p.find(p => p.name === 'page'));
233
+ assert.ok(query_p.find(p => p.name === 'limit'));
234
+ });
235
+ });
236
+
237
+ describe('POST with path params still uses requestBody', () => {
238
+ it('should emit requestBody for POST and path params in parameters', () => {
239
+ const server = {
240
+ _api_registry: [{
241
+ path: '/api/items/:id/update',
242
+ method: 'POST',
243
+ meta: {
244
+ summary: 'Update item',
245
+ params: {
246
+ id: { type: 'integer', description: 'Item ID' },
247
+ name: { type: 'string', required: true },
248
+ value: { type: 'number' }
249
+ }
250
+ }
251
+ }],
252
+ get_listening_endpoints: () => []
253
+ };
254
+ const spec = generate_openapi_spec(server);
255
+ const op = spec.paths['/api/items/{id}/update'].post;
256
+ assert.ok(op.requestBody, 'POST should have requestBody');
257
+ assert.ok(op.parameters, 'POST with :id should have parameters');
258
+ const path_p = op.parameters.filter(p => p.in === 'path');
259
+ assert.strictEqual(path_p.length, 1);
260
+ assert.strictEqual(path_p[0].name, 'id');
261
+ });
262
+ });
263
+ });
264
+
265
+ // ══════════════════════════════════════════════════════════════
266
+ // 3. Query String Parsing — Unit Tests
267
+ // ══════════════════════════════════════════════════════════════
268
+
269
+ describe('Enhancement 3: Query string parsing', () => {
270
+ // These tests need a real server to verify HTTP behaviour.
271
+ // Unit-level spec tests are covered in #4. Integration tests below.
272
+ });
273
+
274
+ // ══════════════════════════════════════════════════════════════
275
+ // 4. OpenAPI Query Parameter Schema — Unit Tests
276
+ // ══════════════════════════════════════════════════════════════
277
+
278
+ describe('Enhancement 4: OpenAPI query parameter schema', () => {
279
+
280
+ it('should emit GET params as query parameters, not requestBody', () => {
281
+ const server = {
282
+ _api_registry: [{
283
+ path: '/api/search',
284
+ method: 'GET',
285
+ meta: {
286
+ summary: 'Search',
287
+ params: {
288
+ q: { type: 'string', required: true, description: 'Search query' },
289
+ limit: { type: 'integer', default: 10 }
290
+ }
291
+ }
292
+ }],
293
+ get_listening_endpoints: () => []
294
+ };
295
+ const spec = generate_openapi_spec(server);
296
+ const op = spec.paths['/api/search'].get;
297
+ assert.strictEqual(op.requestBody, undefined, 'GET should NOT have requestBody');
298
+ assert.ok(op.parameters, 'GET should have parameters');
299
+ const q_param = op.parameters.find(p => p.name === 'q');
300
+ assert.ok(q_param);
301
+ assert.strictEqual(q_param.in, 'query');
302
+ assert.strictEqual(q_param.required, true);
303
+ assert.strictEqual(q_param.description, 'Search query');
304
+ assert.strictEqual(q_param.schema.type, 'string');
305
+
306
+ const limit_param = op.parameters.find(p => p.name === 'limit');
307
+ assert.ok(limit_param);
308
+ assert.strictEqual(limit_param.in, 'query');
309
+ assert.strictEqual(limit_param.required, false);
310
+ assert.strictEqual(limit_param.schema.default, 10);
311
+ });
312
+
313
+ it('should include enum in query parameter schema', () => {
314
+ const server = {
315
+ _api_registry: [{
316
+ path: '/api/list',
317
+ method: 'GET',
318
+ meta: {
319
+ params: {
320
+ sort: { type: 'string', enum: ['name', 'date', 'price'] }
321
+ }
322
+ }
323
+ }],
324
+ get_listening_endpoints: () => []
325
+ };
326
+ const spec = generate_openapi_spec(server);
327
+ const sort_param = spec.paths['/api/list'].get.parameters.find(p => p.name === 'sort');
328
+ assert.deepStrictEqual(sort_param.schema.enum, ['name', 'date', 'price']);
329
+ });
330
+
331
+ it('should still emit POST params as requestBody (no regression)', () => {
332
+ const server = {
333
+ _api_registry: [{
334
+ path: '/api/create',
335
+ method: 'POST',
336
+ meta: {
337
+ params: {
338
+ name: { type: 'string', required: true },
339
+ value: { type: 'number' }
340
+ }
341
+ }
342
+ }],
343
+ get_listening_endpoints: () => []
344
+ };
345
+ const spec = generate_openapi_spec(server);
346
+ const op = spec.paths['/api/create'].post;
347
+ assert.ok(op.requestBody, 'POST should have requestBody');
348
+ assert.strictEqual(op.parameters, undefined, 'POST without path params should NOT have parameters');
349
+ });
350
+
351
+ it('DELETE with params should emit as query parameters', () => {
352
+ const server = {
353
+ _api_registry: [{
354
+ path: '/api/cleanup',
355
+ method: 'DELETE',
356
+ meta: {
357
+ params: {
358
+ older_than: { type: 'string', description: 'ISO date' }
359
+ }
360
+ }
361
+ }],
362
+ get_listening_endpoints: () => []
363
+ };
364
+ const spec = generate_openapi_spec(server);
365
+ const op = spec.paths['/api/cleanup'].delete;
366
+ assert.strictEqual(op.requestBody, undefined, 'DELETE should NOT have requestBody');
367
+ assert.ok(op.parameters);
368
+ assert.strictEqual(op.parameters[0].in, 'query');
369
+ });
370
+ });
371
+
372
+
373
+ // ══════════════════════════════════════════════════════════════
374
+ // Integration Tests (Real HTTP Server)
375
+ // ══════════════════════════════════════════════════════════════
376
+
377
+ describe('Integration: All 4 enhancements with real HTTP server', function () {
378
+ let server_instance;
379
+ let port;
380
+
381
+ // FakePublisher to avoid jsgui3-html heavy loading
382
+ const fake_webpage_publisher_path = require.resolve('../publishers/http-webpage-publisher');
383
+ const fake_website_publisher_path = require.resolve('../publishers/http-webpageorsite-publisher');
384
+ const original_webpage = require.cache[fake_webpage_publisher_path];
385
+ const original_website = require.cache[fake_website_publisher_path];
386
+ const { Evented_Class } = require('lang-tools');
387
+
388
+ class FakePublisher extends Evented_Class {
389
+ constructor() {
390
+ super();
391
+ const self = this;
392
+ setImmediate(() => self.raise('ready', { _arr: [] }));
393
+ }
394
+ handle_http(req, res) {
395
+ res.writeHead(200, { 'Content-Type': 'text/html' });
396
+ res.end('<html><body>fake</body></html>');
397
+ }
398
+ meets_requirements() { return true; }
399
+ start(cb) { cb && cb(null); }
400
+ stop(cb) { cb && cb(null); }
401
+ }
402
+
403
+ require.cache[fake_webpage_publisher_path] = { exports: FakePublisher };
404
+ require.cache[fake_website_publisher_path] = { exports: FakePublisher };
405
+
406
+ const Server = require('../server');
407
+
408
+ before(async () => {
409
+ port = await get_free_port();
410
+ server_instance = await Server.serve({
411
+ host: '127.0.0.1',
412
+ port,
413
+ swagger: true,
414
+ website: false,
415
+ api: {
416
+ // ── Enhancement 1: Raw handler ──
417
+ 'events/stream': {
418
+ handler: (req, res) => {
419
+ res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
420
+ res.write(JSON.stringify({ event: 'start', ts: Date.now() }) + '\n');
421
+ res.write(JSON.stringify({ event: 'data', value: 42 }) + '\n');
422
+ res.write(JSON.stringify({ event: 'end' }) + '\n');
423
+ res.end();
424
+ },
425
+ method: 'GET',
426
+ raw: true,
427
+ summary: 'Stream events as NDJSON',
428
+ tags: ['Events'],
429
+ returns: {
430
+ event: { type: 'string' },
431
+ ts: { type: 'integer' }
432
+ }
433
+ },
434
+
435
+ // ── Enhancement 2: Path parameters ──
436
+ 'item/:id/detail': {
437
+ handler: (input) => ({
438
+ id: input.id,
439
+ name: `Item ${input.id}`,
440
+ found: true
441
+ }),
442
+ method: 'GET',
443
+ summary: 'Get item detail by ID',
444
+ tags: ['Items'],
445
+ params: {
446
+ id: { type: 'string', description: 'Item ID' }
447
+ },
448
+ returns: {
449
+ id: { type: 'string' },
450
+ name: { type: 'string' },
451
+ found: { type: 'boolean' }
452
+ }
453
+ },
454
+
455
+ // ── Enhancement 3: Query string parsing ──
456
+ 'search': {
457
+ handler: (input) => ({
458
+ query: input && input.q,
459
+ limit: input && input.limit,
460
+ results: []
461
+ }),
462
+ method: 'GET',
463
+ summary: 'Search items',
464
+ tags: ['Search'],
465
+ params: {
466
+ q: { type: 'string', required: true, description: 'Search query' },
467
+ limit: { type: 'integer', default: 10, description: 'Max results' }
468
+ },
469
+ returns: {
470
+ query: { type: 'string' },
471
+ limit: { type: 'string' },
472
+ results: { type: 'array', items: { type: 'object' } }
473
+ }
474
+ },
475
+
476
+ // ── Enhancement 4: POST still uses requestBody ──
477
+ 'items/create': {
478
+ handler: (input) => ({
479
+ id: 99,
480
+ name: input && input.name
481
+ }),
482
+ method: 'POST',
483
+ summary: 'Create item',
484
+ tags: ['Items'],
485
+ params: {
486
+ name: { type: 'string', required: true },
487
+ category: { type: 'string' }
488
+ },
489
+ returns: {
490
+ id: { type: 'integer' },
491
+ name: { type: 'string' }
492
+ }
493
+ },
494
+
495
+ // ── Combined: Path param + query string ──
496
+ 'users/:userId/posts': {
497
+ handler: (input) => ({
498
+ userId: input && input.userId,
499
+ page: input && input.page,
500
+ posts: []
501
+ }),
502
+ method: 'GET',
503
+ summary: 'Get user posts',
504
+ tags: ['Users'],
505
+ params: {
506
+ userId: { type: 'string', description: 'User ID' },
507
+ page: { type: 'integer', default: 1 }
508
+ },
509
+ returns: {
510
+ userId: { type: 'string' },
511
+ page: { type: 'string' },
512
+ posts: { type: 'array', items: { type: 'object' } }
513
+ }
514
+ }
515
+ }
516
+ });
517
+ });
518
+
519
+ after(async () => {
520
+ if (original_webpage) require.cache[fake_webpage_publisher_path] = original_webpage;
521
+ if (original_website) require.cache[fake_website_publisher_path] = original_website;
522
+ if (server_instance && typeof server_instance.close === 'function') {
523
+ await new Promise(r => server_instance.close(r));
524
+ }
525
+ });
526
+
527
+ // ── Enhancement 1: Raw handler tests ──
528
+
529
+ it('raw handler streams NDJSON', async () => {
530
+ const { status, headers, body } = await http_get(port, '/api/events/stream');
531
+ assert.strictEqual(status, 200);
532
+ assert.ok(headers['content-type'].includes('application/x-ndjson'));
533
+ const lines = body.trim().split('\n');
534
+ assert.strictEqual(lines.length, 3);
535
+ const first = JSON.parse(lines[0]);
536
+ assert.strictEqual(first.event, 'start');
537
+ const last = JSON.parse(lines[2]);
538
+ assert.strictEqual(last.event, 'end');
539
+ });
540
+
541
+ it('raw handler appears in OpenAPI spec', async () => {
542
+ const { body } = await http_get(port, '/api/openapi.json');
543
+ const spec = JSON.parse(body);
544
+ assert.ok(spec.paths['/api/events/stream']);
545
+ assert.ok(spec.paths['/api/events/stream'].get);
546
+ assert.strictEqual(spec.paths['/api/events/stream'].get.summary, 'Stream events as NDJSON');
547
+ });
548
+
549
+ it('raw handler enforces method (POST → 405)', async () => {
550
+ const { status } = await http_request(port, '/api/events/stream', 'POST');
551
+ assert.strictEqual(status, 405);
552
+ });
553
+
554
+ // ── Enhancement 2: Path parameter tests ──
555
+
556
+ it('path param handler receives params in input', async () => {
557
+ const { status, body } = await http_get(port, '/api/item/42/detail');
558
+ assert.strictEqual(status, 200);
559
+ const data = JSON.parse(body);
560
+ assert.strictEqual(data.id, '42');
561
+ assert.strictEqual(data.name, 'Item 42');
562
+ assert.strictEqual(data.found, true);
563
+ });
564
+
565
+ it('path param appears as {id} in OpenAPI spec', async () => {
566
+ const { body } = await http_get(port, '/api/openapi.json');
567
+ const spec = JSON.parse(body);
568
+ assert.ok(spec.paths['/api/item/{id}/detail']);
569
+ assert.strictEqual(spec.paths['/api/item/:id/detail'], undefined);
570
+ const params = spec.paths['/api/item/{id}/detail'].get.parameters;
571
+ assert.ok(params);
572
+ const id_param = params.find(p => p.name === 'id');
573
+ assert.ok(id_param);
574
+ assert.strictEqual(id_param.in, 'path');
575
+ assert.strictEqual(id_param.required, true);
576
+ });
577
+
578
+ it('path param with different values returns correct data', async () => {
579
+ const { body: body1 } = await http_get(port, '/api/item/100/detail');
580
+ const { body: body2 } = await http_get(port, '/api/item/abc/detail');
581
+ assert.strictEqual(JSON.parse(body1).id, '100');
582
+ assert.strictEqual(JSON.parse(body2).id, 'abc');
583
+ });
584
+
585
+ // ── Enhancement 3: Query string tests ──
586
+
587
+ it('GET with query string passes params to handler', async () => {
588
+ const { status, body } = await http_get(port, '/api/search?q=test&limit=10');
589
+ assert.strictEqual(status, 200);
590
+ const data = JSON.parse(body);
591
+ assert.strictEqual(data.query, 'test');
592
+ assert.strictEqual(data.limit, '10');
593
+ });
594
+
595
+ it('GET without query string still works', async () => {
596
+ const { status } = await http_get(port, '/api/search');
597
+ assert.strictEqual(status, 200);
598
+ });
599
+
600
+ it('combined path param + query string', async () => {
601
+ const { status, body } = await http_get(port, '/api/users/42/posts?page=3');
602
+ assert.strictEqual(status, 200);
603
+ const data = JSON.parse(body);
604
+ assert.strictEqual(data.userId, '42');
605
+ assert.strictEqual(data.page, '3');
606
+ });
607
+
608
+ it('POST body still works (regression check)', async () => {
609
+ const { status, body } = await http_request(port, '/api/items/create', 'POST', { name: 'Widget' });
610
+ assert.strictEqual(status, 200);
611
+ const data = JSON.parse(body);
612
+ assert.strictEqual(data.id, 99);
613
+ assert.strictEqual(data.name, 'Widget');
614
+ });
615
+
616
+ it('POST with query string merges (body wins)', async () => {
617
+ const { status, body } = await http_request(port, '/api/items/create?name=FromQuery&extra=yes', 'POST', { name: 'FromBody' });
618
+ assert.strictEqual(status, 200);
619
+ const data = JSON.parse(body);
620
+ assert.strictEqual(data.name, 'FromBody'); // body wins over query
621
+ });
622
+
623
+ // ── Enhancement 4: OpenAPI query params ──
624
+
625
+ it('GET endpoint shows params as query parameters in spec', async () => {
626
+ const { body } = await http_get(port, '/api/openapi.json');
627
+ const spec = JSON.parse(body);
628
+ const op = spec.paths['/api/search'].get;
629
+ assert.strictEqual(op.requestBody, undefined, 'GET should NOT have requestBody');
630
+ assert.ok(op.parameters, 'GET should have parameters');
631
+ const q_param = op.parameters.find(p => p.name === 'q');
632
+ assert.ok(q_param);
633
+ assert.strictEqual(q_param.in, 'query');
634
+ assert.strictEqual(q_param.required, true);
635
+ });
636
+
637
+ it('POST endpoint still shows params as requestBody in spec', async () => {
638
+ const { body } = await http_get(port, '/api/openapi.json');
639
+ const spec = JSON.parse(body);
640
+ const op = spec.paths['/api/items/create'].post;
641
+ assert.ok(op.requestBody, 'POST should have requestBody');
642
+ });
643
+
644
+ it('combined path + query shows both parameter types', async () => {
645
+ const { body } = await http_get(port, '/api/openapi.json');
646
+ const spec = JSON.parse(body);
647
+ const op = spec.paths['/api/users/{userId}/posts'].get;
648
+ assert.ok(op.parameters);
649
+ const path_p = op.parameters.filter(p => p.in === 'path');
650
+ const query_p = op.parameters.filter(p => p.in === 'query');
651
+ assert.strictEqual(path_p.length, 1);
652
+ assert.strictEqual(path_p[0].name, 'userId');
653
+ assert.ok(query_p.length >= 1);
654
+ assert.ok(query_p.find(p => p.name === 'page'));
655
+ });
656
+
657
+ it('spec has default values in query param schema', async () => {
658
+ const { body } = await http_get(port, '/api/openapi.json');
659
+ const spec = JSON.parse(body);
660
+ const params = spec.paths['/api/search'].get.parameters;
661
+ const limit_param = params.find(p => p.name === 'limit');
662
+ assert.strictEqual(limit_param.schema.default, 10);
663
+ });
664
+
665
+ // ── No regressions ──
666
+
667
+ it('all swagger routes still work', async () => {
668
+ const { status: spec_status } = await http_get(port, '/api/openapi.json');
669
+ const { status: docs_status } = await http_get(port, '/api/docs');
670
+ assert.strictEqual(spec_status, 200);
671
+ assert.strictEqual(docs_status, 200);
672
+ });
673
+ });