@quenty/nevermore-cli 4.37.0 → 4.38.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  3. package/dist/commands/deploy-command/index.js +43 -0
  4. package/dist/commands/deploy-command/index.js.map +1 -1
  5. package/dist/commands/deploy-command/version-command.d.ts +21 -0
  6. package/dist/commands/deploy-command/version-command.d.ts.map +1 -0
  7. package/dist/commands/deploy-command/version-command.js +206 -0
  8. package/dist/commands/deploy-command/version-command.js.map +1 -0
  9. package/dist/utils/build/deploy-config.d.ts +8 -0
  10. package/dist/utils/build/deploy-config.d.ts.map +1 -1
  11. package/dist/utils/build/deploy-config.js +5 -0
  12. package/dist/utils/build/deploy-config.js.map +1 -1
  13. package/dist/utils/build/deploy-config.test.js +86 -1
  14. package/dist/utils/build/deploy-config.test.js.map +1 -1
  15. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  16. package/dist/utils/job-context/base-job-context.js +4 -2
  17. package/dist/utils/job-context/base-job-context.js.map +1 -1
  18. package/dist/utils/open-cloud/open-cloud-client.d.ts +12 -1
  19. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  20. package/dist/utils/open-cloud/open-cloud-client.js +49 -4
  21. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  22. package/dist/utils/open-cloud/open-cloud-client.test.js +89 -0
  23. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/commands/deploy-command/index.ts +71 -0
  26. package/src/commands/deploy-command/version-command.ts +311 -0
  27. package/src/utils/build/deploy-config.test.ts +98 -0
  28. package/src/utils/build/deploy-config.ts +17 -0
  29. package/src/utils/job-context/base-job-context.ts +7 -2
  30. package/src/utils/open-cloud/open-cloud-client.test.ts +132 -0
  31. package/src/utils/open-cloud/open-cloud-client.ts +84 -5
  32. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,311 @@
