@jseeio/jsee 0.4.2 → 0.8.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +583 -55
  4. package/dist/2b3e1faf89f94a483539.png +0 -0
  5. package/dist/416d91365b44e4b4f477.png +0 -0
  6. package/dist/8f2c4d11474275fbc161.png +0 -0
  7. package/dist/jsee.core.js +1 -0
  8. package/dist/jsee.full.js +1 -0
  9. package/dist/jsee.runtime.js +2 -1
  10. package/package.json +84 -18
  11. package/src/app.js +127 -32
  12. package/src/browser-bundle-node.js +9 -0
  13. package/src/cli.js +479 -67
  14. package/src/extended-imports.js +11 -0
  15. package/src/main.js +232 -44
  16. package/src/overlay.js +26 -1
  17. package/src/utils.js +386 -16
  18. package/templates/common-inputs.js +88 -0
  19. package/templates/common-outputs.js +340 -4
  20. package/templates/minimal-app.vue +367 -0
  21. package/templates/minimal-input.vue +573 -0
  22. package/templates/minimal-output.vue +426 -0
  23. package/templates/virtual-table.vue +194 -0
  24. package/.claude/settings.local.json +0 -15
  25. package/.eslintrc.js +0 -38
  26. package/AGENTS.md +0 -65
  27. package/CLAUDE.md +0 -5
  28. package/CNAME +0 -1
  29. package/_config.yml +0 -26
  30. package/dist/jsee.js +0 -1
  31. package/dump.sh +0 -23
  32. package/jest-puppeteer.config.js +0 -14
  33. package/jest.config.js +0 -8
  34. package/jest.unit.config.js +0 -8
  35. package/jsee.dump.txt +0 -5459
  36. package/load/index.html +0 -52
  37. package/templates/bulma-app.vue +0 -242
  38. package/templates/bulma-input.vue +0 -125
  39. package/templates/bulma-output.vue +0 -101
  40. package/test/arrow-main.html +0 -18
  41. package/test/arrow-worker.html +0 -18
  42. package/test/class.html +0 -22
  43. package/test/code.html +0 -25
  44. package/test/codew.html +0 -25
  45. package/test/example-class.js +0 -8
  46. package/test/example-sum.js +0 -3
  47. package/test/fixtures/lodash-like.js +0 -15
  48. package/test/fixtures/upload-sample.csv +0 -3
  49. package/test/importw.html +0 -28
  50. package/test/minimal.html +0 -14
  51. package/test/minimal1.html +0 -13
  52. package/test/minimal2.html +0 -15
  53. package/test/minimal3.html +0 -10
  54. package/test/minimal4.html +0 -22
  55. package/test/pipeline.html +0 -52
  56. package/test/python.html +0 -41
  57. package/test/runtime-arrow.html +0 -18
  58. package/test/string.html +0 -26
  59. package/test/stringw.html +0 -29
  60. package/test/sum.schema.json +0 -17
  61. package/test/sumw.schema.json +0 -15
  62. package/test/test-basic.test.js +0 -630
  63. package/test/test-python.test.js +0 -23
  64. package/test/unit/cli-fetch.test.js +0 -229
  65. package/test/unit/utils.test.js +0 -908
  66. package/webpack.config.js +0 -101
