@quenty/nevermore-cli 4.28.1 → 4.29.0-canary.697.26191410126.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 (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands/batch-command/batch-deploy-command.js +9 -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 +1 -0
  5. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  6. package/dist/commands/batch-command/batch-test-command.js +14 -4
  7. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  8. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  9. package/dist/commands/deploy-command/index.js +20 -20
  10. package/dist/commands/deploy-command/index.js.map +1 -1
  11. package/dist/commands/deploy-command/select-target.d.ts +10 -0
  12. package/dist/commands/deploy-command/select-target.d.ts.map +1 -0
  13. package/dist/commands/deploy-command/select-target.js +53 -0
  14. package/dist/commands/deploy-command/select-target.js.map +1 -0
  15. package/dist/commands/test-command/test-command.d.ts +1 -0
  16. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  17. package/dist/commands/test-command/test-command.js +42 -27
  18. package/dist/commands/test-command/test-command.js.map +1 -1
  19. package/dist/utils/batch/batch-runner.js +1 -1
  20. package/dist/utils/batch/batch-runner.js.map +1 -1
  21. package/dist/utils/build/deploy-config.d.ts +4 -3
  22. package/dist/utils/build/deploy-config.d.ts.map +1 -1
  23. package/dist/utils/build/deploy-config.js +7 -3
  24. package/dist/utils/build/deploy-config.js.map +1 -1
  25. package/dist/utils/build/deploy-config.test.d.ts +2 -0
  26. package/dist/utils/build/deploy-config.test.d.ts.map +1 -0
  27. package/dist/utils/build/deploy-config.test.js +41 -0
  28. package/dist/utils/build/deploy-config.test.js.map +1 -0
  29. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  30. package/dist/utils/job-context/cloud-job-context.js +5 -2
  31. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  32. package/dist/utils/open-cloud/open-cloud-client.d.ts +2 -1
  33. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  34. package/dist/utils/open-cloud/open-cloud-client.js +39 -14
  35. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  36. package/package.json +6 -6
  37. package/src/commands/batch-command/batch-deploy-command.ts +8 -4
  38. package/src/commands/batch-command/batch-test-command.ts +15 -5
  39. package/src/commands/deploy-command/index.ts +21 -24
  40. package/src/commands/deploy-command/select-target.ts +80 -0
  41. package/src/commands/test-command/test-command.ts +48 -33
  42. package/src/utils/batch/batch-runner.ts +1 -1
  43. package/src/utils/build/deploy-config.test.ts +51 -0
  44. package/src/utils/build/deploy-config.ts +7 -3
  45. package/src/utils/job-context/cloud-job-context.ts +10 -2
  46. package/src/utils/open-cloud/open-cloud-client.ts +54 -14
  47. package/templates/game-template/.github/workflows/{deploy.yml → integration.yml} +30 -26
  48. package/templates/game-template/.github/workflows/tests.yml +30 -20
  49. package/templates/game-template/README.md +22 -1
  50. package/templates/game-template/aftman.toml +1 -0
  51. package/templates/game-template/default.project.json +3 -0
  52. package/templates/game-template/package.json +7 -2
  53. package/templates/game-template/src/modules/jest.config.lua +4 -0
  54. package/templates/game-template/src/scripts/Server/ServerMain.server.lua +6 -0
  55. package/templates/plugin-template/.github/workflows/{deploy.yml → integration.yml} +30 -26
  56. package/templates/plugin-template/.github/workflows/tests.yml +18 -13
  57. package/tsconfig.tsbuildinfo +1 -1
@@ -29,6 +29,7 @@ export interface TestProjectArgs extends NevermoreGlobalArgs {
29
29
  scriptTemplate?: string;
30
30
  scriptText?: string;
31
31
  output?: string;
32
+ timeout?: number;
32
33
  }
33
34
 
34
35
  export class TestProjectCommand<T>
@@ -74,42 +75,48 @@ export class TestProjectCommand<T>
74
75
  describe: 'Write JSON results to this file',
75
76
  type: 'string',
76
77
  });
