@sasjs/cli 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -216,6 +216,487 @@ describe('browser runtime', function () {
216
216
  });
217
217
  expect(result.headers).toBe('content-type: application/json');
218
218
  });
219
+ it('hands a byte body back to the host as base64', function () {
220
+ var runtime = loadRuntime();
221
+ // the shape a service that streams a file uses on the server: write the
222
+ // body to the fileref, and say in the headers what it is
223
+ var result = runtime.run({
224
+ code: [
225
+ "fs.writeFileSync(_SASJS_WEBOUT_HEADERS, 'Content-type: application/zip')",
226
+ 'fs.writeFileSync(weboutPath, Uint8Array.from([80, 75, 3, 4]))'
227
+ ].join('\n')
228
+ });
229
+ expect(result.error).toBeNull();
230
+ expect(result.binary).toBe(true);
231
+ expect(result.webout).toBe('UEsDBA==');
232
+ expect(result.headers).toContain('application/zip');
233
+ });
234
+ it('accepts a Buffer, as a program written for the server uses', function () {
235
+ var runtime = loadRuntime();
236
+ expect(runtime.run({
237
+ code: 'fs.writeFileSync(weboutPath, Buffer.from([80, 75, 3, 4]))'
238
+ }).webout).toBe('UEsDBA==');
239
+ expect(runtime.run({
240
+ code: "fs.writeFileSync(weboutPath, Buffer.from('UEsDBA==', 'base64'))"
241
+ }).webout).toBe('UEsDBA==');
242
+ });
243
+ it('decodes a Buffer from a binary string, from hex and from text', function () {
244
+ var runtime = loadRuntime();
245
+ expect(runtime.run({
246
+ code: "fs.writeFileSync(weboutPath, Buffer.from('PK\\u0003\\u0004', 'binary'))"
247
+ }).webout).toBe('UEsDBA==');
248
+ expect(runtime.run({
249
+ code: "fs.writeFileSync(weboutPath, Buffer.from('504b0304', 'hex'))"
250
+ }).webout).toBe('UEsDBA==');
251
+ // no encoding given means utf8, as in node
252
+ expect(runtime.run({ code: "fs.writeFileSync(weboutPath, Buffer.from('ab'))" })
253
+ .webout).toBe('YWI=');
254
+ });
255
+ it('decodes a chunk of a Buffer, as a service streaming a file does', function () {
256
+ var runtime = loadRuntime();
257
+ var result = runtime.run({
258
+ code: [
259
+ "const file = '/sasjs/drive/files/pack.bin'",
260
+ 'fs.writeFileSync(file, Uint8Array.from([80, 75, 3, 4]))',
261
+ 'const read = fs.readFileSync(file)',
262
+ 'const chunk = read.subarray(0, 2)',
263
+ "_webout = [chunk.toString('base64'), read.slice(0, 2).toString('base64'), read.subarray(2).toString('hex'), Buffer.from([1, 2, 3]).subarray(1).toString('hex'), Buffer.isBuffer(chunk)].join('|')"
264
+ ].join('\n')
265
+ });
266
+ expect(result.error).toBeNull();
267
+ expect(result.webout).toBe('UEs=|UEs=|0304|0203|true');
268
+ });
269
+ it('decodes hex and base64 the way node does, including what it rejects', function () {
270
+ var runtime = loadRuntime();
271
+ var result = runtime.run({
272
+ code: [
273
+ // node stops at the first pair that is not hex, and ignores a trailing
274
+ // nibble
275
+ "const hexCases = ['zz', 'abc', '0g', '504b0304'].map((text) => Buffer.from(text, 'hex').toString('hex'))",
276
+ // node ignores junk characters, drops a lone trailing character, and
277
+ // decodes unpadded input
278
+ "const b64Cases = ['zz', 'A', '!!!', 'UEsDBA'].map((text) => Buffer.from(text, 'base64').length)",
279
+ "_webout = [hexCases.join(','), b64Cases.join(',')].join('|')"
280
+ ].join('\n')
281
+ });
282
+ expect(result.error).toBeNull();
283
+ expect(result.webout).toBe(',ab,,504b0304|1,0,0,4');
284
+ });
285
+ it('clamps a toString range the way node does', function () {
286
+ var runtime = loadRuntime();
287
+ var result = runtime.run({
288
+ code: [
289
+ 'const buf = Buffer.from([1, 2, 3, 4])',
290
+ "_webout = [buf.toString('hex', -1), buf.toString('hex', 0, -1), buf.toString('hex', 1, 3), buf.toString('hex', 3, 1)].map((value) => JSON.stringify(value)).join('|')"
291
+ ].join('\n')
292
+ });
293
+ expect(result.webout).toBe('"01020304"|""|"0203"|""');
294
+ });
295
+ it('serialises a Buffer the way node does, with no marker in the JSON', function () {
296
+ var runtime = loadRuntime();
297
+ var result = runtime.run({
298
+ code: "_webout = [JSON.stringify(Buffer.from([80, 75])), Object.keys(Buffer.from([1])).join()].join('|')"
299
+ });
300
+ expect(result.webout).toBe('{"type":"Buffer","data":[80,75]}|0');
301
+ });
302
+ it('does not call an empty byte write a byte body', function () {
303
+ var runtime = loadRuntime();
304
+ // a service that writes no bytes and returns a string can only be answered
305
+ // with that string, so the reply is text rather than base64-decoded text
306
+ var result = runtime.run({
307
+ code: [
308
+ 'fs.writeFileSync(weboutPath, new Uint8Array(0))',
309
+ "return 'from the return'"
310
+ ].join('\n')
311
+ });
312
+ expect(result.binary).toBe(false);
313
+ expect(result.webout).toBe('from the return');
314
+ });
315
+ it('handles the encodings node handles, and refuses the rest', function () {
316
+ var runtime = loadRuntime();
317
+ var result = runtime.run({
318
+ code: [
319
+ "const codes = ['utf16le', 'ucs2', 'ascii'].map((encoding) => Buffer.from('PK', encoding).toString('hex'))",
320
+ // node encodes 'ascii' as latin1 and keeps the high bits
321
+ "const latin = Buffer.from('\\u00e9\\u00ff', 'ascii').toString('hex')",
322
+ "const url = Buffer.from([251, 255]).toString('base64url')",
323
+ "const back = Buffer.from('-_8', 'base64url').toString('hex')",
324
+ "const high = Buffer.from([0xc8]).toString('ascii').charCodeAt(0)",
325
+ 'let refused = "none"',
326
+ "try { Buffer.from('x', 'not-an-encoding') } catch (e) { refused = e.message }",
327
+ "_webout = [codes.join(','), latin, url, back, high, refused].join('|')"
328
+ ].join('\n')
329
+ });
330
+ expect(result.error).toBeNull();
331
+ // 'P' is 0x50 and 'K' is 0x4b, written little-endian for the utf16 pair;
332
+ // ascii encodes as latin1 (e9ff) and drops the high bit only when decoding
333
+ // 0xc8, leaving 0x48
334
+ expect(result.webout).toBe('50004b00,50004b00,504b|e9ff|-_8|fbff|72|Unknown encoding: not-an-encoding');
335
+ });
336
+ it('decodes the url-safe alphabet and keeps a utf16 byte order mark', function () {
337
+ var runtime = loadRuntime();
338
+ // node's base64 decoder accepts the url-safe characters, and its utf16le
339
+ // decoder keeps a leading BOM as a character
340
+ var result = runtime.run({
341
+ code: [
342
+ "const bytes = Buffer.from('-_8', 'base64')",
343
+ 'const bom = Buffer.from([0xff, 0xfe, 0x41, 0x00])',
344
+ "_webout = [bytes.toString('hex'), JSON.stringify(bom.toString('utf16le'))].join('|')"
345
+ ].join('\n')
346
+ });
347
+ expect(result.error).toBeNull();
348
+ expect(result.webout).toBe('fbff|"\ufeffA"');
349
+ });
350
+ it('refuses what node refuses, with the same complaint', function () {
351
+ var runtime = loadRuntime();
352
+ var result = runtime.run({
353
+ code: [
354
+ 'const messages = []',
355
+ "try { Buffer.concat(['ab', Buffer.from([1])]) } catch (e) { messages.push(e instanceof TypeError, e.message) }",
356
+ 'try { Buffer.byteLength({}) } catch (e) { messages.push(e instanceof TypeError, e.message) }',
357
+ '_webout = messages.join("|")'
358
+ ].join('\n')
359
+ });
360
+ expect(result.webout).toContain('true|The "list[0]" argument must be an instance of Buffer or Uint8Array. Received type string (\'ab\')');
361
+ expect(result.webout).toContain('true|The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received an instance of Object');
362
+ });
363
+ it('refuses a write node would refuse, rather than stringifying it', function () {
364
+ var runtime = loadRuntime();
365
+ var result = runtime.run({
366
+ code: [
367
+ 'const outcomes = []',
368
+ "try { fs.writeFileSync('/sasjs/drive/files/list.bin', [1, 2, 3]) } catch (e) { outcomes.push(e instanceof TypeError, e.message) }",
369
+ "try { fs.writeFileSync('/sasjs/drive/files/count.txt', 7) } catch (e) { outcomes.push(e instanceof TypeError, e.message) }",
370
+ '_webout = outcomes.join("|")'
371
+ ].join('\n')
372
+ });
373
+ expect(result.webout).toContain('true|The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of Array');
374
+ expect(result.webout).toContain('Received type number (7)');
375
+ });
376
+ it('reads text with an encoding, and never mistakes a marker-like value for bytes', function () {
377
+ var runtime = loadRuntime();
378
+ var result = runtime.run({
379
+ code: [
380
+ "const file = '/sasjs/drive/files/notes.txt'",
381
+ // a value that begins with the drive's own binary marker
382
+ "fs.writeFileSync(file, '\\u0000base64:AAAA')",
383
+ 'const raw = fs.readFileSync(file)',
384
+ "const b64 = fs.readFileSync(file, 'base64')",
385
+ 'const size = fs.statSync(file).size',
386
+ "fs.copyFileSync(file, '/sasjs/drive/files/copy.txt')",
387
+ "const copied = fs.readFileSync('/sasjs/drive/files/copy.txt')",
388
+ '_webout = [raw === "\\u0000base64:AAAA", b64, size, copied === raw].join("|")'
389
+ ].join('\n')
390
+ });
391
+ expect(result.error).toBeNull();
392
+ // base64 of "\u0000base64:AAAA" - the text, not a decode of it
393
+ expect(result.webout).toBe('true|AGJhc2U2NDpBQUFB|12|true');
394
+ });
395
+ it('takes an encoding on an options object, and hands an escaped body back unchanged', function () {
396
+ var runtime = loadRuntime();
397
+ var result = runtime.run({
398
+ code: [
399
+ "const file = '/sasjs/drive/files/notes.txt'",
400
+ "fs.writeFileSync(file, 'plain notes')",
401
+ "const options = fs.readFileSync(file, { encoding: 'utf8' })",
402
+ // a body that looks like the drive's own marker
403
+ "_webout = '\\u0000text:not really escaped'"
404
+ ].join('\n')
405
+ });
406
+ expect(result.error).toBeNull();
407
+ // the webout comes back exactly as written, escape and all
408
+ expect(result.webout).toBe('\u0000text:not really escaped');
409
+ var read = runtime.run({
410
+ code: [
411
+ "const file = '/sasjs/drive/files/notes.txt'",
412
+ "fs.writeFileSync(file, 'plain notes')",
413
+ "_webout = fs.readFileSync(file, { encoding: 'utf8' })"
414
+ ].join('\n')
415
+ });
416
+ expect(read.webout).toBe('plain notes');
417
+ });
418
+ it('hands bytes back for an explicit null encoding, as node does', function () {
419
+ var runtime = loadRuntime();
420
+ var result = runtime.run({
421
+ code: [
422
+ "const file = '/sasjs/drive/files/notes.txt'",
423
+ "fs.writeFileSync(file, 'h\\u00e9llo')",
424
+ 'const nulled = fs.readFileSync(file, { encoding: null })',
425
+ 'const undef = fs.readFileSync(file, { encoding: undefined })',
426
+ 'const plain = fs.readFileSync(file)',
427
+ "_webout = [Buffer.isBuffer(nulled), nulled.toString('base64'), nulled.length, Buffer.isBuffer(undef), undef.length, typeof plain].join('|')"
428
+ ].join('\n')
429
+ });
430
+ expect(result.error).toBeNull();
431
+ // node: fs.readFileSync(f, { encoding: null }) is a Buffer of the utf8 bytes
432
+ expect(result.webout).toBe('true|aMOpbGxv|6|true|6|string');
433
+ });
434
+ it('refuses a corrupt byte entry wherever it is read, and measures what it hands back', function () {
435
+ var runtime = loadRuntime();
436
+ runtime.load({
437
+ '/sasjs/drive/files/bad.bin': '\u0000base64:!!!not base64!!!',
438
+ // a snapshot carrying a line break inside the base64 still decodes
439
+ '/sasjs/drive/files/spaced.bin': '\u0000base64:AQID\n'
440
+ });
441
+ var result = runtime.run({
442
+ code: [
443
+ "const bad = '/sasjs/drive/files/bad.bin'",
444
+ 'let readError = "none"',
445
+ 'try { fs.readFileSync(bad) } catch (e) { readError = [e.status, e.message].join("|") }',
446
+ 'let statError = "none"',
447
+ 'try { fs.statSync(bad) } catch (e) { statError = e.status }',
448
+ "const spaced = '/sasjs/drive/files/spaced.bin'",
449
+ '_webout = [readError, statError, fs.readFileSync(spaced).length, fs.statSync(spaced).size].join(";")'
450
+ ].join('\n')
451
+ });
452
+ expect(result.error).toBeNull();
453
+ // the same complaint the worker gives when it serves the file, and a size
454
+ // that is the bytes read hands back rather than the stored string
455
+ expect(result.webout).toBe('500|Corrupt binary entry on the drive: not valid base64;500;3;3');
456
+ // the drive total is a read of every entry, so it refuses one too
457
+ expect(function () { return runtime.stats(); }).toThrow('Corrupt binary entry');
458
+ });
459
+ it('measures a byte entry from its stored length, whatever the padding', function () {
460
+ var runtime = loadRuntime();
461
+ var entry = function (bytes) {
462
+ return '\u0000base64:' + Buffer.from(bytes).toString('base64');
463
+ };
464
+ runtime.load({
465
+ '/sasjs/drive/files/b4.bin': entry([1, 2, 3, 4]),
466
+ '/sasjs/drive/files/b5.bin': entry([1, 2, 3, 4, 5]),
467
+ '/sasjs/drive/files/b6.bin': entry([1, 2, 3, 4, 5, 6])
468
+ });
469
+ var result = runtime.run({
470
+ code: [
471
+ "const paths = ['b4.bin', 'b5.bin', 'b6.bin']",
472
+ "_webout = paths.map((name) => { const file = '/sasjs/drive/files/' + name; return [fs.readFileSync(file).length, fs.statSync(file).size].join('/') }).join('|')"
473
+ ].join('\n')
474
+ });
475
+ expect(result.error).toBeNull();
476
+ // the size is the bytes the entry decodes to, padding included
477
+ expect(result.webout).toBe('4/4|5/5|6/6');
478
+ });
479
+ it('fails a run whose byte body will not decode, rather than serving it', function () {
480
+ var runtime = loadRuntime();
481
+ runtime.load({
482
+ '/sasjs/drive/files/broken.bin': '\u0000base64:!!!not base64!!!'
483
+ });
484
+ var result = runtime.run({
485
+ code: "fs.copyFileSync('/sasjs/drive/files/broken.bin', weboutPath)"
486
+ });
487
+ // the body is validated where it is read, so a corrupt entry never reaches
488
+ // the worker's decoder as raw base64
489
+ expect(result.binary).toBeFalsy();
490
+ expect(result.error).toContain('Corrupt binary entry');
491
+ });
492
+ it('reports a service that redeclares a prelude name as a program error', function () {
493
+ var runtime = loadRuntime();
494
+ var result;
495
+ expect(function () {
496
+ result = runtime.run({ code: "const Buffer = 1\n_webout = 'x'" });
497
+ }).not.toThrow();
498
+ expect(result.error).toContain('Buffer');
499
+ });
500
+ it('measures a text file in bytes, as node does', function () {
501
+ var runtime = loadRuntime();
502
+ // a two-byte character: node's statSync().size is the utf8 length
503
+ var result = runtime.run({
504
+ code: [
505
+ "fs.writeFileSync('/sasjs/drive/files/accent.txt', '\\u00e9')",
506
+ "_webout = String(fs.statSync('/sasjs/drive/files/accent.txt').size)"
507
+ ].join('\n')
508
+ });
509
+ expect(result.error).toBeNull();
510
+ expect(result.webout).toBe('2');
511
+ });
512
+ it('measures a Buffer or a string, and refuses what it cannot encode', function () {
513
+ var runtime = loadRuntime();
514
+ expect(runtime.run({
515
+ code: "_webout = [Buffer.isBuffer('no'), Buffer.byteLength('ab'), Buffer.byteLength(Buffer.from([1, 2])), Buffer.allocUnsafe(3).length, Buffer.from([65]).toString()].join('|')"
516
+ }).webout).toBe('false|2|2|3|A');
517
+ var refused = runtime.run({ code: 'Buffer.from({})' });
518
+ expect(refused.error).toContain('unsupported input');
519
+ });
520
+ it('offers the Buffer helpers a service uses to build a body', function () {
521
+ var runtime = loadRuntime();
522
+ var result = runtime.run({
523
+ code: [
524
+ "const joined = Buffer.concat([Buffer.from([80, 75]), Buffer.from('0304', 'hex')])",
525
+ "_webout = [Buffer.isBuffer(joined), Buffer.byteLength(joined), joined.toString('base64'), Buffer.alloc(2).length, joined.toString('latin1').charCodeAt(2)].join('|')"
526
+ ].join('\n')
527
+ });
528
+ expect(result.error).toBeNull();
529
+ expect(result.webout).toBe('true|4|UEsDBA==|2|3');
530
+ });
531
+ it('fills a Buffer.alloc with the value node repeats', function () {
532
+ var runtime = loadRuntime();
533
+ var result = runtime.run({
534
+ code: [
535
+ "const bytes = Buffer.alloc(4, 0xff).toString('hex')",
536
+ "const pair = Buffer.alloc(3, 'ab').toString()",
537
+ "const latin = Buffer.alloc(3, '\\u00e9', 'latin1').toString('hex')",
538
+ "const zeros = Buffer.alloc(2).toString('hex')",
539
+ 'const truncated = Buffer.alloc(1.5).length',
540
+ 'let notNumber = "none"',
541
+ "try { Buffer.alloc('x') } catch (e) { notNumber = e.constructor.name }",
542
+ 'let range = "none"',
543
+ 'try { Buffer.alloc(-1) } catch (e) { range = e.constructor.name }',
544
+ '_webout = [bytes, pair, latin, zeros, truncated, notNumber, range].join("|")'
545
+ ].join('\n')
546
+ });
547
+ expect(result.error).toBeNull();
548
+ // node truncates a fractional size and refuses a non-number or a negative
549
+ expect(result.webout).toBe('ffffffff|aba|e9e9e9|0000|1|TypeError|RangeError');
550
+ });
551
+ it('slices as a view, and names a Buffer method the shim does not carry', function () {
552
+ var runtime = loadRuntime();
553
+ var result = runtime.run({
554
+ code: [
555
+ 'const whole = Buffer.from([1, 2, 3])',
556
+ 'const part = whole.slice(0, 2)',
557
+ 'part[0] = 9',
558
+ 'let named = "none"',
559
+ "try { whole.write('x') } catch (e) { named = e.message }",
560
+ '_webout = [whole.toString("hex"), part.length, named].join("|")'
561
+ ].join('\n')
562
+ });
563
+ expect(result.error).toBeNull();
564
+ // node's Buffer#slice is a view, so the edit reaches the original
565
+ expect(result.webout).toBe('090203|2|Buffer.write is not implemented in the browser host');
566
+ });
567
+ it('honours the encoding and the append flag node takes on a write', function () {
568
+ var runtime = loadRuntime();
569
+ var result = runtime.run({
570
+ code: [
571
+ "const file = '/sasjs/drive/files/notes.md'",
572
+ "fs.writeFileSync(file, 'one')",
573
+ "fs.writeFileSync(file, '-two', { flag: 'a' })",
574
+ 'const appended = fs.readFileSync(file)',
575
+ "const latin = '/sasjs/drive/files/latin.txt'",
576
+ "fs.writeFileSync(latin, '\\u00e9', 'latin1')",
577
+ "const latinHex = fs.readFileSync(latin).toString('hex')",
578
+ "const latinText = fs.readFileSync(latin, 'latin1')",
579
+ "const chunks = '/sasjs/drive/files/chunks.bin'",
580
+ 'fs.writeFileSync(chunks, Buffer.from([1, 2]))',
581
+ "fs.writeFileSync(chunks, Buffer.from([3]), { flag: 'a' })",
582
+ "const chunked = fs.readFileSync(chunks).toString('hex')",
583
+ 'let refused = "none"',
584
+ "try { fs.writeFileSync(file, 'x', { flag: 'wx' }) } catch (e) { refused = e.message }",
585
+ "_webout = [appended, latinHex, latinText, chunked, refused].join('|')"
586
+ ].join('\n')
587
+ });
588
+ expect(result.error).toBeNull();
589
+ expect(result.webout).toBe("one-two|e9|é|010203|Unsupported flag 'wx' in the browser host - 'w' and 'a' only");
590
+ });
591
+ it('lets an assigned webout replace a byte body, as the generated program does', function () {
592
+ var runtime = loadRuntime();
593
+ var result = runtime.run({
594
+ code: [
595
+ 'fs.writeFileSync(weboutPath, Uint8Array.from([80, 75, 3, 4]))',
596
+ "_webout = 'text wins'"
597
+ ].join('\n')
598
+ });
599
+ expect(result.binary).toBe(false);
600
+ expect(result.webout).toBe('text wins');
601
+ });
602
+ it('reads a file written as bytes back as bytes', function () {
603
+ var runtime = loadRuntime();
604
+ var result = runtime.run({
605
+ code: [
606
+ "const file = '/sasjs/drive/files/data.bin'",
607
+ 'fs.writeFileSync(file, Uint8Array.from([1, 2, 3]))',
608
+ 'const read = fs.readFileSync(file)',
609
+ "_webout = [read.length, read.toString('base64'), fs.statSync(file).size].join('|')"
610
+ ].join('\n')
611
+ });
612
+ expect(result.error).toBeNull();
613
+ expect(result.webout).toBe('3|AQID|3');
614
+ });
615
+ it('takes an ArrayBuffer or a view over one as bytes', function () {
616
+ var runtime = loadRuntime();
617
+ expect(runtime.run({
618
+ code: 'fs.writeFileSync(weboutPath, new Uint8Array([80, 75, 3, 4]).buffer)'
619
+ }).webout).toBe('UEsDBA==');
620
+ expect(runtime.run({
621
+ code: 'fs.writeFileSync(weboutPath, new DataView(new Uint8Array([80, 75, 3, 4]).buffer))'
622
+ }).webout).toBe('UEsDBA==');
623
+ // and a service can read one back as hex
624
+ expect(runtime.run({
625
+ code: 'fs.writeFileSync(weboutPath, Uint8Array.from([80, 75, 3, 4]))'
626
+ }).binary).toBe(true);
627
+ expect(runtime.run({
628
+ code: [
629
+ "const file = '/sasjs/drive/files/a.bin'",
630
+ 'fs.writeFileSync(file, Uint8Array.from([80, 75, 3, 4]))',
631
+ "_webout = fs.readFileSync(file).toString('hex')"
632
+ ].join('\n')
633
+ }).webout).toBe('504b0304');
634
+ });
635
+ it('takes the offset and length node takes for an ArrayBuffer, and a length for concat', function () {
636
+ var runtime = loadRuntime();
637
+ var result = runtime.run({
638
+ code: [
639
+ 'const ab = new Uint8Array([1, 2, 3, 4, 5]).buffer',
640
+ "const chunk = Buffer.from(ab, 2, 2).toString('hex')",
641
+ "const rest = Buffer.from(ab, 2).toString('hex')",
642
+ "const whole = Buffer.from(ab, 'utf8').toString('hex')",
643
+ 'let bounds = "none"',
644
+ 'try { Buffer.from(ab, 2, 100) } catch (e) { bounds = e.constructor.name }',
645
+ 'let offsetError = "none"',
646
+ 'try { Buffer.from(ab, 9) } catch (e) { offsetError = e.constructor.name }',
647
+ "const padded = Buffer.concat([Buffer.from([1, 2])], 4).toString('hex')",
648
+ "const cut = Buffer.concat([Buffer.from([1, 2]), Buffer.from([3])], 2).toString('hex')",
649
+ 'let badLength = "none"',
650
+ 'try { Buffer.concat([Buffer.from([1])], -1) } catch (e) { badLength = e.constructor.name }',
651
+ '_webout = [chunk, rest, whole, bounds, offsetError, padded, cut, badLength].join("|")'
652
+ ].join('\n')
653
+ });
654
+ expect(result.error).toBeNull();
655
+ expect(result.webout).toBe('0304|030405|0102030405|RangeError|RangeError|01020000|0102|RangeError');
656
+ });
657
+ it('reports a directory and a missing file the way node does', function () {
658
+ var runtime = loadRuntime();
659
+ var result = runtime.run({
660
+ code: [
661
+ "fs.mkdirSync('/sasjs/drive/files/folder')",
662
+ "const isDir = fs.statSync('/sasjs/drive/files/folder').isDirectory()",
663
+ 'let copyError = "none"',
664
+ "try { fs.copyFileSync('/sasjs/drive/files/none.bin', '/sasjs/drive/files/there.bin') } catch (e) { copyError = e.message }",
665
+ 'let statError = "none"',
666
+ "try { fs.statSync('/sasjs/drive/files/none.bin') } catch (e) { statError = e.code }",
667
+ "_webout = [isDir, copyError.includes('ENOENT'), statError].join('|')"
668
+ ].join('\n')
669
+ });
670
+ expect(result.error).toBeNull();
671
+ expect(result.webout).toBe('true|true|ENOENT');
672
+ });
673
+ it('logs a value it cannot serialise rather than failing the run', function () {
674
+ var runtime = loadRuntime();
675
+ var result = runtime.run({
676
+ code: [
677
+ 'const loop = {}',
678
+ 'loop.self = loop',
679
+ 'console.log(loop)',
680
+ "_webout = 'logged'"
681
+ ].join('\n')
682
+ });
683
+ expect(result.error).toBeNull();
684
+ expect(result.webout).toBe('logged');
685
+ expect(result.log).toContain('[object Object]');
686
+ });
687
+ it('carries a byte file through a copy', function () {
688
+ var runtime = loadRuntime();
689
+ var result = runtime.run({
690
+ code: [
691
+ "const from = '/sasjs/drive/files/a.bin'",
692
+ "const to = '/sasjs/drive/files/b.bin'",
693
+ 'fs.writeFileSync(from, Uint8Array.from([1, 2, 3]))',
694
+ 'fs.copyFileSync(from, to)',
695
+ "_webout = fs.readFileSync(to).toString('base64')"
696
+ ].join('\n')
697
+ });
698
+ expect(result.webout).toBe('AQID');
699
+ });
219
700
  it('lists folders and builds the drive tree', function () { return __awaiter(void 0, void 0, void 0, function () {
220
701
  var runtime, listing, tree;
221
702
  var _a;
@@ -264,6 +745,31 @@ describe('browser runtime', function () {
264
745
  }
265
746
  });
266
747
  }); });