1
+ import inquirer from 'inquirer';
2
+ import * as fs from 'fs/promises';
3
+ import { OutputHelper } from '@quenty/cli-output-helpers';
4
+ import {
5
+ formatTable,
6
+ type TableColumn,
7
+ } from '@quenty/cli-output-helpers/reporting';
8
+ import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers';
9
+ import {
10
+ loadDeployConfigAsync,
11
+ resolveDeployConfigPath,
12
+ resolveDeployTargetPlaces,
13
+ type BasePlaceConfig,
14
+ type DeployConfig,
15
+ } from '../../utils/build/deploy-config.js';
16
+ import { OpenCloudClient } from '../../utils/open-cloud/open-cloud-client.js';
17
+ import { RateLimiter } from '../../utils/open-cloud/rate-limiter.js';
18
+ import { DeployArgs } from './index.js';
19
+
20
+ interface BasePlaceRef {
21
+ targetName: string;
22
+ placeLabel: string;
23
+ basePlace: BasePlaceConfig;
24
+ }
25
+
26
+ /** A pending change: set `ref.basePlace.version` to `to`. */
27
+ interface PinChange {
28
+ ref: BasePlaceRef;
29
+ from?: number;
30
+ to: number;
31
+ }
32
+
33
+ export interface VersionPromoteArgs extends DeployArgs {
34
+ from?: string;
35
+ to?: string;
36
+ }
37
+
38
+ function _plural(count: number): string {
39
+ return count === 1 ? '' : 's';
40
+ }
41
+
42
+ function _requireTarget(config: DeployConfig, targetName: string): void {
43
+ if (!config.targets[targetName]) {
44
+ throw new Error(
45
+ [
46
+ `Target "${targetName}" not found in deploy.nevermore.json.`,
47
+ `Available targets: ${Object.keys(config.targets).join(', ')}`,
48
+ ].join('\n')
49
+ );
50
+ }
51
+ }
52
+
53
+ /** Collect every basePlace across the given targets, keeping live references. */
54
+ function _collectBasePlaceRefs(
55
+ config: DeployConfig,
56
+ targetNames: string[]
57
+ ): BasePlaceRef[] {
58
+ const refs: BasePlaceRef[] = [];
59
+ for (const targetName of targetNames) {
60
+ for (const place of resolveDeployTargetPlaces(config, targetName)) {
61
+ if (place.basePlace) {
62
+ refs.push({
63
+ targetName,
64
+ placeLabel: place.name ?? targetName,
65
+ basePlace: place.basePlace,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ return refs;
71
+ }
72
+
73
+ /**
74
+ * Gate the change set on --dryrun / confirmation, apply it in place, and write
75
+ * the config back. `changes` may include no-op entries; only entries whose
76
+ * version actually differs are counted and applied. Returns nothing — messaging
77
+ * is handled here so the two commands stay consistent.
78
+ */
79
+ async function _commitPinChangesAsync(
80
+ configPath: string,
81
+ config: DeployConfig,
82
+ changes: PinChange[],
83
+ args: { dryrun?: boolean; yes?: boolean }
84
+ ): Promise<void> {
85
+ const changed = changes.filter((c) => c.from !== c.to);
86
+ if (changed.length === 0) {
87
+ OutputHelper.info('Nothing to change — pins are already up to date.');
88
+ return;
89
+ }
90
+
91
+ if (args.dryrun) {
92
+ OutputHelper.info(
93
+ `[DRYRUN] Would update ${changed.length} pin${_plural(
94
+ changed.length
95
+ )} in ${configPath}`
96
+ );
97
+ return;
98
+ }
99
+
100
+ if (!args.yes) {
101
+ const { confirm } = await inquirer.prompt([
102
+ {
103
+ type: 'confirm',
104
+ name: 'confirm',
105
+ message: `Update ${changed.length} version pin${_plural(
106
+ changed.length
107
+ )} in deploy.nevermore.json?`,
108
+ default: true,
109
+ },
110
+ ]);
111
+ if (!confirm) {
112
+ OutputHelper.info('Aborted — no changes written.');
113
+ return;
114
+ }
115
+ }
116
+
117
+ // Mutate in place — every ref.basePlace points into `config`.
118
+ for (const change of changed) {
119
+ change.ref.basePlace.version = change.to;
120
+ }
121
+
122
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
123
+ OutputHelper.info(
124
+ `Updated ${changed.length} version pin${_plural(
125
+ changed.length
126
+ )} in ${configPath}`
127
+ );
128
+ OutputHelper.hint(
129
+ 'Commit deploy.nevermore.json, then deploy to roll base places forward.'
130
+ );
131
+ }
132
+
133
+ /**
134
+ * `nevermore deploy version upgrade [target]` — re-pin every `basePlace` in
135
+ * deploy.nevermore.json to its current latest published version. Without a
136
+ * target it walks every target; pass one to scope it. `--dryrun` previews the
137
+ * change set without writing.
138
+ */
139
+ export async function handleVersionUpgradeAsync(
140
+ args: DeployArgs
141
+ ): Promise<void> {
142
+ const cwd = process.cwd();
143
+ const configPath = resolveDeployConfigPath(cwd);
144
+ const config = await loadDeployConfigAsync(configPath);
145
+
146
+ if (args.target) {
147
+ _requireTarget(config, args.target);
148
+ }
149
+
150
+ const targetNames = args.target ? [args.target] : Object.keys(config.targets);
151
+ const refs = _collectBasePlaceRefs(config, targetNames);
152
+
153
+ if (refs.length === 0) {
154
+ OutputHelper.warn(
155
+ args.target
156
+ ? `Target "${args.target}" has no basePlace to pin.`
157
+ : 'No basePlace found in deploy.nevermore.json — nothing to pin.'
158
+ );
159
+ return;
160
+ }
161
+
162
+ // Resolve the latest version once per unique base place id — several targets
163
+ // (e.g. integration/prod) commonly point at the same base place.
164
+ const uniquePlaceIds = [...new Set(refs.map((r) => r.basePlace.placeId))];
165
+ OutputHelper.info(
166
+ `Resolving latest version for ${uniquePlaceIds.length} base place${_plural(
167
+ uniquePlaceIds.length
168
+ )}...`
169
+ );
170
+
171
+ const apiKey = await getApiKeyAsync(args);
172
+ const client = new OpenCloudClient({
173
+ apiKey,
174
+ rateLimiter: new RateLimiter(),
175
+ });
176
+
177
+ const latestByPlaceId = new Map<number, number>();
178
+ await Promise.all(
179
+ uniquePlaceIds.map(async (placeId) => {
180
+ const ref = refs.find((r) => r.basePlace.placeId === placeId)!;
181
+ const latest = await client.getLatestPlaceVersionAsync(
182
+ ref.basePlace.universeId,
183
+ placeId
184
+ );
185
+ latestByPlaceId.set(placeId, latest);
186
+ })
187
+ );
188
+
189
+ const changes: PinChange[] = refs.map((ref) => ({
190
+ ref,
191
+ from: ref.basePlace.version,
192
+ to: latestByPlaceId.get(ref.basePlace.placeId)!,
193
+ }));
194
+
195
+ const columns: TableColumn<PinChange>[] = [
196
+ { header: 'Target', value: (c) => c.ref.targetName },
197
+ { header: 'Place', value: (c) => c.ref.placeLabel },
198
+ { header: 'Base place', value: (c) => String(c.ref.basePlace.placeId) },
199
+ { header: 'Version', value: (c) => _formatChange(c) },
200
+ ];
201
+
202
+ console.log('');
203
+ console.log(formatTable(changes, columns, { indent: ' ' }));
204
+ console.log('');
205
+
206
+ await _commitPinChangesAsync(configPath, config, changes, args);
207
+ }
208
+
209
+ /**
210
+ * `nevermore deploy version promote <from> <to>` — copy the base-place version
211
+ * pins from one target to another (e.g. promote validated `production-demo`
212
+ * pins to `production`). Places are matched by their base place id, so the same
213
+ * source content lines up even when the two targets name their places
214
+ * differently. Pure config edit — no network. `--dryrun` previews.
215
+ */
216
+ export async function handleVersionPromoteAsync(
217
+ args: VersionPromoteArgs
218
+ ): Promise<void> {
219
+ const fromTarget = args.from;
220
+ const toTarget = args.to;
221
+ if (!fromTarget || !toTarget) {
222
+ throw new Error(
223
+ 'Usage: nevermore deploy version copy <from-target> <to-target>'
224
+ );
225
+ }
226
+ if (fromTarget === toTarget) {
227
+ throw new Error('The <from> and <to> targets must be different.');
228
+ }
229
+
230
+ const cwd = process.cwd();
231
+ const configPath = resolveDeployConfigPath(cwd);
232
+ const config = await loadDeployConfigAsync(configPath);
233
+
234
+ _requireTarget(config, fromTarget);
235
+ _requireTarget(config, toTarget);
236
+
237
+ // Map base place id -> pinned version from the source target. A source with
238
+ // the same base place pinned to two different versions is inconsistent, so
239
+ // fail loudly rather than pick one arbitrarily.
240
+ const versionByPlaceId = new Map<number, number>();
241
+ for (const ref of _collectBasePlaceRefs(config, [fromTarget])) {
242
+ const version = ref.basePlace.version;
243
+ if (version == null) {
244
+ continue;
245
+ }
246
+ const existing = versionByPlaceId.get(ref.basePlace.placeId);
247
+ if (existing != null && existing !== version) {
248
+ throw new Error(
249
+ `Target "${fromTarget}" pins base place ${ref.basePlace.placeId} to ` +
250
+ `two different versions (v${existing} and v${version}). ` +
251
+ `Run "nevermore deploy version upgrade ${fromTarget}" to make it consistent first.`
252
+ );
253
+ }
254
+ versionByPlaceId.set(ref.basePlace.placeId, version);
255
+ }
256
+
257
+ if (versionByPlaceId.size === 0) {
258
+ OutputHelper.warn(
259
+ `Target "${fromTarget}" has no pinned base place versions to copy. ` +
260
+ `Run "nevermore deploy version upgrade ${fromTarget}" first.`
261
+ );
262
+ return;
263
+ }
264
+
265
+ const toRefs = _collectBasePlaceRefs(config, [toTarget]);
266
+ if (toRefs.length === 0) {
267
+ OutputHelper.warn(`Target "${toTarget}" has no basePlace to copy pins to.`);
268
+ return;
269
+ }
270
+
271
+ const changes: PinChange[] = [];
272
+ const unmatched: BasePlaceRef[] = [];
273
+ for (const ref of toRefs) {
274
+ const source = versionByPlaceId.get(ref.basePlace.placeId);
275
+ if (source == null) {
276
+ unmatched.push(ref);
277
+ continue;
278
+ }
279
+ changes.push({ ref, from: ref.basePlace.version, to: source });
280
+ }
281
+
282
+ const columns: TableColumn<PinChange>[] = [
283
+ { header: 'Place', value: (c) => c.ref.placeLabel },
284
+ { header: 'Base place', value: (c) => String(c.ref.basePlace.placeId) },
285
+ { header: 'Version', value: (c) => _formatChange(c) },
286
+ ];
287
+
288
+ console.log('');
289
+ OutputHelper.info(`Promoting pins from "${fromTarget}" to "${toTarget}":`);
290
+ console.log(formatTable(changes, columns, { indent: ' ' }));
291
+ console.log('');
292
+
293
+ if (unmatched.length > 0) {
294
+ OutputHelper.warn(
295
+ `${unmatched.length} place${_plural(
296
+ unmatched.length
297
+ )} in "${toTarget}" had no matching pin in "${fromTarget}" (left unchanged): ` +
298
+ unmatched
299
+ .map((r) => `${r.placeLabel} (${r.basePlace.placeId})`)
300
+ .join(', ')
301
+ );
302
+ }
303
+
304
+ await _commitPinChangesAsync(configPath, config, changes, args);
305
+ }
306
+
307
+ function _formatChange(change: PinChange): string {
308
+ const from = change.from != null ? `v${change.from}` : '(latest)';
309
+ const unchanged = change.from === change.to ? ' (unchanged)' : '';
310
+ return `${from} → v${change.to}${unchanged}`;
311
+ }
@@ -1,10 +1,21 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
2
5
  import {
3
6
  type DeployConfig,
4
7
  type DeployTarget,
8
+ loadDeployConfigAsync,
5
9
  resolveDefaultTargetName,
6
10
  } from './deploy-config.js';
7
11
 
12
+ async function writeTempConfigAsync(config: unknown): Promise<string> {
13
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'deploy-config-test-'));
14
+ const configPath = path.join(dir, 'deploy.nevermore.json');
15
+ await fs.writeFile(configPath, JSON.stringify(config));
16
+ return configPath;
17
+ }
18
+
8
19
  function makeTarget(placeId: number): DeployTarget {
9
20
  return {
10
21
  universeId: 1,
@@ -49,3 +60,90 @@ describe('resolveDefaultTargetName', () => {
49
60
  expect(() => resolveDefaultTargetName(config)).toThrowError(/foo, bar/);
50
61
  });
51
62
  });
63
+
64
+ describe('loadDeployConfigAsync basePlace.version', () => {
65
+ it('accepts an omitted version pin', async () => {
66
+ const configPath = await writeTempConfigAsync({
67
+ targets: {
68
+ test: {
69
+ universeId: 1,
70
+ placeId: 2,
71
+ project: 'default.project.json',
72
+ basePlace: { universeId: 1, placeId: 3 },
73
+ },
74
+ },
75
+ });
76
+ const config = await loadDeployConfigAsync(configPath);
77
+ const target = config.targets['test'] as DeployTarget;
78
+ expect(target.basePlace?.version).toBeUndefined();
79
+ });
80
+
81
+ it('accepts a positive integer version pin', async () => {
82
+ const configPath = await writeTempConfigAsync({
83
+ targets: {
84
+ test: {
85
+ universeId: 1,
86
+ placeId: 2,
87
+ project: 'default.project.json',
88
+ basePlace: { universeId: 1, placeId: 3, version: 42 },
89
+ },
90
+ },
91
+ });
92
+ const config = await loadDeployConfigAsync(configPath);
93
+ const target = config.targets['test'] as DeployTarget;
94
+ expect(target.basePlace?.version).toBe(42);
95
+ });
96
+
97
+ it('rejects a non-integer version pin', async () => {
98
+ const configPath = await writeTempConfigAsync({
99
+ targets: {
100
+ test: {
101
+ universeId: 1,
102
+ placeId: 2,
103
+ project: 'default.project.json',
104
+ basePlace: { universeId: 1, placeId: 3, version: 1.5 },
105
+ },
106
+ },
107
+ });
108
+ await expect(loadDeployConfigAsync(configPath)).rejects.toThrowError(
109
+ /version.*positive integer/
110
+ );
111
+ });
112
+
113
+ it('rejects a zero or negative version pin', async () => {
114
+ const configPath = await writeTempConfigAsync({
115
+ targets: {
116
+ test: {
117
+ universeId: 1,
118
+ placeId: 2,
119
+ project: 'default.project.json',
120
+ basePlace: { universeId: 1, placeId: 3, version: 0 },
121
+ },
122
+ },
123
+ });
124
+ await expect(loadDeployConfigAsync(configPath)).rejects.toThrowError(
125
+ /version.*positive integer/
126
+ );
127
+ });
128
+
129
+ it('validates version pins on multi-place targets too', async () => {
130
+ const configPath = await writeTempConfigAsync({
131
+ targets: {
132
+ prod: {
133
+ places: [
134
+ {
135
+ name: 'chapter0',
136
+ universeId: 1,
137
+ placeId: 2,
138
+ project: 'default.project.json',
139
+ basePlace: { universeId: 1, placeId: 3, version: -1 },
140
+ },
141
+ ],
142
+ },
143
+ },
144
+ });
145
+ await expect(loadDeployConfigAsync(configPath)).rejects.toThrowError(
146
+ /chapter0.*version.*positive integer/
147
+ );
148
+ });
149
+ });
@@ -4,6 +4,14 @@ import * as path from 'path';
4
4
  export interface BasePlaceConfig {
5
5
  universeId: number;
6
6
  placeId: number;
7
+ /**
8
+ * Pin the base place to a specific published version. When set, the deploy
9
+ * downloads exactly this version instead of whatever is currently live, so
10
+ * builds are reproducible and a broken Studio edit can't leak into a deploy.
11
+ * Omit to always pull the latest version. Bump it with
12
+ * `nevermore deploy version upgrade`.
13
+ */
14
+ version?: number;
7
15
  }
8
16
 
9
17
  export interface DeployTarget {
@@ -53,6 +61,15 @@ function _validatePlace(label: string, place: DeployTarget): void {
53
61
  if (typeof place.basePlace.placeId !== 'number') {
54
62
  throw new Error(`${label} basePlace is missing or has invalid "placeId"`);
55
63
  }
64
+ if (
65
+ place.basePlace.version != null &&
66
+ (!Number.isInteger(place.basePlace.version) ||
67
+ place.basePlace.version < 1)
68
+ ) {
69
+ throw new Error(
70
+ `${label} basePlace "version" must be a positive integer when set`
71
+ );
72
+ }
56
73
  }
57
74
  }
58
75
 
@@ -193,7 +193,11 @@ export abstract class BaseJobContext implements JobContext {
193
193
  const resolvedName = options.packageName ?? '';
194
194
 
195
195
  this._reporter.onPackagePhaseChange(resolvedName, 'downloading');
196
- OutputHelper.verbose('Downloading base place for merge...');
196
+ OutputHelper.verbose(
197
+ basePlace.version != null
198
+ ? `Downloading base place (pinned v${basePlace.version}) for merge...`
199
+ : 'Downloading base place (latest) for merge...'
200
+ );
197
201
  const buffer = await this._openCloudClient.downloadPlaceAsync(
198
202
  basePlace.universeId,
199
203
  basePlace.placeId,
@@ -203,7 +207,8 @@ export abstract class BaseJobContext implements JobContext {
203
207
  transferredBytes: transferred,
204
208
  totalBytes: total,
205
209
  });
206
- }
210
+ },
211
+ basePlace.version
207
212
  );
208
213
  const basePath = mergeContext.resolvePath('base.rbxl');
209
214
  await fs.writeFile(basePath, buffer);
@@ -116,3 +116,135 @@ describe('OpenCloudClient.uploadPlaceAsync', () => {
116
116
  expect(urls[1]).toContain('versionType=Saved');
117
117
  });
118
118
  });