78
+ args.option('timeout', {
79
+ describe:
80
+ 'Max script execution time in seconds. Sent to the Open Cloud API so Roblox cancels server-side on overrun (default: 120)',
81
+ type: 'number',
82
+ });
77
83
 
78
84
  return args as Argv<TestProjectArgs>;
79
85
  };
80
86
 
81
87
  public handler = async (args: TestProjectArgs) => {
82
- try {
83
- const cwd = process.cwd();
84
- const packageName =
85
- (await readPackageNameAsync(cwd)) ?? path.basename(cwd);
86
- const showLogs = args.logs ?? false;
87
- const useSpinner = process.stdout.isTTY && !args.verbose;
88
+ const cwd = process.cwd();
89
+ const packageName = (await readPackageNameAsync(cwd)) ?? path.basename(cwd);
90
+ const showLogs = args.logs ?? false;
91
+ const useSpinner = process.stdout.isTTY && !args.verbose;
88
92
 
89
- const reporter = new CompositeReporter(
90
- [packageName],
91
- (state: LiveStateTracker) => {
92
- const reporters: Reporter[] = [
93
- useSpinner
94
- ? new SpinnerReporter(state, {
95
- showLogs,
96
- actionVerb: 'Testing',
97
- })
98
- : new SimpleReporter(state, {
99
- alwaysShowLogs: showLogs,
100
- successMessage: 'Tests passed!',
101
- failureMessage:
102
- 'Tests failed! See output above for more information.',
103
- }),
104
- ];
105
- if (args.output) {
106
- reporters.push(new JsonFileReporter(state, args.output));
107
- }
108
- return reporters;
93
+ const reporter = new CompositeReporter(
94
+ [packageName],
95
+ (state: LiveStateTracker) => {
96
+ const reporters: Reporter[] = [
97
+ useSpinner
98
+ ? new SpinnerReporter(state, {
99
+ showLogs,
100
+ actionVerb: 'Testing',
101
+ })
102
+ : new SimpleReporter(state, {
103
+ alwaysShowLogs: showLogs,
104
+ verbose: args.verbose,
105
+ successMessage: 'Tests passed!',
106
+ failureMessage:
107
+ 'Tests failed! See output above for more information.',
108
+ }),
109
+ ];
110
+ if (args.output) {
111
+ reporters.push(new JsonFileReporter(state, args.output));
109
112
  }
110
- );
111
- await reporter.startAsync();
113
+ return reporters;
114
+ }
115
+ );
116
+ await reporter.startAsync();
112
117
 
118
+ let exitCode = 0;
119
+ try {
113
120
  const context = args.cloud
114
121
  ? new CloudJobContext(
115
122
  reporter,
@@ -126,6 +133,8 @@ export class TestProjectCommand<T>
126
133
  packagePath: cwd,
127
134
  packageName,
128
135
  scriptText: args.scriptText,
136
+ timeoutMs:
137
+ args.timeout !== undefined ? args.timeout * 1000 : undefined,
129
138
  });
130
139
  } finally {
131
140
  await context.disposeAsync();
@@ -140,13 +149,19 @@ export class TestProjectCommand<T>
140
149
  ? { kind: 'test-counts', ...result.testCounts }
141
150
  : undefined,
142
151
  });
143
-
144
- await reporter.stopAsync();
152
+ if (!result.success) exitCode = 1;
145
153
  } catch (err) {
146
- OutputHelper.error(err instanceof Error ? err.message : String(err));
147
- process.exit(1);
154
+ reporter.onPackageResult({
155
+ packageName,
156
+ success: false,
157
+ logs: '',
158
+ durationMs: 0,
159
+ error: OutputHelper.formatErrorChain(err),
160
+ });
161
+ exitCode = 1;
148
162
  }
149
163
 
150
- process.exit(0);
164
+ await reporter.stopAsync();
165
+ process.exit(exitCode);
151
166
  };
152
167
  }
