@quenty/nevermore-cli 4.47.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 (42) hide show
  1. package/CHANGELOG.md +7 -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/utils/build/upload.d.ts.map +1 -1
  6. package/dist/utils/build/upload.js +4 -1
  7. package/dist/utils/build/upload.js.map +1 -1
  8. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  9. package/dist/utils/job-context/cloud-job-context.js +5 -3
  10. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  11. package/dist/utils/open-cloud/open-cloud-client.d.ts +4 -4
  12. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  13. package/dist/utils/open-cloud/open-cloud-client.js +6 -5
  14. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  15. package/dist/utils/open-cloud/rate-limiter.d.ts +29 -2
  16. package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
  17. package/dist/utils/open-cloud/rate-limiter.js +98 -20
  18. package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
  19. package/dist/utils/open-cloud/rate-limiter.test.js +64 -4
  20. package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
  21. package/dist/utils/testing/parsers/batch-log-parser.test.js +1 -1
  22. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  23. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  24. package/dist/utils/testing/runner/test-runner.js +8 -7
  25. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  26. package/dist/utils/testing/runner/test-runner.test.js +1 -1
  27. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/commands/batch-command/batch-deploy-command.ts +11 -3
  30. package/src/utils/build/upload.ts +5 -1
  31. package/src/utils/job-context/cloud-job-context.ts +24 -15
  32. package/src/utils/open-cloud/open-cloud-client.ts +25 -12
  33. package/src/utils/open-cloud/rate-limiter.test.ts +106 -4
  34. package/src/utils/open-cloud/rate-limiter.ts +150 -21
  35. package/src/utils/testing/parsers/batch-log-parser.test.ts +3 -3
  36. package/src/utils/testing/runner/test-runner.test.ts +1 -1
  37. package/src/utils/testing/runner/test-runner.ts +8 -9
  38. package/templates/batch-test-runner.lua +176 -172
  39. package/templates/game-template/src/scripts/Server/ServerMain.server.lua +3 -2
  40. package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  41. package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,5 @@
1
1
  import { OutputHelper } from '@quenty/cli-output-helpers';
2
+ import { type Reporter } from '@quenty/cli-output-helpers/reporting';
2
3
 
