@quenty/nevermore-cli 4.42.0 → 4.42.1-canary.783.30683606415.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 (172) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/args/global-args.d.ts +13 -0
  3. package/dist/args/global-args.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.d.ts +3 -0
  5. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  6. package/dist/commands/batch-command/batch-deploy-command.js +90 -1
  7. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  8. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  9. package/dist/commands/batch-command/batch-test-command.js +7 -2
  10. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  11. package/dist/commands/deploy-command/deploy-init.js +1 -1
  12. package/dist/commands/deploy-command/deploy-init.js.map +1 -1
  13. package/dist/commands/deploy-command/index.d.ts +3 -0
  14. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  15. package/dist/commands/deploy-command/index.js +301 -60
  16. package/dist/commands/deploy-command/index.js.map +1 -1
  17. package/dist/commands/deploy-command/select-target.js +1 -1
  18. package/dist/commands/deploy-command/select-target.js.map +1 -1
  19. package/dist/commands/deploy-command/version-command.d.ts.map +1 -1
  20. package/dist/commands/deploy-command/version-command.js +138 -12
  21. package/dist/commands/deploy-command/version-command.js.map +1 -1
  22. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  23. package/dist/commands/test-command/test-command.js +17 -7
  24. package/dist/commands/test-command/test-command.js.map +1 -1
  25. package/dist/nevermore.js +17 -0
  26. package/dist/nevermore.js.map +1 -1
  27. package/dist/utils/batch/changed-packages-utils.d.ts +1 -1
  28. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  29. package/dist/utils/batch/changed-packages-utils.js +1 -1
  30. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  31. package/dist/utils/build/base-place-resolver-factory.d.ts +25 -0
  32. package/dist/utils/build/base-place-resolver-factory.d.ts.map +1 -0
  33. package/dist/utils/build/base-place-resolver-factory.js +40 -0
  34. package/dist/utils/build/base-place-resolver-factory.js.map +1 -0
  35. package/dist/utils/build/build.d.ts +13 -2
  36. package/dist/utils/build/build.d.ts.map +1 -1
  37. package/dist/utils/build/build.js.map +1 -1
  38. package/dist/utils/build/upload.d.ts +1 -1
  39. package/dist/utils/build/upload.d.ts.map +1 -1
  40. package/dist/utils/deploy/deploy-metadata.d.ts +1 -1
  41. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  42. package/dist/utils/job-context/base-job-context.d.ts +3 -1
  43. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  44. package/dist/utils/job-context/base-job-context.js +45 -6
  45. package/dist/utils/job-context/base-job-context.js.map +1 -1
  46. package/dist/utils/job-context/batch-script-job-context.d.ts +6 -0
  47. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  48. package/dist/utils/job-context/batch-script-job-context.js +13 -0
  49. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  50. package/dist/utils/job-context/cloud-job-context.d.ts +2 -1
  51. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  52. package/dist/utils/job-context/cloud-job-context.js +2 -2
  53. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  54. package/dist/utils/job-context/local-job-context.d.ts +2 -1
  55. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  56. package/dist/utils/job-context/local-job-context.js +2 -2
  57. package/dist/utils/job-context/local-job-context.js.map +1 -1
  58. package/dist/utils/open-cloud/open-cloud-client.d.ts +20 -2
  59. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  60. package/dist/utils/open-cloud/open-cloud-client.js +74 -20
  61. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  62. package/dist/utils/open-cloud/open-cloud-client.test.js +89 -12
  63. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  64. package/dist/utils/testing/runner/combined-project-generator.d.ts +1 -1
  65. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  66. package/dist/utils/testing/runner/test-runner.d.ts +1 -1
  67. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  68. package/dist/utils/watch/github-context.d.ts +50 -0
  69. package/dist/utils/watch/github-context.d.ts.map +1 -0
  70. package/dist/utils/watch/github-context.js +125 -0
  71. package/dist/utils/watch/github-context.js.map +1 -0
  72. package/dist/utils/watch/github-context.test.d.ts +2 -0
  73. package/dist/utils/watch/github-context.test.d.ts.map +1 -0
  74. package/dist/utils/watch/github-context.test.js +112 -0
  75. package/dist/utils/watch/github-context.test.js.map +1 -0
  76. package/dist/utils/watch/http-watch-registry.d.ts +40 -0
  77. package/dist/utils/watch/http-watch-registry.d.ts.map +1 -0
  78. package/dist/utils/watch/http-watch-registry.js +262 -0
  79. package/dist/utils/watch/http-watch-registry.js.map +1 -0
  80. package/dist/utils/watch/http-watch-registry.test.d.ts +2 -0
  81. package/dist/utils/watch/http-watch-registry.test.d.ts.map +1 -0
  82. package/dist/utils/watch/http-watch-registry.test.js +211 -0
  83. package/dist/utils/watch/http-watch-registry.test.js.map +1 -0
  84. package/dist/utils/watch/local-watch-loop.d.ts +50 -0
  85. package/dist/utils/watch/local-watch-loop.d.ts.map +1 -0
  86. package/dist/utils/watch/local-watch-loop.js +103 -0
  87. package/dist/utils/watch/local-watch-loop.js.map +1 -0
  88. package/dist/utils/watch/local-watch-loop.test.d.ts +2 -0
  89. package/dist/utils/watch/local-watch-loop.test.d.ts.map +1 -0
  90. package/dist/utils/watch/local-watch-loop.test.js +170 -0
  91. package/dist/utils/watch/local-watch-loop.test.js.map +1 -0
  92. package/dist/utils/watch/register-notify-watches.d.ts +41 -0
  93. package/dist/utils/watch/register-notify-watches.d.ts.map +1 -0
  94. package/dist/utils/watch/register-notify-watches.js +159 -0
  95. package/dist/utils/watch/register-notify-watches.js.map +1 -0
  96. package/dist/utils/watch/register-notify-watches.test.d.ts +2 -0
  97. package/dist/utils/watch/register-notify-watches.test.d.ts.map +1 -0
  98. package/dist/utils/watch/register-notify-watches.test.js +225 -0
  99. package/dist/utils/watch/register-notify-watches.test.js.map +1 -0
  100. package/dist/utils/watch/register-watches.d.ts +68 -0
  101. package/dist/utils/watch/register-watches.d.ts.map +1 -0
  102. package/dist/utils/watch/register-watches.js +221 -0
  103. package/dist/utils/watch/register-watches.js.map +1 -0
  104. package/dist/utils/watch/register-watches.test.d.ts +2 -0
  105. package/dist/utils/watch/register-watches.test.d.ts.map +1 -0
  106. package/dist/utils/watch/register-watches.test.js +576 -0
  107. package/dist/utils/watch/register-watches.test.js.map +1 -0
  108. package/dist/utils/watch/stream-watch-loop.d.ts +54 -0
  109. package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -0
  110. package/dist/utils/watch/stream-watch-loop.js +282 -0
  111. package/dist/utils/watch/stream-watch-loop.js.map +1 -0
  112. package/dist/utils/watch/stream-watch-loop.test.d.ts +2 -0
  113. package/dist/utils/watch/stream-watch-loop.test.d.ts.map +1 -0
  114. package/dist/utils/watch/stream-watch-loop.test.js +392 -0
  115. package/dist/utils/watch/stream-watch-loop.test.js.map +1 -0
  116. package/dist/utils/watch/websocket-watch-stream.d.ts +25 -0
  117. package/dist/utils/watch/websocket-watch-stream.d.ts.map +1 -0
  118. package/dist/utils/watch/websocket-watch-stream.js +306 -0
  119. package/dist/utils/watch/websocket-watch-stream.js.map +1 -0
  120. package/dist/utils/watch/websocket-watch-stream.test.d.ts +2 -0
  121. package/dist/utils/watch/websocket-watch-stream.test.d.ts.map +1 -0
  122. package/dist/utils/watch/websocket-watch-stream.test.js +475 -0
  123. package/dist/utils/watch/websocket-watch-stream.test.js.map +1 -0
  124. package/package.json +7 -4
  125. package/src/args/global-args.ts +13 -0
  126. package/src/commands/batch-command/batch-deploy-command.ts +105 -1
  127. package/src/commands/batch-command/batch-test-command.ts +8 -2
  128. package/src/commands/deploy-command/deploy-init.ts +1 -1
  129. package/src/commands/deploy-command/index.ts +331 -32
  130. package/src/commands/deploy-command/select-target.ts +1 -1
  131. package/src/commands/deploy-command/version-command.ts +215 -21
  132. package/src/commands/test-command/test-command.ts +24 -18
  133. package/src/nevermore.ts +19 -0
  134. package/src/utils/batch/changed-packages-utils.ts +1 -1
  135. package/src/utils/build/base-place-resolver-factory.ts +49 -0
  136. package/src/utils/build/build.ts +13 -2
  137. package/src/utils/build/upload.ts +1 -1
  138. package/src/utils/deploy/deploy-metadata.ts +1 -1
  139. package/src/utils/job-context/base-job-context.ts +68 -6
  140. package/src/utils/job-context/batch-script-job-context.ts +18 -0
  141. package/src/utils/job-context/cloud-job-context.ts +7 -2
  142. package/src/utils/job-context/local-job-context.ts +7 -2
  143. package/src/utils/open-cloud/open-cloud-client.test.ts +133 -14
  144. package/src/utils/open-cloud/open-cloud-client.ts +127 -34
  145. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  146. package/src/utils/testing/runner/test-runner.ts +1 -1
  147. package/src/utils/watch/github-context.test.ts +154 -0
  148. package/src/utils/watch/github-context.ts +184 -0
  149. package/src/utils/watch/http-watch-registry.test.ts +330 -0
  150. package/src/utils/watch/http-watch-registry.ts +341 -0
  151. package/src/utils/watch/local-watch-loop.test.ts +197 -0
  152. package/src/utils/watch/local-watch-loop.ts +190 -0
  153. package/src/utils/watch/register-notify-watches.test.ts +282 -0
  154. package/src/utils/watch/register-notify-watches.ts +226 -0
  155. package/src/utils/watch/register-watches.test.ts +696 -0
  156. package/src/utils/watch/register-watches.ts +369 -0
  157. package/src/utils/watch/stream-watch-loop.test.ts +625 -0
  158. package/src/utils/watch/stream-watch-loop.ts +411 -0
  159. package/src/utils/watch/websocket-watch-stream.test.ts +581 -0
  160. package/src/utils/watch/websocket-watch-stream.ts +403 -0
  161. package/tsconfig.json +19 -6
  162. package/tsconfig.tsbuildinfo +1 -1
  163. package/dist/utils/build/deploy-config.d.ts +0 -71
  164. package/dist/utils/build/deploy-config.d.ts.map +0 -1
  165. package/dist/utils/build/deploy-config.js +0 -164
  166. package/dist/utils/build/deploy-config.js.map +0 -1
  167. package/dist/utils/build/deploy-config.test.d.ts +0 -2
  168. package/dist/utils/build/deploy-config.test.d.ts.map +0 -1
  169. package/dist/utils/build/deploy-config.test.js +0 -126
  170. package/dist/utils/build/deploy-config.test.js.map +0 -1
  171. package/src/utils/build/deploy-config.test.ts +0 -149
  172. package/src/utils/build/deploy-config.ts +0 -259