@@ -112,7 +112,7 @@ async function _runOneAsync<TResult extends PackageResult>(
112
112
  const durationMs = partial.durationMs ?? Date.now() - startMs;
113
113
  return { ...partial, durationMs } as TResult;
114
114
  } catch (err) {
115
- const errorMessage = err instanceof Error ? err.message : String(err);
115
+ const errorMessage = OutputHelper.formatErrorChain(err);
116
116
  const currentPhase = stateTracker?.getCurrentPhase(pkg.name);
117
117
  const failedPhase =
118
118
  currentPhase &&
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ type DeployConfig,
4
+ type DeployTarget,
5
+ resolveDefaultTargetName,
6
+ } from './deploy-config.js';
7
+
8
+ function makeTarget(placeId: number): DeployTarget {
9
+ return {
10
+ universeId: 1,
11
+ placeId,
12
+ project: 'default.project.json',
13
+ };
14
+ }
15
+
16
+ function makeConfig(targets: Record<string, DeployTarget>): DeployConfig {
17
+ return { targets };
18
+ }
19
+
20
+ describe('resolveDefaultTargetName', () => {
21
+ it('returns the only target when there is just one', () => {
22
+ const config = makeConfig({ prod: makeTarget(1) });
23
+ expect(resolveDefaultTargetName(config)).toBe('prod');
24
+ });
25
+
26
+ it('prefers integration over test when both exist', () => {
27
+ const config = makeConfig({
28
+ test: makeTarget(1),
29
+ integration: makeTarget(2),
30
+ });
31
+ expect(resolveDefaultTargetName(config)).toBe('integration');
32
+ });
33
+
34
+ it('falls back to test when integration is absent', () => {
35
+ const config = makeConfig({ test: makeTarget(1), dev: makeTarget(2) });
36
+ expect(resolveDefaultTargetName(config)).toBe('test');
37
+ });
38
+
39
+ it('returns integration when present alongside non-test targets', () => {
40
+ const config = makeConfig({
41
+ integration: makeTarget(1),
42
+ dev: makeTarget(2),
43
+ });
44
+ expect(resolveDefaultTargetName(config)).toBe('integration');
45
+ });
46
+
47
+ it('throws when no preferred target exists and multiple options are present', () => {
48
+ const config = makeConfig({ foo: makeTarget(1), bar: makeTarget(2) });
49
+ expect(() => resolveDefaultTargetName(config)).toThrowError(/foo, bar/);
50
+ });
51
+ });
@@ -88,9 +88,10 @@ export function resolveDeployTarget(
88
88
  }
89
89
 
90
90
  /**
91
- * Pick a target name when the user did not specify one. If the config has
92
- * exactly one target, use it. Otherwise prefer "test" if it exists; if not,
93
- * throw with the list of available targets.
91
+ * Pick a target name when the user did not specify one and we cannot prompt
92
+ * (non-TTY / CI). Single target wins; otherwise prefer "integration" over
93
+ * "test" so `--publish` does not silently target the test place. Throws when
94
+ * neither is present.
94
95
  */
