@quenty/nevermore-cli 4.35.0 → 4.36.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 +11 -0
- package/build-scripts/transform-inject-deploy-metadata.luau +54 -0
- package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.js +24 -7
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +26 -7
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/utils/deploy/deploy-metadata.d.ts +71 -0
- package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -0
- package/dist/utils/deploy/deploy-metadata.js +118 -0
- package/dist/utils/deploy/deploy-metadata.js.map +1 -0
- package/dist/utils/deploy/deploy-metadata.test.d.ts +2 -0
- package/dist/utils/deploy/deploy-metadata.test.d.ts.map +1 -0
- package/dist/utils/deploy/deploy-metadata.test.js +93 -0
- package/dist/utils/deploy/deploy-metadata.test.js.map +1 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +27 -30
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.d.ts +2 -0
- package/dist/utils/open-cloud/open-cloud-client.test.d.ts.map +1 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js +90 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -0
- package/dist/utils/open-cloud/rate-limiter.d.ts +10 -1
- package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.js +29 -5
- package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.test.js +23 -0
- package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +13 -0
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/package.json +2 -2
- package/src/commands/batch-command/batch-deploy-command.ts +31 -6
- package/src/commands/deploy-command/index.ts +33 -6
- package/src/utils/deploy/deploy-metadata.test.ts +128 -0
- package/src/utils/deploy/deploy-metadata.ts +194 -0
- package/src/utils/open-cloud/open-cloud-client.test.ts +118 -0
- package/src/utils/open-cloud/open-cloud-client.ts +29 -32
- package/src/utils/open-cloud/rate-limiter.test.ts +30 -0
- package/src/utils/open-cloud/rate-limiter.ts +41 -5
- package/src/utils/testing/runner/test-runner.ts +22 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
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';
|
|
5
|
+
import {
|
|
6
|
+
buildDeployMetadataAttributes,
|
|
7
|
+
packageUsesManifestAsync,
|
|
8
|
+
type GitDeployInfo,
|
|
9
|
+
type DeployPlaceInfo,
|
|
10
|
+
} from './deploy-metadata.js';
|
|
11
|
+
|
|
12
|
+
async function withPackageJsonAsync(
|
|
13
|
+
contents: unknown,
|
|
14
|
+
fn: (dir: string) => Promise<void>
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'manifest-gate-'));
|
|
17
|
+
try {
|
|
18
|
+
if (contents !== undefined) {
|
|
19
|
+
await fs.writeFile(
|
|
20
|
+
path.join(dir, 'package.json'),
|
|
21
|
+
JSON.stringify(contents)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
await fn(dir);
|
|
25
|
+
} finally {
|
|
26
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PLACE: DeployPlaceInfo = {
|
|
31
|
+
target: 'integration',
|
|
32
|
+
published: true,
|
|
33
|
+
timestamp: '2026-07-15T00:00:00.000Z',
|
|
34
|
+
placeId: 123,
|
|
35
|
+
universeId: 456,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('buildDeployMetadataAttributes', () => {
|
|
39
|
+
it('always marks the build as deployed and includes place info', () => {
|
|
40
|
+
const attributes = buildDeployMetadataAttributes({}, PLACE);
|
|
41
|
+
expect(attributes.Deployed).toBe(true);
|
|
42
|
+
expect(attributes.Target).toBe('integration');
|
|
43
|
+
expect(attributes.Published).toBe(true);
|
|
44
|
+
expect(attributes.Timestamp).toBe('2026-07-15T00:00:00.000Z');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('stringifies place/universe IDs so Lune float32 does not corrupt them', () => {
|
|
48
|
+
const attributes = buildDeployMetadataAttributes(
|
|
49
|
+
{},
|
|
50
|
+
{
|
|
51
|
+
...PLACE,
|
|
52
|
+
placeId: 123456789,
|
|
53
|
+
universeId: 987654321,
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
// Must be exact strings — as numbers these round to 123456792 / 987654336.
|
|
57
|
+
expect(attributes.PlaceId).toBe('123456789');
|
|
58
|
+
expect(attributes.UniverseId).toBe('987654321');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('includes git fields when present', () => {
|
|
62
|
+
const git: GitDeployInfo = {
|
|
63
|
+
commit: 'a1b2c3d',
|
|
64
|
+
version: 'a1b2c3d4e5f6',
|
|
65
|
+
branch: 'main',
|
|
66
|
+
};
|
|
67
|
+
const attributes = buildDeployMetadataAttributes(git, PLACE);
|
|
68
|
+
expect(attributes.Commit).toBe('a1b2c3d');
|
|
69
|
+
expect(attributes.Version).toBe('a1b2c3d4e5f6');
|
|
70
|
+
expect(attributes.Branch).toBe('main');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('omits git fields that are unavailable', () => {
|
|
74
|
+
const attributes = buildDeployMetadataAttributes({}, PLACE);
|
|
75
|
+
expect('Commit' in attributes).toBe(false);
|
|
76
|
+
expect('Version' in attributes).toBe(false);
|
|
77
|
+
expect('Branch' in attributes).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reflects a Saved (unpublished) deploy', () => {
|
|
81
|
+
const attributes = buildDeployMetadataAttributes(
|
|
82
|
+
{},
|
|
83
|
+
{ ...PLACE, published: false }
|
|
84
|
+
);
|
|
85
|
+
expect(attributes.Published).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('packageUsesManifestAsync', () => {
|
|
90
|
+
it('is true for the manifest package itself', async () => {
|
|
91
|
+
await withPackageJsonAsync(
|
|
92
|
+
{ name: '@quenty/nevermoreclimanifest' },
|
|
93
|
+
async (dir) => {
|
|
94
|
+
expect(await packageUsesManifestAsync(dir)).toBe(true);
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('is true for a direct dependent', async () => {
|
|
100
|
+
await withPackageJsonAsync(
|
|
101
|
+
{
|
|
102
|
+
name: '@quenty/somepackage',
|
|
103
|
+
dependencies: { '@quenty/nevermoreclimanifest': 'workspace:*' },
|
|
104
|
+
},
|
|
105
|
+
async (dir) => {
|
|
106
|
+
expect(await packageUsesManifestAsync(dir)).toBe(true);
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('is false for an unrelated package', async () => {
|
|
112
|
+
await withPackageJsonAsync(
|
|
113
|
+
{
|
|
114
|
+
name: '@quenty/maid',
|
|
115
|
+
dependencies: { '@quenty/loader': 'workspace:*' },
|
|
116
|
+
},
|
|
117
|
+
async (dir) => {
|
|
118
|
+
expect(await packageUsesManifestAsync(dir)).toBe(false);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('is false when there is no package.json', async () => {
|
|
124
|
+
await withPackageJsonAsync(undefined, async (dir) => {
|
|
125
|
+
expect(await packageUsesManifestAsync(dir)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
BuildContext,
|
|
6
|
+
resolvePackagePath,
|
|
7
|
+
} from '@quenty/nevermore-template-helpers';
|
|
8
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
9
|
+
import { type BuiltPlace } from '../build/build.js';
|
|
10
|
+
|
|
11
|
+
/** npm name of the package that ships the manifest module. */
|
|
12
|
+
export const MANIFEST_PACKAGE_NAME = '@quenty/nevermoreclimanifest';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Attribute map written onto the built place's NevermoreCLIManifestUtils module.
|
|
16
|
+
* Keys are attribute names and MUST match the `ATTRIBUTE` table in
|
|
17
|
+
* src/nevermore-cli-manifest/src/Shared/NevermoreCLIManifestUtils.lua.
|
|
18
|
+
*/
|
|
19
|
+
export type DeployMetadataAttributes = Record<
|
|
20
|
+
string,
|
|
21
|
+
string | number | boolean
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
const INJECT_SCRIPT_PATH = resolvePackagePath(
|
|
25
|
+
import.meta.url,
|
|
26
|
+
'build-scripts',
|
|
27
|
+
'transform-inject-deploy-metadata.luau'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function _git(args: string[]): string | undefined {
|
|
31
|
+
try {
|
|
32
|
+
return execSync(`git ${args.join(' ')}`, {
|
|
33
|
+
encoding: 'utf-8',
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
}).trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Git facts about the working tree the deploy was built from. */
|
|
43
|
+
export interface GitDeployInfo {
|
|
44
|
+
/** Short commit SHA, e.g. "a1b2c3d". */
|
|
45
|
+
commit?: string;
|
|
46
|
+
/** Full commit SHA. */
|
|
47
|
+
version?: string;
|
|
48
|
+
/** Current branch name, or undefined in a detached HEAD. */
|
|
49
|
+
branch?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Collect git metadata once per deploy. Every field is best-effort — a missing
|
|
54
|
+
* git binary or a non-repo directory yields an empty object rather than an error.
|
|
55
|
+
*/
|
|
56
|
+
export function gatherGitDeployInfo(): GitDeployInfo {
|
|
57
|
+
const info: GitDeployInfo = {};
|
|
58
|
+
const commit = _git(['rev-parse', '--short', 'HEAD']);
|
|
59
|
+
if (commit) info.commit = commit;
|
|
60
|
+
const version = _git(['rev-parse', 'HEAD']);
|
|
61
|
+
if (version) info.version = version;
|
|
62
|
+
const branch = _git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
63
|
+
if (branch && branch !== 'HEAD') info.branch = branch;
|
|
64
|
+
return info;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Per-place facts known at deploy time. */
|
|
68
|
+
export interface DeployPlaceInfo {
|
|
69
|
+
/** Deploy target name from deploy.nevermore.json (e.g. "test", "integration"). */
|
|
70
|
+
target: string;
|
|
71
|
+
/** True when published live (`--publish`); false when only Saved. */
|
|
72
|
+
published: boolean;
|
|
73
|
+
/** ISO 8601 timestamp — pass one value for the whole deploy so every place agrees. */
|
|
74
|
+
timestamp: string;
|
|
75
|
+
placeId: number;
|
|
76
|
+
universeId: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the attribute map injected into the built place. `Deployed` is always
|
|
81
|
+
* true here — its mere presence is what distinguishes a deployed build from a
|
|
82
|
+
* Studio session at runtime.
|
|
83
|
+
*
|
|
84
|
+
* Place and universe IDs are stringified on purpose: Roblox `number` attributes
|
|
85
|
+
* round-trip through Lune serialization as float32, which silently corrupts IDs
|
|
86
|
+
* above 2^24 (e.g. 123456789 -> 123456792). Strings are exact; the Luau reader
|
|
87
|
+
* converts them back with `tonumber`.
|
|
88
|
+
*/
|
|
89
|
+
export function buildDeployMetadataAttributes(
|
|
90
|
+
git: GitDeployInfo,
|
|
91
|
+
place: DeployPlaceInfo
|
|
92
|
+
): DeployMetadataAttributes {
|
|
93
|
+
const attributes: DeployMetadataAttributes = {
|
|
94
|
+
Deployed: true,
|
|
95
|
+
Target: place.target,
|
|
96
|
+
Timestamp: place.timestamp,
|
|
97
|
+
Published: place.published,
|
|
98
|
+
PlaceId: String(place.placeId),
|
|
99
|
+
UniverseId: String(place.universeId),
|
|
100
|
+
};
|
|
101
|
+
if (git.commit) attributes.Commit = git.commit;
|
|
102
|
+
if (git.version) attributes.Version = git.version;
|
|
103
|
+
if (git.branch) attributes.Branch = git.branch;
|
|
104
|
+
return attributes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A built place with metadata injected, plus a handle to clean up its temp dir. */
|
|
108
|
+
export interface InjectedPlace {
|
|
109
|
+
builtPlace: BuiltPlace;
|
|
110
|
+
cleanupAsync(): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run the Lune transform that writes `attributes` onto the place's manifest
|
|
115
|
+
* module. Produces a fresh .rbxl in its own temp directory so a shared rojo
|
|
116
|
+
* build (multi-place / batch deploys) is never mutated in place. The caller
|
|
117
|
+
* uploads `result.builtPlace` and then calls `result.cleanupAsync()`.
|
|
118
|
+
*/
|
|
119
|
+
export async function injectDeployMetadataAsync(
|
|
120
|
+
builtPlace: BuiltPlace,
|
|
121
|
+
attributes: DeployMetadataAttributes
|
|
122
|
+
): Promise<InjectedPlace> {
|
|
123
|
+
const context = await BuildContext.createAsync({ prefix: 'deploy-meta-' });
|
|
124
|
+
const outputPath = context.resolvePath('deploy-metadata.rbxl');
|
|
125
|
+
|
|
126
|
+
OutputHelper.verbose('Injecting deploy metadata into built place...');
|
|
127
|
+
await context.executeLuneTransformScriptAsync(
|
|
128
|
+
INJECT_SCRIPT_PATH,
|
|
129
|
+
builtPlace.rbxlPath,
|
|
130
|
+
outputPath,
|
|
131
|
+
JSON.stringify(attributes)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
builtPlace: { ...builtPlace, rbxlPath: outputPath },
|
|
136
|
+
cleanupAsync: () => context.cleanupAsync(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Inject `attributes` by rewriting `rbxlPath` in place. Only safe when the
|
|
142
|
+
* caller owns the file exclusively (e.g. a per-session test build); deploys use
|
|
143
|
+
* {@link injectDeployMetadataAsync} instead because they can share one rojo
|
|
144
|
+
* build across multiple places.
|
|
145
|
+
*/
|
|
146
|
+
export async function injectDeployMetadataInPlaceAsync(
|
|
147
|
+
rbxlPath: string,
|
|
148
|
+
attributes: DeployMetadataAttributes
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const context = await BuildContext.createAsync({ prefix: 'deploy-meta-' });
|
|
151
|
+
try {
|
|
152
|
+
OutputHelper.verbose('Injecting deploy metadata into test build...');
|
|
153
|
+
// The transform reads the whole input before writing, so input === output
|
|
154
|
+
// rewrites the file safely.
|
|
155
|
+
await context.executeLuneTransformScriptAsync(
|
|
156
|
+
INJECT_SCRIPT_PATH,
|
|
157
|
+
rbxlPath,
|
|
158
|
+
rbxlPath,
|
|
159
|
+
JSON.stringify(attributes)
|
|
160
|
+
);
|
|
161
|
+
} finally {
|
|
162
|
+
await context.cleanupAsync();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when the package at `packagePath` ships or directly depends on the
|
|
168
|
+
* manifest package, so its built place will contain the module to inject into.
|
|
169
|
+
* Gates injection during tests so unrelated packages don't pay for a Lune pass.
|
|
170
|
+
*/
|
|
171
|
+
export async function packageUsesManifestAsync(
|
|
172
|
+
packagePath: string
|
|
173
|
+
): Promise<boolean> {
|
|
174
|
+
try {
|
|
175
|
+
const raw = await fs.readFile(
|
|
176
|
+
path.join(packagePath, 'package.json'),
|
|
177
|
+
'utf-8'
|
|
178
|
+
);
|
|
179
|
+
const pkg = JSON.parse(raw) as {
|
|
180
|
+
name?: string;
|
|
181
|
+
dependencies?: Record<string, string>;
|
|
182
|
+
devDependencies?: Record<string, string>;
|
|
183
|
+
};
|
|
184
|
+
if (pkg.name === MANIFEST_PACKAGE_NAME) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return Boolean(
|
|
188
|
+
pkg.dependencies?.[MANIFEST_PACKAGE_NAME] ??
|
|
189
|
+
pkg.devDependencies?.[MANIFEST_PACKAGE_NAME]
|
|
190
|
+
);
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import { OpenCloudClient } from './open-cloud-client.js';
|
|
4
|
+
import type { RateLimiter } from './rate-limiter.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('@quenty/cli-output-helpers', () => ({
|
|
7
|
+
OutputHelper: {
|
|
8
|
+
verbose: vi.fn(),
|
|
9
|
+
warn: vi.fn(),
|
|
10
|
+
formatDim: (s: string) => s,
|
|
11
|
+
formatInfo: (s: string) => s,
|
|
12
|
+
formatError: (s: string) => s,
|
|
13
|
+
formatWarning: (s: string) => s,
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('fs/promises', () => ({ readFile: vi.fn() }));
|
|
18
|
+
|
|
19
|
+
describe('OpenCloudClient.uploadPlaceAsync', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
22
|
+
Buffer.from([1, 2, 3, 4, 5]) as unknown as string
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('streams the upload via an injected fetch impl and reports byte-level progress', async () => {
|
|
31
|
+
// The upload streams its body through node-fetch (injected as fetchImpl):
|
|
32
|
+
// undici cannot send a streaming request body to the Open Cloud endpoint on
|
|
33
|
+
// Node 24 ("expected non-null body source"), so the limiter must drive the
|
|
34
|
+
// Node-http-based client. The body is handed over as a per-attempt factory
|
|
35
|
+
// so each retry gets a fresh, un-disturbed stream; a counting Transform on
|
|
36
|
+
// it drives byte-level progress. Content-Length is set so it isn't chunked.
|
|
37
|
+
let capturedInit: RequestInit | undefined;
|
|
38
|
+
let capturedFetchImpl: unknown;
|
|
39
|
+
const fakeLimiter = {
|
|
40
|
+
fetchAsync: vi.fn(
|
|
41
|
+
async (
|
|
42
|
+
_url: string | URL,
|
|
43
|
+
init?: RequestInit | (() => RequestInit),
|
|
44
|
+
options?: { fetchImpl?: unknown }
|
|
45
|
+
) => {
|
|
46
|
+
capturedInit = typeof init === 'function' ? init() : init;
|
|
47
|
+
capturedFetchImpl = options?.fetchImpl;
|
|
48
|
+
// Drain the streaming body so the counting Transform fires progress,
|
|
49
|
+
// exactly as the real transport would as it sends bytes.
|
|
50
|
+
const body = capturedInit?.body as AsyncIterable<Uint8Array>;
|
|
51
|
+
for await (const _chunk of body) {
|
|
52
|
+
void _chunk;
|
|
53
|
+
}
|
|
54
|
+
return new Response(JSON.stringify({ versionNumber: 7 }), {
|
|
55
|
+
status: 200,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
} as unknown as RateLimiter;
|
|
60
|
+
|
|
61
|
+
const client = new OpenCloudClient({
|
|
62
|
+
apiKey: 'test-key',
|
|
63
|
+
rateLimiter: fakeLimiter,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const progress: Array<[number, number]> = [];
|
|
67
|
+
const version = await client.uploadPlaceAsync(
|
|
68
|
+
1,
|
|
69
|
+
2,
|
|
70
|
+
'/tmp/place.rbxl',
|
|
71
|
+
false,
|
|
72
|
+
(transferred, total) => progress.push([transferred, total])
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(version).toBe(7);
|
|
76
|
+
// A dedicated fetch impl (node-fetch) is injected, not the global default.
|
|
77
|
+
expect(typeof capturedFetchImpl).toBe('function');
|
|
78
|
+
// Content-Length pins the length so node-fetch doesn't chunk the upload.
|
|
79
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
80
|
+
expect(headers['Content-Length']).toBe('5');
|
|
81
|
+
// Byte-level progress: an initial 0, then cumulative up to the full size.
|
|
82
|
+
expect(progress[0]).toEqual([0, 5]);
|
|
83
|
+
expect(progress[progress.length - 1]).toEqual([5, 5]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('marks the version Published only when publish is true', async () => {
|
|
87
|
+
const urls: string[] = [];
|
|
88
|
+
const fakeLimiter = {
|
|
89
|
+
fetchAsync: vi.fn(
|
|
90
|
+
async (url: string | URL, init?: RequestInit | (() => RequestInit)) => {
|
|
91
|
+
urls.push(String(url));
|
|
92
|
+
// Drain the streamed body so the upload's Transform can complete.
|
|
93
|
+
const resolved = typeof init === 'function' ? init() : init;
|
|
94
|
+
const body = resolved?.body as AsyncIterable<Uint8Array> | undefined;
|
|
95
|
+
if (body) {
|
|
96
|
+
for await (const _chunk of body) {
|
|
97
|
+
void _chunk;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return new Response(JSON.stringify({ versionNumber: 1 }), {
|
|
101
|
+
status: 200,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
),
|
|
105
|
+
} as unknown as RateLimiter;
|
|
106
|
+
|
|
107
|
+
const client = new OpenCloudClient({
|
|
108
|
+
apiKey: 'test-key',
|
|
109
|
+
rateLimiter: fakeLimiter,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await client.uploadPlaceAsync(1, 2, '/tmp/place.rbxl', true);
|
|
113
|
+
await client.uploadPlaceAsync(1, 2, '/tmp/place.rbxl', false);
|
|
114
|
+
|
|
115
|
+
expect(urls[0]).toContain('versionType=Published');
|
|
116
|
+
expect(urls[1]).toContain('versionType=Saved');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
|
+
import { Readable, Transform } from 'node:stream';
|
|
3
|
+
import nodeFetch from 'node-fetch';
|
|
2
4
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
3
|
-
import { RateLimiter } from './rate-limiter.js';
|
|
5
|
+
import { RateLimiter, type FetchLike } from './rate-limiter.js';
|
|
4
6
|
import {
|
|
5
7
|
parseTestLogs,
|
|
6
8
|
type ParsedTestLogs,
|
|
@@ -130,40 +132,35 @@ export class OpenCloudClient {
|
|
|
130
132
|
'X-API-Key': apiKey,
|
|
131
133
|
};
|
|
132
134
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
offset = end;
|
|
149
|
-
onProgress(offset, totalBytes);
|
|
135
|
+
// Stream the place binary so we can report byte-level upload progress. We
|
|
136
|
+
// pin node-fetch here: undici (global fetch) cannot send a streaming request
|
|
137
|
+
// body to the Open Cloud versions endpoint on Node 24 — it throws "expected
|
|
138
|
+
// non-null body source" — whereas node-fetch's Node http stack accepts it.
|
|
139
|
+
// Content-Length is set so the upload isn't chunked. The rate limiter
|
|
140
|
+
// rebuilds this init per attempt, so each retry gets a fresh, un-disturbed
|
|
141
|
+
// stream (and progress restarts, correctly mirroring the re-sent bytes).
|
|
142
|
+
onProgress?.(0, totalBytes);
|
|
143
|
+
const makeUploadInit = (): RequestInit => {
|
|
144
|
+
let counted = 0;
|
|
145
|
+
const counter = new Transform({
|
|
146
|
+
transform(chunk, _enc, cb) {
|
|
147
|
+
counted += chunk.length;
|
|
148
|
+
onProgress?.(counted, totalBytes);
|
|
149
|
+
cb(null, chunk);
|
|
150
150
|
},
|
|
151
151
|
});
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
152
|
+
// Node stream body: valid for node-fetch, but outside the DOM RequestInit
|
|
153
|
+
// type, so cast through unknown.
|
|
154
|
+
return {
|
|
155
155
|
method: 'POST',
|
|
156
|
-
headers,
|
|
157
|
-
body:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
body: new Uint8Array(fileBuffer),
|
|
165
|
-
});
|
|
166
|
-
}
|
|
156
|
+
headers: { ...headers, 'Content-Length': String(totalBytes) },
|
|
157
|
+
body: Readable.from(fileBuffer).pipe(counter),
|
|
158
|
+
} as unknown as RequestInit;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const response = await this._rateLimiter.fetchAsync(url, makeUploadInit, {
|
|
162
|
+
fetchImpl: nodeFetch as unknown as FetchLike,
|
|
163
|
+
});
|
|
167
164
|
|
|
168
165
|
if (!response.ok) {
|
|
169
166
|
const text = await response.text();
|
|
@@ -197,4 +197,34 @@ describe('RateLimiter', () => {
|
|
|
197
197
|
expect(response.status).toBe(404);
|
|
198
198
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
it('retries a transient HTML edge 400 but not a structured JSON API 400', async () => {
|
|
202
|
+
// A large upload can trip an edge proxy that answers 400 with an HTML body
|
|
203
|
+
// — transient framing noise we should ride out. A genuine Open Cloud 400 is
|
|
204
|
+
// a JSON error and must be returned immediately, not retried.
|
|
205
|
+
vi.useFakeTimers();
|
|
206
|
+
const limiter = new RateLimiter();
|
|
207
|
+
|
|
208
|
+
// Edge 400 (text/html) then success — must retry.
|
|
209
|
+
fetchMock
|
|
210
|
+
.mockResolvedValueOnce(makeResponse(400, { 'content-type': 'text/html' }))
|
|
211
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
212
|
+
|
|
213
|
+
const edge = limiter.fetchAsync('https://apis.roblox.com/cloud/v2/a');
|
|
214
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
215
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
216
|
+
await vi.advanceTimersByTimeAsync(1000); // 2^0 = 1s backoff
|
|
217
|
+
expect((await edge).status).toBe(200);
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
219
|
+
|
|
220
|
+
fetchMock.mockClear();
|
|
221
|
+
|
|
222
|
+
// API 400 (application/json) — must NOT retry.
|
|
223
|
+
fetchMock.mockResolvedValueOnce(
|
|
224
|
+
makeResponse(400, { 'content-type': 'application/json' })
|
|
225
|
+
);
|
|
226
|
+
const api = await limiter.fetchAsync('https://apis.roblox.com/cloud/v2/b');
|
|
227
|
+
expect(api.status).toBe(400);
|
|
228
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
200
230
|
});
|
|
@@ -18,6 +18,17 @@ function _extractRoute(url: string | URL | Request): string {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Minimal fetch shape the limiter drives. Defaults to the global `fetch`
|
|
23
|
+
* (undici). Callers can inject an alternative — e.g. node-fetch, whose Node
|
|
24
|
+
* `http` stack accepts a streaming request body against the Open Cloud upload
|
|
25
|
+
* endpoint where undici throws "expected non-null body source".
|
|
26
|
+
*/
|
|
27
|
+
export type FetchLike = (
|
|
28
|
+
url: string | URL | Request,
|
|
29
|
+
init?: RequestInit
|
|
30
|
+
) => Promise<Response>;
|
|
31
|
+
|
|
21
32
|
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
22
33
|
const DEFAULT_MAX_RETRIES = 6;
|
|
23
34
|
const RETRY_JITTER_FRACTION = 0.3;
|
|
@@ -138,11 +149,13 @@ export class RateLimiter {
|
|
|
138
149
|
*/
|
|
139
150
|
async fetchAsync(
|
|
140
151
|
url: string | URL | Request,
|
|
141
|
-
init?: RequestInit
|
|
152
|
+
init?: RequestInit | (() => RequestInit),
|
|
153
|
+
options?: { fetchImpl?: FetchLike }
|
|
142
154
|
): Promise<Response> {
|
|
143
155
|
await this._acquireAsync();
|
|
144
156
|
|
|
145
157
|
const route = _extractRoute(url);
|
|
158
|
+
const doFetch = options?.fetchImpl ?? fetch;
|
|
146
159
|
|
|
147
160
|
try {
|
|
148
161
|
let lastResponse: Response | null = null;
|
|
@@ -155,13 +168,19 @@ export class RateLimiter {
|
|
|
155
168
|
let transportError: unknown = null;
|
|
156
169
|
|
|
157
170
|
try {
|
|
158
|
-
|
|
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
|
+
response = await doFetch(url, resolvedInit);
|
|
159
178
|
this._updateFromHeaders(response.headers);
|
|
160
179
|
} catch (err) {
|
|
161
180
|
transportError = err;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
if (response && !
|
|
183
|
+
if (response && !_isRetryableResponse(response)) {
|
|
165
184
|
return response;
|
|
166
185
|
}
|
|
167
186
|
|
|
@@ -198,8 +217,25 @@ export class RateLimiter {
|
|
|
198
217
|
}
|
|
199
218
|
}
|
|
200
219
|
|
|
201
|
-
function
|
|
202
|
-
|
|
220
|
+
function _isRetryableResponse(response: Response): boolean {
|
|
221
|
+
const { status } = response;
|
|
222
|
+
if (status === 429 || RETRYABLE_SERVER_ERROR_STATUSES.has(status)) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
// 408 Request Timeout is transient by definition.
|
|
226
|
+
if (status === 408) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
// A large upload occasionally trips an edge/proxy that answers 400 with an
|
|
230
|
+
// HTML body ("Your browser sent an invalid request") — transient framing
|
|
231
|
+
// noise, not the API rejecting the payload. A genuine Open Cloud 400 comes
|
|
232
|
+
// back as a structured JSON error and must NOT be retried, so gate on the
|
|
233
|
+
// content type: retry a 400 only when it isn't application/json.
|
|
234
|
+
if (status === 400) {
|
|
235
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
236
|
+
return !contentType.toLowerCase().includes('application/json');
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
203
239
|
}
|
|
204
240
|
|
|
205
241
|
function _computeWaitSeconds(
|
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
parseTestLogs,
|
|
9
9
|
parseTestCounts,
|
|
10
10
|
} from '../test-log-parser.js';
|
|
11
|
+
import {
|
|
12
|
+
buildDeployMetadataAttributes,
|
|
13
|
+
gatherGitDeployInfo,
|
|
14
|
+
injectDeployMetadataInPlaceAsync,
|
|
15
|
+
packageUsesManifestAsync,
|
|
16
|
+
} from '../../deploy/deploy-metadata.js';
|
|
11
17
|
|
|
12
18
|
export interface SingleTestResult {
|
|
13
19
|
success: boolean;
|
|
@@ -60,6 +66,22 @@ export async function runSingleTestAsync(
|
|
|
60
66
|
packageName,
|
|
61
67
|
});
|
|
62
68
|
|
|
69
|
+
// Packages that ship or use the manifest get the same deploy-metadata stamp a
|
|
70
|
+
// real deploy would apply, so their specs can assert the injection actually
|
|
71
|
+
// ran. The per-session build is exclusively ours, so rewrite it in place.
|
|
72
|
+
if (await packageUsesManifestAsync(packagePath)) {
|
|
73
|
+
await injectDeployMetadataInPlaceAsync(
|
|
74
|
+
builtPlace.rbxlPath,
|
|
75
|
+
buildDeployMetadataAttributes(gatherGitDeployInfo(), {
|
|
76
|
+
target: 'test',
|
|
77
|
+
published: false,
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
placeId: builtPlace.target.placeId,
|
|
80
|
+
universeId: builtPlace.target.universeId,
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
const scriptContent =
|
|
64
86
|
scriptText ??
|
|
65
87
|
(await readTestScriptAsync(packagePath, builtPlace.target.scriptTemplate));
|