@@ -0,0 +1,696 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import {
6
+ BasePlaceResolver,
7
+ parseWatchOption,
8
+ resolveDeployLockPath,
9
+ saveDeployLockAsync,
10
+ type DeployTarget,
11
+ type WatchRegistrationRequest,
12
+ type WatchRegistry,
13
+ type WatchWorkflowDispatchAction,
14
+ } from '@quenty/nevermore-deploy';
15
+ import {
16
+ registerWatchesAsync,
17
+ type WatchCandidate,
18
+ } from './register-watches.js';
19
+
20
+ /** No endpoint ships with the CLI, so tests supply their own. */
21
+ const REGISTER_BASE = 'https://watch.example.com/v1/register/';
22
+ const watchOption = () => parseWatchOption(`${REGISTER_BASE}7d`);
23
+
24
+ /**
25
+ * This path only ever builds dispatching watches, so a recorded action that is
26
+ * anything else is itself the failure — asserted rather than cast past.
27
+ */
28
+ function dispatchAction(
29
+ request: WatchRegistrationRequest,
30
+ index = 0
31
+ ): WatchWorkflowDispatchAction {
32
+ const action = request.watches[index]!.action;
33
+ expect(action.type).toBe('github-workflow-dispatch');
34
+ return action as WatchWorkflowDispatchAction;
35
+ }
36
+
37
+ /** Answers from the lock only; the network source is never the right baseline. */
38
+ function makeResolver(): BasePlaceResolver {
39
+ return new BasePlaceResolver({
40
+ source: {
41
+ resolveLatestPlaceVersionAsync: async () => {
42
+ throw new Error('a baseline must come from the lock, not the network');
43
+ },
44
+ },
45
+ });
46
+ }
47
+
48
+ let packageDir: string;
49
+
50
+ beforeEach(async () => {
51
+ packageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'register-watches-'));
52
+ vi.stubEnv('GITHUB_REPOSITORY', 'Quenty/egg-hunt-2026');
53
+ vi.stubEnv('GITHUB_REF_NAME', 'release');
54
+ vi.stubEnv('NEVERMORE_WATCH_TOKEN', 'token-value');
55
+ });
56
+
57
+ afterEach(async () => {
58
+ vi.unstubAllEnvs();
59
+ await fs.rm(packageDir, { recursive: true, force: true });
60
+ });
61
+
62
+ function makePlace(overrides: Partial<DeployTarget> = {}): DeployTarget {
63
+ return {
64
+ name: 'hub',
65
+ universeId: 1,
66
+ placeId: 2,
67
+ project: 'default.project.json',
68
+ watch: '.github/workflows/build.yml',
69
+ basePlace: { universeId: 10, placeId: 20, version: 'published' },
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ function makeCandidate(
75
+ overrides: Partial<WatchCandidate> = {}
76
+ ): WatchCandidate {
77
+ return {
78
+ targetName: 'integration',
79
+ packageName: '@quenty/egg-hunt-hub',
80
+ packagePath: packageDir,
81
+ place: makePlace(),
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ /** Fills in the parts of the port a given test does not care about. */
87
+ function fakeRegistry(
88
+ partial: Partial<WatchRegistry>
89
+ ): (registerUrl: string) => WatchRegistry {
90
+ return () => ({
91
+ registerAsync: async () => ({}),
92
+ triggerAsync: async () => ({ results: [] }),
93
+ ...partial,
94
+ });
95
+ }
96
+
97
+ function makeRecordingRegistry() {
98
+ const requests: WatchRegistrationRequest[] = [];
99
+ return {
100
+ requests,
101
+ create: fakeRegistry({
102
+ registerAsync: async (request: WatchRegistrationRequest) => {
103
+ requests.push(request);
104
+ return {
105
+ monitorId: 'mon_1',
106
+ leaseExpiresAt: '2026-08-06T00:00:00Z',
107
+ changed: true,
108
+ };
109
+ },
110
+ }),
111
+ };
112
+ }
113
+
114
+ async function writeLockAsync(placeId: number, version: number) {
115
+ await saveDeployLockAsync(resolveDeployLockPath(packageDir), {
116
+ lockfileVersion: 1,
117
+ basePlaces: { [String(placeId)]: { version, from: 'published' } },
118
+ });
119
+ }
120
+
121
+ describe('registerWatchesAsync', () => {
122
+ it('registers one monitor holding every watch', async () => {
123
+ const registry = makeRecordingRegistry();
124
+ await writeLockAsync(20, 158);
125
+
126
+ const result = await registerWatchesAsync({
127
+ option: watchOption(),
128
+ monitorName: 'integration',
129
+ resolver: makeResolver(),
130
+ candidates: [
131
+ makeCandidate({ place: makePlace({ name: 'hub' }) }),
132
+ makeCandidate({ place: makePlace({ name: 'lobby' }) }),
133
+ ],
134
+ createRegistry: registry.create,
135
+ });
136
+
137
+ expect(result).toEqual({ registered: 2, changed: true });
138
+ expect(registry.requests).toHaveLength(1);
139
+
140
+ const request = registry.requests[0]!;
141
+ expect(request.monitorName).toBe('integration/release');
142
+ expect(request.repository).toBe('Quenty/egg-hunt-2026');
143
+ expect(request.githubToken).toBe('token-value');
144
+ expect(request.watches).toEqual([
145
+ {
146
+ name: 'quenty/egg-hunt-hub/hub',
147
+ source: {
148
+ type: 'roblox-place',
149
+ universeId: 10,
150
+ placeId: 20,
151
+ versionType: 'published',
152
+ },
153
+ action: {
154
+ type: 'github-workflow-dispatch',
155
+ workflow: '.github/workflows/build.yml',
156
+ ref: 'release',
157
+ inputs: { target: 'integration.places.hub' },
158
+ },
159
+ },
160
+ {
161
+ name: 'quenty/egg-hunt-hub/lobby',
162
+ source: {
163
+ type: 'roblox-place',
164
+ universeId: 10,
165
+ placeId: 20,
166
+ versionType: 'published',
167
+ },
168
+ action: {
169
+ type: 'github-workflow-dispatch',
170
+ workflow: '.github/workflows/build.yml',
171
+ ref: 'release',
172
+ inputs: { target: 'integration.places.lobby' },
173
+ },
174
+ },
175
+ ]);
176
+ });
177
+
178
+ // Regression: the selector was previously re-appended to a target name that
179
+ // already carried one, producing `integration.places.hub.places.hub` — which
180
+ // the dispatched `deploy run` then could not resolve, killing the reload.
181
+ it('does not re-append a place to a selector that already names one', async () => {
182
+ const registry = makeRecordingRegistry();
183
+
184
+ await registerWatchesAsync({
185
+ option: watchOption(),
186
+ monitorName: 'integration',
187
+ resolver: makeResolver(),
188
+ candidates: [makeCandidate({ targetName: 'integration.places.hub' })],
189
+ createRegistry: registry.create,
190
+ });
191
+
192
+ expect(dispatchAction(registry.requests[0]!).inputs).toEqual({
193
+ target: 'integration.places.hub',
194
+ });
195
+ });
196
+
197
+ it('puts the ref in the monitor name so branches cannot collide', async () => {
198
+ const registry = makeRecordingRegistry();
199
+
200
+ await registerWatchesAsync({
201
+ option: watchOption(),
202
+ monitorName: 'integration',
203
+ resolver: makeResolver(),
204
+ candidates: [makeCandidate()],
205
+ createRegistry: registry.create,
206
+ });
207
+
208
+ // GITHUB_REF_NAME is stubbed to "release" for these tests.
209
+ expect(registry.requests[0]!.monitorName).toBe('integration/release');
210
+ expect(dispatchAction(registry.requests[0]!).ref).toBe('release');
211
+ });
212
+
213
+ it('refuses to register from a pull request merge ref', async () => {
214
+ vi.stubEnv('GITHUB_REF_NAME', '42/merge');
215
+
216
+ await expect(
217
+ registerWatchesAsync({
218
+ option: watchOption(),
219
+ monitorName: 'integration',
220
+ resolver: makeResolver(),
221
+ candidates: [makeCandidate()],
222
+ createRegistry: fakeRegistry({}),
223
+ })
224
+ ).rejects.toThrowError(/pull request merge ref/);
225
+ });
226
+
227
+ // Places are only optionally named, so a multi-place target can collide by
228
+ // construction. The service resolves duplicates its own way, which silently
229
+ // drops a watch.
230
+ it('disambiguates watch names that would otherwise collide', async () => {
231
+ const registry = makeRecordingRegistry();
232
+
233
+ await registerWatchesAsync({
234
+ option: watchOption(),
235
+ monitorName: 'integration',
236
+ resolver: makeResolver(),
237
+ candidates: [
238
+ makeCandidate({
239
+ place: makePlace({
240
+ name: undefined,
241
+ basePlace: { universeId: 10, placeId: 20, version: 'published' },
242
+ }),
243
+ }),
244
+ makeCandidate({
245
+ place: makePlace({
246
+ name: undefined,
247
+ basePlace: { universeId: 10, placeId: 21, version: 'published' },
248
+ }),
249
+ }),
250
+ ],
251
+ createRegistry: registry.create,
252
+ });
253
+
254
+ const names = registry.requests[0]!.watches.map((w) => w.name);
255
+ expect(names).toEqual([
256
+ 'quenty/egg-hunt-hub/integration/20',
257
+ 'quenty/egg-hunt-hub/integration/21',
258
+ ]);
259
+ expect(new Set(names).size).toBe(2);
260
+ });
261
+
262
+ // The service refuses "saved" at registration until it has a credentialed
263
+ // driver. That refusal would fail the whole request — every watch in the
264
+ // monitor — so it has to be filtered before sending, not sent and rejected.
265
+ it('leaves out a "saved" pin without dropping its siblings', async () => {
266
+ const registry = makeRecordingRegistry();
267
+
268
+ const result = await registerWatchesAsync({
269
+ option: watchOption(),
270
+ monitorName: 'integration',
271
+ resolver: makeResolver(),
272
+ candidates: [
273
+ makeCandidate({
274
+ place: makePlace({
275
+ name: 'studio',
276
+ basePlace: { universeId: 10, placeId: 20, version: 'saved' },
277
+ }),
278
+ }),
279
+ makeCandidate({ place: makePlace({ name: 'hub' }) }),
280
+ ],
281
+ createRegistry: registry.create,
282
+ });
283
+
284
+ expect(result.registered).toBe(1);
285
+ expect(registry.requests[0]!.watches.map((w) => w.name)).toEqual([
286
+ 'quenty/egg-hunt-hub/hub',
287
+ ]);
288
+ });
289
+
290
+ it('sends the version type an omitted pin resolves to', async () => {
291
+ const registry = makeRecordingRegistry();
292
+
293
+ await registerWatchesAsync({
294
+ option: watchOption(),
295
+ monitorName: 'integration',
296
+ resolver: makeResolver(),
297
+ candidates: [
298
+ makeCandidate({
299
+ place: makePlace({ basePlace: { universeId: 10, placeId: 20 } }),
300
+ }),
301
+ ],
302
+ createRegistry: registry.create,
303
+ });
304
+
305
+ expect(registry.requests[0]!.watches[0]!.source.versionType).toBe(
306
+ 'published'
307
+ );
308
+ });
309
+
310
+ it('leaves distinct watch names untouched', async () => {
311
+ const registry = makeRecordingRegistry();
312
+
313
+ await registerWatchesAsync({
314
+ option: watchOption(),
315
+ monitorName: 'integration',
316
+ resolver: makeResolver(),
317
+ candidates: [
318
+ makeCandidate({ place: makePlace({ name: 'hub' }) }),
319
+ makeCandidate({ place: makePlace({ name: 'lobby' }) }),
320
+ ],
321
+ createRegistry: registry.create,
322
+ });
323
+
324
+ expect(registry.requests[0]!.watches.map((w) => w.name)).toEqual([
325
+ 'quenty/egg-hunt-hub/hub',
326
+ 'quenty/egg-hunt-hub/lobby',
327
+ ]);
328
+ });
329
+
330
+ it('strips the npm scope marker from names the service would reject', async () => {
331
+ const registry = makeRecordingRegistry();
332
+
333
+ await registerWatchesAsync({
334
+ option: watchOption(),
335
+ monitorName: '@quenty/egg-hunt-hub/integration',
336
+ resolver: makeResolver(),
337
+ candidates: [makeCandidate()],
338
+ createRegistry: registry.create,
339
+ });
340
+
341
+ expect(registry.requests[0]!.monitorName).toBe(
342
+ 'quenty/egg-hunt-hub/integration/release'
343
+ );
344
+ expect(registry.requests[0]!.watches[0]!.name).toBe(
345
+ 'quenty/egg-hunt-hub/hub'
346
+ );
347
+ });
348
+
349
+ // Reading anonymously, the service compares asset-delivery content hashes, and
350
+ // the lock's Open Cloud version number could only ever look like drift —
351
+ // dispatching a rebuild of what was just built. Omitting makes the first poll
352
+ // adopt what is there.
353
+ it('sends no baseline when no key is shared', async () => {
354
+ const registry = makeRecordingRegistry();
355
+ await writeLockAsync(20, 42);
356
+
357
+ await registerWatchesAsync({
358
+ option: watchOption(),
359
+ monitorName: 'integration',
360
+ resolver: makeResolver(),
361
+ candidates: [makeCandidate()],
362
+ createRegistry: registry.create,
363
+ });
364
+
365
+ expect(registry.requests[0]!.watches[0]!.baselineVersion).toBeUndefined();
366
+ expect(
367
+ registry.requests[0]!.watches[0]!.baselineVersionKind
368
+ ).toBeUndefined();
369
+ });
370
+
371
+ // With a key the service reads version history and answers in place versions
372
+ // — the same language the lock is written in — so a baseline finally means
373
+ // what it says.
374
+ it('sends the lock baseline, declared, when a key is shared', async () => {
375
+ const registry = makeRecordingRegistry();
376
+ await writeLockAsync(20, 42);
377
+
378
+ await registerWatchesAsync({
379
+ option: watchOption(),
380
+ monitorName: 'integration',
381
+ resolver: makeResolver(),
382
+ robloxApiKey: 'secret',
383
+ candidates: [makeCandidate()],
384
+ createRegistry: registry.create,
385
+ });
386
+
387
+ expect(registry.requests[0]!.watches[0]!.baselineVersion).toBe('42');
388
+ expect(registry.requests[0]!.watches[0]!.baselineVersionKind).toBe(
389
+ 'roblox-place-version'
390
+ );
391
+ });
392
+
393
+ // This is what a baseline is for in a batch: a package that did not deploy
394
+ // this run still has a lock entry, and without it the first poll would adopt
395
+ // current and never notice the change that already happened.
396
+ it('takes the baseline from the lock, never the network', async () => {
397
+ const registry = makeRecordingRegistry();
398
+ await writeLockAsync(20, 42);
399
+
400
+ await registerWatchesAsync({
401
+ option: watchOption(),
402
+ monitorName: 'integration',
403
+ resolver: makeResolver(),
404
+ robloxApiKey: 'secret',
405
+ candidates: [makeCandidate()],
406
+ createRegistry: registry.create,
407
+ });
408
+
409
+ // makeResolver throws if anything reaches for the network.
410
+ expect(registry.requests[0]!.watches[0]!.baselineVersion).toBe('42');
411
+ });
412
+
413
+ it('omits the baseline when the lock has no entry for the place', async () => {
414
+ const registry = makeRecordingRegistry();
415
+
416
+ await registerWatchesAsync({
417
+ option: watchOption(),
418
+ monitorName: 'integration',
419
+ resolver: makeResolver(),
420
+ robloxApiKey: 'secret',
421
+ candidates: [makeCandidate()],
422
+ createRegistry: registry.create,
423
+ });
424
+
425
+ expect(registry.requests[0]!.watches[0]!.baselineVersion).toBeUndefined();
426
+ });
427
+
428
+ it('registers nothing when no place is watchable', async () => {
429
+ const registry = makeRecordingRegistry();
430
+
431
+ const result = await registerWatchesAsync({
432
+ option: watchOption(),
433
+ monitorName: 'integration',
434
+ resolver: makeResolver(),
435
+ candidates: [makeCandidate({ place: makePlace({ watch: undefined }) })],
436
+ createRegistry: registry.create,
437
+ });
438
+
439
+ expect(result).toEqual({ registered: 0 });
440
+ expect(registry.requests).toEqual([]);
441
+ });
442
+
443
+ it('leaves out a place whose base place is pinned to an exact version', async () => {
444
+ const registry = makeRecordingRegistry();
445
+
446
+ const result = await registerWatchesAsync({
447
+ option: watchOption(),
448
+ monitorName: 'integration',
449
+ resolver: makeResolver(),
450
+ candidates: [
451
+ makeCandidate({
452
+ place: makePlace({
453
+ basePlace: { universeId: 10, placeId: 20, version: 158 },
454
+ }),
455
+ }),
456
+ ],
457
+ createRegistry: registry.create,
458
+ });
459
+
460
+ expect(result).toEqual({ registered: 0 });
461
+ expect(registry.requests).toEqual([]);
462
+ });
463
+
464
+ it('keeps watchable places when a sibling is not watchable', async () => {
465
+ const registry = makeRecordingRegistry();
466
+
467
+ const result = await registerWatchesAsync({
468
+ option: watchOption(),
469
+ monitorName: 'integration',
470
+ resolver: makeResolver(),
471
+ candidates: [
472
+ makeCandidate({ place: makePlace({ name: 'hub' }) }),
473
+ makeCandidate({
474
+ place: makePlace({ name: 'lobby', watch: undefined }),
475
+ }),
476
+ ],
477
+ createRegistry: registry.create,
478
+ });
479
+
480
+ expect(result.registered).toBe(1);
481
+ expect(registry.requests[0]!.watches.map((w) => w.name)).toEqual([
482
+ 'quenty/egg-hunt-hub/hub',
483
+ ]);
484
+ });
485
+
486
+ it('shares the Open Cloud key only when given one', async () => {
487
+ const registry = makeRecordingRegistry();
488
+
489
+ await registerWatchesAsync({
490
+ option: watchOption(),
491
+ monitorName: 'integration',
492
+ resolver: makeResolver(),
493
+ candidates: [makeCandidate()],
494
+ createRegistry: registry.create,
495
+ });
496
+ expect(registry.requests[0]!.robloxApiKey).toBeUndefined();
497
+
498
+ await registerWatchesAsync({
499
+ option: watchOption(),
500
+ monitorName: 'integration',
501
+ resolver: makeResolver(),
502
+ robloxApiKey: 'oc-key',
503
+ candidates: [makeCandidate()],
504
+ createRegistry: registry.create,
505
+ });
506
+ expect(registry.requests[1]!.robloxApiKey).toBe('oc-key');
507
+ });
508
+
509
+ it('reports an unchanged re-apply', async () => {
510
+ const result = await registerWatchesAsync({
511
+ option: watchOption(),
512
+ monitorName: 'integration',
513
+ resolver: makeResolver(),
514
+ candidates: [makeCandidate()],
515
+ createRegistry: fakeRegistry({
516
+ registerAsync: async () => ({ changed: false }),
517
+ }),
518
+ });
519
+
520
+ expect(result).toEqual({ registered: 1, changed: false });
521
+ });
522
+
523
+ describe('triggerAfterRegister', () => {
524
+ it('fires the monitor after registering', async () => {
525
+ const triggered: Array<{ monitorId: string; credential: string }> = [];
526
+
527
+ const result = await registerWatchesAsync({
528
+ option: watchOption(),
529
+ monitorName: 'integration',
530
+ resolver: makeResolver(),
531
+ candidates: [makeCandidate()],
532
+ triggerAfterRegister: true,
533
+ createRegistry: fakeRegistry({
534
+ registerAsync: async () => ({ monitorId: 'mon_7' }),
535
+ triggerAsync: async (monitorId, credential) => {
536
+ triggered.push({ monitorId, credential });
537
+ return { results: [{ watchName: 'w', ok: true }] };
538
+ },
539
+ }),
540
+ });
541
+
542
+ expect(triggered).toEqual([
543
+ { monitorId: 'mon_7', credential: 'token-value' },
544
+ ]);
545
+ expect(result.triggered).toBe(1);
546
+ });
547
+
548
+ it('does not fire unless asked', async () => {
549
+ const trigger = vi.fn(async () => ({ results: [] }));
550
+
551
+ const result = await registerWatchesAsync({
552
+ option: watchOption(),
553
+ monitorName: 'integration',
554
+ resolver: makeResolver(),
555
+ candidates: [makeCandidate()],
556
+ createRegistry: fakeRegistry({
557
+ registerAsync: async () => ({ monitorId: 'mon_7' }),
558
+ triggerAsync: trigger,
559
+ }),
560
+ });
561
+
562
+ expect(trigger).not.toHaveBeenCalled();
563
+ expect(result.triggered).toBeUndefined();
564
+ });
565
+
566
+ // The monitor exists and is worth keeping; only the demonstration failed.
567
+ it('keeps the registration when the forced dispatch fails', async () => {
568
+ const result = await registerWatchesAsync({
569
+ option: watchOption(),
570
+ monitorName: 'integration',
571
+ resolver: makeResolver(),
572
+ candidates: [makeCandidate()],
573
+ triggerAfterRegister: true,
574
+ createRegistry: fakeRegistry({
575
+ registerAsync: async () => ({ monitorId: 'mon_7' }),
576
+ triggerAsync: async () => {
577
+ throw new Error('service said no');
578
+ },
579
+ }),
580
+ });
581
+
582
+ expect(result.registered).toBe(1);
583
+ expect(result.triggered).toBeUndefined();
584
+ });
585
+
586
+ it('counts only the watches that actually dispatched', async () => {
587
+ const result = await registerWatchesAsync({
588
+ option: watchOption(),
589
+ monitorName: 'integration',
590
+ resolver: makeResolver(),
591
+ candidates: [makeCandidate()],
592
+ triggerAfterRegister: true,
593
+ createRegistry: fakeRegistry({
594
+ registerAsync: async () => ({ monitorId: 'mon_7' }),
595
+ triggerAsync: async () => ({
596
+ results: [
597
+ { watchName: 'a', ok: true },
598
+ { watchName: 'b', ok: false, detail: 'workflow missing' },
599
+ ],
600
+ }),
601
+ }),
602
+ });
603
+
604
+ expect(result.triggered).toBe(1);
605
+ });
606
+ });
607
+
608
+ it('uses the explicit register URL when one was given', async () => {
609
+ const urls: string[] = [];
610
+
611
+ await registerWatchesAsync({
612
+ option: parseWatchOption('https://watch.example.com/v1/register/2w/'),
613
+ monitorName: 'integration',
614
+ resolver: makeResolver(),
615
+ candidates: [makeCandidate()],
616
+ createRegistry: (url) => {
617
+ urls.push(url);
618
+ return fakeRegistry({})(url);
619
+ },
620
+ });
621
+
622
+ expect(urls).toEqual(['https://watch.example.com/v1/register/2w/']);
623
+ });
624
+
625
+ it('fails when there is no repository to dispatch', async () => {
626
+ // An absent GITHUB_REPOSITORY falls back to the "origin" remote, so the
627
+ // only way to have no repository is to also be outside a git checkout.
628
+ // The temp package dir is exactly that.
629
+ const originalCwd = process.cwd();
630
+ vi.stubEnv('GITHUB_REPOSITORY', '');
631
+ process.chdir(packageDir);
632
+
633
+ try {
634
+ await expect(
635
+ registerWatchesAsync({
636
+ option: watchOption(),
637
+ monitorName: 'integration',
638
+ resolver: makeResolver(),
639
+ candidates: [makeCandidate()],
640
+ createRegistry: fakeRegistry({}),
641
+ })
642
+ ).rejects.toThrowError(/GitHub repository/);
643
+ } finally {
644
+ process.chdir(originalCwd);
645
+ }
646
+ });
647
+
648
+ it('falls back to the origin remote when GITHUB_REPOSITORY is unset', async () => {
649
+ const registry = makeRecordingRegistry();
650
+ vi.stubEnv('GITHUB_REPOSITORY', '');
651
+
652
+ await registerWatchesAsync({
653
+ option: watchOption(),
654
+ monitorName: 'integration',
655
+ resolver: makeResolver(),
656
+ candidates: [makeCandidate()],
657
+ createRegistry: registry.create,
658
+ });
659
+
660
+ // Resolved from this repo's own remote, whatever it is named.
661
+ expect(registry.requests[0]!.repository).toMatch(/^[\w.-]+\/[\w.-]+$/);
662
+ });
663
+
664
+ // An unset CI secret expands to "" rather than being absent, so the fallback
665
+ // has to treat empty as missing or it never fires.
666
+ it('falls back to GITHUB_TOKEN when the watch token is set but empty', async () => {
667
+ const registry = makeRecordingRegistry();
668
+ vi.stubEnv('NEVERMORE_WATCH_TOKEN', '');
669
+ vi.stubEnv('GITHUB_TOKEN', 'fallback-token');
670
+
671
+ await registerWatchesAsync({
672
+ option: watchOption(),
673
+ monitorName: 'integration',
674
+ resolver: makeResolver(),
675
+ candidates: [makeCandidate()],
676
+ createRegistry: registry.create,
677
+ });
678
+
679
+ expect(registry.requests[0]!.githubToken).toBe('fallback-token');
680
+ });
681
+
682
+ it('fails when there is no token to dispatch with', async () => {
683
+ vi.stubEnv('NEVERMORE_WATCH_TOKEN', '');
684
+ vi.stubEnv('GITHUB_TOKEN', '');
685
+
686
+ await expect(
687
+ registerWatchesAsync({
688
+ option: watchOption(),
689
+ monitorName: 'integration',
690
+ resolver: makeResolver(),
691
+ candidates: [makeCandidate()],
692
+ createRegistry: fakeRegistry({}),
693
+ })
694
+ ).rejects.toThrowError(/No GitHub token found/);
695
+ });
696
+ });