@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
@@ -1,8 +1,13 @@
1
1
  import * as fs from 'fs/promises';
2
+ import * as path from 'path';
2
3
  import { Readable, Transform } from 'node:stream';
3
4
  import nodeFetch from 'node-fetch';
4
5
  import { OutputHelper } from '@quenty/cli-output-helpers';
5
- import { RateLimiter, type FetchLike } from './rate-limiter.js';
6
+ import {
7
+ RateLimiter,
8
+ type FetchLike,
9
+ type RateLimitWait,
10
+ } from './rate-limiter.js';
6
11
  import {
7
12
  parseTestLogs,
8
13
  type ParsedTestLogs,
@@ -90,6 +95,109 @@ export interface OpenCloudClientOptions {
90
95
  */
91
96
  export type PlaceVersionType = 'saved' | 'published';
92
97
 
98
+ /** Who owns a newly created asset. */
99
+ export type AssetCreator = { userId: number } | { groupId: number };
100
+
101
+ export interface AssetFile {
102
+ filePath: string;
103
+ /** MIME type of the file. The Assets API picks its importer from it. */
104
+ contentType: string;
105
+ }
106
+
107
+ export interface CreateAssetOptions extends AssetFile {
108
+ /** `Model`, `Image`, `Audio`, ... as the Assets API spells them. */
109
+ assetType: string;
110
+ displayName: string;
111
+ description: string;
112
+ creator: AssetCreator;
113
+ }
114
+
115
+ /**
116
+ * The long-running operation every asset upload returns. Creating an asset is
117
+ * not synchronous — the file is imported and moderated first — so the id only
118
+ * exists once the operation reports `done`.
119
+ */
120
+ export interface AssetOperation {
121
+ /** `operations/{operationId}` */
122
+ path: string;
123
+ done?: boolean;
124
+ response?: {
125
+ /** A string in practice, though the API's own schema calls it an integer. */
126
+ assetId?: string | number;
127
+ revisionId?: string;
128
+ moderationResult?: { moderationState?: string };
129
+ };
130
+ error?: { code?: number | string; message?: string };
131
+ }
132
+
133
+ export interface UploadedAsset {
134
+ assetId: number;
135
+ revisionId?: string;
136
+ /**
137
+ * `Approved`, `Reviewing`, ... exactly as the API reports it — which is not
138
+ * the `MODERATION_STATE_APPROVED` spelling its documentation shows. Audio
139
+ * routinely finishes uploading while still `Reviewing`.
140
+ */
141
+ moderationState?: string;
142
+ }
143
+
144
+ const ASSETS_API_URL = 'https://apis.roblox.com/assets/v1';
145
+ const ASSET_OPERATION_POLL_MS = 2000;
146
+ const ASSET_OPERATION_TIMEOUT_MS = 5 * 60 * 1000;
147
+
148
+ /** Pull the human-readable message out of an Open Cloud error body. */
149
+ function _extractApiMessage(body: string): string {
150
+ try {
151
+ const parsed = JSON.parse(body);
152
+ if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
153
+ return parsed.errors[0].message;
154
+ }
155
+ if (typeof parsed.message === 'string') {
156
+ return parsed.message;
157
+ }
158
+ } catch {
159
+ // Not JSON
160
+ }
161
+ return '';
162
+ }
163
+
164
+ function formatAssetApiError(
165
+ action: string,
166
+ status: number,
167
+ statusText: string,
168
+ body: string,
169
+ method: string,
170
+ url: string
171
+ ): string {
172
+ if (status !== 401 && status !== 403) {
173
+ return `${action} failed: ${status} ${statusText}: ${body}`;
174
+ }
175
+
176
+ const apiMessage = _extractApiMessage(body);
177
+ const headline = apiMessage
178
+ ? `${action} failed: ${apiMessage} (${status})`
179
+ : `${action} failed: ${status} ${statusText}`;
180
+
181
+ const dim = OutputHelper.formatDim;
182
+ const info = OutputHelper.formatInfo;
183
+
184
+ return [
185
+ OutputHelper.formatError(headline),
186
+ '',
187
+ ` ${dim('Scope:')} asset:read, asset:write`,
188
+ '',
189
+ OutputHelper.formatWarning(
190
+ 'Ensure the API key has the Assets API with both Read and Write, and ' +
191
+ 'that its owner may create assets for the user or group named as creator.'
192
+ ),
193
+ '',
194
+ ` ${dim('Credentials:')} ${info(
195
+ 'https://create.roblox.com/dashboard/credentials'
196
+ )}`,
197
+ ` ${dim('Route:')} ${dim(`${method} ${url}`)}`,
198
+ ].join('\n');
199
+ }
200
+
93
201
  function formatAuthError(
94
202
  action: string,
95
203
  scope: string,
@@ -101,18 +209,7 @@ function formatAuthError(
101
209
  method: string,
102
210
  url: string
103
211
  ): string {
104
- // Extract human-readable message from JSON body if possible
105
- let apiMessage = '';
106
- try {
107
- const parsed = JSON.parse(body);
108
- if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
109
- apiMessage = parsed.errors[0].message;
110
- } else if (typeof parsed.message === 'string') {
111
- apiMessage = parsed.message;
112
- }
113
- } catch {
114
- // Not JSON
115
- }
212
+ const apiMessage = _extractApiMessage(body);
116
213
 
117
214
  const headline = apiMessage
118
215
  ? `${action} failed: ${apiMessage} (${status})`
@@ -171,7 +268,8 @@ export class OpenCloudClient {
171
268
  placeId: number,
172
269
  rbxlPath: string,
173
270
  publish?: boolean,
174
- onProgress?: (transferredBytes: number, totalBytes: number) => void
271
+ onProgress?: (transferredBytes: number, totalBytes: number) => void,
272
+ onRateLimitWait?: (wait: RateLimitWait) => void
175
273
  ): Promise<number> {
176
274
  OutputHelper.verbose(
177
275
  `Uploading to https://www.roblox.com/games/${placeId}/place ...`
@@ -217,6 +315,7 @@ export class OpenCloudClient {
217
315
 
218
316
  const response = await this._rateLimiter.fetchAsync(url, makeUploadInit, {
219
317
  fetchImpl: nodeFetch as unknown as FetchLike,
318
+ onWait: onRateLimitWait,
220
319
  });
221
320
 
222
321
  if (!response.ok) {
@@ -269,7 +368,8 @@ export class OpenCloudClient {
269
368
  placeId: number,
270
369
  placeVersion: number,
271
370
  script: string,
272
- timeoutMs?: number
371
+ timeoutMs?: number,
372
+ onRateLimitWait?: (wait: RateLimitWait) => void
273
373
  ): Promise<LuauTask> {
274
374
  const apiKey = await this._resolveApiKeyAsync();
275
375
  const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}/places/${placeId}/versions/${placeVersion}/luau-execution-session-tasks`;
@@ -293,14 +393,18 @@ export class OpenCloudClient {
293
393
  body.timeout = `${clampedSeconds}s`;
294
394
  }
295
395
 
296
- const response = await this._rateLimiter.fetchAsync(url, {
297
- method: 'POST',
298
- headers: {
299
- 'Content-Type': 'application/json',
300
- 'X-API-Key': apiKey,
396
+ const response = await this._rateLimiter.fetchAsync(
397
+ url,
398
+ {
399
+ method: 'POST',
400
+ headers: {
401
+ 'Content-Type': 'application/json',
402
+ 'X-API-Key': apiKey,
403
+ },
404
+ body: JSON.stringify(body),
301
405
  },
302
- body: JSON.stringify(body),
303
- });
406
+ { onWait: onRateLimitWait }
407
+ );
304
408
 
305
409
  if (!response.ok) {
306
410
  const text = await response.text();
@@ -329,7 +433,8 @@ export class OpenCloudClient {
329
433
 
330
434
  async pollTaskCompletionAsync(
331
435
  taskPath: string,
332
- onProgress?: (state: LuauTask['state']) => void
436
+ onProgress?: (state: LuauTask['state']) => void,
437
+ onRateLimitWait?: (wait: RateLimitWait) => void
333
438
  ): Promise<LuauTask> {
334
439
  const apiKey = await this._resolveApiKeyAsync();
335
440
  const pollIntervalMs = 3000;
@@ -342,7 +447,8 @@ export class OpenCloudClient {
342
447
  headers: {
343
448
  'X-API-Key': apiKey,
344
449
  },
345
- }
450
+ },
451
+ { onWait: onRateLimitWait }
346
452
  );
347
453
 
348
454
  if (!response.ok) {
@@ -682,6 +788,217 @@ export class OpenCloudClient {
682
788
  `. Pin an explicit version number in deploy.nevermore.json, or publish the place once.`
683
789
  );
684
790
  }
791
+
792
+ /**
793
+ * Start uploading a file as a new asset via the Open Cloud Assets API
794
+ * (`asset:read` and `asset:write`). Returns the operation to hand to
795
+ * {@link pollAssetOperationAsync}; the asset has no id until that finishes.
796
+ */
797
+ async createAssetAsync(options: CreateAssetOptions): Promise<AssetOperation> {
798
+ const { creator } = options;
799
+ return this._sendAssetAsync(
800
+ 'POST',
801
+ `${ASSETS_API_URL}/assets`,
802
+ {
803
+ assetType: options.assetType,
804
+ displayName: options.displayName,
805
+ description: options.description,
806
+ creationContext: {
807
+ creator:
808
+ 'userId' in creator
809
+ ? { userId: String(creator.userId) }
810
+ : { groupId: String(creator.groupId) },
811
+ },
812
+ },
813
+ options
814
+ );
815
+ }
816
+
817
+ /**
818
+ * Start replacing an existing asset's file, keeping its id — the upload
819
+ * becomes a new version of the same asset. The API only does this for models.
820
+ */
821
+ async updateAssetContentAsync(
822
+ assetId: number,
823
+ file: AssetFile
824
+ ): Promise<AssetOperation> {
825
+ return this._sendAssetAsync(
826
+ 'PATCH',
827
+ `${ASSETS_API_URL}/assets/${assetId}`,
828
+ { assetId },
829
+ file
830
+ );
831
+ }
832
+
833
+ /**
834
+ * Wait for an asset upload to finish and return the asset it produced.
835
+ *
836
+ * Throws if the operation reports an error or outlives `timeoutMs`. A timeout
837
+ * is not a failed upload: Roblox may still finish creating the asset, which
838
+ * is why the message says to look before uploading again.
839
+ */
840
+ async pollAssetOperationAsync(
841
+ operation: AssetOperation,
842
+ timeoutMs: number = ASSET_OPERATION_TIMEOUT_MS
843
+ ): Promise<UploadedAsset> {
844
+ const apiKey = await this._resolveApiKeyAsync();
845
+ const url = `${ASSETS_API_URL}/${operation.path}`;
846
+ const maxPolls = Math.ceil(timeoutMs / ASSET_OPERATION_POLL_MS);
847
+
848
+ let current = operation;
849
+ for (let poll = 0; !current.done && poll < maxPolls; poll++) {
850
+ await new Promise((resolve) =>
851
+ setTimeout(resolve, ASSET_OPERATION_POLL_MS)
852
+ );
853
+
854
+ const response = await this._rateLimiter.fetchAsync(url, {
855
+ method: 'GET',
856
+ headers: { 'x-api-key': apiKey },
857
+ });
858
+ if (!response.ok) {
859
+ throw new Error(
860
+ formatAssetApiError(
861
+ 'Read asset upload status',
862
+ response.status,
863
+ response.statusText,
864
+ await response.text(),
865
+ 'GET',
866
+ url
867
+ )
868
+ );
869
+ }
870
+ current = (await response.json()) as AssetOperation;
871
+ }
872
+
873
+ if (!current.done) {
874
+ throw new Error(
875
+ `Asset upload ${operation.path} did not finish within ` +
876
+ `${Math.round(timeoutMs / 1000)}s. Roblox may still create the ` +
877
+ `asset — check https://create.roblox.com/dashboard/creations ` +
878
+ `before uploading it again.`
879
+ );
880
+ }
881
+ if (current.error) {
882
+ throw new Error(
883
+ `Asset upload failed: ${
884
+ current.error.message ?? JSON.stringify(current.error)
885
+ }`
886
+ );
887
+ }
888
+
889
+ const assetId = Number(current.response?.assetId);
890
+ if (!Number.isSafeInteger(assetId) || assetId < 1) {
891
+ throw new Error(
892
+ `Asset upload ${operation.path} finished without an asset id: ` +
893
+ JSON.stringify(current)
894
+ );
895
+ }
896
+
897
+ return {
898
+ assetId,
899
+ revisionId: current.response?.revisionId,
900
+ moderationState: current.response?.moderationResult?.moderationState,
901
+ };
902
+ }
903
+
904
+ /**
905
+ * Who owns a universe: the user or group its assets should belong to if the
906
+ * experience is to use them. Needs no scope beyond a valid key.
907
+ */
908
+ async getUniverseOwnerAsync(universeId: number): Promise<AssetCreator> {
909
+ const apiKey = await this._resolveApiKeyAsync();
910
+ const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}`;
911
+
912
+ const response = await this._rateLimiter.fetchAsync(url, {
913
+ method: 'GET',
914
+ headers: { 'x-api-key': apiKey },
915
+ });
916
+ if (!response.ok) {
917
+ throw new Error(
918
+ `Read universe ${universeId} failed: ${response.status} ${response.statusText}`
919
+ );
920
+ }
921
+
922
+ // Exactly one of the two is present: `users/123` or `groups/456`.
923
+ const universe = (await response.json()) as {
924
+ user?: string;
925
+ group?: string;
926
+ };
927
+ const userId = Number(universe.user?.match(/^users\/(\d+)$/)?.[1]);
928
+ if (Number.isSafeInteger(userId)) {
929
+ return { userId };
930
+ }
931
+ const groupId = Number(universe.group?.match(/^groups\/(\d+)$/)?.[1]);
932
+ if (Number.isSafeInteger(groupId)) {
933
+ return { groupId };
934
+ }
935
+ throw new Error(`Universe ${universeId} did not report an owner`);
936
+ }
937
+
938
+ /** The user the API key acts as — whoever created it. */
939
+ async getApiKeyUserIdAsync(): Promise<number> {
940
+ const apiKey = await this._resolveApiKeyAsync();
941
+ const url = 'https://apis.roblox.com/api-keys/v1/introspect';
942
+
943
+ // The key travels in the body here, not the usual header.
944
+ const response = await this._rateLimiter.fetchAsync(url, {
945
+ method: 'POST',
946
+ headers: { 'Content-Type': 'application/json' },
947
+ body: JSON.stringify({ apiKey }),
948
+ });
949
+ if (!response.ok) {
950
+ throw new Error(
951
+ `Read API key owner failed: ${response.status} ${response.statusText}`
952
+ );
953
+ }
954
+
955
+ const key = (await response.json()) as { authorizedUserId?: number };
956
+ if (!Number.isSafeInteger(key.authorizedUserId)) {
957
+ throw new Error('The API key did not report which user it belongs to');
958
+ }
959
+ return key.authorizedUserId!;
960
+ }
961
+
962
+ private async _sendAssetAsync(
963
+ method: 'POST' | 'PATCH',
964
+ url: string,
965
+ request: Record<string, unknown>,
966
+ file: AssetFile
967
+ ): Promise<AssetOperation> {
968
+ const apiKey = await this._resolveApiKeyAsync();
969
+ const fileBuffer = await fs.readFile(file.filePath);
970
+
971
+ const form = new FormData();
972
+ form.append('request', JSON.stringify(request));
973
+ form.append(
974
+ 'fileContent',
975
+ new Blob([new Uint8Array(fileBuffer)], { type: file.contentType }),
976
+ path.basename(file.filePath)
977
+ );
978
+
979
+ // No Content-Type header: fetch writes the multipart boundary itself, and
980
+ // setting one by hand sends a body the API cannot split into parts.
981
+ const response = await this._rateLimiter.fetchAsync(url, {
982
+ method,
983
+ headers: { 'x-api-key': apiKey },
984
+ body: form,
985
+ });
986
+
987
+ if (!response.ok) {
988
+ throw new Error(
989
+ formatAssetApiError(
990
+ method === 'POST' ? 'Create asset' : 'Update asset',
991
+ response.status,
992
+ response.statusText,
993
+ await response.text(),
994
+ method,
995
+ url
996
+ )
997
+ );
998
+ }
999
+
1000
+ return (await response.json()) as AssetOperation;
1001
+ }
685
1002
  }
686
1003
 
687
1004
  /** How the Assets API reports one entry of a place's version history. */
@@ -5,10 +5,11 @@ vi.mock('@quenty/cli-output-helpers', () => ({
5
5
  warn: vi.fn(),
6
6
  info: vi.fn(),
7
7
  error: vi.fn(),
8
+ verbose: vi.fn(),
8
9
  },
9
10
  }));
10
11
 
11
- import { RateLimiter } from './rate-limiter.js';
12
+ import { formatRateLimitWait, RateLimiter } from './rate-limiter.js';
12
13
 
13
14
  interface Deferred<T> {
14
15
  promise: Promise<T>;
@@ -105,9 +106,10 @@ describe('RateLimiter', () => {
105
106
  expect(fetchMock).toHaveBeenCalledTimes(2);
106
107
  });
107
108
 
108
- it('blocks the next request when x-ratelimit-remaining hits 0 until reset elapses', async () => {
109
+ it('blocks the next request to the same endpoint when x-ratelimit-remaining hits 0 until reset elapses', async () => {
109
110
  vi.useFakeTimers();
110
111
  const limiter = new RateLimiter();
112
+ const onWait = vi.fn();
111
113
 
112
114
  fetchMock
113
115
  .mockResolvedValueOnce(
@@ -118,20 +120,83 @@ describe('RateLimiter', () => {
118
120
  )
119
121
  .mockResolvedValueOnce(makeResponse(200));
120
122
 
121
- await limiter.fetchAsync('https://apis.roblox.com/cloud/v2/a');
123
+ await limiter.fetchAsync(
124
+ 'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/3/luau-execution-session-tasks',
125
+ { method: 'POST' }
126
+ );
122
127
  expect(fetchMock).toHaveBeenCalledTimes(1);
123
128
 
124
- const second = limiter.fetchAsync('https://apis.roblox.com/cloud/v2/b');
129
+ const second = limiter.fetchAsync(
130
+ 'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/4/luau-execution-session-tasks',
131
+ { method: 'POST' },
132
+ { onWait }
133
+ );
125
134
 
126
135
  // Without advancing time, the second fetch is parked in _waitIfNeededAsync
127
136
  await vi.advanceTimersByTimeAsync(0);
128
137
  expect(fetchMock).toHaveBeenCalledTimes(1);
138
+ expect(onWait).toHaveBeenCalledWith(
139
+ expect.objectContaining({ reason: 'quota', waitMs: 5000 })
140
+ );
129
141
 
130
142
  await vi.advanceTimersByTimeAsync(5000);
131
143
  await second;
132
144
  expect(fetchMock).toHaveBeenCalledTimes(2);
133
145
  });
134
146
 
147
+ it('does not let a spent quota on one endpoint stall another', async () => {
148
+ // Open Cloud meters task creation (5/min) and task polling (200/min)
149
+ // separately; creation running dry must not park the poll.
150
+ vi.useFakeTimers();
151
+ const limiter = new RateLimiter();
152
+
153
+ fetchMock
154
+ .mockResolvedValueOnce(
155
+ makeResponse(200, {
156
+ 'x-ratelimit-remaining': '0',
157
+ 'x-ratelimit-reset': '60',
158
+ })
159
+ )
160
+ .mockResolvedValueOnce(makeResponse(200));
161
+
162
+ await limiter.fetchAsync(
163
+ 'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/3/luau-execution-session-tasks',
164
+ { method: 'POST' }
165
+ );
166
+
167
+ const poll = limiter.fetchAsync(
168
+ 'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/3/luau-execution-sessions/12b4bf19-8ddc-4885-9e54-fa40bb0faca8/tasks/12b4bf19-8ddc-4885-9e54-fa40bb0faca8',
169
+ { method: 'GET' }
170
+ );
171
+
172
+ await vi.advanceTimersByTimeAsync(0);
173
+ expect(fetchMock).toHaveBeenCalledTimes(2);
174
+ expect((await poll).status).toBe(200);
175
+ });
176
+
177
+ it('reports retry waits to onWait with the triggering status', async () => {
178
+ vi.useFakeTimers();
179
+ const limiter = new RateLimiter();
180
+ const onWait = vi.fn();
181
+
182
+ fetchMock
183
+ .mockResolvedValueOnce(makeResponse(429, { 'retry-after': '5' }))
184
+ .mockResolvedValueOnce(makeResponse(200));
185
+
186
+ const promise = limiter.fetchAsync(
187
+ 'https://apis.roblox.com/cloud/v2/a',
188
+ undefined,
189
+ { onWait }
190
+ );
191
+ await vi.advanceTimersByTimeAsync(5000);
192
+ await promise;
193
+
194
+ expect(onWait).toHaveBeenCalledTimes(1);
195
+ expect(onWait).toHaveBeenCalledWith(
196
+ expect.objectContaining({ reason: 'retry', status: 429, waitMs: 5000 })
197
+ );
198
+ });
199
+
135
200
  it('returns the last 429 response after exhausting all retries without sleeping past the final attempt', async () => {
136
201
  vi.useFakeTimers();
137
202
  const limiter = new RateLimiter({ maxRetries: 3 });
@@ -228,3 +293,40 @@ describe('RateLimiter', () => {
228
293
  expect(fetchMock).toHaveBeenCalledTimes(1);
229
294
  });
230
295
  });
296
+
297
+ describe('formatRateLimitWait', () => {
298
+ it('says whether the wait is a rate limit or a retry', () => {
299
+ expect(
300
+ formatRateLimitWait({ route: 'r', waitMs: 54_000, reason: 'quota' })
301
+ ).toBe('rate limited 54s');
302
+ expect(
303
+ formatRateLimitWait({
304
+ route: 'r',
305
+ waitMs: 5_500,
306
+ reason: 'retry',
307
+ status: 429,
308
+ })
309
+ ).toBe('rate limited 6s');
310
+ expect(
311
+ formatRateLimitWait({
312
+ route: 'r',
313
+ waitMs: 1_000,
314
+ reason: 'retry',
315
+ status: 503,
316
+ })
317
+ ).toBe('retrying in 1s');
318
+ expect(
319
+ formatRateLimitWait({ route: 'r', waitMs: 2_000, reason: 'retry' })
320
+ ).toBe('retrying in 2s');
321
+ });
322
+
323
+ it('fits the spinner status column while scheduling', () => {
324
+ // "◇ Scheduling " is 13 columns; the status cell is 32.
325
+ const longest = formatRateLimitWait({
326
+ route: 'r',
327
+ waitMs: 300_000,
328
+ reason: 'quota',
329
+ });
330
+ expect(`◇ Scheduling (${longest})`.length).toBeLessThanOrEqual(32);
331
+ });
332
+ });