@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,625 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ type PlaceVersionSource,
4
+ type WatchStream,
5
+ type WatchStreamOptions,
6
+ type WatchStreamReady,
7
+ type WatchStreamResult,
8
+ type WatchStreamStatus,
9
+ } from '@quenty/nevermore-deploy';
10
+ import {
11
+ runStreamWatchLoopAsync,
12
+ type StreamWatchEntry,
13
+ } from './stream-watch-loop.js';
14
+
15
+ /**
16
+ * The service's version token for a Roblox place is the asset-delivery content
17
+ * hash, not a place version — so these are deliberately not numbers. The number
18
+ * a rebuild is made from comes from Open Cloud instead, which is what
19
+ * `makeSource` stands in for.
20
+ */
21
+ const TOKEN_A = 'e3b0c44298fc1c14';
22
+ const TOKEN_B = '9f86d081884c7d65';
23
+
24
+ function makeEntry(
25
+ overrides: Partial<StreamWatchEntry> = {}
26
+ ): StreamWatchEntry {
27
+ return {
28
+ label: 'integration.places.hub',
29
+ watchName: 'integration.places.hub',
30
+ universeId: 10,
31
+ placeId: 20,
32
+ versionType: 'published',
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function makeStatus(
38
+ overrides: Partial<WatchStreamStatus> = {}
39
+ ): WatchStreamStatus {
40
+ return {
41
+ name: 'integration.places.hub',
42
+ sourceId: 'src_1',
43
+ state: 'up-to-date',
44
+ baselineVersion: null,
45
+ currentVersion: null,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function makeReady(watches: WatchStreamStatus[]): WatchStreamReady {
51
+ return {
52
+ type: 'ready',
53
+ monitorId: 'mon_1',
54
+ repository: 'Quenty/egg-hunt-2026',
55
+ name: 'egg-hunt/integration/local',
56
+ leaseExpiresAt: '2026-08-01T00:00:00Z',
57
+ serverTime: '2026-07-31T00:00:00Z',
58
+ watches,
59
+ };
60
+ }
61
+
62
+ function makeNotify(overrides: { version?: string | null } = {}) {
63
+ return {
64
+ type: 'notify' as const,
65
+ watchName: 'integration.places.hub',
66
+ sourceId: 'src_1',
67
+ version: TOKEN_B,
68
+ previousVersion: TOKEN_A,
69
+ payload: { target: 'integration.places.hub' },
70
+ at: '2026-07-31T00:00:00Z',
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Replays a scripted set of frames, then ends. Reconnection is the real
77
+ * stream's job, so a script that wants to test reconciliation after a drop
78
+ * simply delivers `ready` twice.
79
+ */
80
+ function scriptedStream(
81
+ script: (options: WatchStreamOptions) => Promise<void>,
82
+ ending: WatchStreamResult['ending'] = 'aborted'
83
+ ): WatchStream {
84
+ return {
85
+ runAsync: async (options) => {
86
+ await script(options);
87
+ return { ending, reconnects: 0, notifications: 0 };
88
+ },
89
+ };
90
+ }
91
+
92
+ function makeSource(version: number): PlaceVersionSource {
93
+ return { resolveLatestPlaceVersionAsync: async () => version };
94
+ }
95
+
96
+ async function runAsync(
97
+ entries: StreamWatchEntry[],
98
+ script: (options: WatchStreamOptions) => Promise<void>,
99
+ redeployAsync: (entry: StreamWatchEntry, version: number) => Promise<void>,
100
+ options: {
101
+ source?: PlaceVersionSource;
102
+ ending?: WatchStreamResult['ending'];
103
+ } = {}
104
+ ) {
105
+ return runStreamWatchLoopAsync({
106
+ entries,
107
+ stream: scriptedStream(script, options.ending ?? 'aborted'),
108
+ source: options.source ?? makeSource(159),
109
+ monitorId: 'mon_1',
110
+ credential: 'token',
111
+ signal: new AbortController().signal,
112
+ redeployAsync,
113
+ });
114
+ }
115
+
116
+ describe('runStreamWatchLoopAsync ready reconciliation', () => {
117
+ // Nothing is replayed while a client is away, so `ready` is the entire
118
+ // catch-up: a change that landed while the laptop was shut has to be found
119
+ // by re-checking rather than by being told again.
120
+ it('rebuilds a place that moved on while it was away', async () => {
121
+ const built: number[] = [];
122
+
123
+ const result = await runAsync(
124
+ [makeEntry({ baseline: 158 })],
125
+ async (options) => {
126
+ await options.handlers.onReadyAsync(
127
+ makeReady([makeStatus({ currentVersion: TOKEN_B })])
128
+ );
129
+ },
130
+ async (_entry, version) => {
131
+ built.push(version);
132
+ },
133
+ { source: makeSource(159) }
134
+ );
135
+
136
+ expect(built).toEqual([159]);
137
+ expect(result.redeploys).toBe(1);
138
+ });
139
+
140
+ // The service's token moving is not on its own a reason to rebuild: only the
141
+ // place version the lock records can answer "is what I built still current".
142
+ it('does not rebuild when the place is still at the built version', async () => {
143
+ const built: number[] = [];
144
+
145
+ const result = await runAsync(
146
+ [makeEntry({ baseline: 158 })],
147
+ async (options) => {
148
+ await options.handlers.onReadyAsync(
149
+ makeReady([makeStatus({ currentVersion: TOKEN_B })])
150
+ );
151
+ },
152
+ async (_entry, version) => {
153
+ built.push(version);
154
+ },
155
+ { source: makeSource(158) }
156
+ );
157
+
158
+ expect(built).toEqual([]);
159
+ expect(result.redeploys).toBe(0);
160
+ });
161
+
162
+ // "Never built here" is not a change. Rebuilding on it would mean every fresh
163
+ // clone deploys once on connect, whatever the base place has been doing.
164
+ it('adopts the current version for a place with no baseline', async () => {
165
+ const built: number[] = [];
166
+
167
+ await runAsync(
168
+ [makeEntry({ baseline: undefined })],
169
+ async (options) => {
170
+ await options.handlers.onReadyAsync(
171
+ makeReady([makeStatus({ currentVersion: TOKEN_A })])
172
+ );
173
+ await options.handlers.onReadyAsync(
174
+ makeReady([makeStatus({ currentVersion: TOKEN_B })])
175
+ );
176
+ },
177
+ async (_entry, version) => {
178
+ built.push(version);
179
+ }
180
+ );
181
+
182
+ expect(built).toEqual([]);
183
+ });
184
+
185
+ it('ignores a watch this run does not own', async () => {
186
+ const source = vi.fn(async () => 159);
187
+
188
+ await runAsync(
189
+ [makeEntry({ baseline: 158 })],
190
+ async (options) => {
191
+ await options.handlers.onReadyAsync(
192
+ makeReady([
193
+ makeStatus({ name: 'someone-else', currentVersion: TOKEN_B }),
194
+ ])
195
+ );
196
+ },
197
+ async () => {},
198
+ { source: { resolveLatestPlaceVersionAsync: source } }
199
+ );
200
+
201
+ expect(source).not.toHaveBeenCalled();
202
+ });
203
+
204
+ // The service not having polled yet says nothing about whether the place
205
+ // moved, and this machine can just ask.
206
+ it('checks anyway when the service has not polled the source yet', async () => {
207
+ const built: number[] = [];
208
+
209
+ await runAsync(
210
+ [makeEntry({ baseline: 158 })],
211
+ async (options) => {
212
+ await options.handlers.onReadyAsync(
213
+ makeReady([makeStatus({ currentVersion: null })])
214
+ );
215
+ },
216
+ async (_entry, version) => {
217
+ built.push(version);
218
+ },
219
+ { source: makeSource(159) }
220
+ );
221
+
222
+ expect(built).toEqual([159]);
223
+ });
224
+
225
+ // A reconnect that reports what was already settled should cost nothing.
226
+ it('does not re-check a token it already settled', async () => {
227
+ const source = vi.fn(async () => 158);
228
+
229
+ await runAsync(
230
+ [makeEntry({ baseline: 158 })],
231
+ async (options) => {
232
+ for (let i = 0; i < 3; i++) {
233
+ await options.handlers.onReadyAsync(
234
+ makeReady([makeStatus({ currentVersion: TOKEN_A })])
235
+ );
236
+ }
237
+ },
238
+ async () => {},
239
+ { source: { resolveLatestPlaceVersionAsync: source } }
240
+ );
241
+
242
+ expect(source).toHaveBeenCalledTimes(1);
243
+ });
244
+
245
+ // A watch left running for hours will outlive an outage; the next frame asks
246
+ // again rather than the loop ending.
247
+ it('survives a failure to reach Open Cloud', async () => {
248
+ const built: number[] = [];
249
+ let calls = 0;
250
+
251
+ const result = await runAsync(
252
+ [makeEntry({ baseline: 158 })],
253
+ async (options) => {
254
+ await options.handlers.onReadyAsync(
255
+ makeReady([makeStatus({ currentVersion: TOKEN_A })])
256
+ );
257
+ await options.handlers.onReadyAsync(
258
+ makeReady([makeStatus({ currentVersion: TOKEN_B })])
259
+ );
260
+ },
261
+ async (_entry, version) => {
262
+ built.push(version);
263
+ },
264
+ {
265
+ source: {
266
+ resolveLatestPlaceVersionAsync: async () => {
267
+ if (++calls === 1) {
268
+ throw new Error('Open Cloud is down');
269
+ }
270
+ return 159;
271
+ },
272
+ },
273
+ }
274
+ );
275
+
276
+ expect(built).toEqual([159]);
277
+ expect(result.failures).toBe(0);
278
+ });
279
+ });
280
+
281
+ describe('runStreamWatchLoopAsync unobservable sources', () => {
282
+ // A private base place 401s the service's credential-free asset-delivery
283
+ // driver. The socket says nothing about that, so without this the watch waits
284
+ // forever on a monitor that can never fire.
285
+ //
286
+ // Keyed on `sourceError`, not `state`: a poll failure never moves `state`,
287
+ // and a notify that reaches nobody counts as a successful dispatch — so an
288
+ // unreadable place sits at "pending" indefinitely.
289
+ it('gives up the stream when the service cannot read any of them', async () => {
290
+ const source = vi.fn(async () => 159);
291
+
292
+ const result = await runAsync(
293
+ [makeEntry({ baseline: 158 })],
294
+ async (options) => {
295
+ await options.handlers.onReadyAsync(
296
+ makeReady([
297
+ makeStatus({
298
+ state: 'pending',
299
+ sourceError: 'Authentication required to access Asset.',
300
+ sourceConsecutiveFailures: 3,
301
+ }),
302
+ ])
303
+ );
304
+ // The real stream stops here; a scripted one has to be told.
305
+ await new Promise((resolve) => setTimeout(resolve, 5));
306
+ },
307
+ async () => {},
308
+ { source: { resolveLatestPlaceVersionAsync: source } }
309
+ );
310
+
311
+ expect(result.unobservable).toBe(true);
312
+ expect(source).not.toHaveBeenCalled();
313
+ });
314
+
315
+ // One dead place should not cost the others their hot reload.
316
+ it('keeps streaming when only some are unreadable', async () => {
317
+ const built: number[] = [];
318
+
319
+ const result = await runAsync(
320
+ [
321
+ makeEntry({ baseline: 158 }),
322
+ makeEntry({
323
+ label: 'integration.places.lobby',
324
+ watchName: 'integration.places.lobby',
325
+ placeId: 21,
326
+ baseline: 158,
327
+ }),
328
+ ],
329
+ async (options) => {
330
+ await options.handlers.onReadyAsync(
331
+ makeReady([
332
+ makeStatus({ sourceError: 'nope', sourceConsecutiveFailures: 3 }),
333
+ makeStatus({
334
+ name: 'integration.places.lobby',
335
+ currentVersion: TOKEN_B,
336
+ }),
337
+ ])
338
+ );
339
+ },
340
+ async (_entry, version) => {
341
+ built.push(version);
342
+ },
343
+ { source: makeSource(159) }
344
+ );
345
+
346
+ expect(result.unobservable).toBe(false);
347
+ expect(built).toEqual([159]);
348
+ });
349
+ });
350
+
351
+ describe('runStreamWatchLoopAsync unobservable edge cases', () => {
352
+ // One failed read is a blip — a rate limit, an asset-delivery hiccup — and
353
+ // the next poll usually clears it. Abandoning the stream for that would
354
+ // downgrade every watch to polling on a transient error.
355
+ it('tolerates a single source failure', async () => {
356
+ const result = await runAsync(
357
+ [makeEntry({ baseline: 158 })],
358
+ async (options) => {
359
+ await options.handlers.onReadyAsync(
360
+ makeReady([
361
+ makeStatus({
362
+ currentVersion: TOKEN_A,
363
+ sourceError: 'timed out',
364
+ sourceConsecutiveFailures: 1,
365
+ }),
366
+ ])
367
+ );
368
+ },
369
+ async () => {},
370
+ { source: makeSource(158) }
371
+ );
372
+
373
+ expect(result.unobservable).toBe(false);
374
+ });
375
+
376
+ // The monitor exists but holds none of our watches — another checkout
377
+ // re-registered the name. Nothing can ever arrive for us, which is the same
378
+ // silent wait by a different route.
379
+ it('gives up when the monitor holds none of our watches', async () => {
380
+ const result = await runAsync(
381
+ [makeEntry({ baseline: 158 })],
382
+ async (options) => {
383
+ await options.handlers.onReadyAsync(
384
+ makeReady([makeStatus({ name: 'someone-elses-place' })])
385
+ );
386
+ },
387
+ async () => {}
388
+ );
389
+
390
+ expect(result.unobservable).toBe(true);
391
+ });
392
+ });
393
+
394
+ describe('runStreamWatchLoopAsync live poll failures', () => {
395
+ function pollFailed(watchName: string) {
396
+ return {
397
+ type: 'event' as const,
398
+ event: {
399
+ id: 2,
400
+ watchName,
401
+ type: 'poll-failed',
402
+ message: `Could not read ${watchName} after 3 attempts: Authentication required to access Asset.`,
403
+ createdAt: '2026-07-31T00:00:00Z',
404
+ },
405
+ };
406
+ }
407
+
408
+ // A connection opened before the service's first failed poll gets a clean
409
+ // snapshot, and the next `ready` is an hour away — so without the live tail a
410
+ // private base place looks healthy for an hour while doing nothing.
411
+ it('gives up when the tail reports every source unreadable', async () => {
412
+ const result = await runAsync(
413
+ [makeEntry({ baseline: 158 })],
414
+ async (options) => {
415
+ await options.handlers.onReadyAsync(
416
+ makeReady([makeStatus({ currentVersion: TOKEN_A })])
417
+ );
418
+ options.handlers.onEvent?.(pollFailed('integration.places.hub'));
419
+ await new Promise((resolve) => setTimeout(resolve, 5));
420
+ },
421
+ async () => {},
422
+ { source: makeSource(158) }
423
+ );
424
+
425
+ expect(result.unobservable).toBe(true);
426
+ });
427
+
428
+ it('keeps streaming while something is still readable', async () => {
429
+ const result = await runAsync(
430
+ [
431
+ makeEntry({ baseline: 158 }),
432
+ makeEntry({
433
+ label: 'integration.places.lobby',
434
+ watchName: 'integration.places.lobby',
435
+ placeId: 21,
436
+ baseline: 158,
437
+ }),
438
+ ],
439
+ async (options) => {
440
+ await options.handlers.onReadyAsync(
441
+ makeReady([
442
+ makeStatus({ currentVersion: TOKEN_A }),
443
+ makeStatus({
444
+ name: 'integration.places.lobby',
445
+ currentVersion: TOKEN_A,
446
+ }),
447
+ ])
448
+ );
449
+ options.handlers.onEvent?.(pollFailed('integration.places.hub'));
450
+ await new Promise((resolve) => setTimeout(resolve, 5));
451
+ },
452
+ async () => {},
453
+ { source: makeSource(158) }
454
+ );
455
+
456
+ expect(result.unobservable).toBe(false);
457
+ });
458
+
459
+ // A snapshot naming only some of our watches leaves the rest unable to ever
460
+ // fire; holding the socket for the readable one would silently strand it.
461
+ it('falls back when one watch is unreadable and another has vanished', async () => {
462
+ const result = await runAsync(
463
+ [
464
+ makeEntry({ baseline: 158 }),
465
+ makeEntry({
466
+ label: 'integration.places.lobby',
467
+ watchName: 'integration.places.lobby',
468
+ placeId: 21,
469
+ baseline: 158,
470
+ }),
471
+ ],
472
+ async (options) => {
473
+ await options.handlers.onReadyAsync(
474
+ makeReady([
475
+ makeStatus({
476
+ sourceError: 'nope',
477
+ sourceConsecutiveFailures: 3,
478
+ }),
479
+ ])
480
+ );
481
+ },
482
+ async () => {},
483
+ { source: makeSource(158) }
484
+ );
485
+
486
+ expect(result.unobservable).toBe(true);
487
+ });
488
+
489
+ it('ignores a poll failure for a watch this run does not own', async () => {
490
+ const result = await runAsync(
491
+ [makeEntry({ baseline: 158 })],
492
+ async (options) => {
493
+ await options.handlers.onReadyAsync(
494
+ makeReady([makeStatus({ currentVersion: TOKEN_A })])
495
+ );
496
+ options.handlers.onEvent?.(pollFailed('someone-elses-place'));
497
+ await new Promise((resolve) => setTimeout(resolve, 5));
498
+ },
499
+ async () => {},
500
+ { source: makeSource(158) }
501
+ );
502
+
503
+ expect(result.unobservable).toBe(false);
504
+ });
505
+ });
506
+
507
+ describe('runStreamWatchLoopAsync notifications', () => {
508
+ it('rebuilds when told the source moved', async () => {
509
+ const built: number[] = [];
510
+
511
+ const result = await runAsync(
512
+ [makeEntry({ baseline: 158 })],
513
+ async (options) => {
514
+ await options.handlers.onNotifyAsync(makeNotify());
515
+ },
516
+ async (_entry, version) => {
517
+ built.push(version);
518
+ },
519
+ { source: makeSource(159) }
520
+ );
521
+
522
+ expect(built).toEqual([159]);
523
+ expect(result.redeploys).toBe(1);
524
+ });
525
+
526
+ // A `ready` covering a change and a `notify` for the same one both arrive on
527
+ // a reconnect. Even with different tokens, the version is what decides.
528
+ it('does not rebuild a version it already built', async () => {
529
+ const built: number[] = [];
530
+
531
+ await runAsync(
532
+ [makeEntry({ baseline: 158 })],
533
+ async (options) => {
534
+ await options.handlers.onReadyAsync(
535
+ makeReady([makeStatus({ currentVersion: TOKEN_A })])
536
+ );
537
+ await options.handlers.onNotifyAsync(makeNotify({ version: TOKEN_B }));
538
+ },
539
+ async (_entry, version) => {
540
+ built.push(version);
541
+ },
542
+ { source: makeSource(159) }
543
+ );
544
+
545
+ expect(built).toEqual([159]);
546
+ });
547
+
548
+ it('still checks when notified without a version', async () => {
549
+ const built: number[] = [];
550
+
551
+ await runAsync(
552
+ [makeEntry({ baseline: 158 })],
553
+ async (options) => {
554
+ await options.handlers.onNotifyAsync(makeNotify({ version: null }));
555
+ },
556
+ async (_entry, version) => {
557
+ built.push(version);
558
+ },
559
+ { source: makeSource(159) }
560
+ );
561
+
562
+ expect(built).toEqual([159]);
563
+ });
564
+
565
+ it('ignores a notification for a watch this run does not own', async () => {
566
+ const source = vi.fn(async () => 159);
567
+
568
+ await runAsync(
569
+ [makeEntry({ baseline: 158 })],
570
+ async (options) => {
571
+ await options.handlers.onNotifyAsync({
572
+ ...makeNotify(),
573
+ watchName: 'someone-else',
574
+ });
575
+ },
576
+ async () => {},
577
+ { source: { resolveLatestPlaceVersionAsync: source } }
578
+ );
579
+
580
+ expect(source).not.toHaveBeenCalled();
581
+ });
582
+ });
583
+
584
+ describe('runStreamWatchLoopAsync failures', () => {
585
+ // The service advances its own baseline whether or not anyone was listening,
586
+ // so it will never mention this change again. Leaving the watch unsettled
587
+ // here is the only thing that gets the rebuild retried.
588
+ it('retries on the next ready when a rebuild fails', async () => {
589
+ const attempts: number[] = [];
590
+
591
+ const result = await runAsync(
592
+ [makeEntry({ baseline: 158 })],
593
+ async (options) => {
594
+ await options.handlers.onReadyAsync(
595
+ makeReady([makeStatus({ currentVersion: TOKEN_B })])
596
+ );
597
+ await options.handlers.onReadyAsync(
598
+ makeReady([makeStatus({ currentVersion: TOKEN_B })])
599
+ );
600
+ },
601
+ async (_entry, version) => {
602
+ attempts.push(version);
603
+ if (attempts.length === 1) {
604
+ throw new Error('upload exploded');
605
+ }
606
+ },
607
+ { source: makeSource(159) }
608
+ );
609
+
610
+ expect(attempts).toEqual([159, 159]);
611
+ expect(result.failures).toBe(1);
612
+ expect(result.redeploys).toBe(1);
613
+ });
614
+
615
+ it('reports a monitor that went away', async () => {
616
+ const result = await runAsync(
617
+ [makeEntry({ baseline: 158 })],
618
+ async () => {},
619
+ async () => {},
620
+ { ending: 'monitor-gone' }
621
+ );
622
+
623
+ expect(result.monitorGone).toBe(true);
624
+ });
625
+ });