119
+
120
+ describe('OpenCloudClient.downloadPlaceAsync', () => {
121
+ afterEach(() => {
122
+ vi.restoreAllMocks();
123
+ vi.unstubAllGlobals();
124
+ });
125
+
126
+ it('downloads the latest version when no version is pinned', async () => {
127
+ const urls: string[] = [];
128
+ const fakeLimiter = {
129
+ fetchAsync: vi.fn(async (url: string | URL) => {
130
+ urls.push(String(url));
131
+ return new Response(JSON.stringify({ location: 'https://cdn/x' }), {
132
+ status: 200,
133
+ });
134
+ }),
135
+ } as unknown as RateLimiter;
136
+ vi.stubGlobal(
137
+ 'fetch',
138
+ vi.fn(async () => new Response(Buffer.from([1, 2, 3]), { status: 200 }))
139
+ );
140
+
141
+ const client = new OpenCloudClient({
142
+ apiKey: 'test-key',
143
+ rateLimiter: fakeLimiter,
144
+ });
145
+ const buffer = await client.downloadPlaceAsync(1, 22);
146
+
147
+ expect(urls[0]).toBe(
148
+ 'https://apis.roblox.com/asset-delivery-api/v1/assetId/22'
149
+ );
150
+ expect(urls[0]).not.toContain('/version/');
151
+ expect([...buffer]).toEqual([1, 2, 3]);
152
+ });
153
+
154
+ it('downloads a specific version when pinned', async () => {
155
+ const urls: string[] = [];
156
+ const fakeLimiter = {
157
+ fetchAsync: vi.fn(async (url: string | URL) => {
158
+ urls.push(String(url));
159
+ return new Response(JSON.stringify({ location: 'https://cdn/x' }), {
160
+ status: 200,
161
+ });
162
+ }),
163
+ } as unknown as RateLimiter;
164
+ vi.stubGlobal(
165
+ 'fetch',
166
+ vi.fn(async () => new Response(Buffer.from([9]), { status: 200 }))
167
+ );
168
+
169
+ const client = new OpenCloudClient({
170
+ apiKey: 'test-key',
171
+ rateLimiter: fakeLimiter,
172
+ });
173
+ await client.downloadPlaceAsync(1, 22, undefined, 42);
174
+
175
+ expect(urls[0]).toBe(
176
+ 'https://apis.roblox.com/asset-delivery-api/v1/assetId/22/version/42'
177
+ );
178
+ });
179
+
180
+ it('throws a clear error when a pinned version does not exist', async () => {
181
+ // The Asset Delivery API reports a missing version as HTTP 200 with an
182
+ // errors array and no location.
183
+ const fakeLimiter = {
184
+ fetchAsync: vi.fn(
185
+ async () =>
186
+ new Response(
187
+ JSON.stringify({
188
+ errors: [{ code: 404, message: 'Request asset was not found' }],
189
+ }),
190
+ { status: 200 }
191
+ )
192
+ ),
193
+ } as unknown as RateLimiter;
194
+
195
+ const client = new OpenCloudClient({
196
+ apiKey: 'test-key',
197
+ rateLimiter: fakeLimiter,
198
+ });
199
+
200
+ await expect(
201
+ client.downloadPlaceAsync(1, 22, undefined, 999999)
202
+ ).rejects.toThrowError(/no version 999999/);
203
+ });
204
+ });
205
+
206
+ describe('OpenCloudClient.getLatestPlaceVersionAsync', () => {
207
+ afterEach(() => {
208
+ vi.restoreAllMocks();
209
+ });
210
+
211
+ it('returns the numeric revisionId from the Assets API', async () => {
212
+ const urls: string[] = [];
213
+ const fakeLimiter = {
214
+ fetchAsync: vi.fn(async (url: string | URL) => {
215
+ urls.push(String(url));
216
+ return new Response(
217
+ JSON.stringify({ assetId: '22', revisionId: '5781' }),
218
+ { status: 200 }
219
+ );
220
+ }),
221
+ } as unknown as RateLimiter;
222
+
223
+ const client = new OpenCloudClient({
224
+ apiKey: 'test-key',
225
+ rateLimiter: fakeLimiter,
226
+ });
227
+ const version = await client.getLatestPlaceVersionAsync(1, 22);
228
+
229
+ expect(version).toBe(5781);
230
+ expect(urls[0]).toBe('https://apis.roblox.com/assets/v1/assets/22');
231
+ });
232
+
233
+ it('throws when the Assets API omits a revisionId', async () => {
234
+ const fakeLimiter = {
235
+ fetchAsync: vi.fn(
236
+ async () =>
237
+ new Response(JSON.stringify({ assetId: '22' }), { status: 200 })
238
+ ),
239
+ } as unknown as RateLimiter;
240
+
241
+ const client = new OpenCloudClient({
242
+ apiKey: 'test-key',
243
+ rateLimiter: fakeLimiter,
244
+ });
245
+
246
+ await expect(client.getLatestPlaceVersionAsync(1, 22)).rejects.toThrowError(
247
+ /no revisionId/
248
+ );
249
+ });
250
+ });