@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,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type WatchRegistrationRequest,
|
|
3
|
+
type WatchRegistrationResult,
|
|
4
|
+
type WatchRegistry,
|
|
5
|
+
type WatchTriggerResult,
|
|
6
|
+
} from '@quenty/nevermore-deploy';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export interface HttpWatchRegistryOptions {
|
|
11
|
+
/** Full register endpoint, ending in the lease — `.../v1/register/7d/`. */
|
|
12
|
+
registerUrl: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Injected in tests. Defaults to the global fetch. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `WatchRegistry` over the Roblox Analytics Watch API.
|
|
20
|
+
*
|
|
21
|
+
* One `POST /v1/register/{lease}` per invocation. The endpoint is whatever URL
|
|
22
|
+
* `--watch` resolved to, so nothing here assumes a host, and the lease is
|
|
23
|
+
* already the last path segment of that URL.
|
|
24
|
+
*
|
|
25
|
+
* The endpoint takes no `Authorization` header — the GitHub token travels in
|
|
26
|
+
* `auth.github.token`, because the service holds it to dispatch with later
|
|
27
|
+
* rather than using it to authenticate this call.
|
|
28
|
+
*/
|
|
29
|
+
export class HttpWatchRegistry implements WatchRegistry {
|
|
30
|
+
private _registerUrl: string;
|
|
31
|
+
private _timeoutMs: number;
|
|
32
|
+
private _fetch: typeof fetch;
|
|
33
|
+
|
|
34
|
+
constructor(options: HttpWatchRegistryOptions) {
|
|
35
|
+
this._registerUrl = options.registerUrl;
|
|
36
|
+
this._timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
37
|
+
this._fetch = options.fetchImpl ?? fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async registerAsync(
|
|
41
|
+
request: WatchRegistrationRequest
|
|
42
|
+
): Promise<WatchRegistrationResult> {
|
|
43
|
+
const body = {
|
|
44
|
+
name: request.monitorName,
|
|
45
|
+
auth: {
|
|
46
|
+
github: {
|
|
47
|
+
token: request.githubToken,
|
|
48
|
+
repository: request.repository,
|
|
49
|
+
},
|
|
50
|
+
...(request.robloxApiKey
|
|
51
|
+
? { robloxOpenCloud: { apiKey: request.robloxApiKey } }
|
|
52
|
+
: {}),
|
|
53
|
+
},
|
|
54
|
+
watches: request.watches.map((watch) => ({
|
|
55
|
+
name: watch.name,
|
|
56
|
+
source: {
|
|
57
|
+
type: watch.source.type,
|
|
58
|
+
universeId: watch.source.universeId,
|
|
59
|
+
placeId: watch.source.placeId,
|
|
60
|
+
versionType: watch.source.versionType,
|
|
61
|
+
},
|
|
62
|
+
// The kind travels with the value or not at all. Without it the service
|
|
63
|
+
// ignores a baseline outright on the one transition it matters for —
|
|
64
|
+
// the first registration that supplies an Open Cloud key — and the 422
|
|
65
|
+
// that should catch a vocabulary mismatch can never fire.
|
|
66
|
+
...(watch.baselineVersion == null
|
|
67
|
+
? {}
|
|
68
|
+
: {
|
|
69
|
+
baselineVersion: watch.baselineVersion,
|
|
70
|
+
...(watch.baselineVersionKind == null
|
|
71
|
+
? {}
|
|
72
|
+
: { baselineVersionKind: watch.baselineVersionKind }),
|
|
73
|
+
}),
|
|
74
|
+
action: watch.action,
|
|
75
|
+
})),
|
|
76
|
+
...(request.cooldownSeconds == null
|
|
77
|
+
? {}
|
|
78
|
+
: { cooldownSeconds: request.cooldownSeconds }),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
83
|
+
|
|
84
|
+
let response: Response;
|
|
85
|
+
try {
|
|
86
|
+
response = await this._fetch(this._registerUrl, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (controller.signal.aborted) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Watch registration timed out after ${this._timeoutMs}ms: ${this._registerUrl}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Watch registration could not reach ${this._registerUrl}: ` +
|
|
100
|
+
(err instanceof Error ? err.message : String(err))
|
|
101
|
+
);
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const text = await _readTextAsync(response);
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(_describeFailure(response, text, request));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return _parseResult(text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async triggerAsync(
|
|
115
|
+
monitorId: string,
|
|
116
|
+
credential: string,
|
|
117
|
+
options: { watchName?: string } = {}
|
|
118
|
+
): Promise<WatchTriggerResult> {
|
|
119
|
+
const url = `${this._apiBase()}/v1/monitors/${encodeURIComponent(
|
|
120
|
+
monitorId
|
|
121
|
+
)}/trigger`;
|
|
122
|
+
|
|
123
|
+
const response = await this._fetchWithTimeoutAsync(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
// Unlike register, this endpoint authenticates the caller — the token
|
|
128
|
+
// is the identity, so it travels as a bearer rather than in the body.
|
|
129
|
+
Authorization: `Bearer ${credential}`,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify(
|
|
132
|
+
options.watchName == null ? {} : { watchName: options.watchName }
|
|
133
|
+
),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const text = await _readTextAsync(response);
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const detail = _extractError(text);
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Forcing a dispatch failed (${response.status} ${response.statusText})` +
|
|
141
|
+
(detail ? `: ${detail}` : '')
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(text) as WatchTriggerResult;
|
|
147
|
+
return { results: Array.isArray(parsed.results) ? parsed.results : [] };
|
|
148
|
+
} catch {
|
|
149
|
+
return { results: [] };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The API root, recovered from the register URL.
|
|
155
|
+
*
|
|
156
|
+
* `--watch` names the register endpoint because that is the one a user has to
|
|
157
|
+
* type; every other call is derived from it. The lease-bearing
|
|
158
|
+
* `/v1/register/<lease>/` suffix is the documented shape, so trimming it is
|
|
159
|
+
* how a single flag reaches the rest of the API.
|
|
160
|
+
*/
|
|
161
|
+
private _apiBase(): string {
|
|
162
|
+
const marker = '/v1/register/';
|
|
163
|
+
const index = this._registerUrl.indexOf(marker);
|
|
164
|
+
if (index === -1) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Cannot derive the watch API root from "${this._registerUrl}" — ` +
|
|
167
|
+
`expected it to contain "${marker}".`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return this._registerUrl.slice(0, index);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async _fetchWithTimeoutAsync(
|
|
174
|
+
url: string,
|
|
175
|
+
init: RequestInit
|
|
176
|
+
): Promise<Response> {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timeout = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
179
|
+
try {
|
|
180
|
+
return await this._fetch(url, { ...init, signal: controller.signal });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (controller.signal.aborted) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Watch request timed out after ${this._timeoutMs}ms: ${url}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Watch request could not reach ${url}: ` +
|
|
189
|
+
(err instanceof Error ? err.message : String(err))
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Turn a status code into something actionable. The API documents exactly what
|
|
199
|
+
* each failure means, and every one of them has a different fix — a generic
|
|
200
|
+
* "registration failed (403)" would send someone to the wrong place.
|
|
201
|
+
*
|
|
202
|
+
* The service's own message wins when it has one, since it knows specifics we
|
|
203
|
+
* can only guess at; ours is the fallback for a bare status. Only the remedy is
|
|
204
|
+
* always ours, and it is what the service never supplies.
|
|
205
|
+
*/
|
|
206
|
+
function _describeFailure(
|
|
207
|
+
response: Response,
|
|
208
|
+
text: string,
|
|
209
|
+
request: WatchRegistrationRequest
|
|
210
|
+
): string {
|
|
211
|
+
const detail = _extractError(text);
|
|
212
|
+
|
|
213
|
+
const compose = (fallback: string, remedy?: string): string => {
|
|
214
|
+
const head = detail || fallback;
|
|
215
|
+
return remedy ? `${head}\n${remedy}` : head;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
switch (response.status) {
|
|
219
|
+
case 400:
|
|
220
|
+
return compose(
|
|
221
|
+
'The watch service rejected the registration as invalid.',
|
|
222
|
+
'This is a bug in how the CLI built the request — please report it.'
|
|
223
|
+
);
|
|
224
|
+
case 401:
|
|
225
|
+
return compose(
|
|
226
|
+
'GitHub rejected the dispatch token.',
|
|
227
|
+
'Set NEVERMORE_WATCH_TOKEN to a valid, unexpired token.'
|
|
228
|
+
);
|
|
229
|
+
case 403:
|
|
230
|
+
return compose(
|
|
231
|
+
`The dispatch token cannot write to ${request.repository}.`,
|
|
232
|
+
`The token needs "actions: write" on ${request.repository}.`
|
|
233
|
+
);
|
|
234
|
+
case 409:
|
|
235
|
+
return compose(
|
|
236
|
+
`Watch quota exceeded for ${request.repository}.`,
|
|
237
|
+
'Release monitors you no longer need, or let their leases expire.'
|
|
238
|
+
);
|
|
239
|
+
case 422: {
|
|
240
|
+
// 422 is the service's "your config cannot work" answer, and it covers
|
|
241
|
+
// several unrelated causes now — a missing workflow, an Open Cloud key
|
|
242
|
+
// without universe.place:read, a "saved" watch with no key, a baseline in
|
|
243
|
+
// the wrong vocabulary. Its own message names which, so the only thing
|
|
244
|
+
// worth adding is a remedy that could not already be in it.
|
|
245
|
+
const workflows = [
|
|
246
|
+
...new Set(
|
|
247
|
+
request.watches.flatMap((w) =>
|
|
248
|
+
w.action.type === 'github-workflow-dispatch'
|
|
249
|
+
? [w.action.workflow]
|
|
250
|
+
: []
|
|
251
|
+
)
|
|
252
|
+
),
|
|
253
|
+
];
|
|
254
|
+
// Volunteering a workflow remedy for a monitor that dispatches none once
|
|
255
|
+
// produced "Referenced: ." under a message about an API key scope.
|
|
256
|
+
if (workflows.length === 0) {
|
|
257
|
+
return compose('The watch service rejected the configuration.');
|
|
258
|
+
}
|
|
259
|
+
return compose(
|
|
260
|
+
`A referenced workflow does not exist in ${request.repository}.`,
|
|
261
|
+
`Workflows referenced: ${workflows.join(', ')}. A "watch" path in ` +
|
|
262
|
+
'deploy.nevermore.json must name a workflow that exists on the ' +
|
|
263
|
+
'dispatched ref.'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return compose(
|
|
268
|
+
`Watch registration failed (${response.status} ${response.statusText}).`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Pull the API's `{error, errors}` shape out, falling back to raw text. */
|
|
274
|
+
function _extractError(text: string): string {
|
|
275
|
+
if (text === '') {
|
|
276
|
+
return '';
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const parsed = JSON.parse(text) as {
|
|
280
|
+
error?: unknown;
|
|
281
|
+
errors?: unknown[];
|
|
282
|
+
};
|
|
283
|
+
const parts: string[] = [];
|
|
284
|
+
if (typeof parsed.error === 'string') {
|
|
285
|
+
parts.push(parsed.error);
|
|
286
|
+
}
|
|
287
|
+
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
288
|
+
parts.push(JSON.stringify(parsed.errors));
|
|
289
|
+
}
|
|
290
|
+
return parts.length > 0 ? parts.join(' ') : text;
|
|
291
|
+
} catch {
|
|
292
|
+
return text;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function _readTextAsync(response: Response): Promise<string> {
|
|
297
|
+
try {
|
|
298
|
+
return (await response.text()).trim();
|
|
299
|
+
} catch {
|
|
300
|
+
return '';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read back the fields worth reporting. A 2xx whose body we cannot parse still
|
|
306
|
+
* counts as registered — the service accepted it, and failing here would claim
|
|
307
|
+
* otherwise.
|
|
308
|
+
*/
|
|
309
|
+
function _parseResult(text: string): WatchRegistrationResult {
|
|
310
|
+
if (text === '') {
|
|
311
|
+
return {};
|
|
312
|
+
}
|
|
313
|
+
let parsed: unknown;
|
|
314
|
+
try {
|
|
315
|
+
parsed = JSON.parse(text);
|
|
316
|
+
} catch {
|
|
317
|
+
return {};
|
|
318
|
+
}
|
|
319
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
320
|
+
return {};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const record = parsed as Record<string, unknown>;
|
|
324
|
+
const result: WatchRegistrationResult = {};
|
|
325
|
+
if (typeof record['monitorId'] === 'string') {
|
|
326
|
+
result.monitorId = record['monitorId'];
|
|
327
|
+
}
|
|
328
|
+
if (typeof record['revision'] === 'string') {
|
|
329
|
+
result.revision = record['revision'];
|
|
330
|
+
}
|
|
331
|
+
if (typeof record['leaseExpiresAt'] === 'string') {
|
|
332
|
+
result.leaseExpiresAt = record['leaseExpiresAt'];
|
|
333
|
+
}
|
|
334
|
+
if (typeof record['changed'] === 'boolean') {
|
|
335
|
+
result.changed = record['changed'];
|
|
336
|
+
}
|
|
337
|
+
if (Array.isArray(record['watches'])) {
|
|
338
|
+
result.watchCount = record['watches'].length;
|
|
339
|
+
}
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
runLocalWatchLoopAsync,
|
|
4
|
+
type LocalWatchEntry,
|
|
5
|
+
} from './local-watch-loop.js';
|
|
6
|
+
|
|
7
|
+
function makeEntry(overrides: Partial<LocalWatchEntry> = {}): LocalWatchEntry {
|
|
8
|
+
return {
|
|
9
|
+
label: 'integration.places.hub',
|
|
10
|
+
universeId: 10,
|
|
11
|
+
placeId: 20,
|
|
12
|
+
versionType: 'published',
|
|
13
|
+
baseline: 158,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drives the loop deterministically: time only advances when the loop sleeps,
|
|
20
|
+
* so a test controls exactly how many poll rounds happen.
|
|
21
|
+
*/
|
|
22
|
+
function makeClock(pollIntervalMs: number) {
|
|
23
|
+
let current = 0;
|
|
24
|
+
return {
|
|
25
|
+
now: () => current,
|
|
26
|
+
sleepAsync: async () => {
|
|
27
|
+
current += pollIntervalMs;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('runLocalWatchLoopAsync', () => {
|
|
33
|
+
it('redeploys when the base place moves', async () => {
|
|
34
|
+
const versions = [158, 158, 159];
|
|
35
|
+
let i = 0;
|
|
36
|
+
const redeployed: number[] = [];
|
|
37
|
+
const clock = makeClock(1000);
|
|
38
|
+
|
|
39
|
+
const result = await runLocalWatchLoopAsync({
|
|
40
|
+
entries: [makeEntry()],
|
|
41
|
+
source: {
|
|
42
|
+
resolveLatestPlaceVersionAsync: async () =>
|
|
43
|
+
versions[Math.min(i++, versions.length - 1)]!,
|
|
44
|
+
},
|
|
45
|
+
durationMs: 3000,
|
|
46
|
+
pollIntervalMs: 1000,
|
|
47
|
+
redeployAsync: async (_entry, version) => {
|
|
48
|
+
redeployed.push(version);
|
|
49
|
+
},
|
|
50
|
+
...clock,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(redeployed).toEqual([159]);
|
|
54
|
+
expect(result.redeploys).toBe(1);
|
|
55
|
+
expect(result.failures).toBe(0);
|
|
56
|
+
expect(result.expired).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not redeploy while the version holds still', async () => {
|
|
60
|
+
const redeployed: number[] = [];
|
|
61
|
+
const clock = makeClock(1000);
|
|
62
|
+
|
|
63
|
+
const result = await runLocalWatchLoopAsync({
|
|
64
|
+
entries: [makeEntry()],
|
|
65
|
+
source: { resolveLatestPlaceVersionAsync: async () => 158 },
|
|
66
|
+
durationMs: 5000,
|
|
67
|
+
pollIntervalMs: 1000,
|
|
68
|
+
redeployAsync: async (_e, v) => {
|
|
69
|
+
redeployed.push(v);
|
|
70
|
+
},
|
|
71
|
+
...clock,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(redeployed).toEqual([]);
|
|
75
|
+
expect(result.redeploys).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// An entry with no lock entry has no baseline. Treating "unknown -> 158" as a
|
|
79
|
+
// change would rebuild immediately on startup for no reason.
|
|
80
|
+
it('adopts the first observed version when there is no baseline', async () => {
|
|
81
|
+
const redeployed: number[] = [];
|
|
82
|
+
const clock = makeClock(1000);
|
|
83
|
+
|
|
84
|
+
await runLocalWatchLoopAsync({
|
|
85
|
+
entries: [makeEntry({ baseline: undefined })],
|
|
86
|
+
source: { resolveLatestPlaceVersionAsync: async () => 158 },
|
|
87
|
+
durationMs: 3000,
|
|
88
|
+
pollIntervalMs: 1000,
|
|
89
|
+
redeployAsync: async (_e, v) => {
|
|
90
|
+
redeployed.push(v);
|
|
91
|
+
},
|
|
92
|
+
...clock,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(redeployed).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('keeps watching after a poll failure', async () => {
|
|
99
|
+
let call = 0;
|
|
100
|
+
const redeployed: number[] = [];
|
|
101
|
+
const clock = makeClock(1000);
|
|
102
|
+
|
|
103
|
+
const result = await runLocalWatchLoopAsync({
|
|
104
|
+
entries: [makeEntry()],
|
|
105
|
+
source: {
|
|
106
|
+
resolveLatestPlaceVersionAsync: async () => {
|
|
107
|
+
call++;
|
|
108
|
+
if (call === 1) {
|
|
109
|
+
throw new Error('ECONNRESET');
|
|
110
|
+
}
|
|
111
|
+
return 159;
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
durationMs: 3000,
|
|
115
|
+
pollIntervalMs: 1000,
|
|
116
|
+
redeployAsync: async (_e, v) => {
|
|
117
|
+
redeployed.push(v);
|
|
118
|
+
},
|
|
119
|
+
...clock,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(redeployed).toEqual([159]);
|
|
123
|
+
expect(result.redeploys).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// The new version is recorded only on a successful rebuild, so a transient
|
|
127
|
+
// build failure is retried rather than silently skipped forever.
|
|
128
|
+
it('retries a version whose rebuild failed', async () => {
|
|
129
|
+
let attempts = 0;
|
|
130
|
+
const clock = makeClock(1000);
|
|
131
|
+
|
|
132
|
+
const result = await runLocalWatchLoopAsync({
|
|
133
|
+
entries: [makeEntry()],
|
|
134
|
+
source: { resolveLatestPlaceVersionAsync: async () => 159 },
|
|
135
|
+
durationMs: 3000,
|
|
136
|
+
pollIntervalMs: 1000,
|
|
137
|
+
redeployAsync: async () => {
|
|
138
|
+
attempts++;
|
|
139
|
+
if (attempts === 1) {
|
|
140
|
+
throw new Error('rojo exploded');
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
...clock,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(attempts).toBeGreaterThan(1);
|
|
147
|
+
expect(result.failures).toBe(1);
|
|
148
|
+
expect(result.redeploys).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('stops when the caller aborts', async () => {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const clock = makeClock(1000);
|
|
154
|
+
const poll = vi.fn(async () => {
|
|
155
|
+
controller.abort();
|
|
156
|
+
return 158;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const result = await runLocalWatchLoopAsync({
|
|
160
|
+
entries: [makeEntry()],
|
|
161
|
+
source: { resolveLatestPlaceVersionAsync: poll },
|
|
162
|
+
durationMs: 60_000,
|
|
163
|
+
pollIntervalMs: 1000,
|
|
164
|
+
redeployAsync: async () => {},
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
...clock,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(poll).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(result.expired).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('watches every entry independently', async () => {
|
|
174
|
+
const seen: string[] = [];
|
|
175
|
+
const clock = makeClock(1000);
|
|
176
|
+
|
|
177
|
+
await runLocalWatchLoopAsync({
|
|
178
|
+
entries: [
|
|
179
|
+
makeEntry({ label: 'hub', placeId: 20, baseline: 1 }),
|
|
180
|
+
makeEntry({ label: 'lobby', placeId: 21, baseline: 1 }),
|
|
181
|
+
],
|
|
182
|
+
source: {
|
|
183
|
+
resolveLatestPlaceVersionAsync: async (_u, placeId) =>
|
|
184
|
+
placeId === 21 ? 2 : 1,
|
|
185
|
+
},
|
|
186
|
+
durationMs: 2000,
|
|
187
|
+
pollIntervalMs: 1000,
|
|
188
|
+
redeployAsync: async (entry) => {
|
|
189
|
+
seen.push(entry.label);
|
|
190
|
+
},
|
|
191
|
+
...clock,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(seen).toContain('lobby');
|
|
195
|
+
expect(seen).not.toContain('hub');
|
|
196
|
+
});
|
|
197
|
+
});
|