@quenty/nevermore-cli 4.22.0 → 4.23.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 (75) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/commands/batch-command/batch-deploy-command.js +1 -2
  3. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  4. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  5. package/dist/commands/batch-command/batch-test-command.js +35 -7
  6. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  7. package/dist/commands/deploy-command/index.js +1 -1
  8. package/dist/commands/deploy-command/index.js.map +1 -1
  9. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  10. package/dist/commands/test-command/test-command.js +6 -3
  11. package/dist/commands/test-command/test-command.js.map +1 -1
  12. package/dist/utils/batch/batch-runner.d.ts +2 -1
  13. package/dist/utils/batch/batch-runner.d.ts.map +1 -1
  14. package/dist/utils/batch/batch-runner.js +8 -3
  15. package/dist/utils/batch/batch-runner.js.map +1 -1
  16. package/dist/utils/build/upload.d.ts.map +1 -1
  17. package/dist/utils/build/upload.js +10 -1
  18. package/dist/utils/build/upload.js.map +1 -1
  19. package/dist/utils/job-context/base-job-context.d.ts +4 -3
  20. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  21. package/dist/utils/job-context/base-job-context.js +6 -4
  22. package/dist/utils/job-context/base-job-context.js.map +1 -1
  23. package/dist/utils/job-context/batch-script-job-context.d.ts +4 -2
  24. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  25. package/dist/utils/job-context/batch-script-job-context.js +39 -19
  26. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  27. package/dist/utils/job-context/cloud-job-context.d.ts +3 -3
  28. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  29. package/dist/utils/job-context/cloud-job-context.js +23 -8
  30. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  31. package/dist/utils/job-context/job-context.d.ts +2 -3
  32. package/dist/utils/job-context/job-context.d.ts.map +1 -1
  33. package/dist/utils/job-context/local-job-context.d.ts +3 -3
  34. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  35. package/dist/utils/job-context/local-job-context.js +6 -6
  36. package/dist/utils/job-context/local-job-context.js.map +1 -1
  37. package/dist/utils/open-cloud/open-cloud-client.d.ts +1 -1
  38. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  39. package/dist/utils/open-cloud/open-cloud-client.js +40 -11
  40. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  41. package/dist/utils/testing/parsers/batch-log-parser.d.ts +2 -0
  42. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  43. package/dist/utils/testing/parsers/batch-log-parser.js +3 -1
  44. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  45. package/dist/utils/testing/runner/combined-project-generator.d.ts +9 -0
  46. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  47. package/dist/utils/testing/runner/combined-project-generator.js +17 -1
  48. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  49. package/dist/utils/testing/runner/test-runner.d.ts +3 -2
  50. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  51. package/dist/utils/testing/runner/test-runner.js +5 -5
  52. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  53. package/dist/utils/testing/test-log-parser.d.ts +11 -0
  54. package/dist/utils/testing/test-log-parser.d.ts.map +1 -1
  55. package/dist/utils/testing/test-log-parser.js +22 -0
  56. package/dist/utils/testing/test-log-parser.js.map +1 -1
  57. package/package.json +6 -6
  58. package/src/commands/batch-command/batch-deploy-command.ts +1 -2
  59. package/src/commands/batch-command/batch-test-command.ts +42 -10
  60. package/src/commands/deploy-command/index.ts +1 -1
  61. package/src/commands/test-command/test-command.ts +6 -2
  62. package/src/utils/batch/batch-runner.ts +12 -2
  63. package/src/utils/build/upload.ts +13 -1
  64. package/src/utils/job-context/base-job-context.ts +8 -6
  65. package/src/utils/job-context/batch-script-job-context.ts +40 -21
  66. package/src/utils/job-context/cloud-job-context.ts +24 -8
  67. package/src/utils/job-context/job-context.ts +2 -3
  68. package/src/utils/job-context/local-job-context.ts +4 -6
  69. package/src/utils/open-cloud/open-cloud-client.ts +43 -11
  70. package/src/utils/testing/parsers/batch-log-parser.ts +4 -1
  71. package/src/utils/testing/runner/combined-project-generator.ts +28 -1
  72. package/src/utils/testing/runner/test-runner.ts +5 -6
  73. package/src/utils/testing/test-log-parser.ts +33 -0
  74. package/templates/batch-test-runner.luau +9 -9
  75. package/tsconfig.tsbuildinfo +1 -1
