@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,184 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Env var holding the token the watch service dispatches with. Checked before
|
|
5
|
+
* `GITHUB_TOKEN` so a long-lived registration token can be kept separate from
|
|
6
|
+
* the short-lived one a workflow run is handed — an Actions `GITHUB_TOKEN`
|
|
7
|
+
* expires with its job, which would outlive nothing.
|
|
8
|
+
*/
|
|
9
|
+
const WATCH_TOKEN_ENV = 'NEVERMORE_WATCH_TOKEN';
|
|
10
|
+
|
|
11
|
+
/** Everything the watch service needs to dispatch a workflow back at us. */
|
|
12
|
+
export interface GithubDispatchContext {
|
|
13
|
+
/** `owner/repo`. */
|
|
14
|
+
repository: string;
|
|
15
|
+
/** Git ref the dispatched workflow should run on. */
|
|
16
|
+
ref: string;
|
|
17
|
+
/** Token the service dispatches with; absent when none was provided. */
|
|
18
|
+
token?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GithubContextOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Read the token from the GitHub CLI when no environment variable supplies
|
|
24
|
+
* one.
|
|
25
|
+
*
|
|
26
|
+
* Off unless asked for, and that is the whole point. Registering sends a PAT
|
|
27
|
+
* to the watch service and creates a monitor against the repository, so the
|
|
28
|
+
* credential has to be one the user handed over — an env var is handing it
|
|
29
|
+
* over, reading whatever `gh` happens to be holding is not. Nearly every
|
|
30
|
+
* developer machine has a logged-in `gh`, so defaulting this on would turn a
|
|
31
|
+
* command that used to watch locally into one that quietly ships a token.
|
|
32
|
+
*/
|
|
33
|
+
useGhAuth?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type GithubContextResult =
|
|
37
|
+
| { success: true; context: GithubDispatchContext }
|
|
38
|
+
| {
|
|
39
|
+
success: false;
|
|
40
|
+
reason: 'no_repository' | 'no_ref' | 'undispatchable_ref';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A `pull_request` run exposes `GITHUB_REF_NAME` as `42/merge` — a synthetic
|
|
45
|
+
* merge ref that exists only for that check run. It is not a branch, so
|
|
46
|
+
* `workflow_dispatch` cannot run on it, and it disappears when the PR closes.
|
|
47
|
+
*/
|
|
48
|
+
const MERGE_REF_PATTERN = /^\d+\/(merge|head)$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read an env var, treating empty as absent.
|
|
52
|
+
*
|
|
53
|
+
* CI sets a variable to the empty string when the secret behind it is missing
|
|
54
|
+
* (`FOO: ${{ secrets.ABSENT }}`), which `??` would accept as a real value —
|
|
55
|
+
* so the documented fallback to the next source would never fire.
|
|
56
|
+
*/
|
|
57
|
+
function _env(name: string): string | undefined {
|
|
58
|
+
const value = process.env[name];
|
|
59
|
+
return value == null || value === '' ? undefined : value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Work out which repo and ref a watch should dispatch.
|
|
64
|
+
*
|
|
65
|
+
* Prefers the Actions environment, which is authoritative when we are already
|
|
66
|
+
* running inside the workflow being registered, and falls back to the git
|
|
67
|
+
* remote so `--watch <url>` also works from a developer's machine. Best-effort:
|
|
68
|
+
* outside a git repo with no Actions env there is simply nothing to dispatch.
|
|
69
|
+
*/
|
|
70
|
+
export function tryResolveGithubDispatchContext(
|
|
71
|
+
options: GithubContextOptions = {}
|
|
72
|
+
): GithubContextResult {
|
|
73
|
+
const repository = _env('GITHUB_REPOSITORY') ?? _repositoryFromRemote();
|
|
74
|
+
if (!repository) {
|
|
75
|
+
return { success: false, reason: 'no_repository' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ref =
|
|
79
|
+
_env('GITHUB_REF_NAME') ?? _git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
80
|
+
// A detached HEAD gives "HEAD", which is not a ref a dispatch can run on.
|
|
81
|
+
if (!ref || ref === 'HEAD') {
|
|
82
|
+
return { success: false, reason: 'no_ref' };
|
|
83
|
+
}
|
|
84
|
+
if (MERGE_REF_PATTERN.test(ref)) {
|
|
85
|
+
return { success: false, reason: 'undispatchable_ref' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const token =
|
|
89
|
+
_env(WATCH_TOKEN_ENV) ??
|
|
90
|
+
_env('GITHUB_TOKEN') ??
|
|
91
|
+
(options.useGhAuth ? _githubCliToken() : undefined);
|
|
92
|
+
|
|
93
|
+
return { success: true, context: { repository, ref, token } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Human-facing explanation for a failed context resolution. */
|
|
97
|
+
export function describeGithubContextFailure(
|
|
98
|
+
reason: 'no_repository' | 'no_ref' | 'undispatchable_ref'
|
|
99
|
+
): string {
|
|
100
|
+
if (reason === 'no_repository') {
|
|
101
|
+
return (
|
|
102
|
+
'Could not determine the GitHub repository to dispatch. Set ' +
|
|
103
|
+
'GITHUB_REPOSITORY (owner/repo), or run from a clone with a GitHub ' +
|
|
104
|
+
'"origin" remote.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (reason === 'undispatchable_ref') {
|
|
108
|
+
return (
|
|
109
|
+
'Refusing to register a watch from a pull request merge ref. That ref ' +
|
|
110
|
+
'exists only for this check run, so a dispatch against it would fail ' +
|
|
111
|
+
'and it disappears when the PR closes. Register from a branch instead ' +
|
|
112
|
+
'(e.g. on push to main).'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return (
|
|
116
|
+
'Could not determine the git ref to dispatch on. Set GITHUB_REF_NAME, or ' +
|
|
117
|
+
'check out a branch (a detached HEAD has no ref to run a workflow from).'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Hint printed when a cloud registration has no credential to hand over. */
|
|
122
|
+
export function describeMissingWatchToken(): string {
|
|
123
|
+
return [
|
|
124
|
+
'No GitHub token found. The watch service identifies you by repository, ' +
|
|
125
|
+
'so registering needs a token with "actions: write" on it. Either:',
|
|
126
|
+
` - Set: ${WATCH_TOKEN_ENV} (or GITHUB_TOKEN)`,
|
|
127
|
+
' - Pass: --watch-use-gh-auth, to use the token `gh` already holds',
|
|
128
|
+
].join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pull `owner/repo` out of a git remote URL. Handles the HTTPS, SCP-style, and
|
|
133
|
+
* ssh:// spellings git hands out, and ignores non-GitHub hosts — a watch
|
|
134
|
+
* registration names a GitHub workflow, so anything else is not dispatchable.
|
|
135
|
+
*/
|
|
136
|
+
export function parseGithubRepository(remoteUrl: string): string | undefined {
|
|
137
|
+
const trimmed = remoteUrl.trim();
|
|
138
|
+
const match =
|
|
139
|
+
/^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/i.exec(
|
|
140
|
+
trimmed
|
|
141
|
+
);
|
|
142
|
+
if (!match) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
return `${match[1]}/${match[2]}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _repositoryFromRemote(): string | undefined {
|
|
149
|
+
const remote = _git(['remote', 'get-url', 'origin']);
|
|
150
|
+
return remote == null ? undefined : parseGithubRepository(remote);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The token `gh` is already holding, if it is installed and logged in.
|
|
155
|
+
*
|
|
156
|
+
* Only ever called when the caller opted in. Not stored anywhere by us on
|
|
157
|
+
* purpose: `gh` owns its own refresh, and copying a token into `~/.nevermore`
|
|
158
|
+
* would leave a stale duplicate to expire behind everyone's back — the point is
|
|
159
|
+
* reading the live one, for the one run that asked.
|
|
160
|
+
*/
|
|
161
|
+
function _githubCliToken(): string | undefined {
|
|
162
|
+
const token = _run('gh', ['auth', 'token']);
|
|
163
|
+
// A logged-out `gh` exits non-zero, but an odd version could print a notice
|
|
164
|
+
// on stdout instead; a token is never whitespace.
|
|
165
|
+
return token == null || /\s/.test(token) ? undefined : token;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _git(args: string[]): string | undefined {
|
|
169
|
+
return _run('git', args);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _run(command: string, args: string[]): string | undefined {
|
|
173
|
+
try {
|
|
174
|
+
const output = execSync(`${command} ${args.join(' ')}`, {
|
|
175
|
+
encoding: 'utf-8',
|
|
176
|
+
timeout: 5000,
|
|
177
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
178
|
+
}).trim();
|
|
179
|
+
return output === '' ? undefined : output;
|
|
180
|
+
} catch {
|
|
181
|
+
// Not installed, not logged in, or not a repo — all "no answer here".
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { type WatchRegistrationRequest } from '@quenty/nevermore-deploy';
|
|
3
|
+
import { HttpWatchRegistry } from './http-watch-registry.js';
|
|
4
|
+
|
|
5
|
+
const REGISTER_URL = 'https://watch.example.com/v1/register/7d/';
|
|
6
|
+
|
|
7
|
+
function makeRequest(
|
|
8
|
+
overrides: Partial<WatchRegistrationRequest> = {}
|
|
9
|
+
): WatchRegistrationRequest {
|
|
10
|
+
return {
|
|
11
|
+
monitorName: 'integration',
|
|
12
|
+
repository: 'Quenty/egg-hunt-2026',
|
|
13
|
+
githubToken: 'token-value',
|
|
14
|
+
watches: [
|
|
15
|
+
{
|
|
16
|
+
name: 'quenty/hub/hub',
|
|
17
|
+
source: {
|
|
18
|
+
type: 'roblox-place',
|
|
19
|
+
universeId: 10,
|
|
20
|
+
placeId: 20,
|
|
21
|
+
versionType: 'published',
|
|
22
|
+
},
|
|
23
|
+
baselineVersion: '158',
|
|
24
|
+
baselineVersionKind: 'roblox-place-version',
|
|
25
|
+
action: {
|
|
26
|
+
type: 'github-workflow-dispatch',
|
|
27
|
+
workflow: '.github/workflows/build.yml',
|
|
28
|
+
ref: 'main',
|
|
29
|
+
inputs: { target: 'integration.places.hub' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeResponse(
|
|
38
|
+
body: string,
|
|
39
|
+
init: { status?: number; statusText?: string } = {}
|
|
40
|
+
): Response {
|
|
41
|
+
return {
|
|
42
|
+
ok: (init.status ?? 200) < 400,
|
|
43
|
+
status: init.status ?? 200,
|
|
44
|
+
statusText: init.statusText ?? 'OK',
|
|
45
|
+
text: async () => body,
|
|
46
|
+
} as Response;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registryReturning(
|
|
50
|
+
response: Response,
|
|
51
|
+
calls?: Array<{ url: string; init: RequestInit }>
|
|
52
|
+
): HttpWatchRegistry {
|
|
53
|
+
return new HttpWatchRegistry({
|
|
54
|
+
registerUrl: REGISTER_URL,
|
|
55
|
+
fetchImpl: (async (url: string, init: RequestInit) => {
|
|
56
|
+
calls?.push({ url, init });
|
|
57
|
+
return response;
|
|
58
|
+
}) as unknown as typeof fetch,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('HttpWatchRegistry', () => {
|
|
63
|
+
it('posts a MonitorRequest to the register URL', async () => {
|
|
64
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
65
|
+
await registryReturning(makeResponse('{}'), calls).registerAsync(
|
|
66
|
+
makeRequest()
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(calls).toHaveLength(1);
|
|
70
|
+
expect(calls[0]!.url).toBe(REGISTER_URL);
|
|
71
|
+
expect(calls[0]!.init.method).toBe('POST');
|
|
72
|
+
|
|
73
|
+
expect(JSON.parse(calls[0]!.init.body as string)).toEqual({
|
|
74
|
+
name: 'integration',
|
|
75
|
+
auth: {
|
|
76
|
+
github: {
|
|
77
|
+
token: 'token-value',
|
|
78
|
+
repository: 'Quenty/egg-hunt-2026',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
watches: [
|
|
82
|
+
{
|
|
83
|
+
name: 'quenty/hub/hub',
|
|
84
|
+
source: {
|
|
85
|
+
type: 'roblox-place',
|
|
86
|
+
universeId: 10,
|
|
87
|
+
placeId: 20,
|
|
88
|
+
versionType: 'published',
|
|
89
|
+
},
|
|
90
|
+
baselineVersion: '158',
|
|
91
|
+
baselineVersionKind: 'roblox-place-version',
|
|
92
|
+
action: {
|
|
93
|
+
type: 'github-workflow-dispatch',
|
|
94
|
+
workflow: '.github/workflows/build.yml',
|
|
95
|
+
ref: 'main',
|
|
96
|
+
inputs: { target: 'integration.places.hub' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('sends every watch in one request', async () => {
|
|
104
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
105
|
+
const base = makeRequest();
|
|
106
|
+
await registryReturning(makeResponse('{}'), calls).registerAsync({
|
|
107
|
+
...base,
|
|
108
|
+
watches: [base.watches[0]!, { ...base.watches[0]!, name: 'pkg/lobby' }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(calls).toHaveLength(1);
|
|
112
|
+
expect(JSON.parse(calls[0]!.init.body as string).watches).toHaveLength(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('omits the Open Cloud key unless one was shared', async () => {
|
|
116
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
117
|
+
await registryReturning(makeResponse('{}'), calls).registerAsync(
|
|
118
|
+
makeRequest()
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(JSON.parse(calls[0]!.init.body as string).auth).not.toHaveProperty(
|
|
122
|
+
'robloxOpenCloud'
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('includes the Open Cloud key when shared', async () => {
|
|
127
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
128
|
+
await registryReturning(makeResponse('{}'), calls).registerAsync(
|
|
129
|
+
makeRequest({ robloxApiKey: 'oc-key' })
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(
|
|
133
|
+
JSON.parse(calls[0]!.init.body as string).auth.robloxOpenCloud
|
|
134
|
+
).toEqual({ apiKey: 'oc-key' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// The kind describes the value, so sending it alone would declare the
|
|
138
|
+
// vocabulary of a baseline that is not there.
|
|
139
|
+
it('omits the declared kind along with the baseline', async () => {
|
|
140
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
141
|
+
const base = makeRequest();
|
|
142
|
+
await registryReturning(makeResponse('{}'), calls).registerAsync({
|
|
143
|
+
...base,
|
|
144
|
+
watches: [{ ...base.watches[0]!, baselineVersion: undefined }],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(
|
|
148
|
+
JSON.parse(calls[0]!.init.body as string).watches[0]
|
|
149
|
+
).not.toHaveProperty('baselineVersionKind');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('omits baselineVersion when there is no baseline', async () => {
|
|
153
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
154
|
+
const base = makeRequest();
|
|
155
|
+
await registryReturning(makeResponse('{}'), calls).registerAsync({
|
|
156
|
+
...base,
|
|
157
|
+
watches: [{ ...base.watches[0]!, baselineVersion: undefined }],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
JSON.parse(calls[0]!.init.body as string).watches[0]
|
|
162
|
+
).not.toHaveProperty('baselineVersion');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('reads back the monitor the service reports', async () => {
|
|
166
|
+
const result = await registryReturning(
|
|
167
|
+
makeResponse(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
monitorId: 'mon_9f2a',
|
|
170
|
+
revision: 'abc123',
|
|
171
|
+
leaseExpiresAt: '2026-08-06T00:00:00Z',
|
|
172
|
+
changed: true,
|
|
173
|
+
watches: [{ name: 'a' }, { name: 'b' }],
|
|
174
|
+
unexpected: 'ignored',
|
|
175
|
+
})
|
|
176
|
+
)
|
|
177
|
+
).registerAsync(makeRequest());
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual({
|
|
180
|
+
monitorId: 'mon_9f2a',
|
|
181
|
+
revision: 'abc123',
|
|
182
|
+
leaseExpiresAt: '2026-08-06T00:00:00Z',
|
|
183
|
+
changed: true,
|
|
184
|
+
watchCount: 2,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('reports an unchanged re-apply', async () => {
|
|
189
|
+
const result = await registryReturning(
|
|
190
|
+
makeResponse(JSON.stringify({ monitorId: 'mon_1', changed: false }))
|
|
191
|
+
).registerAsync(makeRequest());
|
|
192
|
+
|
|
193
|
+
expect(result.changed).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('treats a 2xx with an unusable body as registered without details', async () => {
|
|
197
|
+
expect(
|
|
198
|
+
await registryReturning(makeResponse('not json')).registerAsync(
|
|
199
|
+
makeRequest()
|
|
200
|
+
)
|
|
201
|
+
).toEqual({});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('failure messages', () => {
|
|
205
|
+
it('leads with the service message and adds our remedy on 401', async () => {
|
|
206
|
+
await expect(
|
|
207
|
+
registryReturning(
|
|
208
|
+
makeResponse(JSON.stringify({ error: 'bad credentials' }), {
|
|
209
|
+
status: 401,
|
|
210
|
+
statusText: 'Unauthorized',
|
|
211
|
+
})
|
|
212
|
+
).registerAsync(makeRequest())
|
|
213
|
+
).rejects.toThrowError(/^bad credentials\nSet NEVERMORE_WATCH_TOKEN/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('falls back to our own wording when the service says nothing', async () => {
|
|
217
|
+
await expect(
|
|
218
|
+
registryReturning(
|
|
219
|
+
makeResponse('', { status: 401, statusText: 'Unauthorized' })
|
|
220
|
+
).registerAsync(makeRequest())
|
|
221
|
+
).rejects.toThrowError(/^GitHub rejected the dispatch token\./);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('names the repository and required scope on 403', async () => {
|
|
225
|
+
await expect(
|
|
226
|
+
registryReturning(
|
|
227
|
+
makeResponse('', { status: 403, statusText: 'Forbidden' })
|
|
228
|
+
).registerAsync(makeRequest())
|
|
229
|
+
).rejects.toThrowError(
|
|
230
|
+
/cannot write to Quenty\/egg-hunt-2026.*actions: write/s
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('explains a quota failure on 409', async () => {
|
|
235
|
+
await expect(
|
|
236
|
+
registryReturning(
|
|
237
|
+
makeResponse('', { status: 409, statusText: 'Conflict' })
|
|
238
|
+
).registerAsync(makeRequest())
|
|
239
|
+
).rejects.toThrowError(/quota exceeded for Quenty\/egg-hunt-2026/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('lists the workflows it referenced on 422', async () => {
|
|
243
|
+
await expect(
|
|
244
|
+
registryReturning(
|
|
245
|
+
makeResponse('', { status: 422, statusText: 'Unprocessable Entity' })
|
|
246
|
+
).registerAsync(makeRequest())
|
|
247
|
+
).rejects.toThrowError(
|
|
248
|
+
/workflow does not exist.*\.github\/workflows\/build\.yml/s
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Observed against prod: a notify-only monitor was refused 422 because the
|
|
253
|
+
// Open Cloud key lacked a scope, and the reply appended "Referenced: ." plus
|
|
254
|
+
// advice about a workflow path — under a message that had nothing to do with
|
|
255
|
+
// workflows. 422 now covers several causes and the service names which.
|
|
256
|
+
it('adds no workflow advice when the monitor dispatches none', async () => {
|
|
257
|
+
const request = makeRequest();
|
|
258
|
+
request.watches = request.watches.map((watch) => ({
|
|
259
|
+
...watch,
|
|
260
|
+
action: { type: 'notify' as const, payload: { target: 'x' } },
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
registryReturning(
|
|
265
|
+
makeResponse(
|
|
266
|
+
JSON.stringify({
|
|
267
|
+
error: 'The Open Cloud key is not allowed to read place 1 (403).',
|
|
268
|
+
}),
|
|
269
|
+
{ status: 422, statusText: 'Unprocessable Entity' }
|
|
270
|
+
)
|
|
271
|
+
).registerAsync(request)
|
|
272
|
+
).rejects.toThrowError(/not allowed to read place 1/);
|
|
273
|
+
|
|
274
|
+
await expect(
|
|
275
|
+
registryReturning(
|
|
276
|
+
makeResponse(JSON.stringify({ error: 'nope' }), {
|
|
277
|
+
status: 422,
|
|
278
|
+
statusText: 'Unprocessable Entity',
|
|
279
|
+
})
|
|
280
|
+
).registerAsync(request)
|
|
281
|
+
).rejects.not.toThrowError(/Referenced|workflow/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('surfaces the API error body', async () => {
|
|
285
|
+
await expect(
|
|
286
|
+
registryReturning(
|
|
287
|
+
makeResponse(JSON.stringify({ error: 'lease too long' }), {
|
|
288
|
+
status: 400,
|
|
289
|
+
statusText: 'Bad Request',
|
|
290
|
+
})
|
|
291
|
+
).registerAsync(makeRequest())
|
|
292
|
+
).rejects.toThrowError(/lease too long/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('includes the API validation detail list', async () => {
|
|
296
|
+
await expect(
|
|
297
|
+
registryReturning(
|
|
298
|
+
makeResponse(
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
error: "request/body must have required property 'name'",
|
|
301
|
+
errors: [{ path: '/body/name', errorCode: 'required' }],
|
|
302
|
+
}),
|
|
303
|
+
{ status: 400, statusText: 'Bad Request' }
|
|
304
|
+
)
|
|
305
|
+
).registerAsync(makeRequest())
|
|
306
|
+
).rejects.toThrowError(/\/body\/name/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('falls back to status text on an unmapped code', async () => {
|
|
310
|
+
await expect(
|
|
311
|
+
registryReturning(
|
|
312
|
+
makeResponse('', { status: 503, statusText: 'Service Unavailable' })
|
|
313
|
+
).registerAsync(makeRequest())
|
|
314
|
+
).rejects.toThrowError(/503 Service Unavailable/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('reports the endpoint when the request cannot be made', async () => {
|
|
318
|
+
const registry = new HttpWatchRegistry({
|
|
319
|
+
registerUrl: REGISTER_URL,
|
|
320
|
+
fetchImpl: (async () => {
|
|
321
|
+
throw new Error('ENOTFOUND');
|
|
322
|
+
}) as unknown as typeof fetch,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await expect(registry.registerAsync(makeRequest())).rejects.toThrowError(
|
|
326
|
+
/could not reach https:\/\/watch\.example\.com/
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|