@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.
- package/CHANGELOG.md +22 -0
- package/dist/args/global-args.d.ts +13 -0
- package/dist/args/global-args.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.d.ts +3 -0
- package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.js +90 -1
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +7 -2
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/commands/deploy-command/deploy-init.js +1 -1
- package/dist/commands/deploy-command/deploy-init.js.map +1 -1
- package/dist/commands/deploy-command/index.d.ts +3 -0
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +301 -60
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/commands/deploy-command/select-target.js +1 -1
- package/dist/commands/deploy-command/select-target.js.map +1 -1
- package/dist/commands/deploy-command/version-command.d.ts.map +1 -1
- package/dist/commands/deploy-command/version-command.js +138 -12
- package/dist/commands/deploy-command/version-command.js.map +1 -1
- package/dist/commands/test-command/test-command.d.ts.map +1 -1
- package/dist/commands/test-command/test-command.js +17 -7
- package/dist/commands/test-command/test-command.js.map +1 -1
- package/dist/nevermore.js +17 -0
- package/dist/nevermore.js.map +1 -1
- package/dist/utils/batch/changed-packages-utils.d.ts +1 -1
- package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
- package/dist/utils/batch/changed-packages-utils.js +1 -1
- package/dist/utils/batch/changed-packages-utils.js.map +1 -1
- package/dist/utils/build/base-place-resolver-factory.d.ts +25 -0
- package/dist/utils/build/base-place-resolver-factory.d.ts.map +1 -0
- package/dist/utils/build/base-place-resolver-factory.js +40 -0
- package/dist/utils/build/base-place-resolver-factory.js.map +1 -0
- package/dist/utils/build/build.d.ts +13 -2
- package/dist/utils/build/build.d.ts.map +1 -1
- package/dist/utils/build/build.js.map +1 -1
- package/dist/utils/build/upload.d.ts +1 -1
- package/dist/utils/build/upload.d.ts.map +1 -1
- package/dist/utils/deploy/deploy-metadata.d.ts +1 -1
- package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.d.ts +3 -1
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +45 -6
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.d.ts +6 -0
- package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.js +13 -0
- package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.d.ts +2 -1
- package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/cloud-job-context.js +2 -2
- package/dist/utils/job-context/cloud-job-context.js.map +1 -1
- package/dist/utils/job-context/local-job-context.d.ts +2 -1
- package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/local-job-context.js +2 -2
- package/dist/utils/job-context/local-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +20 -2
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +74 -20
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +89 -12
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/dist/utils/testing/runner/combined-project-generator.d.ts +1 -1
- package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/watch/github-context.d.ts +50 -0
- package/dist/utils/watch/github-context.d.ts.map +1 -0
- package/dist/utils/watch/github-context.js +125 -0
- package/dist/utils/watch/github-context.js.map +1 -0
- package/dist/utils/watch/github-context.test.d.ts +2 -0
- package/dist/utils/watch/github-context.test.d.ts.map +1 -0
- package/dist/utils/watch/github-context.test.js +112 -0
- package/dist/utils/watch/github-context.test.js.map +1 -0
- package/dist/utils/watch/http-watch-registry.d.ts +40 -0
- package/dist/utils/watch/http-watch-registry.d.ts.map +1 -0
- package/dist/utils/watch/http-watch-registry.js +262 -0
- package/dist/utils/watch/http-watch-registry.js.map +1 -0
- package/dist/utils/watch/http-watch-registry.test.d.ts +2 -0
- package/dist/utils/watch/http-watch-registry.test.d.ts.map +1 -0
- package/dist/utils/watch/http-watch-registry.test.js +211 -0
- package/dist/utils/watch/http-watch-registry.test.js.map +1 -0
- package/dist/utils/watch/local-watch-loop.d.ts +50 -0
- package/dist/utils/watch/local-watch-loop.d.ts.map +1 -0
- package/dist/utils/watch/local-watch-loop.js +103 -0
- package/dist/utils/watch/local-watch-loop.js.map +1 -0
- package/dist/utils/watch/local-watch-loop.test.d.ts +2 -0
- package/dist/utils/watch/local-watch-loop.test.d.ts.map +1 -0
- package/dist/utils/watch/local-watch-loop.test.js +170 -0
- package/dist/utils/watch/local-watch-loop.test.js.map +1 -0
- package/dist/utils/watch/register-notify-watches.d.ts +41 -0
- package/dist/utils/watch/register-notify-watches.d.ts.map +1 -0
- package/dist/utils/watch/register-notify-watches.js +159 -0
- package/dist/utils/watch/register-notify-watches.js.map +1 -0
- package/dist/utils/watch/register-notify-watches.test.d.ts +2 -0
- package/dist/utils/watch/register-notify-watches.test.d.ts.map +1 -0
- package/dist/utils/watch/register-notify-watches.test.js +225 -0
- package/dist/utils/watch/register-notify-watches.test.js.map +1 -0
- package/dist/utils/watch/register-watches.d.ts +68 -0
- package/dist/utils/watch/register-watches.d.ts.map +1 -0
- package/dist/utils/watch/register-watches.js +221 -0
- package/dist/utils/watch/register-watches.js.map +1 -0
- package/dist/utils/watch/register-watches.test.d.ts +2 -0
- package/dist/utils/watch/register-watches.test.d.ts.map +1 -0
- package/dist/utils/watch/register-watches.test.js +576 -0
- package/dist/utils/watch/register-watches.test.js.map +1 -0
- package/dist/utils/watch/stream-watch-loop.d.ts +54 -0
- package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -0
- package/dist/utils/watch/stream-watch-loop.js +282 -0
- package/dist/utils/watch/stream-watch-loop.js.map +1 -0
- package/dist/utils/watch/stream-watch-loop.test.d.ts +2 -0
- package/dist/utils/watch/stream-watch-loop.test.d.ts.map +1 -0
- package/dist/utils/watch/stream-watch-loop.test.js +392 -0
- package/dist/utils/watch/stream-watch-loop.test.js.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.d.ts +25 -0
- package/dist/utils/watch/websocket-watch-stream.d.ts.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.js +306 -0
- package/dist/utils/watch/websocket-watch-stream.js.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.test.d.ts +2 -0
- package/dist/utils/watch/websocket-watch-stream.test.d.ts.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.test.js +475 -0
- package/dist/utils/watch/websocket-watch-stream.test.js.map +1 -0
- package/package.json +7 -4
- package/src/args/global-args.ts +13 -0
- package/src/commands/batch-command/batch-deploy-command.ts +105 -1
- package/src/commands/batch-command/batch-test-command.ts +8 -2
- package/src/commands/deploy-command/deploy-init.ts +1 -1
- package/src/commands/deploy-command/index.ts +331 -32
- package/src/commands/deploy-command/select-target.ts +1 -1
- package/src/commands/deploy-command/version-command.ts +215 -21
- package/src/commands/test-command/test-command.ts +24 -18
- package/src/nevermore.ts +19 -0
- package/src/utils/batch/changed-packages-utils.ts +1 -1
- package/src/utils/build/base-place-resolver-factory.ts +49 -0
- package/src/utils/build/build.ts +13 -2
- package/src/utils/build/upload.ts +1 -1
- package/src/utils/deploy/deploy-metadata.ts +1 -1
- package/src/utils/job-context/base-job-context.ts +68 -6
- package/src/utils/job-context/batch-script-job-context.ts +18 -0
- package/src/utils/job-context/cloud-job-context.ts +7 -2
- package/src/utils/job-context/local-job-context.ts +7 -2
- package/src/utils/open-cloud/open-cloud-client.test.ts +133 -14
- package/src/utils/open-cloud/open-cloud-client.ts +127 -34
- package/src/utils/testing/runner/combined-project-generator.ts +1 -1
- package/src/utils/testing/runner/test-runner.ts +1 -1
- package/src/utils/watch/github-context.test.ts +154 -0
- package/src/utils/watch/github-context.ts +184 -0
- package/src/utils/watch/http-watch-registry.test.ts +330 -0
- package/src/utils/watch/http-watch-registry.ts +341 -0
- package/src/utils/watch/local-watch-loop.test.ts +197 -0
- package/src/utils/watch/local-watch-loop.ts +190 -0
- package/src/utils/watch/register-notify-watches.test.ts +282 -0
- package/src/utils/watch/register-notify-watches.ts +226 -0
- package/src/utils/watch/register-watches.test.ts +696 -0
- package/src/utils/watch/register-watches.ts +369 -0
- package/src/utils/watch/stream-watch-loop.test.ts +625 -0
- package/src/utils/watch/stream-watch-loop.ts +411 -0
- package/src/utils/watch/websocket-watch-stream.test.ts +581 -0
- package/src/utils/watch/websocket-watch-stream.ts +403 -0
- package/tsconfig.json +19 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/build/deploy-config.d.ts +0 -71
- package/dist/utils/build/deploy-config.d.ts.map +0 -1
- package/dist/utils/build/deploy-config.js +0 -164
- package/dist/utils/build/deploy-config.js.map +0 -1
- package/dist/utils/build/deploy-config.test.d.ts +0 -2
- package/dist/utils/build/deploy-config.test.d.ts.map +0 -1
- package/dist/utils/build/deploy-config.test.js +0 -126
- package/dist/utils/build/deploy-config.test.js.map +0 -1
- package/src/utils/build/deploy-config.test.ts +0 -149
- 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
|
+
});
|