@jseeio/jsee 0.3.7 → 0.3.9

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.
@@ -1,16 +1,16 @@
1
1
  require('expect-puppeteer')
2
+ const path = require('path')
2
3
 
3
4
  page.setDefaultTimeout(10000)
4
5
 
5
- // Tests require a server to be running on port 8080
6
- // If you have php installed: php -S localhost:8080
7
- // Python: python -m http.server 8080
8
- // Node: npm install -g http-server && http-server -p 8080
6
+ // Server on port 8484 is auto-started by jest-puppeteer (see jest-puppeteer.config.js)
9
7
 
10
- const port = 8080
8
+ const port = 8484
11
9
  const urlSchema = (name) => `http://localhost:${port}/load/?s=/test/${name}.schema.json`
12
10
  const urlHTML = (name) => `http://localhost:${port}/test/${name}.html`
13
11
  const urlQuery = (schema) => `http://localhost:${port}/load/?s=${JSON.stringify(schema)}`
12
+ const urlQueryEscaped = (schema) => `http://localhost:${port}/load/?s=${encodeURIComponent(JSON.stringify(schema))}`
13
+ const uploadFixture = path.resolve(__dirname, 'fixtures', 'upload-sample.csv')
14
14
 
15
15
  describe('Initial test', () => {
16
16
  beforeAll(async () => {
@@ -52,12 +52,12 @@ describe('Initial test (worker)', () => {
52
52
  describe('Minimal examples', () => {
53
53
  const schema = {
54
54
  'model': {
55
- 'code': 'function (a, b) { return a / b }', // TODO: check '+'
55
+ 'code': 'function (a, b) { return a / b }',
56
56
  }
57
57
  }
58
58
  test('Code only (text) (main window)', async () => {
59
59
  schema.model.worker = false
60
- await page.goto(urlQuery(schema))
60
+ await page.goto(urlQueryEscaped(schema))
61
61
  await expect(page).toFill('#a', '100')
62
62
  await expect(page).toFill('#b', '4')
63
63
  await expect(page).toClick('button', { text: 'Run' })
@@ -115,6 +115,42 @@ describe('Load code directly', () => {
115
115
  await expect(page).toClick('button', { text: 'Run' })
116
116
  await expect(page).toMatchTextContent('15')
117
117
  })
118
+
119
+ test('Window infers function type for URL-loaded JS when type is omitted', async () => {
120
+ const schema = {
121
+ model: {
122
+ name: 'sum',
123
+ container: 'args',
124
+ url: '/test/example-sum.js',
125
+ worker: false
126
+ },
127
+ inputs: [
128
+ { name: 'a', type: 'int', default: 8 },
129
+ { name: 'b', type: 'int', default: 7 }
130
+ ]
131
+ }
132
+ await page.goto(urlQueryEscaped(schema))
133
+ await expect(page).toClick('button', { text: 'Run' })
134
+ await expect(page).toMatchTextContent('15')
135
+ })
136
+
137
+ test('Worker infers function type for URL-loaded JS when type is omitted', async () => {
138
+ const schema = {
139
+ model: {
140
+ name: 'sum',
141
+ container: 'args',
142
+ url: '/test/example-sum.js',
143
+ worker: true
144
+ },
145
+ inputs: [
146
+ { name: 'a', type: 'int', default: 8 },
147
+ { name: 'b', type: 'int', default: 7 }
148
+ ]
149
+ }
150
+ await page.goto(urlQueryEscaped(schema))
151
+ await expect(page).toClick('button', { text: 'Run' })
152
+ await expect(page).toMatchTextContent('15')
153
+ })
118
154
  })
119
155
 
120
156
  describe('Classes', () => {
@@ -133,14 +169,14 @@ describe('Classes', () => {
133
169
 
134
170
  test('Window', async () => {
135
171
  schema.model.worker = false
136
- await page.goto(urlQuery(schema))
172
+ await page.goto(urlQueryEscaped(schema))
137
173
  await expect(page).toClick('button', { text: 'Run' })
138
174
  await expect(page).toMatchTextContent('200')
139
175
  })
140
176
 
141
177
  test('Worker', async () => {
142
178
  schema.model.worker = true
143
- await page.goto(urlQuery(schema))
179
+ await page.goto(urlQueryEscaped(schema))
144
180
  await expect(page).toClick('button', { text: 'Run' })
145
181
  await expect(page).toMatchTextContent('200')
146
182
  })
@@ -154,7 +190,7 @@ describe('Some edge cases', () => {
154
190
  }
155
191
  }
156
192
  schema.model.worker = false
157
- await page.goto(urlQuery(schema))
193
+ await page.goto(urlQueryEscaped(schema))
158
194
  await expect(page).toFill('#a', '0')
159
195
  await expect(page).toFill('#b', '0')
160
196
  await expect(page).toClick('button', { text: 'Run' })
@@ -174,21 +210,34 @@ describe('Imports', () => {
174
210
  }
175
211
  `
176
212
  },
177
- 'imports': 'lodash@4.17.21/lodash.min.js',
213
+ 'imports': `http://localhost:${port}/test/fixtures/lodash-like.js`,
178
214
  'inputs': [
179
215
  { 'name': 'str', 'type': 'string', 'default': 'FooBar' },
180
216
  ]
181
217
  }
182
218
  test('Window', async () => {
183
219
  schema.model.worker = false
184
- await page.goto(urlQuery(schema))
220
+ await page.goto(urlQueryEscaped(schema))
185
221
  await expect(page).toClick('button', { text: 'Run' })
186
222
  await expect(page).toMatchTextContent('foo-bar')
187
223
  })
188
224
  test('Worker', async () => {
189
225
  schema.model.worker = true
190
- await page.goto(urlQuery(schema))
191
- await expect(page).toClick('button', { text: 'Run' })
226
+ await page.goto(urlQueryEscaped(schema))
227
+ const runUntilReady = async (attempts=3) => {
228
+ if (attempts <= 0) {
229
+ return false
230
+ }
231
+ await expect(page).toClick('button', { text: 'Run' })
232
+ try {
233
+ await page.waitForFunction(() => document.body.innerText.includes('foo-bar'), { timeout: 5000 })
234
+ return true
235
+ } catch (error) {
236
+ return runUntilReady(attempts - 1)
237
+ }
238
+ }
239
+ const ready = await runUntilReady()
240
+ expect(ready).toBe(true)
192
241
  await expect(page).toMatchTextContent('foo-bar')
193
242
  // await (new Promise(resolve => setTimeout(resolve, 1000)))
194
243
  })
@@ -209,14 +258,14 @@ describe('Buttons, button titles and caller', () => {
209
258
  }
210
259
  test('Window', async () => {
211
260
  schema.model.worker = false
212
- await page.goto(urlQuery(schema))
261
+ await page.goto(urlQueryEscaped(schema))
213
262
  await expect(page).toMatchTextContent('Test Button')
214
263
  await expect(page).toClick('button', { text: 'Test Button' })
215
264
  await expect(page).toMatchTextContent('test_button')
216
265
  })
217
266
  test('Worker', async () => {
218
267
  schema.model.worker = true
219
- await page.goto(urlQuery(schema))
268
+ await page.goto(urlQueryEscaped(schema))
220
269
  await expect(page).toClick('button', { text: 'Test Button' })
221
270
  await expect(page).toMatchTextContent('test_button')
222
271
  })
@@ -234,4 +283,321 @@ describe('Pipeline', () => {
234
283
  await expect(page).toClick('button', { text: 'Run' })
235
284
  await expect(page).toMatchTextContent((Math.pow((a + b), 2) + 2).toString())
236
285
  })
237
- })
286
+ })
287
+
288
+ describe('File uploads', () => {
289
+ test('default file input reads uploaded text', async () => {
290
+ const schema = {
291
+ model: {
292
+ worker: false,
293
+ code: `function filePreview (inputs) {
294
+ return {
295
+ preview: inputs.file.split('\\n')[0]
296
+ }
297
+ }`
298
+ },
299
+ inputs: [
300
+ { name: 'file', type: 'file' }
301
+ ]
302
+ }
303
+ await page.goto(urlQueryEscaped(schema))
304
+ await page.waitForSelector('#vfp-filePicker')
305
+ const fileInput = await page.$('#vfp-filePicker')
306
+ await fileInput.uploadFile(uploadFixture)
307
+ await page.waitForFunction(() => document.body.innerText.includes('Selected 1 file(s)'))
308
+ await expect(page).toClick('button', { text: 'Run' })
309
+ await expect(page).toMatchTextContent('name,age')
310
+ })
311
+
312
+ test('raw file input passes File object (not text content)', async () => {
313
+ const schema = {
314
+ model: {
315
+ worker: false,
316
+ code: `function fileRawMeta (inputs) {
317
+ const isFileObject = !!inputs.file
318
+ && (typeof inputs.file === 'object')
319
+ && (typeof inputs.file.name === 'string')
320
+ && (typeof inputs.file.text === 'function')
321
+ return {
322
+ is_file_object: isFileObject,
323
+ is_string: typeof inputs.file === 'string',
324
+ file_name: inputs.file && inputs.file.name ? inputs.file.name : '',
325
+ content_prefix: typeof inputs.file === 'string' ? inputs.file.slice(0, 10) : 'NONE'
326
+ }
327
+ }`
328
+ },
329
+ inputs: [
330
+ { name: 'file', type: 'file', raw: true }
331
+ ]
332
+ }
333
+ await page.goto(urlQueryEscaped(schema))
334
+ await page.waitForSelector('#vfp-filePicker')
335
+ const fileInput = await page.$('#vfp-filePicker')
336
+ await fileInput.uploadFile(uploadFixture)
337
+ await page.waitForFunction(() => document.body.innerText.includes('Selected 1 file(s)'))
338
+ await expect(page).toClick('button', { text: 'Run' })
339
+ await expect(page).toMatchTextContent('is_file_object')
340
+ await expect(page).toMatchTextContent('true')
341
+ await expect(page).toMatchTextContent('file_name')
342
+ await expect(page).toMatchTextContent('upload-sample.csv')
343
+ await expect(page).toMatchTextContent('content_prefix')
344
+ await expect(page).toMatchTextContent('NONE')
345
+ })
346
+
347
+ test('raw url input passes URL handle (not fetched text content)', async () => {
348
+ const schema = {
349
+ model: {
350
+ worker: false,
351
+ code: `function fileRawUrlMeta (inputs) {
352
+ const isUrlHandle = !!inputs.file
353
+ && (typeof inputs.file === 'object')
354
+ && (inputs.file.kind === 'url')
355
+ && (typeof inputs.file.url === 'string')
356
+ return {
357
+ is_url_handle: isUrlHandle,
358
+ is_string: typeof inputs.file === 'string',
359
+ url_value: isUrlHandle ? inputs.file.url : '',
360
+ content_prefix: typeof inputs.file === 'string' ? inputs.file.slice(0, 10) : 'NONE'
361
+ }
362
+ }`
363
+ },
364
+ inputs: [
365
+ { name: 'file', type: 'file', raw: true }
366
+ ]
367
+ }
368
+ const rawUrl = 'http://127.0.0.1:9999/nope.csv'
369
+ await page.goto(urlQueryEscaped(schema))
370
+ await expect(page).toClick('button', { text: 'From URL' })
371
+ await page.waitForSelector('input.vfp-urlInput[type="text"]')
372
+ await page.click('input.vfp-urlInput[type="text"]', { clickCount: 3 })
373
+ await page.type('input.vfp-urlInput[type="text"]', rawUrl)
374
+ await page.evaluate(() => {
375
+ const loadButton = Array.from(document.querySelectorAll('button'))
376
+ .find(btn => btn.textContent.trim() === 'Load')
377
+ if (!loadButton) throw new Error('Load button not found')
378
+ loadButton.click()
379
+ })
380
+ await expect(page).toClick('button', { text: 'Run' })
381
+ await page.waitForFunction((expectedUrl) => {
382
+ return document.querySelector('#outputs').innerText.includes(expectedUrl)
383
+ }, {}, rawUrl)
384
+ await expect(page).toMatchTextContent('is_url_handle')
385
+ await expect(page).toMatchTextContent('true')
386
+ await expect(page).toMatchTextContent('url_value')
387
+ await expect(page).toMatchTextContent(rawUrl)
388
+ await expect(page).toMatchTextContent('content_prefix')
389
+ await expect(page).toMatchTextContent('NONE')
390
+ })
391
+
392
+ test('file query param auto-loads URL without clicking Load', async () => {
393
+ const schema = {
394
+ model: {
395
+ worker: false,
396
+ code: `function filePreviewFromQuery (inputs) {
397
+ return {
398
+ preview: inputs.file.split('\\n')[0]
399
+ }
400
+ }`
401
+ },
402
+ inputs: [
403
+ { name: 'file', type: 'file' }
404
+ ]
405
+ }
406
+ const fileUrl = `http://localhost:${port}/test/fixtures/upload-sample.csv`
407
+ await page.goto(`${urlQueryEscaped(schema)}&file=${encodeURIComponent(fileUrl)}`)
408
+ await page.waitForFunction(() => document.body.innerText.includes('Loaded from URL:'), { timeout: 5000 })
409
+ await expect(page).toClick('button', { text: 'Run' })
410
+ await expect(page).toMatchTextContent('preview')
411
+ await expect(page).toMatchTextContent('name,age')
412
+ })
413
+ })
414
+
415
+ describe('Streamed file inputs', () => {
416
+ test('main thread receives uploaded file as async iterable stream', async () => {
417
+ const schema = {
418
+ model: {
419
+ worker: false,
420
+ code: `async function streamMainFile (inputs) {
421
+ const reader = inputs.file
422
+ const isIterable = !!reader
423
+ && typeof reader[Symbol.asyncIterator] === 'function'
424
+ && typeof reader.text === 'function'
425
+ && typeof reader.bytes === 'function'
426
+ && typeof reader.lines === 'function'
427
+ if (!isIterable) {
428
+ return { is_iterable: false, header: 'NONE' }
429
+ }
430
+ const text = await reader.text()
431
+ return {
432
+ is_iterable: true,
433
+ header: text.split('\\n')[0]
434
+ }
435
+ }`
436
+ },
437
+ inputs: [
438
+ { name: 'file', type: 'file', raw: true, stream: true }
439
+ ]
440
+ }
441
+ await page.goto(urlQueryEscaped(schema))
442
+ await page.waitForSelector('#vfp-filePicker')
443
+ const fileInput = await page.$('#vfp-filePicker')
444
+ await fileInput.uploadFile(uploadFixture)
445
+ await expect(page).toClick('button', { text: 'Run' })
446
+ await expect(page).toMatchTextContent('is_iterable')
447
+ await expect(page).toMatchTextContent('true')
448
+ await expect(page).toMatchTextContent('header')
449
+ await expect(page).toMatchTextContent('name,age')
450
+ })
451
+
452
+ test('stream metadata is preserved for downstream pipeline models', async () => {
453
+ const schema = {
454
+ model: [
455
+ {
456
+ worker: false,
457
+ code: `function streamStageOne (inputs) {
458
+ return {
459
+ stage1_name: inputs.file && inputs.file.name ? inputs.file.name : 'NONE'
460
+ }
461
+ }`
462
+ },
463
+ {
464
+ worker: false,
465
+ code: `function streamStageTwo (inputs) {
466
+ return {
467
+ stage2_name: inputs.file && inputs.file.name ? inputs.file.name : 'NONE',
468
+ stage2_size: inputs.file && typeof inputs.file.size === 'number' ? inputs.file.size : -1
469
+ }
470
+ }`
471
+ }
472
+ ],
473
+ inputs: [
474
+ { name: 'file', type: 'file', raw: true, stream: true }
475
+ ]
476
+ }
477
+ await page.goto(urlQueryEscaped(schema))
478
+ await page.waitForSelector('#vfp-filePicker')
479
+ const fileInput = await page.$('#vfp-filePicker')
480
+ await fileInput.uploadFile(uploadFixture)
481
+ await expect(page).toClick('button', { text: 'Run' })
482
+ await expect(page).toMatchTextContent('stage1_name')
483
+ await expect(page).toMatchTextContent('stage2_name')
484
+ await expect(page).toMatchTextContent('upload-sample.csv')
485
+ await expect(page).toMatchTextContent('stage2_size')
486
+ })
487
+
488
+ test('main thread receives URL source as async iterable stream', async () => {
489
+ const schema = {
490
+ model: {
491
+ worker: false,
492
+ code: `async function streamMainUrl (inputs) {
493
+ const reader = inputs.file
494
+ const text = await reader.text()
495
+ return {
496
+ is_iterable: typeof reader[Symbol.asyncIterator] === 'function',
497
+ header: text.split('\\n')[0]
498
+ }
499
+ }`
500
+ },
501
+ inputs: [
502
+ { name: 'file', type: 'file', raw: true, stream: true }
503
+ ]
504
+ }
505
+ const sampleUrl = `http://localhost:${port}/test/fixtures/upload-sample.csv`
506
+ await page.goto(urlQueryEscaped(schema))
507
+ await expect(page).toClick('button', { text: 'From URL' })
508
+ await page.waitForSelector('input.vfp-urlInput[type="text"]')
509
+ await page.click('input.vfp-urlInput[type="text"]', { clickCount: 3 })
510
+ await page.type('input.vfp-urlInput[type="text"]', sampleUrl)
511
+ await page.evaluate(() => {
512
+ const loadButton = Array.from(document.querySelectorAll('button'))
513
+ .find(btn => btn.textContent.trim() === 'Load')
514
+ if (!loadButton) throw new Error('Load button not found')
515
+ loadButton.click()
516
+ })
517
+ await expect(page).toClick('button', { text: 'Run' })
518
+ await page.waitForFunction(() => {
519
+ const outputs = document.querySelector('#outputs')
520
+ return outputs && outputs.innerText.includes('header')
521
+ }, { timeout: 5000 })
522
+ await expect(page).toMatchTextContent('is_iterable')
523
+ await expect(page).toMatchTextContent('true')
524
+ await expect(page).toMatchTextContent('header')
525
+ await expect(page).toMatchTextContent('name,age')
526
+ })
527
+
528
+ test('worker receives uploaded file as async iterable stream', async () => {
529
+ const schema = {
530
+ model: {
531
+ worker: true,
532
+ code: `async function streamWorkerFile (inputs) {
533
+ const reader = inputs.file
534
+ const lines = []
535
+ for await (const line of reader.lines()) {
536
+ lines.push(line)
537
+ }
538
+ return {
539
+ is_iterable: typeof reader.text === 'function',
540
+ header: lines[0] || 'NONE'
541
+ }
542
+ }`
543
+ },
544
+ inputs: [
545
+ { name: 'file', type: 'file', raw: true, stream: true }
546
+ ]
547
+ }
548
+ await page.goto(urlQueryEscaped(schema))
549
+ await page.waitForSelector('#vfp-filePicker')
550
+ const fileInput = await page.$('#vfp-filePicker')
551
+ await fileInput.uploadFile(uploadFixture)
552
+ await expect(page).toClick('button', { text: 'Run' })
553
+ await expect(page).toMatchTextContent('is_iterable')
554
+ await expect(page).toMatchTextContent('true')
555
+ await expect(page).toMatchTextContent('header')
556
+ await expect(page).toMatchTextContent('name,age')
557
+ })
558
+
559
+ test('worker receives URL source as async iterable stream', async () => {
560
+ const schema = {
561
+ model: {
562
+ worker: true,
563
+ code: `async function streamWorkerUrl (inputs) {
564
+ const reader = inputs.file
565
+ const decoder = new TextDecoder()
566
+ let text = ''
567
+ for await (const chunk of reader) {
568
+ text += decoder.decode(chunk, { stream: true })
569
+ }
570
+ text += decoder.decode()
571
+ return {
572
+ is_iterable: typeof reader.bytes === 'function',
573
+ header: text.split('\\n')[0]
574
+ }
575
+ }`
576
+ },
577
+ inputs: [
578
+ { name: 'file', type: 'file', raw: true, stream: true }
579
+ ]
580
+ }
581
+ const sampleUrl = `http://localhost:${port}/test/fixtures/upload-sample.csv`
582
+ await page.goto(urlQueryEscaped(schema))
583
+ await expect(page).toClick('button', { text: 'From URL' })
584
+ await page.waitForSelector('input.vfp-urlInput[type="text"]')
585
+ await page.click('input.vfp-urlInput[type="text"]', { clickCount: 3 })
586
+ await page.type('input.vfp-urlInput[type="text"]', sampleUrl)
587
+ await page.evaluate(() => {
588
+ const loadButton = Array.from(document.querySelectorAll('button'))
589
+ .find(btn => btn.textContent.trim() === 'Load')
590
+ if (!loadButton) throw new Error('Load button not found')
591
+ loadButton.click()
592
+ })
593
+ await expect(page).toClick('button', { text: 'Run' })
594
+ await page.waitForFunction(() => {
595
+ const outputs = document.querySelector('#outputs')
596
+ return outputs && outputs.innerText.includes('header')
597
+ }, { timeout: 5000 })
598
+ await expect(page).toMatchTextContent('is_iterable')
599
+ await expect(page).toMatchTextContent('true')
600
+ await expect(page).toMatchTextContent('header')
601
+ await expect(page).toMatchTextContent('name,age')
602
+ })
603
+ })
@@ -2,12 +2,9 @@ require('expect-puppeteer')
2
2
 
3
3
  page.setDefaultTimeout(30000)
4
4
 
5
- // Tests require a server to be running on port 8080
6
- // If you have php installed: php -S localhost:8080
7
- // Python: python -m http.server 8080
8
- // Node: npm install -g http-server && http-server -p 8080
5
+ // Server on port 8484 is auto-started by jest-puppeteer (see jest-puppeteer.config.js)
9
6
 
10
- const port = 8080
7
+ const port = 8484
11
8
  const urlSchema = (name) => `http://localhost:${port}/load/?s=/test/${name}.schema.json`
12
9
  const urlHTML = (name) => `http://localhost:${port}/test/${name}.html`
13
10
  const urlQuery = (schema) => `http://localhost:${port}/load/?s=${JSON.stringify(schema)}`
@@ -0,0 +1,126 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const gen = require('../../src/cli')
5
+ const { collectFetchBundleBlocks, resolveFetchImport, resolveRuntimeMode } = gen
6
+
7
+ describe('collectFetchBundleBlocks', () => {
8
+ test('collects model, view and render blocks', () => {
9
+ const schema = {
10
+ model: { name: 'model', url: './model.js' },
11
+ view: { name: 'view', url: './view.js' },
12
+ render: { name: 'render', url: './render.js' }
13
+ }
14
+
15
+ const blocks = collectFetchBundleBlocks(schema)
16
+
17
+ expect(blocks.map(b => b.name)).toEqual(['model', 'view', 'render'])
18
+ })
19
+
20
+ test('supports arrays and skips missing sections', () => {
21
+ const schema = {
22
+ model: [
23
+ { name: 'm1', url: './m1.js' },
24
+ { name: 'm2', url: './m2.js' }
25
+ ],
26
+ view: [{ name: 'v1', url: './v1.js' }]
27
+ }
28
+
29
+ const blocks = collectFetchBundleBlocks(schema)
30
+
31
+ expect(blocks.map(b => b.name)).toEqual(['m1', 'm2', 'v1'])
32
+ })
33
+ })
34
+
35
+ describe('resolveFetchImport', () => {
36
+ test('resolves local relative import paths against model location', () => {
37
+ const cwd = '/tmp/jsee-workspace'
38
+ const result = resolveFetchImport('./helpers/math.js', 'apps/qrcode/model.js', cwd)
39
+
40
+ expect(result.schemaImport).toBe('apps/qrcode/helpers/math.js')
41
+ expect(result.importUrl).toBe('https://cdn.jsdelivr.net/npm/apps/qrcode/helpers/math.js')
42
+ expect(result.localFilePath).toBe(path.join(cwd, 'apps/qrcode/helpers/math.js'))
43
+ expect(result.remoteUrl).toBeNull()
44
+ })
45
+
46
+ test('keeps package imports as remote URLs', () => {
47
+ const result = resolveFetchImport('chart.js', 'apps/qrcode/model.js', '/tmp/jsee-workspace')
48
+
49
+ expect(result.schemaImport).toBe('chart.js')
50
+ expect(result.importUrl).toBe('https://cdn.jsdelivr.net/npm/chart.js')
51
+ expect(result.localFilePath).toBeNull()
52
+ expect(result.remoteUrl).toBe('https://cdn.jsdelivr.net/npm/chart.js')
53
+ })
54
+
55
+ test('supports object imports and preserves extra fields', () => {
56
+ const result = resolveFetchImport(
57
+ { url: './helpers/math.js', integrity: 'sha-123' },
58
+ 'apps/qrcode/model.js',
59
+ '/tmp/jsee-workspace'
60
+ )
61
+
62
+ expect(result.schemaEntry).toEqual({
63
+ url: 'apps/qrcode/helpers/math.js',
64
+ integrity: 'sha-123'
65
+ })
66
+ expect(result.importUrl).toBe('https://cdn.jsdelivr.net/npm/apps/qrcode/helpers/math.js')
67
+ expect(result.localFilePath).toBe(path.join('/tmp/jsee-workspace', 'apps/qrcode/helpers/math.js'))
68
+ expect(result.remoteUrl).toBeNull()
69
+ })
70
+ })
71
+
72
+ describe('resolveRuntimeMode', () => {
73
+ test('defaults to cdn for generated output when fetch is disabled', () => {
74
+ expect(resolveRuntimeMode('auto', false, true)).toBe('cdn')
75
+ })
76
+
77
+ test('defaults to local for served apps when fetch is disabled', () => {
78
+ expect(resolveRuntimeMode('auto', false, false)).toBe('local')
79
+ })
80
+
81
+ test('uses inline mode when fetch is enabled', () => {
82
+ expect(resolveRuntimeMode('auto', true, true)).toBe('inline')
83
+ })
84
+
85
+ test('honors explicit runtime mode', () => {
86
+ expect(resolveRuntimeMode('local', true, true)).toBe('local')
87
+ expect(resolveRuntimeMode('inline', false, false)).toBe('inline')
88
+ })
89
+
90
+ test('throws on invalid runtime mode', () => {
91
+ expect(() => resolveRuntimeMode('invalid', false, false)).toThrow('Invalid runtime mode')
92
+ })
93
+ })
94
+
95
+ describe('output writes', () => {
96
+ test('writes absolute output paths and keeps json output intact', async () => {
97
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsee-cli-output-'))
98
+ const schemaPath = path.join(tmpDir, 'schema.json')
99
+ const jsonOutputPath = path.join(tmpDir, 'result.json')
100
+ const htmlOutputPath = path.join(tmpDir, 'result.html')
101
+
102
+ fs.writeFileSync(schemaPath, JSON.stringify({
103
+ model: [
104
+ {
105
+ name: 'demo',
106
+ type: 'function',
107
+ code: 'function demo () { return 1 }'
108
+ }
109
+ ],
110
+ inputs: [],
111
+ outputs: []
112
+ }, null, 2))
113
+
114
+ try {
115
+ await gen(['--inputs', schemaPath, '--outputs', `${jsonOutputPath},${htmlOutputPath}`])
116
+
117
+ const jsonContent = fs.readFileSync(jsonOutputPath, 'utf8')
118
+ const htmlContent = fs.readFileSync(htmlOutputPath, 'utf8')
119
+
120
+ expect(() => JSON.parse(jsonContent)).not.toThrow()
121
+ expect(htmlContent).toContain('<!DOCTYPE html>')
122
+ } finally {
123
+ fs.rmSync(tmpDir, { recursive: true, force: true })
124
+ }
125
+ })
126
+ })