@@ -30,22 +30,30 @@ class CloudDeployment implements Deployment {
30
30
  }
31
31
 
32
32
  export class CloudJobContext extends BaseJobContext {
33
- constructor(openCloudClient: OpenCloudClient) {
34
- super(openCloudClient);
33
+ constructor(reporter: Reporter, openCloudClient: OpenCloudClient) {
34
+ super(reporter, openCloudClient);
35
35
  }
36
36
 
37
37
  async deployBuiltPlaceAsync(
38
- reporter: Reporter,
39
38
  options: DeployPlaceOptions
40
39
  ): Promise<Deployment> {
41
40
  const { builtPlace, packageName, packagePath } = options;
42
41
  const { rbxlPath, target } = builtPlace;
43
42
 
44
- reporter.onPackagePhaseChange(packageName, 'uploading');
43
+ this._reporter.onPackagePhaseChange(packageName, 'uploading');
44
+
45
45
  const version = await this._openCloudClient!.uploadPlaceAsync(
46
46
  target.universeId,
47
47
  target.placeId,
48
- rbxlPath
48
+ rbxlPath,
49
+ undefined,
50
+ (transferred, total) => {
51
+ this._reporter.onPackageProgressUpdate(packageName, {
52
+ kind: 'bytes',
53
+ transferredBytes: transferred,
54
+ totalBytes: total,
55
+ });
56
+ }
49
57
  );
50
58
 
51
59
  // Eagerly release build artifacts after upload (disposeAsync is safety net)
@@ -62,13 +70,12 @@ export class CloudJobContext extends BaseJobContext {
62
70
 
63
71
  async runScriptAsync(
64
72
  deployment: Deployment,
65
- reporter: Reporter,
66
73
  options: RunScriptOptions
67
74
  ): Promise<ScriptRunResult> {
68
75
  const cloudDeployment = deployment as CloudDeployment;
69
76
  const { scriptContent, packageName, timeoutMs = 120_000 } = options;
70
77
 
71
- reporter.onPackagePhaseChange(packageName, 'scheduling');
78
+ this._reporter.onPackagePhaseChange(packageName, 'scheduling');
72
79
  const task = await this._openCloudClient!.createExecutionTaskAsync(
73
80
  cloudDeployment.universeId,
74
81
  cloudDeployment.placeId,
@@ -76,11 +83,20 @@ export class CloudJobContext extends BaseJobContext {
76
83
  scriptContent
77
84
  );
78
85
 
86
+ let pollCount = 0;
79
87
  const completedTask = await Promise.race([
80
88
  this._openCloudClient!.pollTaskCompletionAsync(task.path, (state) => {
89
+ pollCount++;
81
90
  if (state === 'PROCESSING') {
82
- reporter.onPackagePhaseChange(packageName, 'executing');
91
+ this._reporter.onPackagePhaseChange(packageName, 'executing');
83
92
  }
93
+ const label = state === 'QUEUED' ? 'queued' : `poll #${pollCount}`;
94
+ this._reporter.onPackageProgressUpdate(packageName, {
95
+ kind: 'steps',
96
+ completed: pollCount,
97
+ total: 0,
98
+ label,
99
+ });
84
100
  }),
85
101
  timeoutAsync(timeoutMs, `Test timed out after ${timeoutMs / 1000}s`),
86
102
  ]);
@@ -1,4 +1,3 @@
1
- import { type Reporter } from '@quenty/cli-output-helpers/reporting';
2
1
  import { type BuildPlaceOptions, type BuiltPlace } from '../build/build.js';
3
2
 
4
3
  export type { BuiltPlace } from '../build/build.js';
@@ -32,10 +31,10 @@ export interface JobContext {
32
31
  buildPlaceAsync(options: BuildPlaceOptions): Promise<BuiltPlace>;
33
32
 
34
33
  /** Deploy a built place to the execution environment. Returns a handle for subsequent operations. */
35
- deployBuiltPlaceAsync(reporter: Reporter, options: DeployPlaceOptions): Promise<Deployment>;
34
+ deployBuiltPlaceAsync(options: DeployPlaceOptions): Promise<Deployment>;
36
35
 
37
36
  /** Execute a Luau script in a deployed place. */
38
- runScriptAsync(deployment: Deployment, reporter: Reporter, options: RunScriptOptions): Promise<ScriptRunResult>;
37
+ runScriptAsync(deployment: Deployment, options: RunScriptOptions): Promise<ScriptRunResult>;
39
38
 
40
39
  /** Retrieve raw logs from the most recent script execution on this deployment. */
41
40
  getLogsAsync(deployment: Deployment): Promise<string>;
@@ -24,12 +24,11 @@ class LocalDeployment implements Deployment {
24
24
  export class LocalJobContext extends BaseJobContext {
25
25
  private _deployments = new Set<LocalDeployment>();
26
26
 
27
- constructor(openCloudClient?: OpenCloudClient) {
28
- super(openCloudClient);
27
+ constructor(reporter: Reporter, openCloudClient?: OpenCloudClient) {
28
+ super(reporter, openCloudClient);
29
29
  }
30
30
 
31
31
  async deployBuiltPlaceAsync(
32
- reporter: Reporter,
33
32
  options: DeployPlaceOptions
34
33
  ): Promise<Deployment> {
35
34
  const { builtPlace, packageName } = options;
@@ -38,7 +37,7 @@ export class LocalJobContext extends BaseJobContext {
38
37
  placePath: builtPlace.rbxlPath,
39
38
  onPhase: (phase) => {
40
39
  if (phase === 'launching' || phase === 'connecting') {
41
- reporter.onPackagePhaseChange(packageName, phase);
40
+ this._reporter.onPackagePhaseChange(packageName, phase);
42
41
  }
43
42
  },
44
43
  });
@@ -52,13 +51,12 @@ export class LocalJobContext extends BaseJobContext {
52
51
 
53
52
  async runScriptAsync(
54
53
  deployment: Deployment,
55
- reporter: Reporter,
56
54
  options: RunScriptOptions
57
55
  ): Promise<ScriptRunResult> {
58
56
  const localDeployment = deployment as LocalDeployment;
59
57
  const { scriptContent, packageName, timeoutMs } = options;
60
58
 
61
- reporter.onPackagePhaseChange(packageName, 'executing');
59
+ this._reporter.onPackagePhaseChange(packageName, 'executing');
62
60
 
63
61
  try {
64
62
  const result = await localDeployment.bridge.executeAsync({
@@ -106,7 +106,8 @@ export class OpenCloudClient {
106
106
  universeId: number,
107
107
  placeId: number,
108
108
  rbxlPath: string,
109
- publish?: boolean
109
+ publish?: boolean,
110
+ onProgress?: (transferredBytes: number, totalBytes: number) => void
110
111
  ): Promise<number> {
111
112
  OutputHelper.verbose(
112
113
  `Uploading to https://www.roblox.com/games/${placeId}/place ...`
@@ -114,19 +115,50 @@ export class OpenCloudClient {
114
115
 
115
116
  const apiKey = await this._resolveApiKeyAsync();
116
117
  const fileBuffer = await fs.readFile(rbxlPath);
117
- const body = new Uint8Array(fileBuffer);
118
+ const totalBytes = fileBuffer.length;
118
119
  const versionType = publish ? 'Published' : 'Saved';
119
120
  const url = `https://apis.roblox.com/universes/v1/${universeId}/places/${placeId}/versions?versionType=${versionType}`;
120
121
 
121
- const response = await this._rateLimiter.fetchAsync(url, {
122
- method: 'POST',
123
- headers: {
124
- 'Content-Type': 'application/octet-stream',
125
- Accept: 'application/json',
126
- 'X-API-Key': apiKey,
127
- },
128
- body,
129
- });
122
+ const headers: Record<string, string> = {
123
+ 'Content-Type': 'application/octet-stream',
124
+ Accept: 'application/json',
125
+ 'X-API-Key': apiKey,
126
+ };
127
+
128
+ let response: Response;
129
+
130
+ if (onProgress) {
131
+ // Wrap the buffer in a ReadableStream that reports bytes as each chunk
132
+ // is consumed by the transport, giving real-time upload progress.
133
+ const CHUNK_SIZE = 64 * 1024;
134
+ let offset = 0;
135
+ const stream = new ReadableStream<Uint8Array>({
136
+ pull(controller) {
137
+ if (offset >= totalBytes) {
138
+ controller.close();
139
+ return;
140
+ }
141
+ const end = Math.min(offset + CHUNK_SIZE, totalBytes);
142
+ controller.enqueue(fileBuffer.subarray(offset, end));
143
+ offset = end;
144
+ onProgress(offset, totalBytes);
145
+ },
146
+ });
147
+
148
+ // fetch with ReadableStream body requires duplex: 'half' in Node
149
+ response = await this._rateLimiter.fetchAsync(url, {
150
+ method: 'POST',
151
+ headers,
152
+ body: stream,
153
+ duplex: 'half',
154
+ } as RequestInit);
155
+ } else {
156
+ response = await this._rateLimiter.fetchAsync(url, {
157
+ method: 'POST',
158
+ headers,
159
+ body: new Uint8Array(fileBuffer),
160
+ });
161
+ }
130
162
 
131
163
  if (!response.ok) {
132
164
  const text = await response.text();
@@ -1,9 +1,11 @@
1
1
  import { OutputHelper } from '@quenty/cli-output-helpers';
2
+ import { type ParsedTestCounts, parseTestCounts } from '../test-log-parser.js';
2
3
 
3
4
  export interface BatchPackageResult {
4
5
  slug: string;
5
6
  success: boolean;
6
7
  logs: string;
8
+ testCounts?: ParsedTestCounts;
7
9
  }
8
10
 
9
11
  const BEGIN_MARKER = '===BATCH_TEST_BEGIN ';
@@ -136,7 +138,8 @@ export function parseBatchTestLogs(
136
138
  );
137
139
  }
138
140
 
139
- results.set(packageName, { slug, success, logs: sectionLogs });
141
+ const testCounts = sectionLogs ? parseTestCounts(sectionLogs) : undefined;
142
+ results.set(packageName, { slug, success, logs: sectionLogs, testCounts });
140
143
  }
141
144
 
142
145
  return results;
@@ -28,6 +28,16 @@ interface RojoNode {
28
28
  [key: string]: unknown;
29
29
  }
30
30
 
31
+ import { type StepProgress } from '@quenty/cli-output-helpers/reporting';
32
+
33
+ /** Progress callbacks for granular phase reporting during combined builds. */
34
+ export interface CombinedBuildProgress {
35
+ onPackageBuildStart?(packageName: string): void;
36
+ onPackageBuildComplete?(packageName: string): void;
37
+ onCombineStart?(): void;
38
+ onStepProgress?(progress: StepProgress): void;
39
+ }
40
+
31
41
  /** Per-package build info collected during phase 1. */
32
42
  interface PackageBuildInfo {
33
43
  slug: string;
@@ -47,8 +57,9 @@ export async function generateCombinedProjectAsync(options: {
47
57
  repoRoot: string;
48
58
  batchPlaceId?: number;
49
59
  batchUniverseId?: number;
60
+ progress?: CombinedBuildProgress;
50
61
  }): Promise<CombinedProjectResult> {
51
- const { packages, batchPlaceId, batchUniverseId } = options;
62
+ const { packages, batchPlaceId, batchUniverseId, progress } = options;
52
63
 
53
64
  if (packages.length === 0) {
54
65
  throw new Error('No packages provided for combined project generation');
@@ -64,6 +75,7 @@ export async function generateCombinedProjectAsync(options: {
64
75
 
65
76
  // ── Phase 1: Build each package individually ──
66
77
 
78
+ let buildIndex = 0;
67
79
  for (const pkg of packages) {
68
80
  const configPath = resolveDeployConfigPath(pkg.path);
69
81
  const config = await loadDeployConfigAsync(configPath);
@@ -92,7 +104,16 @@ export async function generateCombinedProjectAsync(options: {
92
104
  // Build this package's .rbxl
93
105
  const rbxlPath = buildContext.resolvePath(`${slug}.rbxl`);
94
106
  OutputHelper.verbose(`Building ${pkg.name} (${slug})...`);
107
+ progress?.onPackageBuildStart?.(pkg.name);
95
108
  await buildContext.rojoBuildAsync({ projectPath, output: rbxlPath });
109
+ buildIndex++;
110
+ progress?.onPackageBuildComplete?.(pkg.name);
111
+ progress?.onStepProgress?.({
112
+ kind: 'steps',
113
+ completed: buildIndex,
114
+ total: packages.length,
115
+ label: pkg.name,
116
+ });
96
117
 
97
118
  // Resolve scriptTemplate path
98
119
  if (!target.scriptTemplate) {
@@ -123,6 +144,12 @@ export async function generateCombinedProjectAsync(options: {
123
144
  OutputHelper.verbose(
124
145
  `Merging ${builds.length} packages into combined .rbxl...`
125
146
  );
147
+ progress?.onCombineStart?.();
148
+ progress?.onStepProgress?.({
149
+ kind: 'steps',
150
+ completed: 0,
151
+ total: builds.length,
152
+ });
126
153
  await buildContext.executeLuneTransformScriptAsync(luneScriptPath, ...luneArgs);
127
154
 
128
155
  OutputHelper.verbose(
@@ -1,13 +1,13 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
- import { type Reporter } from '@quenty/cli-output-helpers/reporting';
5
4
  import { type JobContext } from '../../job-context/job-context.js';
6
- import { parseTestLogs } from '../test-log-parser.js';
5
+ import { type ParsedTestCounts, parseTestLogs, parseTestCounts } from '../test-log-parser.js';
7
6
 
8
7
  export interface SingleTestResult {
9
8
  success: boolean;
10
9
  logs: string;
10
+ testCounts?: ParsedTestCounts;
11
11
  }
12
12
 
13
13
  export interface SingleTestOptions {
@@ -26,7 +26,6 @@ export interface SingleTestOptions {
26
26
  */
27
27
  export async function runSingleTestAsync(
28
28
  context: JobContext,
29
- reporter: Reporter,
30
29
  options: SingleTestOptions
31
30
  ): Promise<SingleTestResult> {
32
31
  const {
@@ -41,21 +40,20 @@ export async function runSingleTestAsync(
41
40
  targetName: 'test',
42
41
  outputFileName: `test-${sessionId}.rbxl`,
43
42
  packagePath,
44
- reporter,
45
43
  packageName,
46
44
  });
47
45
 
48
46
  const scriptContent =
49
47
  scriptText ?? (await readTestScriptAsync(packagePath, builtPlace.target.scriptTemplate));
50
48
 
51
- const deployment = await context.deployBuiltPlaceAsync(reporter, {
49
+ const deployment = await context.deployBuiltPlaceAsync({
52
50
  builtPlace,
53
51
  packageName,
54
52
  packagePath,
55
53
  });
56
54
 
57
55
  try {
58
- const result = await context.runScriptAsync(deployment, reporter, {
56
+ const result = await context.runScriptAsync(deployment, {
59
57
  scriptContent,
60
58
  packageName,
61
59
  timeoutMs,
@@ -67,6 +65,7 @@ export async function runSingleTestAsync(
67
65
  return {
68
66
  success: result.success && parsed.success,
69
67
  logs: parsed.logs,
68
+ testCounts: parseTestCounts(parsed.logs),
70
69
  };
71
70
  } finally {
72
71
  await context.releaseAsync(deployment);
@@ -1,8 +1,15 @@
1
1
  import { OutputHelper } from '@quenty/cli-output-helpers';
2
2
 
3
+ export interface ParsedTestCounts {
4
+ passed: number;
5
+ failed: number;
6
+ total: number;
7
+ }
8
+
3
9
  export interface ParsedTestLogs {
4
10
  success: boolean;
5
11
  logs: string;
12
+ testCounts?: ParsedTestCounts;
6
13
  }
7
14
 
8
15
  /**
@@ -25,5 +32,31 @@ export function parseTestLogs(rawOutput: string): ParsedTestLogs {
25
32
  return {
26
33
  success: !hasJestFailures && !hasRuntimeError,
27
34
  logs: rawOutput,
35
+ testCounts: parseTestCounts(rawOutput),
28
36
  };
29
37
  }
38
+
39
+ /**
40
+ * Parse Jest "Tests: N failed, N passed, N total" line into structured counts.
41
+ * Returns undefined if no test summary line is found.
42
+ */
43
+ export function parseTestCounts(rawOutput: string): ParsedTestCounts | undefined {
44
+ const clean = OutputHelper.stripAnsi(rawOutput);
45
+
46
+ // Match "Tests: 2 failed, 23 passed, 25 total" or "Tests: 25 passed, 25 total"
47
+ const match = clean.match(/Tests:\s+(.+?)\s+total/);
48
+ if (!match) return undefined;
49
+
50
+ const prefix = match[1];
51
+ const totalMatch = clean.match(/Tests:\s+.+?(\d+)\s+total/);
52
+ if (!totalMatch) return undefined;
53
+
54
+ const total = parseInt(totalMatch[1], 10);
55
+ const passedMatch = prefix.match(/(\d+)\s+passed/);
56
+ const failedMatch = prefix.match(/(\d+)\s+failed/);
57
+
58
+ const passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
59
+ const failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
60
+
61
+ return { passed, failed, total };
62
+ }
@@ -84,15 +84,6 @@ for _, slug in packageSlugs do
84
84
  -- the marker in the log stream.
85
85
  RunService.Heartbeat:Wait()
86
86
 
87
- if testOk then
88
- print("===BATCH_TEST_END " .. slug .. " PASS===")
89
- table.insert(results, { slug = slug, success = true })
90
- else
91
- warn("[BatchTest] " .. slug .. ": " .. tostring(testErr))
92
- print("===BATCH_TEST_END " .. slug .. " FAIL===")
93
- table.insert(results, { slug = slug, success = false, error = tostring(testErr) })
94
- end
95
-
96
87
  -- CLEANUP: restore service state
97
88
  allPackages[slug].Parent = nil
98
89
  for _, c in workspace:GetChildren() do
@@ -119,6 +110,15 @@ for _, slug in packageSlugs do
119
110
  for _ = 1, 3 do
120
111
  RunService.Heartbeat:Wait()
121
112
  end
113
+
114
+ if testOk then
115
+ print("===BATCH_TEST_END " .. slug .. " PASS===")
116
+ table.insert(results, { slug = slug, success = true })
117
+ else
118
+ warn("[BatchTest] " .. slug .. ": " .. tostring(testErr))
119
+ print("===BATCH_TEST_END " .. slug .. " FAIL===")
120
+ table.insert(results, { slug = slug, success = false, error = tostring(testErr) })
121
+ end
122
122
  end
123
123
 
124
124
  -- Restore all packages