@quenty/nevermore-cli 4.46.0 → 4.47.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 (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  3. package/dist/commands/batch-command/batch-deploy-command.js +4 -3
  4. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  5. package/dist/commands/deploy-command/asset-command.d.ts +44 -0
  6. package/dist/commands/deploy-command/asset-command.d.ts.map +1 -0
  7. package/dist/commands/deploy-command/asset-command.js +182 -0
  8. package/dist/commands/deploy-command/asset-command.js.map +1 -0
  9. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  10. package/dist/commands/deploy-command/index.js +62 -0
  11. package/dist/commands/deploy-command/index.js.map +1 -1
  12. package/dist/utils/assets/asset-creator.d.ts +32 -0
  13. package/dist/utils/assets/asset-creator.d.ts.map +1 -0
  14. package/dist/utils/assets/asset-creator.js +62 -0
  15. package/dist/utils/assets/asset-creator.js.map +1 -0
  16. package/dist/utils/assets/asset-creator.test.d.ts +2 -0
  17. package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
  18. package/dist/utils/assets/asset-creator.test.js +71 -0
  19. package/dist/utils/assets/asset-creator.test.js.map +1 -0
  20. package/dist/utils/assets/asset-formats.d.ts +28 -0
  21. package/dist/utils/assets/asset-formats.d.ts.map +1 -0
  22. package/dist/utils/assets/asset-formats.js +72 -0
  23. package/dist/utils/assets/asset-formats.js.map +1 -0
  24. package/dist/utils/assets/asset-formats.test.d.ts +2 -0
  25. package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
  26. package/dist/utils/assets/asset-formats.test.js +43 -0
  27. package/dist/utils/assets/asset-formats.test.js.map +1 -0
  28. package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
  29. package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
  30. package/dist/utils/assets/asset-upload-plan.js +43 -0
  31. package/dist/utils/assets/asset-upload-plan.js.map +1 -0
  32. package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
  33. package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
  34. package/dist/utils/assets/asset-upload-plan.test.js +57 -0
  35. package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
  36. package/dist/utils/build/upload.d.ts.map +1 -1
  37. package/dist/utils/build/upload.js +4 -1
  38. package/dist/utils/build/upload.js.map +1 -1
  39. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  40. package/dist/utils/job-context/cloud-job-context.js +5 -3
  41. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  42. package/dist/utils/open-cloud/open-cloud-client.d.ts +81 -4
  43. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  44. package/dist/utils/open-cloud/open-cloud-client.js +172 -11
  45. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  46. package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
  47. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  48. package/dist/utils/open-cloud/rate-limiter.d.ts +29 -2
  49. package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
  50. package/dist/utils/open-cloud/rate-limiter.js +98 -20
  51. package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
  52. package/dist/utils/open-cloud/rate-limiter.test.js +64 -4
  53. package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
  54. package/dist/utils/testing/parsers/batch-log-parser.test.js +1 -1
  55. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  56. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  57. package/dist/utils/testing/runner/test-runner.js +8 -7
  58. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  59. package/dist/utils/testing/runner/test-runner.test.js +1 -1
  60. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  61. package/package.json +4 -4
  62. package/src/commands/batch-command/batch-deploy-command.ts +11 -3
  63. package/src/commands/deploy-command/asset-command.ts +279 -0
  64. package/src/commands/deploy-command/index.ts +85 -0
  65. package/src/utils/assets/asset-creator.test.ts +106 -0
  66. package/src/utils/assets/asset-creator.ts +96 -0
  67. package/src/utils/assets/asset-formats.test.ts +57 -0
  68. package/src/utils/assets/asset-formats.ts +102 -0
  69. package/src/utils/assets/asset-upload-plan.test.ts +75 -0
  70. package/src/utils/assets/asset-upload-plan.ts +79 -0
  71. package/src/utils/build/upload.ts +5 -1
  72. package/src/utils/job-context/cloud-job-context.ts +24 -15
  73. package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
  74. package/src/utils/open-cloud/open-cloud-client.ts +341 -24
  75. package/src/utils/open-cloud/rate-limiter.test.ts +106 -4
  76. package/src/utils/open-cloud/rate-limiter.ts +150 -21
  77. package/src/utils/testing/parsers/batch-log-parser.test.ts +3 -3
  78. package/src/utils/testing/runner/test-runner.test.ts +1 -1
  79. package/src/utils/testing/runner/test-runner.ts +8 -9
  80. package/templates/batch-test-runner.lua +176 -172
  81. package/templates/game-template/src/scripts/Server/ServerMain.server.lua +3 -2
  82. package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  83. package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  84. package/tsconfig.tsbuildinfo +1 -1
@@ -372,6 +372,367 @@ describe('OpenCloudClient.resolveLatestPlaceVersionAsync', () => {
372
372
  });
373
373
  });