748
+ it('reports the drive total in bytes, not in stored characters', function () {
749
+ var runtime = loadRuntime();
750
+ runtime.load({});
751
+ runtime.run({
752
+ code: [
753
+ "const file = '/sasjs/drive/files/data/notes.txt'",
754
+ "fs.writeFileSync('/sasjs/drive/files/data/pack.bin', Uint8Array.from([80, 75, 3, 4]))",
755
+ "fs.writeFileSync(file, 'h\\u00e9llo')"
756
+ ].join('\n')
757
+ });
758
+ // the drive total is measured per entry: a byte entry counts its bytes, not
759
+ // the base64 and marker it is stored behind, and text counts utf8 bytes
760
+ var storedBytes = function (contents) {
761
+ return contents.startsWith('\u0000base64:')
762
+ ? Buffer.from(contents.slice('\u0000base64:'.length), 'base64').length
763
+ : Buffer.byteLength(contents.replace(/^\u0000text:/, ''));
764
+ };
765
+ var drive = runtime.dumpAll();
766
+ var expected = Object.values(drive).reduce(function (total, contents) { return total + storedBytes(contents); }, 0);
767
+ var written = Object.keys(drive).filter(function (k) { return k.includes('/data/'); });
768
+ expect(written.length).toBe(2);
769
+ expect(runtime.stats().bytes).toBe(expected);
770
+ // the stored representation is longer than the bytes it holds
771
+ expect(Object.values(drive).reduce(function (total, contents) { return total + contents.length; }, 0)).toBeGreaterThan(expected);
772
+ });
267
773
  it('clears the drive and re-seeds on reset', function () { return __awaiter(void 0, void 0, void 0, function () {
268
774
  var runtime;
269
775
  var _a;