@@ -1,908 +0,0 @@
1
- const {
2
- isObject,
3
- sanitizeName,
4
- getUrl,
5
- delay,
6
- debounce,
7
- getName,
8
- isWorkerInitMessage,
9
- getProgressState,
10
- shouldContinueInterval,
11
- getModelFuncJS,
12
- getModelFuncAPI,
13
- validateSchema,
14
- toWorkerSerializable,
15
- wrapStreamInputs,
16
- containsBinaryPayload,
17
- isCssImport,
18
- isRelativeImport,
19
- getUrlParam,
20
- coerceParam
21
- } = require('../../src/utils')
22
-
23
- describe('isObject', () => {
24
- test('returns true for plain objects', () => {
25
- expect(isObject({})).toBe(true)
26
- expect(isObject({ a: 1 })).toBe(true)
27
- })
28
- test('returns false for arrays', () => {
29
- expect(isObject([])).toBe(false)
30
- expect(isObject([1, 2])).toBe(false)
31
- })
32
- test('returns false for null', () => {
33
- expect(isObject(null)).toBe(false)
34
- })
35
- test('returns false for primitives', () => {
36
- expect(isObject(42)).toBe(false)
37
- expect(isObject('string')).toBe(false)
38
- expect(isObject(true)).toBe(false)
39
- expect(isObject(undefined)).toBe(false)
40
- })
41
- })
42
-
43
- describe('sanitizeName', () => {
44
- test('lowercases and replaces non-alphanumeric', () => {
45
- expect(sanitizeName('Hello World')).toBe('hello_world')
46
- })
47
- test('keeps underscores and digits', () => {
48
- expect(sanitizeName('input_1')).toBe('input_1')
49
- })
50
- test('replaces special characters', () => {
51
- expect(sanitizeName('My Input!')).toBe('my_input_')
52
- })
53
- test('handles already clean names', () => {
54
- expect(sanitizeName('foo')).toBe('foo')
55
- })
56
- })
57
-
58
- describe('isCssImport', () => {
59
- test('.css extension returns true', () => {
60
- expect(isCssImport('styles/main.css')).toBe(true)
61
- expect(isCssImport('https://cdn.example.com/lib.css')).toBe(true)
62
- })
63
- test('.css with query string returns true', () => {
64
- expect(isCssImport('styles/main.css?v=2')).toBe(true)
65
- })
66
- test('.css with hash returns true', () => {
67
- expect(isCssImport('styles/main.css#id')).toBe(true)
68
- })
69
- test('.js extension returns false', () => {
70
- expect(isCssImport('lib/app.js')).toBe(false)
71
- })
72
- test('bare package name returns false', () => {
73
- expect(isCssImport('lodash')).toBe(false)
74
- })
75
- test('non-string returns false', () => {
76
- expect(isCssImport(null)).toBe(false)
77
- expect(isCssImport(undefined)).toBe(false)
78
- expect(isCssImport(42)).toBe(false)
79
- })
80
- })
81
-
82
- describe('isRelativeImport', () => {
83
- test('./ prefix is relative', () => {
84
- expect(isRelativeImport('./lib.js')).toBe(true)
85
- })
86
- test('../ prefix is relative', () => {
87
- expect(isRelativeImport('../utils.js')).toBe(true)
88
- })
89
- test('/ prefix is relative', () => {
90
- expect(isRelativeImport('/dist/app.js')).toBe(true)
91
- })
92
- test('path with dir separator and .js extension is relative', () => {
93
- expect(isRelativeImport('dist/core.js')).toBe(true)
94
- })
95
- test('path with dir separator and .css extension is relative', () => {
96
- expect(isRelativeImport('styles/main.css')).toBe(true)
97
- })
98
- test('bare package name is not relative', () => {
99
- expect(isRelativeImport('lodash')).toBe(false)
100
- })
101
- test('package name ending in .js without slash is not relative', () => {
102
- expect(isRelativeImport('chart.js')).toBe(false)
103
- })
104
- test('scoped package is not relative', () => {
105
- expect(isRelativeImport('@org/pkg')).toBe(false)
106
- })
107
- test('versioned package is not relative', () => {
108
- expect(isRelativeImport('lodash@4.17.21/lodash.min.js')).toBe(false)
109
- })
110
- test('absolute URL is not relative', () => {
111
- expect(isRelativeImport('https://example.com/lib.js')).toBe(false)
112
- })
113
- test('non-string returns false', () => {
114
- expect(isRelativeImport(null)).toBe(false)
115
- expect(isRelativeImport(undefined)).toBe(false)
116
- })
117
- })
118
-
119
- describe('getUrl', () => {
120
- test('returns absolute URLs as-is', () => {
121
- expect(getUrl('https://example.com/lib.js')).toBe('https://example.com/lib.js')
122
- })
123
- test('prepends CDN base for bare package names', () => {
124
- expect(getUrl('lodash@4.17.21/lodash.min.js'))
125
- .toBe('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js')
126
- })
127
- test('resolves relative paths with ./ using window.location when available', () => {
128
- global.window = { location: { href: 'https://example.com/app/' } }
129
- const result = getUrl('./lib/helper.js')
130
- expect(result).toBe('https://example.com/app/lib/helper.js')
131
- delete global.window
132
- })
133
- test('resolves paths with dir separator and extension using window.location', () => {
134
- global.window = { location: { href: 'https://example.com/app/' } }
135
- const result = getUrl('dist/profile-core.js')
136
- expect(result).toBe('https://example.com/app/dist/profile-core.js')
137
- delete global.window
138
- })
139
- test('falls back to CDN for relative paths when window is unavailable', () => {
140
- const result = getUrl('./lib/helper.js')
141
- expect(result).toContain('cdn.jsdelivr.net')
142
- })
143
- test('bare names without extension still resolve to CDN', () => {
144
- expect(getUrl('lodash')).toBe('https://cdn.jsdelivr.net/npm/lodash')
145
- })
146
- })
147
-
148
- describe('delay', () => {
149
- test('resolves after specified ms', async () => {
150
- const start = Date.now()
151
- await delay(50)
152
- const elapsed = Date.now() - start
153
- expect(elapsed).toBeGreaterThanOrEqual(40) // allow small timing variance
154
- })
155
- test('defaults to 1ms if no argument', async () => {
156
- await expect(delay()).resolves.toBeUndefined()
157
- })
158
- })
159
-
160
- describe('toWorkerSerializable', () => {
161
- test('clones plain objects and arrays recursively', () => {
162
- const original = {
163
- file: { kind: 'url', url: 'http://localhost:8080/test.csv' },
164
- nested: [{ a: 1 }, { b: 2 }]
165
- }
166
- const result = toWorkerSerializable(original)
167
- expect(result).toEqual(original)
168
- expect(result).not.toBe(original)
169
- expect(result.file).not.toBe(original.file)
170
- expect(result.nested).not.toBe(original.nested)
171
- expect(result.nested[0]).not.toBe(original.nested[0])
172
- })
173
-
174
- test('preserves native clone-safe objects by reference', () => {
175
- const date = new Date('2024-01-01T00:00:00.000Z')
176
- const original = { date }
177
- const result = toWorkerSerializable(original)
178
- expect(result.date).toBe(date)
179
- })
180
-
181
- test('de-proxies custom object-like values into plain data', () => {
182
- class CustomType {
183
- constructor (v) {
184
- this.value = v
185
- }
186
- }
187
- const custom = new CustomType(42)
188
- const original = { custom }
189
- const result = toWorkerSerializable(original)
190
- expect(result.custom).toEqual({ value: 42 })
191
- expect(result.custom).not.toBe(custom)
192
- })
193
- })
194
-
195
- describe('containsBinaryPayload', () => {
196
- test('returns true for nested binary payloads', () => {
197
- const payload = {
198
- file: {
199
- blob: new Uint8Array([1, 2, 3])
200
- }
201
- }
202
- expect(containsBinaryPayload(payload)).toBe(true)
203
- })
204
-
205
- test('returns false for plain JSON-like payloads', () => {
206
- const payload = {
207
- file: { kind: 'url', url: 'http://localhost:8080/test.csv' },
208
- rows: [{ a: 1 }, { b: 2 }]
209
- }
210
- expect(containsBinaryPayload(payload)).toBe(false)
211
- })
212
- })
213
-
214
- describe('wrapStreamInputs', () => {
215
- test('wraps file-like source into async iterable chunked reader', async () => {
216
- const content = new TextEncoder().encode('name,age\n1,2\n')
217
- const fakeFile = {
218
- size: content.byteLength,
219
- slice (start, end) {
220
- const chunk = content.slice(start, end)
221
- return {
222
- async arrayBuffer () {
223
- return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
224
- }
225
- }
226
- }
227
- }
228
- const wrapped = wrapStreamInputs(
229
- { file: fakeFile },
230
- { file: { stream: true } }
231
- )
232
- expect(wrapped.file).not.toBe(fakeFile)
233
- expect(typeof wrapped.file[Symbol.asyncIterator]).toBe('function')
234
- expect(typeof wrapped.file.text).toBe('function')
235
- expect(typeof wrapped.file.bytes).toBe('function')
236
- expect(typeof wrapped.file.lines).toBe('function')
237
-
238
- const text = await wrapped.file.text()
239
- expect(text).toBe('name,age\n1,2\n')
240
- })
241
-
242
- test('copies file metadata to chunked reader', () => {
243
- const fakeFile = {
244
- name: 'input.csv',
245
- size: 4,
246
- type: 'text/csv',
247
- slice () {
248
- return {
249
- async arrayBuffer () {
250
- return new Uint8Array([1, 2, 3, 4]).buffer
251
- }
252
- }
253
- }
254
- }
255
- const wrapped = wrapStreamInputs(
256
- { file: fakeFile },
257
- { file: { stream: true } }
258
- )
259
- expect(wrapped.file.name).toBe('input.csv')
260
- expect(wrapped.file.size).toBe(4)
261
- expect(wrapped.file.type).toBe('text/csv')
262
- })
263
-
264
- test('wraps URL handle into async iterable chunked reader', async () => {
265
- const fetchMock = jest.fn().mockResolvedValue({
266
- ok: true,
267
- headers: { get: () => null },
268
- body: {
269
- getReader () {
270
- let readCount = 0
271
- return {
272
- async read () {
273
- readCount += 1
274
- if (readCount === 1) {
275
- return { done: false, value: new TextEncoder().encode('name,age\n') }
276
- }
277
- return { done: true, value: undefined }
278
- },
279
- releaseLock () {}
280
- }
281
- }
282
- }
283
- })
284
-
285
- const wrapped = wrapStreamInputs(
286
- { file: { kind: 'url', url: 'http://localhost:8080/test.csv' } },
287
- { file: { stream: true } },
288
- { fetch: fetchMock }
289
- )
290
- expect(typeof wrapped.file[Symbol.asyncIterator]).toBe('function')
291
-
292
- const text = await wrapped.file.text()
293
- expect(text).toBe('name,age\n')
294
- expect(fetchMock).toHaveBeenCalled()
295
- expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8080/test.csv')
296
- })
297
-
298
- test('copies URL metadata to chunked reader', async () => {
299
- const fetchMock = jest.fn().mockResolvedValue({
300
- ok: true,
301
- headers: {
302
- get: (name) => {
303
- if (name === 'content-length') return '9'
304
- if (name === 'content-type') return 'text/csv; charset=utf-8'
305
- return null
306
- }
307
- },
308
- body: {
309
- getReader () {
310
- let readCount = 0
311
- return {
312
- async read () {
313
- readCount += 1
314
- if (readCount === 1) {
315
- return { done: false, value: new TextEncoder().encode('a,b\n1,2\n') }
316
- }
317
- return { done: true, value: undefined }
318
- },
319
- releaseLock () {}
320
- }
321
- }
322
- }
323
- })
324
-
325
- const wrapped = wrapStreamInputs(
326
- {
327
- file: {
328
- kind: 'url',
329
- url: 'http://localhost:8080/files/upload-sample.csv'
330
- }
331
- },
332
- { file: { stream: true } },
333
- { fetch: fetchMock }
334
- )
335
- expect(wrapped.file.name).toBe('upload-sample.csv')
336
-
337
- const text = await wrapped.file.text()
338
- expect(text).toBe('a,b\n1,2\n')
339
- expect(wrapped.file.size).toBe(9)
340
- expect(wrapped.file.type).toBe('text/csv')
341
- })
342
-
343
- test('does not re-wrap chunked readers on downstream stages', () => {
344
- const content = new TextEncoder().encode('hello')
345
- const fakeFile = {
346
- size: content.byteLength,
347
- slice (start, end) {
348
- const chunk = content.slice(start, end)
349
- return {
350
- async arrayBuffer () {
351
- return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
352
- }
353
- }
354
- }
355
- }
356
- const onceWrapped = wrapStreamInputs(
357
- { file: fakeFile },
358
- { file: { stream: true } }
359
- )
360
- const twiceWrapped = wrapStreamInputs(
361
- onceWrapped,
362
- { file: { stream: true } }
363
- )
364
- expect(twiceWrapped.file).toBe(onceWrapped.file)
365
- })
366
-
367
- test('lines() yields individual lines from chunked input', async () => {
368
- const content = new TextEncoder().encode('line1\nline2\nline3')
369
- const fakeFile = {
370
- size: content.byteLength,
371
- slice (start, end) {
372
- const chunk = content.slice(start, end)
373
- return {
374
- async arrayBuffer () {
375
- return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
376
- }
377
- }
378
- }
379
- }
380
- const wrapped = wrapStreamInputs(
381
- { file: fakeFile },
382
- { file: { stream: true } }
383
- )
384
-
385
- const lines = []
386
- for await (const line of wrapped.file.lines()) {
387
- lines.push(line)
388
- }
389
- expect(lines).toEqual(['line1', 'line2', 'line3'])
390
- })
391
-
392
- test('bytes() returns concatenated Uint8Array', async () => {
393
- const content = new TextEncoder().encode('hello')
394
- const fakeFile = {
395
- size: content.byteLength,
396
- slice (start, end) {
397
- const chunk = content.slice(start, end)
398
- return {
399
- async arrayBuffer () {
400
- return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
401
- }
402
- }
403
- }
404
- }
405
- const wrapped = wrapStreamInputs(
406
- { file: fakeFile },
407
- { file: { stream: true } }
408
- )
409
-
410
- const bytes = await wrapped.file.bytes()
411
- expect(bytes).toBeInstanceOf(Uint8Array)
412
- expect(new TextDecoder().decode(bytes)).toBe('hello')
413
- })
414
- })
415
-
416
- describe('debounce', () => {
417
- beforeEach(() => jest.useFakeTimers())
418
- afterEach(() => jest.useRealTimers())
419
-
420
- test('delays execution', () => {
421
- const fn = jest.fn()
422
- const debounced = debounce(fn, 100)
423
- debounced()
424
- expect(fn).not.toHaveBeenCalled()
425
- jest.advanceTimersByTime(100)
426
- expect(fn).toHaveBeenCalledTimes(1)
427
- })
428
-
429
- test('resets timer on rapid calls', () => {
430
- const fn = jest.fn()
431
- const debounced = debounce(fn, 100)
432
- debounced()
433
- jest.advanceTimersByTime(50)
434
- debounced()
435
- jest.advanceTimersByTime(50)
436
- expect(fn).not.toHaveBeenCalled()
437
- jest.advanceTimersByTime(50)
438
- expect(fn).toHaveBeenCalledTimes(1)
439
- })
440
-
441
- test('passes arguments through', () => {
442
- const fn = jest.fn()
443
- const debounced = debounce(fn, 100)
444
- debounced('a', 'b')
445
- jest.advanceTimersByTime(100)
446
- expect(fn).toHaveBeenCalledWith('a', 'b')
447
- })
448
- })
449
-
450
- describe('getName', () => {
451
- test('regular named function string', () => {
452
- expect(getName('function sum (a, b) { return a + b }')).toBe('sum')
453
- })
454
- test('async function string', () => {
455
- expect(getName('async function fetchData () { }')).toBe('fetchData')
456
- })
457
- test('anonymous function string', () => {
458
- expect(getName('function (a, b) { return a + b }')).toBeUndefined()
459
- })
460
- test('arrow function returns undefined', () => {
461
- expect(getName('(a, b) => a + b')).toBeUndefined()
462
- })
463
- test('actual function reference', () => {
464
- function myFunc () {}
465
- expect(getName(myFunc)).toBe('myFunc')
466
- })
467
- test('arrow function reference returns undefined (not inferred property name)', () => {
468
- const obj = { code: (a, b) => a + b }
469
- expect(getName(obj.code)).toBeUndefined()
470
- })
471
- test('anonymous function reference returns undefined', () => {
472
- const fn = function () {}
473
- // When assigned to a variable, .name is inferred, but toString() doesn't
474
- // start with 'function <name>' — it starts with 'function ()'
475
- // For this case getName should still work because fn.name = 'fn'
476
- // and fn.toString() starts with 'function'
477
- expect(getName(fn)).toBe('fn')
478
- })
479
- test('async function reference', () => {
480
- async function fetchData () {}
481
- expect(getName(fetchData)).toBe('fetchData')
482
- })
483
- test('async arrow function returns undefined', () => {
484
- const obj = { code: async (a) => a }
485
- expect(getName(obj.code)).toBeUndefined()
486
- })
487
- test('non-string non-function returns undefined', () => {
488
- expect(getName(42)).toBeUndefined()
489
- expect(getName(null)).toBeUndefined()
490
- expect(getName(undefined)).toBeUndefined()
491
- })
492
- })
493
-
494
- describe('isWorkerInitMessage', () => {
495
- test('returns true for first model payload with code', () => {
496
- expect(isWorkerInitMessage({ code: 'function run () {}' }, false)).toBe(true)
497
- })
498
-
499
- test('returns true for first model payload with url', () => {
500
- expect(isWorkerInitMessage({ url: '/apps/test/model.js' }, false)).toBe(true)
501
- })
502
-
503
- test('returns false after worker was initialized', () => {
504
- expect(isWorkerInitMessage({ url: '/apps/test/input.csv' }, true)).toBe(false)
505
- })
506
-
507
- test('returns false for non-model execution payloads', () => {
508
- expect(isWorkerInitMessage({ input: 1, caller: 'run' }, false)).toBe(false)
509
- })
510
- })
511
-
512
- describe('getProgressState', () => {
513
- test('returns indeterminate mode for null', () => {
514
- expect(getProgressState(null)).toEqual({ mode: 'indeterminate', value: null })
515
- })
516
-
517
- test('returns determinate mode for numeric values', () => {
518
- expect(getProgressState(42)).toEqual({ mode: 'determinate', value: 42 })
519
- expect(getProgressState('25')).toEqual({ mode: 'determinate', value: 25 })
520
- })
521
-
522
- test('clamps determinate values to [0, 100]', () => {
523
- expect(getProgressState(-5)).toEqual({ mode: 'determinate', value: 0 })
524
- expect(getProgressState(120)).toEqual({ mode: 'determinate', value: 100 })
525
- })
526
-
527
- test('returns null for non-numeric non-null values', () => {
528
- expect(getProgressState('unknown')).toBeNull()
529
- expect(getProgressState(undefined)).toBeNull()
530
- })
531
- })
532
-
533
- describe('shouldContinueInterval', () => {
534
- test('returns true only for active non-cancelled run caller', () => {
535
- expect(shouldContinueInterval(1000, true, false, 'run')).toBe(true)
536
- })
537
-
538
- test('returns false when run is cancelled', () => {
539
- expect(shouldContinueInterval(1000, true, true, 'run')).toBe(false)
540
- })
541
-
542
- test('returns false for non-run callers', () => {
543
- expect(shouldContinueInterval(1000, true, false, 'reactive')).toBe(false)
544
- expect(shouldContinueInterval(1000, true, false, 'autorun')).toBe(false)
545
- })
546
-
547
- test('returns false when interval is missing or run is inactive', () => {
548
- expect(shouldContinueInterval(0, true, false, 'run')).toBe(false)
549
- expect(shouldContinueInterval(null, true, false, 'run')).toBe(false)
550
- expect(shouldContinueInterval(1000, false, false, 'run')).toBe(false)
551
- })
552
- })
553
-
554
- describe('getModelFuncJS', () => {
555
- const mockApp = { log: jest.fn() }
556
-
557
- test('wraps function with object container (default)', async () => {
558
- const target = (inputs) => inputs.a + inputs.b
559
- const wrapped = await getModelFuncJS({ type: 'function' }, target, mockApp)
560
- expect(wrapped({ a: 1, b: 2 })).toBe(3)
561
- })
562
-
563
- test('wraps function with args container', async () => {
564
- const target = (a, b) => a + b
565
- const wrapped = await getModelFuncJS(
566
- { type: 'function', container: 'args' },
567
- target,
568
- mockApp
569
- )
570
- // Object values spread as args
571
- expect(wrapped({ a: 10, b: 20 })).toBe(30)
572
- })
573
-
574
- test('passes app context for object container', async () => {
575
- const ctxApp = {
576
- log: jest.fn(),
577
- isCancelled: jest.fn(() => true)
578
- }
579
- const target = (inputs, ctx) => ({ value: inputs.a, cancelled: ctx.isCancelled() })
580
- const wrapped = await getModelFuncJS({ type: 'function' }, target, ctxApp)
581
- expect(wrapped({ a: 7 })).toEqual({ value: 7, cancelled: true })
582
- expect(ctxApp.isCancelled).toHaveBeenCalledTimes(1)
583
- })
584
-
585
- test('wraps class type', async () => {
586
- class Calculator {
587
- predict (inputs) { return inputs.x * 2 }
588
- }
589
- const wrapped = await getModelFuncJS({ type: 'class' }, Calculator, mockApp)
590
- expect(wrapped({ x: 5 })).toBe(10)
591
- })
592
-
593
- test('class type with custom method', async () => {
594
- class Calculator {
595
- double (a, b) { return (a + b) * 2 }
596
- }
597
- const wrapped = await getModelFuncJS(
598
- { type: 'class', method: 'double', container: 'args' },
599
- Calculator,
600
- mockApp
601
- )
602
- expect(wrapped({ a: 3, b: 4 })).toBe(14)
603
- })
604
- })
605
-
606
- describe('getModelFuncAPI', () => {
607
- const mockLog = jest.fn()
608
-
609
- beforeEach(() => {
610
- global.fetch = jest.fn()
611
- })
612
- afterEach(() => {
613
- delete global.fetch
614
- })
615
-
616
- test('creates GET function', async () => {
617
- global.fetch.mockResolvedValue({ json: () => Promise.resolve({ result: 42 }) })
618
- const fn = getModelFuncAPI({ type: 'get', url: 'https://api.example.com/run' }, mockLog)
619
- const result = await fn({ a: 1, b: 2 })
620
- expect(result).toEqual({ result: 42 })
621
- expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/run?a=1&b=2')
622
- })
623
-
624
- test('creates POST function', async () => {
625
- global.fetch.mockResolvedValue({ json: () => Promise.resolve({ result: 99 }) })
626
- const fn = getModelFuncAPI({ type: 'post', url: 'https://api.example.com/run' }, mockLog)
627
- const result = await fn({ x: 10 })
628
- expect(result).toEqual({ result: 99 })
629
- expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/run', {
630
- method: 'POST',
631
- headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
632
- body: JSON.stringify({ x: 10 })
633
- })
634
- })
635
- })
636
-
637
- describe('validateSchema', () => {
638
- test('returns empty report for valid minimal schema', () => {
639
- const report = validateSchema({
640
- model: {
641
- code: 'function sum (a, b) { return a + b }'
642
- },
643
- inputs: [
644
- { name: 'a', type: 'int' },
645
- { name: 'b', type: 'int' }
646
- ]
647
- })
648
- expect(report.errors).toEqual([])
649
- expect(report.warnings).toEqual([])
650
- })
651
-
652
- test('returns error when schema has no model and no view/render', () => {
653
- const report = validateSchema({
654
- inputs: [{ name: 'x', type: 'int' }]
655
- })
656
- expect(report.errors.length).toBeGreaterThan(0)
657
- expect(report.errors.join(' ')).toContain('model')
658
- })
659
-
660
- test('returns warning when schema uses view/render without model', () => {
661
- const report = validateSchema({
662
- render: {
663
- type: 'function',
664
- code: 'function render () {}'
665
- }
666
- })
667
- expect(report.errors).toEqual([])
668
- expect(report.warnings.length).toBeGreaterThan(0)
669
- })
670
-
671
- test('returns error for non-array inputs', () => {
672
- const report = validateSchema({
673
- model: { code: 'function run () {}' },
674
- inputs: { name: 'a', type: 'int' }
675
- })
676
- expect(report.errors.length).toBeGreaterThan(0)
677
- expect(report.errors.join(' ')).toContain('inputs')
678
- })
679
-
680
- test('returns warnings for unsupported input/model options', () => {
681
- const report = validateSchema({
682
- model: { type: 'unsupported-model', timeout: -1 },
683
- inputs: [{ name: 123, type: 'unsupported-input', alias: [1, 2, 3] }]
684
- })
685
- expect(report.errors).toEqual([])
686
- expect(report.warnings.length).toBeGreaterThan(0)
687
- expect(report.warnings.join(' ')).toContain('not recognized')
688
- })
689
-
690
- test('accepts file input raw flag when it is boolean', () => {
691
- const report = validateSchema({
692
- model: { code: 'function run () {}' },
693
- inputs: [{ name: 'file', type: 'file', raw: true }]
694
- })
695
- expect(report.errors).toEqual([])
696
- expect(report.warnings).toEqual([])
697
- })
698
-
699
- test('warns when file input raw flag is not boolean', () => {
700
- const report = validateSchema({
701
- model: { code: 'function run () {}' },
702
- inputs: [{ name: 'file', type: 'file', raw: 'yes' }]
703
- })
704
- expect(report.errors).toEqual([])
705
- expect(report.warnings.join(' ')).toContain('raw should be a boolean')
706
- })
707
-
708
- test('accepts file input stream flag when it is boolean', () => {
709
- const report = validateSchema({
710
- model: { code: 'function run () {}' },
711
- inputs: [{ name: 'file', type: 'file', stream: true }]
712
- })
713
- expect(report.errors).toEqual([])
714
- expect(report.warnings).toEqual([])
715
- })
716
-
717
- test('warns when file input stream flag is not boolean', () => {
718
- const report = validateSchema({
719
- model: { code: 'function run () {}' },
720
- inputs: [{ name: 'file', type: 'file', stream: 'yes' }]
721
- })
722
- expect(report.errors).toEqual([])
723
- expect(report.warnings.join(' ')).toContain('stream should be a boolean')
724
- })
725
-
726
- test('warns when stream flag is used on non-file input', () => {
727
- const report = validateSchema({
728
- model: { code: 'function run () {}' },
729
- inputs: [{ name: 'text', type: 'string', stream: true }]
730
- })
731
- expect(report.errors).toEqual([])
732
- expect(report.warnings.join(' ')).toContain('stream is supported only for file inputs')
733
- })
734
- })
735
-
736
- describe('getUrlParam', () => {
737
- function makeParams (obj) {
738
- return new URLSearchParams(obj)
739
- }
740
-
741
- test('matches by input name', () => {
742
- const params = makeParams({ myInput: '42' })
743
- expect(getUrlParam(params, { name: 'myInput' })).toBe('42')
744
- })
745
-
746
- test('matches by sanitized name', () => {
747
- const params = makeParams({ my_input: '42' })
748
- expect(getUrlParam(params, { name: 'my input' })).toBe('42')
749
- })
750
-
751
- test('matches by string alias', () => {
752
- const params = makeParams({ f: 'data.csv' })
753
- expect(getUrlParam(params, { name: 'file', alias: 'f' })).toBe('data.csv')
754
- })
755
-
756
- test('matches by array alias', () => {
757
- const params = makeParams({ src: 'data.csv' })
758
- expect(getUrlParam(params, { name: 'file', alias: ['f', 'src'] })).toBe('data.csv')
759
- })
760
-
761
- test('returns null when no match', () => {
762
- const params = makeParams({ other: '1' })
763
- expect(getUrlParam(params, { name: 'file' })).toBeNull()
764
- })
765
-
766
- test('returns null when no alias match', () => {
767
- const params = makeParams({ other: '1' })
768
- expect(getUrlParam(params, { name: 'file', alias: ['f', 'data'] })).toBeNull()
769
- })
770
-
771
- test('prefers name over alias', () => {
772
- const params = makeParams({ file: 'direct', f: 'alias' })
773
- expect(getUrlParam(params, { name: 'file', alias: 'f' })).toBe('direct')
774
- })
775
- })
776
-
777
- describe('coerceParam', () => {
778
- test('coerces to number', () => {
779
- expect(coerceParam('42', 'number', 'x')).toBe(42)
780
- expect(coerceParam('3.14', 'number', 'x')).toBeCloseTo(3.14)
781
- })
782
-
783
- test('coerces to boolean', () => {
784
- expect(coerceParam('true', 'boolean', 'x')).toBe(true)
785
- expect(coerceParam('false', 'boolean', 'x')).toBe(false)
786
- expect(coerceParam('yes', 'boolean', 'x')).toBe(false)
787
- })
788
-
789
- test('coerces to JSON', () => {
790
- expect(coerceParam('{"a":1}', 'json', 'x')).toEqual({ a: 1 })
791
- expect(coerceParam('[1,2,3]', 'json', 'x')).toEqual([1, 2, 3])
792
- })
793
-
794
- test('returns original string for invalid JSON', () => {
795
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
796
- expect(coerceParam('not json', 'json', 'x')).toBe('not json')
797
- expect(consoleSpy).toHaveBeenCalled()
798
- consoleSpy.mockRestore()
799
- })
800
-
801
- test('returns string as-is for unknown types', () => {
802
- expect(coerceParam('hello', 'string', 'x')).toBe('hello')
803
- expect(coerceParam('hello', 'text', 'x')).toBe('hello')
804
- })
805
- })
806
-
807
- describe('error scenarios', () => {
808
- describe('validateSchema edge cases', () => {
809
- test('handles empty schema', () => {
810
- const report = validateSchema({})
811
- expect(report.errors.length).toBeGreaterThan(0)
812
- })
813
-
814
- test('handles null/undefined inputs gracefully', () => {
815
- const report = validateSchema({
816
- model: { code: 'function run () {}' },
817
- inputs: null
818
- })
819
- // null inputs should not crash
820
- expect(report).toBeDefined()
821
- })
822
-
823
- test('warns on multiple issues at once', () => {
824
- const report = validateSchema({
825
- model: { type: 'bad-type', timeout: -100 },
826
- inputs: [
827
- { name: 123, type: 'invalid-type', alias: [1], raw: 'yes', stream: 'yes' }
828
- ]
829
- })
830
- expect(report.warnings.length).toBeGreaterThan(3)
831
- })
832
- })
833
-
834
- describe('getName edge cases', () => {
835
- test('handles empty string', () => {
836
- expect(getName('')).toBeUndefined()
837
- })
838
-
839
- test('handles whitespace-only string', () => {
840
- expect(getName(' ')).toBeUndefined()
841
- })
842
-
843
- test('handles object input', () => {
844
- expect(getName({})).toBeUndefined()
845
- })
846
- })
847
-
848
- describe('sanitizeName edge cases', () => {
849
- test('handles empty string', () => {
850
- expect(sanitizeName('')).toBe('')
851
- })
852
-
853
- test('handles string with only special chars', () => {
854
- const result = sanitizeName('!@#$%')
855
- expect(typeof result).toBe('string')
856
- })
857
-
858
- test('handles numbers as input', () => {
859
- expect(sanitizeName('123abc')).toBe('123abc')
860
- })
861
- })
862
-
863
- describe('getModelFuncJS error handling', () => {
864
- const mockApp = { log: jest.fn() }
865
-
866
- test('class without predict method throws', async () => {
867
- class NoPredict {}
868
- await expect(async () => {
869
- const wrapped = await getModelFuncJS({ type: 'class' }, NoPredict, mockApp)
870
- wrapped({ x: 1 })
871
- }).rejects.toThrow()
872
- })
873
- })
874
-
875
- describe('toWorkerSerializable edge cases', () => {
876
- test('handles empty object', () => {
877
- const result = toWorkerSerializable({})
878
- expect(result).toEqual({})
879
- })
880
-
881
- test('handles nested objects without binary', () => {
882
- const input = { a: 1, b: { c: 'hello' } }
883
- const result = toWorkerSerializable(input)
884
- expect(result).toEqual(input)
885
- })
886
-
887
- test('handles null values', () => {
888
- const input = { a: null, b: undefined }
889
- const result = toWorkerSerializable(input)
890
- expect(result.a).toBeNull()
891
- })
892
- })
893
-
894
- describe('containsBinaryPayload edge cases', () => {
895
- test('returns false for empty object', () => {
896
- expect(containsBinaryPayload({})).toBe(false)
897
- })
898
-
899
- test('returns false for string values', () => {
900
- expect(containsBinaryPayload({ a: 'hello', b: '123' })).toBe(false)
901
- })
902
-
903
- test('returns false for null/undefined', () => {
904
- expect(containsBinaryPayload(null)).toBe(false)
905
- expect(containsBinaryPayload(undefined)).toBe(false)
906
- })
907
- })
908
- })