374
374
 
375
+ describe('OpenCloudClient asset uploads', () => {
376
+ beforeEach(() => {
377
+ vi.mocked(fs.readFile).mockResolvedValue(
378
+ Buffer.from([1, 2, 3]) as unknown as string
379
+ );
380
+ });
381
+
382
+ afterEach(() => {
383
+ vi.restoreAllMocks();
384
+ });
385
+
386
+ interface CapturedRequest {
387
+ url: string;
388
+ method?: string;
389
+ headers: Record<string, string>;
390
+ form: FormData;
391
+ }
392
+
393
+ /** Answer every request with `operation`, recording what was sent. */
394
+ function makeUploadLimiter(
395
+ operation: unknown,
396
+ captured: CapturedRequest[] = []
397
+ ): RateLimiter {
398
+ return {
399
+ fetchAsync: vi.fn(async (url: string | URL, init?: RequestInit) => {
400
+ captured.push({
401
+ url: String(url),
402
+ method: init?.method,
403
+ headers: init?.headers as Record<string, string>,
404
+ form: init?.body as FormData,
405
+ });
406
+ return new Response(JSON.stringify(operation), { status: 200 });
407
+ }),
408
+ } as unknown as RateLimiter;
409
+ }
410
+
411
+ it('creates an asset as a multipart request naming its owner', async () => {
412
+ const captured: CapturedRequest[] = [];
413
+ const client = new OpenCloudClient({
414
+ apiKey: 'test-key',
415
+ rateLimiter: makeUploadLimiter({ path: 'operations/abc' }, captured),
416
+ });
417
+
418
+ const operation = await client.createAssetAsync({
419
+ filePath: '/art/icons_1.png',
420
+ contentType: 'image/png',
421
+ assetType: 'Image',
422
+ displayName: 'icons_1',
423
+ description: 'Interface art',
424
+ creator: { userId: 4397833 },
425
+ });
426
+
427
+ expect(operation.path).toBe('operations/abc');
428
+ expect(captured[0]?.url).toBe('https://apis.roblox.com/assets/v1/assets');
429
+ expect(captured[0]?.method).toBe('POST');
430
+ // The multipart boundary is fetch's to write; a hand-set Content-Type would
431
+ // send a body the API cannot split into its two parts.
432
+ expect(captured[0]?.headers).toEqual({ 'x-api-key': 'test-key' });
433
+
434
+ const form = captured[0]!.form;
435
+ expect(JSON.parse(form.get('request') as string)).toEqual({
436
+ assetType: 'Image',
437
+ displayName: 'icons_1',
438
+ description: 'Interface art',
439
+ creationContext: { creator: { userId: '4397833' } },
440
+ });
441
+ const file = form.get('fileContent') as File;
442
+ expect(file.name).toBe('icons_1.png');
443
+ expect(file.type).toBe('image/png');
444
+ expect([...new Uint8Array(await file.arrayBuffer())]).toEqual([1, 2, 3]);
445
+ });
446
+
447
+ it('names a group as the owner when given a group id', async () => {
448
+ const captured: CapturedRequest[] = [];
449
+ const client = new OpenCloudClient({
450
+ apiKey: 'test-key',
451
+ rateLimiter: makeUploadLimiter({ path: 'operations/abc' }, captured),
452
+ });
453
+
454
+ await client.createAssetAsync({
455
+ filePath: '/models/City.fbx',
456
+ contentType: 'model/fbx',
457
+ assetType: 'Model',
458
+ displayName: 'City',
459
+ description: 'City',
460
+ creator: { groupId: 77 },
461
+ });
462
+
463
+ const request = JSON.parse(captured[0]!.form.get('request') as string);
464
+ expect(request.creationContext).toEqual({ creator: { groupId: '77' } });
465
+ });
466
+
467
+ it('replaces an asset in place with a PATCH to its id', async () => {
468
+ const captured: CapturedRequest[] = [];
469
+ const client = new OpenCloudClient({
470
+ apiKey: 'test-key',
471
+ rateLimiter: makeUploadLimiter({ path: 'operations/abc' }, captured),
472
+ });
473
+
474
+ await client.updateAssetContentAsync(123, {
475
+ filePath: '/models/City.fbx',
476
+ contentType: 'model/fbx',
477
+ });
478
+
479
+ expect(captured[0]?.url).toBe(
480
+ 'https://apis.roblox.com/assets/v1/assets/123'
481
+ );
482
+ expect(captured[0]?.method).toBe('PATCH');
483
+ expect(JSON.parse(captured[0]!.form.get('request') as string)).toEqual({
484
+ assetId: 123,
485
+ });
486
+ expect((captured[0]!.form.get('fileContent') as File).type).toBe(
487
+ 'model/fbx'
488
+ );
489
+ });
490
+
491
+ it('names the scopes an upload needs when the key is refused', async () => {
492
+ const rateLimiter = {
493
+ fetchAsync: vi.fn(
494
+ async () =>
495
+ new Response(JSON.stringify({ code: 7, message: 'Forbidden' }), {
496
+ status: 403,
497
+ statusText: 'Forbidden',
498
+ })
499
+ ),
500
+ } as unknown as RateLimiter;
501
+ const client = new OpenCloudClient({ apiKey: 'test-key', rateLimiter });
502
+
503
+ await expect(
504
+ client.createAssetAsync({
505
+ filePath: '/art/icon.png',
506
+ contentType: 'image/png',
507
+ assetType: 'Image',
508
+ displayName: 'icon',
509
+ description: 'icon',
510
+ creator: { userId: 1 },
511
+ })
512
+ ).rejects.toThrowError(
513
+ /Create asset failed: Forbidden \(403\)[\s\S]*asset:read, asset:write/
514
+ );
515
+ });
516
+
517
+ describe('pollAssetOperationAsync', () => {
518
+ beforeEach(() => {
519
+ vi.spyOn(global, 'setTimeout').mockImplementation((fn: () => void) => {
520
+ fn();
521
+ return 0 as unknown as NodeJS.Timeout;
522
+ });
523
+ });
524
+
525
+ /** Answer successive polls with successive operation states. */
526
+ function makePollLimiter(
527
+ states: unknown[],
528
+ urls: string[] = []
529
+ ): RateLimiter {
530
+ return {
531
+ fetchAsync: vi.fn(async (url: string | URL) => {
532
+ urls.push(String(url));
533
+ const state = states[Math.min(urls.length, states.length) - 1];
534
+ return new Response(JSON.stringify(state), { status: 200 });
535
+ }),
536
+ } as unknown as RateLimiter;
537
+ }
538
+
539
+ it('polls the operation until it is done and returns the asset', async () => {
540
+ const urls: string[] = [];
541
+ const client = new OpenCloudClient({
542
+ apiKey: 'test-key',
543
+ rateLimiter: makePollLimiter(
544
+ [
545
+ { path: 'operations/abc' },
546
+ {
547
+ path: 'operations/abc',
548
+ done: true,
549
+ // As the live API answers: the id is a string despite the
550
+ // schema's integer, and the moderation state is a bare word.
551
+ response: {
552
+ assetId: '2205400862',
553
+ revisionId: '1',
554
+ moderationResult: { moderationState: 'Reviewing' },
555
+ },
556
+ },
557
+ ],
558
+ urls
559
+ ),
560
+ });
561
+
562
+ const asset = await client.pollAssetOperationAsync({
563
+ path: 'operations/abc',
564
+ });
565
+
566
+ expect(asset).toEqual({
567
+ assetId: 2205400862,
568
+ revisionId: '1',
569
+ moderationState: 'Reviewing',
570
+ });
571
+ expect(urls).toEqual([
572
+ 'https://apis.roblox.com/assets/v1/operations/abc',
573
+ 'https://apis.roblox.com/assets/v1/operations/abc',
574
+ ]);
575
+ });
576
+
577
+ it('does not poll an operation that is already done', async () => {
578
+ const urls: string[] = [];
579
+ const client = new OpenCloudClient({
580
+ apiKey: 'test-key',
581
+ rateLimiter: makePollLimiter([], urls),
582
+ });
583
+
584
+ const asset = await client.pollAssetOperationAsync({
585
+ path: 'operations/abc',
586
+ done: true,
587
+ response: { assetId: 9 },
588
+ });
589
+
590
+ expect(asset.assetId).toBe(9);
591
+ expect(urls).toHaveLength(0);
592
+ });
593
+
594
+ it('throws the reason when the upload is rejected', async () => {
595
+ const client = new OpenCloudClient({
596
+ apiKey: 'test-key',
597
+ rateLimiter: makePollLimiter([
598
+ {
599
+ path: 'operations/abc',
600
+ done: true,
601
+ error: { code: 3, message: 'File type is not supported' },
602
+ },
603
+ ]),
604
+ });
605
+
606
+ await expect(
607
+ client.pollAssetOperationAsync({ path: 'operations/abc' })
608
+ ).rejects.toThrowError(/Asset upload failed: File type is not supported/);
609
+ });
610
+
611
+ it('gives up after the timeout, without calling the upload failed', async () => {
612
+ // An operation that outlives the wait may still produce an asset, so the
613
+ // caller has to be told to look before uploading the same file again.
614
+ const urls: string[] = [];
615
+ const client = new OpenCloudClient({
616
+ apiKey: 'test-key',
617
+ rateLimiter: makePollLimiter([{ path: 'operations/abc' }], urls),
618
+ });
619
+
620
+ await expect(
621
+ client.pollAssetOperationAsync({ path: 'operations/abc' }, 6000)
622
+ ).rejects.toThrowError(
623
+ /did not finish within 6s\. Roblox may still create the asset/
624
+ );
625
+ expect(urls).toHaveLength(3);
626
+ });
627
+
628
+ it('refuses a finished operation that carries no asset id', async () => {
629
+ const client = new OpenCloudClient({
630
+ apiKey: 'test-key',
631
+ rateLimiter: makePollLimiter([
632
+ { path: 'operations/abc', done: true, response: {} },
633
+ ]),
634
+ });
635
+
636
+ await expect(
637
+ client.pollAssetOperationAsync({ path: 'operations/abc' })
638
+ ).rejects.toThrowError(/finished without an asset id/);
639
+ });
640
+ });
641
+ });
642
+
643
+ describe('OpenCloudClient asset owner lookups', () => {
644
+ afterEach(() => {
645
+ vi.restoreAllMocks();
646
+ });
647
+
648
+ interface CapturedRequest {
649
+ url: string;
650
+ init?: RequestInit;
651
+ }
652
+
653
+ function makeLimiter(
654
+ body: unknown,
655
+ status = 200,
656
+ captured: CapturedRequest[] = []
657
+ ): RateLimiter {
658
+ return {
659
+ fetchAsync: vi.fn(async (url: string | URL, init?: RequestInit) => {
660
+ captured.push({ url: String(url), init });
661
+ return new Response(JSON.stringify(body), { status });
662
+ }),
663
+ } as unknown as RateLimiter;
664
+ }
665
+
666
+ it('reads a user-owned universe as that user', async () => {
667
+ const captured: CapturedRequest[] = [];
668
+ const client = new OpenCloudClient({
669
+ apiKey: 'test-key',
670
+ rateLimiter: makeLimiter(
671
+ { path: 'universes/10', user: 'users/4397833' },
672
+ 200,
673
+ captured
674
+ ),
675
+ });
676
+
677
+ expect(await client.getUniverseOwnerAsync(10)).toEqual({ userId: 4397833 });
678
+ expect(captured[0]?.url).toBe(
679
+ 'https://apis.roblox.com/cloud/v2/universes/10'
680
+ );
681
+ });
682
+
683
+ it('reads a group-owned universe as that group', async () => {
684
+ const client = new OpenCloudClient({
685
+ apiKey: 'test-key',
686
+ rateLimiter: makeLimiter({ path: 'universes/10', group: 'groups/77' }),
687
+ });
688
+
689
+ expect(await client.getUniverseOwnerAsync(10)).toEqual({ groupId: 77 });
690
+ });
691
+
692
+ it('throws when the universe cannot be read', async () => {
693
+ const client = new OpenCloudClient({
694
+ apiKey: 'test-key',
695
+ rateLimiter: makeLimiter({}, 403),
696
+ });
697
+
698
+ await expect(client.getUniverseOwnerAsync(10)).rejects.toThrowError(
699
+ /Read universe 10 failed: 403/
700
+ );
701
+ });
702
+
703
+ it('asks Roblox which user the API key belongs to', async () => {
704
+ const captured: CapturedRequest[] = [];
705
+ const client = new OpenCloudClient({
706
+ apiKey: 'test-key',
707
+ rateLimiter: makeLimiter(
708
+ { name: 'my-key', authorizedUserId: 4397833 },
709
+ 200,
710
+ captured
711
+ ),
712
+ });
713
+
714
+ expect(await client.getApiKeyUserIdAsync()).toBe(4397833);
715
+ expect(captured[0]?.url).toBe(
716
+ 'https://apis.roblox.com/api-keys/v1/introspect'
717
+ );
718
+ // Introspection takes the key in the body rather than the usual header.
719
+ expect(JSON.parse(captured[0]!.init!.body as string)).toEqual({
720
+ apiKey: 'test-key',
721
+ });
722
+ });
723
+
724
+ it('throws when the key does not name a user', async () => {
725
+ const client = new OpenCloudClient({
726
+ apiKey: 'test-key',
727
+ rateLimiter: makeLimiter({ name: 'my-key' }),
728
+ });
729
+
730
+ await expect(client.getApiKeyUserIdAsync()).rejects.toThrowError(
731
+ /did not report which user/
732
+ );
733
+ });
734
+ });
735
+
375
736
  describe('getTaskReturnValues', () => {
376
737
  function makeTask(overrides: Partial<LuauTask>): LuauTask {
377
738
  return {