@quenty/nevermore-cli 4.46.0 → 4.47.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.
- package/CHANGELOG.md +6 -0
- package/dist/commands/deploy-command/asset-command.d.ts +44 -0
- package/dist/commands/deploy-command/asset-command.d.ts.map +1 -0
- package/dist/commands/deploy-command/asset-command.js +182 -0
- package/dist/commands/deploy-command/asset-command.js.map +1 -0
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +62 -0
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/utils/assets/asset-creator.d.ts +32 -0
- package/dist/utils/assets/asset-creator.d.ts.map +1 -0
- package/dist/utils/assets/asset-creator.js +62 -0
- package/dist/utils/assets/asset-creator.js.map +1 -0
- package/dist/utils/assets/asset-creator.test.d.ts +2 -0
- package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-creator.test.js +71 -0
- package/dist/utils/assets/asset-creator.test.js.map +1 -0
- package/dist/utils/assets/asset-formats.d.ts +28 -0
- package/dist/utils/assets/asset-formats.d.ts.map +1 -0
- package/dist/utils/assets/asset-formats.js +72 -0
- package/dist/utils/assets/asset-formats.js.map +1 -0
- package/dist/utils/assets/asset-formats.test.d.ts +2 -0
- package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-formats.test.js +43 -0
- package/dist/utils/assets/asset-formats.test.js.map +1 -0
- package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
- package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
- package/dist/utils/assets/asset-upload-plan.js +43 -0
- package/dist/utils/assets/asset-upload-plan.js.map +1 -0
- package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
- package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-upload-plan.test.js +57 -0
- package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts +77 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +166 -6
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/deploy-command/asset-command.ts +279 -0
- package/src/commands/deploy-command/index.ts +85 -0
- package/src/utils/assets/asset-creator.test.ts +106 -0
- package/src/utils/assets/asset-creator.ts +96 -0
- package/src/utils/assets/asset-formats.test.ts +57 -0
- package/src/utils/assets/asset-formats.ts +102 -0
- package/src/utils/assets/asset-upload-plan.test.ts +75 -0
- package/src/utils/assets/asset-upload-plan.ts +79 -0
- package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
- package/src/utils/open-cloud/open-cloud-client.ts +316 -12
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `assetType` values `nevermore deploy asset upload` can send to the Open
|
|
5
|
+
* Cloud Assets API. Mesh is left out on purpose: the API only accepts mesh data
|
|
6
|
+
* that was itself downloaded from Roblox, which is not something a file on disk
|
|
7
|
+
* can be recognized as.
|
|
8
|
+
*
|
|
9
|
+
* https://create.roblox.com/docs/cloud/guides/usage-assets
|
|
10
|
+
*/
|
|
11
|
+
export const ASSET_TYPES = [
|
|
12
|
+
'Animation',
|
|
13
|
+
'Audio',
|
|
14
|
+
'Decal',
|
|
15
|
+
'Image',
|
|
16
|
+
'Model',
|
|
17
|
+
'Video',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type AssetType = typeof ASSET_TYPES[number];
|
|
21
|
+
|
|
22
|
+
interface AssetFileFormat {
|
|
23
|
+
/** Sent as the `fileContent` part's type; the API picks its importer from it. */
|
|
24
|
+
contentType: string;
|
|
25
|
+
/** Every type this extension can upload as. The first is the default. */
|
|
26
|
+
assetTypes: readonly AssetType[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// An image defaults to Image rather than Decal: an Image id is usable directly
|
|
30
|
+
// as `rbxassetid://`, where a Decal is a wrapper whose id has to be resolved to
|
|
31
|
+
// the image inside it before an ImageLabel can show it.
|
|
32
|
+
const IMAGE_TYPES = ['Image', 'Decal'] as const;
|
|
33
|
+
|
|
34
|
+
// A Roblox model file holds either, and nothing in its name says which.
|
|
35
|
+
const ROBLOX_MODEL_TYPES = ['Model', 'Animation'] as const;
|
|
36
|
+
|
|
37
|
+
const ASSET_FILE_FORMATS: Record<string, AssetFileFormat> = {
|
|
38
|
+
'.mp3': { contentType: 'audio/mpeg', assetTypes: ['Audio'] },
|
|
39
|
+
'.ogg': { contentType: 'audio/ogg', assetTypes: ['Audio'] },
|
|
40
|
+
'.wav': { contentType: 'audio/wav', assetTypes: ['Audio'] },
|
|
41
|
+
'.flac': { contentType: 'audio/flac', assetTypes: ['Audio'] },
|
|
42
|
+
'.png': { contentType: 'image/png', assetTypes: IMAGE_TYPES },
|
|
43
|
+
'.jpg': { contentType: 'image/jpeg', assetTypes: IMAGE_TYPES },
|
|
44
|
+
'.jpeg': { contentType: 'image/jpeg', assetTypes: IMAGE_TYPES },
|
|
45
|
+
'.bmp': { contentType: 'image/bmp', assetTypes: IMAGE_TYPES },
|
|
46
|
+
'.tga': { contentType: 'image/tga', assetTypes: IMAGE_TYPES },
|
|
47
|
+
'.fbx': { contentType: 'model/fbx', assetTypes: ['Model'] },
|
|
48
|
+
'.gltf': { contentType: 'model/gltf+json', assetTypes: ['Model'] },
|
|
49
|
+
'.glb': { contentType: 'model/gltf-binary', assetTypes: ['Model'] },
|
|
50
|
+
'.rbxm': { contentType: 'model/x-rbxm', assetTypes: ROBLOX_MODEL_TYPES },
|
|
51
|
+
'.rbxmx': { contentType: 'model/x-rbxm', assetTypes: ROBLOX_MODEL_TYPES },
|
|
52
|
+
'.mp4': { contentType: 'video/mp4', assetTypes: ['Video'] },
|
|
53
|
+
'.mov': { contentType: 'video/mov', assetTypes: ['Video'] },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface ResolvedAssetFormat {
|
|
57
|
+
assetType: AssetType;
|
|
58
|
+
contentType: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Work out how a file uploads: its asset type and the content type to send.
|
|
63
|
+
* The extension decides both unless `explicitType` picks one of the
|
|
64
|
+
* alternatives it allows (`Decal` for an image, `Animation` for an `.rbxm`).
|
|
65
|
+
*/
|
|
66
|
+
export function resolveAssetFormat(
|
|
67
|
+
filePath: string,
|
|
68
|
+
explicitType?: AssetType
|
|
69
|
+
): ResolvedAssetFormat {
|
|
70
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
71
|
+
const format = ASSET_FILE_FORMATS[extension];
|
|
72
|
+
if (!format) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Cannot upload "${filePath}": ${
|
|
75
|
+
extension === '' ? 'a file with no extension' : `"${extension}"`
|
|
76
|
+
} is not an asset format the Open Cloud Assets API accepts.\n` +
|
|
77
|
+
`Supported: ${Object.keys(ASSET_FILE_FORMATS).join(', ')}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (explicitType != null && !format.assetTypes.includes(explicitType)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Cannot upload "${filePath}" as ${explicitType}: ` +
|
|
84
|
+
`a "${extension}" file uploads as ${format.assetTypes.join(' or ')}.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
assetType: explicitType ?? format.assetTypes[0]!,
|
|
90
|
+
contentType: format.contentType,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether an existing asset of this type can have its file replaced in place,
|
|
96
|
+
* keeping its id. The API documents this for models only, and marks images,
|
|
97
|
+
* audio and video "not available for updating" — so a changed image or sound
|
|
98
|
+
* is always a new asset with a new id.
|
|
99
|
+
*/
|
|
100
|
+
export function canUpdateAssetContent(assetType: AssetType): boolean {
|
|
101
|
+
return assetType === 'Model';
|
|
102
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildAssetUploadPlan } from './asset-upload-plan.js';
|
|
3
|
+
|
|
4
|
+
describe('buildAssetUploadPlan', () => {
|
|
5
|
+
it('names each asset after its file, and describes it by its name', () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildAssetUploadPlan({ files: ['art/icons_1.png', 'models/City.fbx'] })
|
|
8
|
+
).toEqual([
|
|
9
|
+
{
|
|
10
|
+
filePath: 'art/icons_1.png',
|
|
11
|
+
assetType: 'Image',
|
|
12
|
+
contentType: 'image/png',
|
|
13
|
+
displayName: 'icons_1',
|
|
14
|
+
description: 'icons_1',
|
|
15
|
+
assetId: undefined,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
filePath: 'models/City.fbx',
|
|
19
|
+
assetType: 'Model',
|
|
20
|
+
contentType: 'model/fbx',
|
|
21
|
+
displayName: 'City',
|
|
22
|
+
description: 'City',
|
|
23
|
+
assetId: undefined,
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('takes an explicit name and description for a single file', () => {
|
|
29
|
+
const [entry] = buildAssetUploadPlan({
|
|
30
|
+
files: ['City.fbx'],
|
|
31
|
+
name: 'Openfront City',
|
|
32
|
+
description: 'Unit model',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(entry?.displayName).toBe('Openfront City');
|
|
36
|
+
expect(entry?.description).toBe('Unit model');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('refuses one name for several files', () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
buildAssetUploadPlan({ files: ['a.png', 'b.png'], name: 'Icon' })
|
|
42
|
+
).toThrowError(/--name names one asset, but 2 files were given/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('plans a replacement when given the id of an existing model', () => {
|
|
46
|
+
const [entry] = buildAssetUploadPlan({
|
|
47
|
+
files: ['City.fbx'],
|
|
48
|
+
assetId: 123,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(entry?.assetId).toBe(123);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('refuses to replace anything but a model', () => {
|
|
55
|
+
// The API has no in-place update for these, so letting the request through
|
|
56
|
+
// would only trade a clear message for whatever the server says instead.
|
|
57
|
+
expect(() =>
|
|
58
|
+
buildAssetUploadPlan({ files: ['icon.png'], assetId: 123 })
|
|
59
|
+
).toThrowError(/only replaces the content of a Model/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('refuses one asset id for several files', () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
buildAssetUploadPlan({ files: ['a.fbx', 'b.fbx'], assetId: 123 })
|
|
65
|
+
).toThrowError(/--asset-id replaces one asset, but 2 files were given/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects the whole batch when one file cannot be uploaded', () => {
|
|
69
|
+
// Nothing is uploaded until every file is known to be uploadable: a batch
|
|
70
|
+
// that fails partway has already created assets that cannot be taken back.
|
|
71
|
+
expect(() =>
|
|
72
|
+
buildAssetUploadPlan({ files: ['a.png', 'notes.txt'] })
|
|
73
|
+
).toThrowError(/"\.txt" is not an asset format/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import {
|
|
3
|
+
canUpdateAssetContent,
|
|
4
|
+
resolveAssetFormat,
|
|
5
|
+
type AssetType,
|
|
6
|
+
} from './asset-formats.js';
|
|
7
|
+
|
|
8
|
+
export interface AssetUploadPlanOptions {
|
|
9
|
+
files: string[];
|
|
10
|
+
/** Applied to every file; each must be able to upload as it. */
|
|
11
|
+
type?: AssetType;
|
|
12
|
+
/** Display name. Single file only — defaults to the file's base name. */
|
|
13
|
+
name?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Replace this asset's content instead of creating one. Single file only. */
|
|
16
|
+
assetId?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AssetUploadPlanEntry {
|
|
20
|
+
filePath: string;
|
|
21
|
+
assetType: AssetType;
|
|
22
|
+
contentType: string;
|
|
23
|
+
displayName: string;
|
|
24
|
+
description: string;
|
|
25
|
+
/** Set when the upload replaces an existing asset's content in place. */
|
|
26
|
+
assetId?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Turn the command line into one upload per file, rejecting anything that
|
|
31
|
+
* could not work before a single request is sent — a batch that fails on its
|
|
32
|
+
* fifth file has already created four assets.
|
|
33
|
+
*/
|
|
34
|
+
export function buildAssetUploadPlan(
|
|
35
|
+
options: AssetUploadPlanOptions
|
|
36
|
+
): AssetUploadPlanEntry[] {
|
|
37
|
+
const { files, type, name, description, assetId } = options;
|
|
38
|
+
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
throw new Error('No files to upload.');
|
|
41
|
+
}
|
|
42
|
+
if (files.length > 1 && name != null) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`--name names one asset, but ${files.length} files were given. ` +
|
|
45
|
+
`Drop it to name each asset after its file.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (files.length > 1 && assetId != null) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`--asset-id replaces one asset, but ${files.length} files were given.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (assetId != null && (!Number.isSafeInteger(assetId) || assetId < 1)) {
|
|
54
|
+
throw new Error('--asset-id must be a positive integer.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return files.map((filePath) => {
|
|
58
|
+
const { assetType, contentType } = resolveAssetFormat(filePath, type);
|
|
59
|
+
if (assetId != null && !canUpdateAssetContent(assetType)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Cannot replace asset ${assetId} with "${filePath}": the Assets API ` +
|
|
62
|
+
`only replaces the content of a Model. Upload the ${assetType} ` +
|
|
63
|
+
`without --asset-id to create a new asset instead.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const displayName = name ?? path.basename(filePath, path.extname(filePath));
|
|
68
|
+
return {
|
|
69
|
+
filePath,
|
|
70
|
+
assetType,
|
|
71
|
+
contentType,
|
|
72
|
+
displayName,
|
|
73
|
+
// The API requires a description on create, so an omitted one falls back
|
|
74
|
+
// to the name rather than to text nobody chose.
|
|
75
|
+
description: description ?? displayName,
|
|
76
|
+
assetId,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -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 {
|