3
4
  /**
4
5
  * Extracts a short route label from a URL for logging.
@@ -18,6 +19,84 @@ function _extractRoute(url: string | URL | Request): string {
18
19
  }
19
20
  }
20
21
 
22
+ const ID_SEGMENT_PATTERN =
23
+ /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
24
+
25
+ /**
26
+ * Identifies the quota a request draws from. Open Cloud meters each endpoint
27
+ * separately (task creation: 5/min, task polling: 200/min, …), so the key is
28
+ * the method plus the path with ids collapsed — every poll of every task
29
+ * shares one bucket, and it is not the bucket task creation uses.
30
+ */
31
+ function _bucketKey(url: string | URL | Request, init?: RequestInit): string {
32
+ try {
33
+ const urlStr =
34
+ typeof url === 'string' ? url : url instanceof URL ? url.href : url.url;
35
+ const parsed = new URL(urlStr);
36
+ const path = parsed.pathname
37
+ .split('/')
38
+ .map((segment) => (ID_SEGMENT_PATTERN.test(segment) ? ':id' : segment))
39
+ .join('/');
40
+ const method = (init?.method ?? 'GET').toUpperCase();
41
+ return `${method} ${parsed.host}${path}`;
42
+ } catch {
43
+ return 'unknown';
44
+ }
45
+ }
46
+
47
+ /** A wait the limiter is about to sleep through before sending a request. */
48
+ export interface RateLimitWait {
49
+ route: string;
50
+ waitMs: number;
51
+ /**
52
+ * `quota`: a prior response said this endpoint's quota is spent.
53
+ * `retry`: this request failed (429, 5xx, transport) and is backing off.
54
+ */
55
+ reason: 'quota' | 'retry';
56
+ /** HTTP status that triggered a `retry` wait, absent for transport errors. */
57
+ status?: number;
58
+ }
59
+
60
+ /**
61
+ * Builds an `onWait` that shows the wait as the package's progress label, so
62
+ * a spinner stuck on "Scheduling" reads as rate limiting rather than a hang.
63
+ */
64
+ export function reportRateLimitWait(
65
+ reporter: Reporter,
66
+ packageName: string
67
+ ): (wait: RateLimitWait) => void {
68
+ return (wait) => {
69
+ reporter.onPackageProgressUpdate(packageName, {
70
+ kind: 'steps',
71
+ completed: 0,
72
+ total: 0,
73
+ label: formatRateLimitWait(wait),
74
+ });
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Kept short enough that "◇ Scheduling (…)" fits the spinner's 32-column
80
+ * status cell: a longer label wraps on an 80-column terminal, and the
81
+ * spinner's cursor rewind doesn't count wrapped lines. The `--verbose`
82
+ * warnings carry the status code and route.
83
+ */
84
+ export function formatRateLimitWait(wait: RateLimitWait): string {
85
+ if (wait.reason === 'quota') {
86
+ return `rate limited ${Math.ceil(wait.waitMs / 1000)}s`;
87
+ }
88
+ const waitSec = Math.round(wait.waitMs / 1000);
89
+ if (wait.status === 429) {
90
+ return `rate limited ${waitSec}s`;
91
+ }
92
+ return `retrying in ${waitSec}s`;
93
+ }
94
+
95
+ interface QuotaState {
96
+ remaining: number | null;
97
+ resetAtMs: number | null;
98
+ }
99
+
21
100
  /**
22
101
  * Minimal fetch shape the limiter drives. Defaults to the global `fetch`
23
102
  * (undici). Callers can inject an alternative — e.g. node-fetch, whose Node
@@ -53,6 +132,7 @@ export interface RateLimiterOptions {
53
132
  * Shared rate limiter for Roblox Open Cloud API requests.
54
133
  *
55
134
  * - Tracks `x-ratelimit-remaining` and `x-ratelimit-reset` (seconds until reset)
135
+ * per endpoint, so a spent task-creation quota doesn't stall task polling
56
136
  * - Caps concurrent in-flight requests (default 4) via a semaphore so callers
57
137
  * benefit from parallelism (e.g. multi-place deploys) while still getting
58
138
  * header feedback before each wave widens past the quota
@@ -60,8 +140,7 @@ export interface RateLimiterOptions {
60
140
  * concurrent callers don't all wake up on the same millisecond and re-collide
61
141
  */
62
142
  export class RateLimiter {
63
- private _remaining: number | null = null;
64
- private _resetAtMs: number | null = null;
143
+ private _quotas = new Map<string, QuotaState>();
65
144
 
66
145
  /** Queue of callers waiting to send a request. */
67
146
  private _queue: Array<() => void> = [];
@@ -77,38 +156,71 @@ export class RateLimiter {
77
156
  this._maxRetries = Math.max(1, options.maxRetries ?? DEFAULT_MAX_RETRIES);
78
157
  }
79
158
 
80
- private _updateFromHeaders(headers: Headers): void {
159
+ private _updateFromHeaders(
160
+ bucketKey: string,
161
+ route: string,
162
+ status: number,
163
+ headers: Headers
164
+ ): void {
81
165
  const remaining = headers.get('x-ratelimit-remaining');
82
166
  const reset = headers.get('x-ratelimit-reset');
83
167
 
168
+ const rateLimitHeaders: string[] = [];
169
+ headers.forEach((value, key) => {
170
+ if (key.toLowerCase().includes('ratelimit') || key === 'retry-after') {
171
+ rateLimitHeaders.push(`${key}=${value}`);
172
+ }
173
+ });
174
+ OutputHelper.verbose(
175
+ `[rate-limit] ${status} ${route} ${
176
+ rateLimitHeaders.join(' ') || '(no rate-limit headers)'
177
+ }`
178
+ );
179
+
180
+ if (remaining === null && reset === null) {
181
+ return;
182
+ }
183
+
184
+ let quota = this._quotas.get(bucketKey);
185
+ if (!quota) {
186
+ quota = { remaining: null, resetAtMs: null };
187
+ this._quotas.set(bucketKey, quota);
188
+ }
189
+
84
190
  if (remaining !== null) {
85
- this._remaining = parseInt(remaining, 10);
191
+ quota.remaining = parseInt(remaining, 10);
86
192
  }
87
193
 
88
194
  // x-ratelimit-reset is seconds until reset, not an epoch timestamp
89
195
  if (reset !== null) {
90
196
  const resetSec = parseFloat(reset);
91
- this._resetAtMs = Date.now() + resetSec * 1000;
197
+ quota.resetAtMs = Date.now() + resetSec * 1000;
92
198
  }
93
199
  }
94
200
 
95
- private async _waitIfNeededAsync(route: string): Promise<void> {
201
+ private async _waitIfNeededAsync(
202
+ bucketKey: string,
203
+ route: string,
204
+ onWait?: (wait: RateLimitWait) => void
205
+ ): Promise<void> {
206
+ const quota = this._quotas.get(bucketKey);
96
207
  if (
97
- this._remaining !== null &&
98
- this._remaining <= 0 &&
99
- this._resetAtMs !== null
208
+ quota &&
209
+ quota.remaining !== null &&
210
+ quota.remaining <= 0 &&
211
+ quota.resetAtMs !== null
100
212
  ) {
101
- const waitMs = this._resetAtMs - Date.now();
213
+ const waitMs = quota.resetAtMs - Date.now();
102
214
  if (waitMs > 0) {
103
215
  const waitSec = Math.ceil(waitMs / 1000);
104
216
  OutputHelper.warn(
105
217
  `Rate limit reached on ${route}. Waiting ${waitSec}s (Roblox requested)...`
106
218
  );
219
+ onWait?.({ route, waitMs, reason: 'quota' });
107
220
  await new Promise((resolve) => setTimeout(resolve, waitMs));
108
221
  }
109
222
  // Reset after waiting so the next request can proceed
110
- this._remaining = null;
111
- this._resetAtMs = null;
223
+ this._quotas.delete(bucketKey);
112
224
  }
113
225
  }
114
226
 
@@ -146,36 +258,47 @@ export class RateLimiter {
146
258
  * - Retries on 429, retryable 5xx (502/503/504), and transport errors
147
259
  * with jittered back-off
148
260
  * - Tracks rate-limit headers from every response
261
+ * - Tells `onWait` before every sleep, so callers can say why they stalled
149
262
  */
150
263
  async fetchAsync(
151
264
  url: string | URL | Request,
152
265
  init?: RequestInit | (() => RequestInit),
153
- options?: { fetchImpl?: FetchLike }
266
+ options?: {
267
+ fetchImpl?: FetchLike;
268
+ onWait?: (wait: RateLimitWait) => void;
269
+ }
154
270
  ): Promise<Response> {
155
271
  await this._acquireAsync();
156
272
 
157
273
  const route = _extractRoute(url);
158
274
  const doFetch = options?.fetchImpl ?? fetch;
275
+ const onWait = options?.onWait;
159
276
 
160
277
  try {
161
278
  let lastResponse: Response | null = null;
162
279
  let lastError: unknown = null;
163
280
 
164
281
  for (let attempt = 0; attempt < this._maxRetries; attempt++) {
165
- await this._waitIfNeededAsync(route);
282
+ // Resolve init per attempt so a caller streaming a one-shot body
283
+ // (e.g. a progress-reporting upload) hands us a fresh, un-disturbed
284
+ // stream on every retry; a reused stream throws "body ... disturbed
285
+ // or locked". Plain-object inits (Uint8Array/string bodies) are just
286
+ // returned as-is and replay safely.
287
+ const resolvedInit = typeof init === 'function' ? init() : init;
288
+ const bucketKey = _bucketKey(url, resolvedInit);
289
+ await this._waitIfNeededAsync(bucketKey, route, onWait);
166
290
 
167
291
  let response: Response | null = null;
168
292
  let transportError: unknown = null;
169
293
 
170
294
  try {
171
- // Resolve init per attempt so a caller streaming a one-shot body
172
- // (e.g. a progress-reporting upload) hands us a fresh, un-disturbed
173
- // stream on every retry; a reused stream throws "body ... disturbed
174
- // or locked". Plain-object inits (Uint8Array/string bodies) are just
175
- // returned as-is and replay safely.
176
- const resolvedInit = typeof init === 'function' ? init() : init;
177
295
  response = await doFetch(url, resolvedInit);
178
- this._updateFromHeaders(response.headers);
296
+ this._updateFromHeaders(
297
+ bucketKey,
298
+ route,
299
+ response.status,
300
+ response.headers
301
+ );
179
302
  } catch (err) {
180
303
  transportError = err;
181
304
  }
@@ -204,6 +327,12 @@ export class RateLimiter {
204
327
  this._maxRetries
205
328
  }). Retrying in ${waitSec.toFixed(1)}s...`
206
329
  );
330
+ onWait?.({
331
+ route,
332
+ waitMs: waitSec * 1000,
333
+ reason: 'retry',
334
+ status: response?.status,
335
+ });
207
336
  await new Promise((resolve) => setTimeout(resolve, waitSec * 1000));
208
337
  }
209
338
 
@@ -317,9 +317,9 @@ describe('parseBatchTestLogs', () => {
317
317
  SLUG_MAP
318
318
  );
319
319
 
320
- expect(warnings().some((w) => w.includes('returned no test results'))).toBe(
321
- false
322
- );
320
+ expect(
321
+ warnings().some((w) => w.includes('fell back to log scraping'))
322
+ ).toBe(false);
323
323
  });
324
324
 
325
325
  it('passes every package whose returned counts are clean', () => {
@@ -203,7 +203,7 @@ describe('runSingleTestAsync', () => {
203
203
 
204
204
  await runAsync({ success: true, returnValues: [] }, PASSING_LOGS);
205
205
 
206
- expect(warnings().some((w) => w.includes('returned no test results'))).toBe(
206
+ expect(warnings().some((w) => w.includes('counts read from logs'))).toBe(
207
207
  true
208
208
  );
209
209
  });
@@ -313,15 +313,14 @@ function reportCountsProvenance(options: {
313
313
  return;
314
314
  }
315
315
 
316
- OutputHelper.warn(
317
- `${packageName}: the run returned no test results, so its counts were ` +
318
- (scrapedCounts ? 'scraped from log text' : 'unavailable') +
319
- ` — the channel Open Cloud truncates on long runs (${logVolume}). ` +
320
- (hadReturnChannel
321
- ? 'The run delivered a return channel but nothing recognizable in it: the ' +
322
- 'test script should end with "return results" (see docs/testing/testing.md).'
323
- : 'No return channel was delivered at all, so the transport lost it.')
324
- );
316
+ const counts = scrapedCounts
317
+ ? 'counts read from logs, which Open Cloud truncates on long runs'
318
+ : 'no counts, the run returned none and the logs held none';
319
+ const remedy = hadReturnChannel
320
+ ? 'For exact counts, end the test script with `return results` (docs/testing/testing.md).'
321
+ : 'Open Cloud returned nothing from the run.';
322
+ OutputHelper.warn(`${packageName}: ${counts}. ${remedy}`);
323
+ OutputHelper.verbose(`${packageName}: log volume ${logVolume}`);
325
324
  }
326
325
 
327
326
  /**