95
96
  export function resolveDefaultTargetName(config: DeployConfig): string {
96
97
  const availableTargets = Object.keys(config.targets);
@@ -98,6 +99,9 @@ export function resolveDefaultTargetName(config: DeployConfig): string {
98
99
  if (availableTargets.length === 1) {
99
100
  return availableTargets[0]!;
100
101
  }
102
+ if (config.targets['integration']) {
103
+ return 'integration';
104
+ }
101
105
  if (config.targets['test']) {
102
106
  return 'test';
103
107
  }
@@ -80,9 +80,14 @@ export class CloudJobContext extends BaseJobContext {
80
80
  cloudDeployment.universeId,
81
81
  cloudDeployment.placeId,
82
82
  cloudDeployment.version,
83
- scriptContent
83
+ scriptContent,
84
+ timeoutMs
84
85
  );
85
86
 
87
+ // Give the server-side timeout a head start so we observe the cancelled
88
+ // task state instead of bailing out on the client first.
89
+ const CLIENT_TIMEOUT_GRACE_MS = 30_000;
90
+
86
91
  let pollCount = 0;
87
92
  const completedTask = await Promise.race([
88
93
  this._openCloudClient!.pollTaskCompletionAsync(task.path, (state) => {
@@ -98,7 +103,10 @@ export class CloudJobContext extends BaseJobContext {
98
103
  label,
99
104
  });
100
105
  }),
101
- timeoutAsync(timeoutMs, `Test timed out after ${timeoutMs / 1000}s`),
106
+ timeoutAsync(
107
+ timeoutMs + CLIENT_TIMEOUT_GRACE_MS,
108
+ `Test timed out after ${timeoutMs / 1000}s`
109
+ ),
102
110
  ]);
103
111
 
104
112
  cloudDeployment.taskPath = task.path;
@@ -19,6 +19,7 @@ export interface LuauTask {
19
19
  | 'COMPLETE'
20
20
  | 'FAILED';
21
21
  script: string;
22
+ timeout?: string;
22
23
  }
23
24
 
24
25
  export interface OpenCloudClientOptions {
@@ -209,18 +210,30 @@ export class OpenCloudClient {
209
210
  universeId: number,
210
211
  placeId: number,
211
212
  placeVersion: number,
212
- script: string
213
+ script: string,
214
+ timeoutMs?: number
213
215
  ): Promise<LuauTask> {
214
216
  const apiKey = await this._resolveApiKeyAsync();
215
217
  const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}/places/${placeId}/versions/${placeVersion}/luau-execution-session-tasks`;
216
218
 
219
+ const body: {
220
+ script: string;
221
+ timeout?: string;
222
+ enableBinaryOutput?: boolean;
223
+ } = { script, enableBinaryOutput: false };
224
+ if (timeoutMs !== undefined) {
225
+ // Roblox encodes durations as Google AIP duration strings (e.g. "120s").
226
+ // The server uses this to cancel runaway scripts on its end.
227
+ body.timeout = `${Math.max(1, Math.ceil(timeoutMs / 1000))}s`;
228
+ }
229
+
217
230
  const response = await this._rateLimiter.fetchAsync(url, {
218
231
  method: 'POST',
219
232
  headers: {
220
233
  'Content-Type': 'application/json',
221
234
  'X-API-Key': apiKey,
222
235
  },
223
- body: JSON.stringify({ script }),
236
+ body: JSON.stringify(body),
224
237
  });
225
238
 
226
239
  if (!response.ok) {
@@ -313,26 +326,53 @@ export class OpenCloudClient {
313
326
 
314
327
  private async _fetchRawLogsAsync(taskPath: string): Promise<string> {
315
328
  const apiKey = await this._resolveApiKeyAsync();
316
- const response = await this._rateLimiter.fetchAsync(
317
- `https://apis.roblox.com/cloud/v2/${taskPath}/logs`,
318
- {
329
+ const messages: string[] = [];
330
+ let pageToken: string | undefined;
331
+
332
+ do {
333
+ const url = new URL(`https://apis.roblox.com/cloud/v2/${taskPath}/logs`);
334
+ url.searchParams.set('view', 'STRUCTURED');
335
+ if (pageToken) {
336
+ url.searchParams.set('pageToken', pageToken);
337
+ }
338
+
339
+ const response = await this._rateLimiter.fetchAsync(url.toString(), {
319
340
  method: 'GET',
320
341
  headers: {
321
342
  'X-API-Key': apiKey,
322
343
  },
344
+ });
345
+
346
+ if (!response.ok) {
347
+ const text = await response.text();
348
+ throw new Error(`Get logs failed: ${response.status}: ${text}`);
323
349
  }
324
- );
325
350
 
326
- if (!response.ok) {
327
- const text = await response.text();
328
- throw new Error(`Get logs failed: ${response.status}: ${text}`);
329
- }
351
+ const data = (await response.json()) as {
352
+ luauExecutionSessionTaskLogs?: Array<{
353
+ messages?: string[];
354
+ structuredMessages?: Array<{
355
+ message: string;
356
+ createTime: string;
357
+ messageType: string;
358
+ }>;
359
+ }>;
360
+ nextPageToken?: string;
361
+ };
362
+
363
+ for (const entry of data.luauExecutionSessionTaskLogs ?? []) {
364
+ if (entry.structuredMessages?.length) {
365
+ for (const msg of entry.structuredMessages) {
366
+ messages.push(msg.message);
367
+ }
368
+ } else if (entry.messages?.length) {
369
+ messages.push(...entry.messages);
370
+ }
371
+ }
330
372
 
331
- const data = (await response.json()) as {
332
- luauExecutionSessionTaskLogs: Array<{ messages: string[] }>;
333
- };
373
+ pageToken = data.nextPageToken || undefined;
374
+ } while (pageToken);
334
375
 
335
- const messages = data.luauExecutionSessionTaskLogs?.[0]?.messages ?? [];
336
376
  return messages.join('\n');
337
377
  }
338
378
 
@@ -1,45 +1,44 @@
1
- # Roblox Deploy
1
+ # Roblox Integration Deploy
2
2
  #
3
- # Builds and uploads the project to your Roblox place.
3
+ # Builds and uploads the project to your Roblox integration place on every pull request.
4
4
  # This workflow is inactive until you complete the setup below.
5
5
  #
6
6
  # Setup:
7
- # 1. Run `nevermore deploy init` to create deploy.nevermore.json
7
+ # 1. Run `nevermore deploy init` to create deploy.nevermore.json (with an `integration` target)
8
8
  # 2. Add ROBLOX_OPEN_CLOUD_API_KEY as a repository secret
9
9
  # (Create one at https://create.roblox.com/dashboard/credentials)
10
- # 3. Deploys will run automatically when code is pushed to main
11
- # 4. You can also trigger a deploy manually from the Actions tab
10
+ # 3. Deploys will run automatically on every pull request
12
11
  #
13
12
  # Docs: https://quenty.github.io/NevermoreEngine/docs/testing/integration-testing
14
13
 
15
- name: deploy
16
- on:
17
- push:
18
- branches:
19
- - main
20
- workflow_dispatch:
14
+ name: integration
15
+ on: [pull_request]
21
16
  jobs:
22
- config-check:
17
+ integration:
23
18
  runs-on: ubuntu-latest
24
- outputs:
25
- enabled: $\{{ steps.check.outputs.enabled }}
19
+ environment: integration
26
20
  steps:
27
21
  - id: check
22
+ name: Check config
28
23
  run: |
29
- if [ -n "$KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT"
30
- else echo "::notice::Deploy skipped — ROBLOX_OPEN_CLOUD_API_KEY not configured. See top of this file for setup."; fi
24
+ if [ -n "$KEY" ]; then
25
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
26
+ else
27
+ echo "::notice::Integration skipped — ROBLOX_OPEN_CLOUD_API_KEY not configured. See top of this file for setup."
28
+ fi
31
29
  env:
32
30
  KEY: $\{{ secrets.ROBLOX_OPEN_CLOUD_API_KEY }}
33
31
 
34
- deploy:
35
- needs: config-check
36
- if: needs.config-check.outputs.enabled == 'true'
37
- runs-on: ubuntu-latest
38
- steps:
39
32
  - name: Checkout repository
33
+ if: steps.check.outputs.enabled == 'true'
40
34
  uses: actions/checkout@v6
41
35
 
36
+ - name: Fetch base branch for changeset diff
37
+ if: steps.check.outputs.enabled == 'true'
38
+ run: git fetch origin $\{{ github.base_ref }} --depth=1
39
+
42
40
  - name: Setup Aftman
41
+ if: steps.check.outputs.enabled == 'true'
43
42
  uses: ok-nick/setup-aftman@v0.4.2
44
43
  with:
45
44
  version: 'v0.3.0'
@@ -47,11 +46,13 @@ jobs:
47
46
  cache: true
48
47
 
49
48
  - name: Setup pnpm
49
+ if: steps.check.outputs.enabled == 'true'
50
50
  uses: pnpm/action-setup@v4
51
51
  with:
52
52
  cache: true
53
53
 
54
54
  - name: Setup npm for GitHub Packages
55
+ if: steps.check.outputs.enabled == 'true'
55
56
  run: |
56
57
  if [[ -n "$NPM_GITHUB_QUENTYSTUDIOS_TOKEN" ]]; then
57
58
  echo "//npm.pkg.github.com/:_authToken=$NPM_GITHUB_QUENTYSTUDIOS_TOKEN" >> .npmrc
@@ -67,16 +68,19 @@ jobs:
67
68
  NPM_TOKEN: $\{{ secrets.NPM_TOKEN }}
68
69
 
69
70
  - name: Link and install packages
71
+ if: steps.check.outputs.enabled == 'true'
70
72
  run: pnpm install --frozen-lockfile
71
73
 
72
- - name: Deploy
73
- id: deploy
74
- run: npx @quenty/nevermore-cli deploy run --yes --verbose --logs --output deploy-results.json
74
+ - name: Deploy integration
75
+ id: deploy-integration
76
+ if: steps.check.outputs.enabled == 'true'
77
+ run: npx @quenty/nevermore-cli batch deploy --target integration --publish --base origin/$\{{ github.base_ref }} --output integration-results.json --yes --verbose --logs
75
78
  env:
76
79
  ROBLOX_OPEN_CLOUD_API_KEY: $\{{ secrets.ROBLOX_OPEN_CLOUD_API_KEY }}
80
+ GITHUB_TOKEN: $\{{ secrets.GITHUB_TOKEN }}
77
81
 
78
82
  - name: Post deploy results
79
- if: always()
80
- run: npx @quenty/nevermore-cli tools post-deploy-results deploy-results.json --yes --run-outcome $\{{ steps.deploy.outcome }}
83
+ if: always() && steps.check.outputs.enabled == 'true'
84
+ run: npx @quenty/nevermore-cli tools post-deploy-results integration-results.json --yes --run-outcome $\{{ steps.deploy-integration.outcome }}
81
85
  env:
82
86
  GITHUB_TOKEN: $\{{ secrets.GITHUB_TOKEN }}
@@ -7,47 +7,46 @@
7
7
  # 1. Run `nevermore deploy init` to create deploy.nevermore.json with a test target
8
8
  # 2. Add ROBLOX_OPEN_CLOUD_API_KEY as a repository secret
9
9
  # (Create one at https://create.roblox.com/dashboard/credentials)
10
- # 3. Tests will run automatically on pull requests
10
+ # 3. Tests will run automatically on pull requests and on push to main
11
11
  #
12
12
  # Docs: https://quenty.github.io/NevermoreEngine/docs/testing
13
13
 
14
14
  name: tests
15
15
  on:
16
16
  pull_request:
17
+ push:
18
+ branches: [main]
17
19
  jobs:
18
- config-check:
20
+ tests:
19
21
  runs-on: ubuntu-latest
20
- outputs:
21
- enabled: $\{{ steps.check.outputs.enabled }}
22
22
  steps:
23
23
  - id: check
24
+ name: Check config
24
25
  run: |
25
- if [ -n "$KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT"
26
- else echo "::notice::Tests skipped — ROBLOX_OPEN_CLOUD_API_KEY not configured. See top of this file for setup."; fi
26
+ if [ -n "$KEY" ]; then
27
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
28
+ else
29
+ echo "::notice::Tests skipped — ROBLOX_OPEN_CLOUD_API_KEY not configured. See top of this file for setup."
30
+ fi
27
31
  env:
28
32
  KEY: $\{{ secrets.ROBLOX_OPEN_CLOUD_API_KEY }}
29
33
 
30
- tests:
31
- needs: config-check
32
- if: needs.config-check.outputs.enabled == 'true'
33
- runs-on: ubuntu-latest
34
- steps:
35
34
  - name: Checkout repository
35
+ if: steps.check.outputs.enabled == 'true'
36
36
  uses: actions/checkout@v6
37
37
 
38
- - name: Setup Aftman
39
- uses: ok-nick/setup-aftman@v0.4.2
40
- with:
41
- version: 'v0.3.0'
42
- token: $\{{ secrets.GITHUB_TOKEN }}
43
- cache: true
38
+ - name: Fetch base branch for changeset diff
39
+ if: steps.check.outputs.enabled == 'true' && github.event_name == 'pull_request'
40
+ run: git fetch origin $\{{ github.base_ref }} --depth=1
44
41
 
45
42
  - name: Setup pnpm
43
+ if: steps.check.outputs.enabled == 'true'
46
44
  uses: pnpm/action-setup@v4
47
45
  with:
48
46
  cache: true
49
47
 
50
48
  - name: Setup npm for GitHub Packages
49
+ if: steps.check.outputs.enabled == 'true'
51
50
  run: |
52
51
  if [[ -n "$NPM_GITHUB_QUENTYSTUDIOS_TOKEN" ]]; then
53
52
  echo "//npm.pkg.github.com/:_authToken=$NPM_GITHUB_QUENTYSTUDIOS_TOKEN" >> .npmrc
@@ -63,20 +62,31 @@ jobs:
63
62
  NPM_TOKEN: $\{{ secrets.NPM_TOKEN }}
64
63
 
65
64
  - name: Link and install packages
65
+ if: steps.check.outputs.enabled == 'true'
66
66
  run: pnpm install --frozen-lockfile
67
67
 
68
+ - name: Setup Aftman
69
+ if: steps.check.outputs.enabled == 'true'
70
+ uses: ok-nick/setup-aftman@v0.4.2
71
+ with:
72
+ version: 'v0.3.0'
73
+ token: $\{{ secrets.GITHUB_TOKEN }}
74
+ cache: true
75
+
68
76
  - name: Run tests
69
77
  id: run-tests
70
- run: npx @quenty/nevermore-cli test --cloud --yes --verbose --logs --output test-results.json
78
+ if: steps.check.outputs.enabled == 'true'
79
+ run: npx @quenty/nevermore-cli batch test --cloud --aggregated $\{{ github.event_name == 'pull_request' && format('--base origin/{0}', github.base_ref) || '' }} --output test-results.json --yes --verbose --logs
71
80
  env:
72
81
  ROBLOX_OPEN_CLOUD_API_KEY: $\{{ secrets.ROBLOX_OPEN_CLOUD_API_KEY }}
82
+ GITHUB_TOKEN: $\{{ secrets.GITHUB_TOKEN }}
73
83
 
74
84
  - name: Build sourcemap for annotation resolution
75
- if: always()
85
+ if: always() && steps.check.outputs.enabled == 'true'
76
86
  run: npm run build:sourcemap
77
87
 
78
88
  - name: Post test results
79
- if: always()
89
+ if: always() && steps.check.outputs.enabled == 'true'
80
90
  run: npx @quenty/nevermore-cli tools post-test-results test-results.json --yes --run-outcome $\{{ steps.run-tests.outcome }}
81
91
  env:
82
92
  GITHUB_TOKEN: $\{{ secrets.GITHUB_TOKEN }}
@@ -56,15 +56,36 @@ npm run format
56
56
 
57
57
  Note that you should also configure your editor to automatically format files using Stylua.
58
58
 
59
+ # Tests
60
+
61
+ Tests live next to the code they cover and end in `.spec.lua`. They run on Roblox via [`@quenty/nevermore-test-runner`](https://www.npmjs.com/package/@quenty/nevermore-test-runner) using [jest-lua](https://github.com/jsdotlua/jest-lua).
62
+
63
+ Run tests locally against the cloud:
64
+
65
+ ```bash
66
+ npm test
67
+ ```
68
+
69
+ The matcher and runner config live in [`src/modules/jest.config.lua`](src/modules/jest.config.lua).
70
+
71
+ # Deploying
72
+
73
+ Deploy targets are defined in `deploy.nevermore.json`. Run `npx @quenty/nevermore-cli deploy init` to scaffold one. To push an integration build manually:
74
+
75
+ ```bash
76
+ npm run deploy
77
+ ```
78
+
59
79
  # CI/CD
60
80
 
61
81
  This project includes GitHub Actions workflows in `.github/workflows/`:
62
82
 
63
83
  - **Linting** (`linting.yml`) — Runs automatically on PRs and push to main. Posts inline annotations on PRs for luau-lsp, stylua, selene, and moonwave issues.
64
84
  - **Tests** (`tests.yml`) — Runs on PRs when configured. Set up with `nevermore deploy init` to create a `deploy.nevermore.json`, then add `ROBLOX_OPEN_CLOUD_API_KEY` as a repository secret.
85
+ - **Integration** (`integration.yml`) — Deploys PRs to an `integration` target so reviewers can play the change. Same setup as tests; optionally create a GitHub Environment named `integration` to scope the secret.
65
86
  - **Deploy** (`deploy.yml`) — Runs on push to main when configured. Same setup as tests: `nevermore deploy init` + `ROBLOX_OPEN_CLOUD_API_KEY` secret.
66
87
 
67
- Tests and deploy workflows are inactive until configured — they skip cleanly with a notice annotation explaining the setup steps.
88
+ Tests, integration, and deploy workflows are inactive until configured — they skip cleanly with a notice annotation explaining the setup steps.
68
89
 
69
90
  # Getting Luau-lsp to work in VSCode
70
91
 
@@ -2,6 +2,7 @@
2
2
  # For more information, see https://github.com/LPGhatguy/aftman
3
3
  [tools]
4
4
  luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1"
5
+ lune = "lune-org/lune@0.10.4"
5
6
  moonwave-extractor = "UpliftGames/moonwave@1.3.0"
6
7
  rojo = "quenty/rojo@7.7.0-rc.1-quenty.4"
7
8
  selene = "Kampfkarren/selene@0.29.0"
@@ -11,6 +11,9 @@
11
11
  "tree": {
12
12
  "$className": "DataModel",
13
13
  "ServerScriptService": {
14
+ "$properties": {
15
+ "LoadStringEnabled": true
16
+ },
14
17
  "{{gameNameProper}}": {
15
18
  "$className": "Folder",
16
19
  "game": {
@@ -12,15 +12,20 @@
12
12
  "scripts": {
13
13
  "build:sourcemap": "rojo sourcemap default.project.json --output sourcemap.json --absolute",
14
14
  "format": "stylua --config-path=stylua.toml src",
15
- "lint:luau": "luau-lsp analyze --sourcemap=sourcemap.json --base-luaurc=.luaurc --defs=globalTypes.d.lua --flag:LuauSolverV2=false --ignore=**/node_modules/** --ignore=**/*.story.lua --ignore=**/*.client.lua --ignore=**/*.server.lua src",
15
+ "lint:luau": "luau-lsp analyze --sourcemap=sourcemap.json --base-luaurc=.luaurc --defs=globalTypes.d.lua --flag:LuauSolverV2=false --ignore=**/node_modules/** --ignore=**/*.spec.lua --ignore=**/*.story.lua --ignore=**/*.client.lua --ignore=**/*.server.lua src",
16
16
  "lint:moonwave": "moonwave-extractor extract src",
17
17
  "lint:selene": "selene --no-summary --config=selene.toml src",
18
18
  "lint:stylua": "stylua --config-path=stylua.toml --check src",
19
19
  "prelint:selene": "selene generate-roblox-std",
20
20
  "prelint:luau": "npm run build:sourcemap && npx @quenty/nevermore-cli tools download-roblox-types",
21
+ "test": "npx @quenty/nevermore-cli test",
22
+ "deploy": "npx @quenty/nevermore-cli deploy --target integration",
21
23
  "preinstall": "npx only-allow pnpm"
22
24
  },
23
- "dependencies": {},
25
+ "dependencies": {
26
+ "@quenty/nevermore-test-runner": "^1.4.0",
27
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
28
+ },
24
29
  "publishConfig": {
25
30
  "access": "restricted"
26
31
  }
@@ -0,0 +1,4 @@
1
+ return {
2
+ passWithNoTests = true,
3
+ testMatch = { "**/*.spec" },
4
+ }
@@ -8,6 +8,12 @@ local root = ServerScriptService.{{gameNameProper}}
8
8
  local loader = root:FindFirstChild("LoaderUtils", true).Parent
9
9
  local require = require(loader).bootstrapGame(root)
10
10
 
11
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
12
+
13
+ if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root.game) then
14
+ return
15
+ end
16
+
11
17
  local serviceBag = require("ServiceBag").new()
12
18
  serviceBag:GetService(require("{{gameNameProper}}Service"))
13
19
  serviceBag:Init()