@quenty/nevermore-cli 4.41.1 → 4.42.1-canary.783.30683193716.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 +28 -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 +25 -4
- 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 +297 -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 +23 -11
- 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 +78 -21
- 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/parsers/batch-log-parser.d.ts +13 -5
- package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.js +114 -68
- package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js +57 -5
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
- package/dist/utils/testing/parsers/traceback-parser.d.ts +31 -0
- package/dist/utils/testing/parsers/traceback-parser.d.ts.map +1 -0
- package/dist/utils/testing/parsers/traceback-parser.js +91 -0
- package/dist/utils/testing/parsers/traceback-parser.js.map +1 -0
- package/dist/utils/testing/parsers/traceback-parser.test.d.ts +2 -0
- package/dist/utils/testing/parsers/traceback-parser.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/traceback-parser.test.js +45 -0
- package/dist/utils/testing/parsers/traceback-parser.test.js.map +1 -0
- 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 +9 -1
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +28 -3
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.test.d.ts +2 -0
- package/dist/utils/testing/runner/test-runner.test.d.ts.map +1 -0
- package/dist/utils/testing/runner/test-runner.test.js +18 -0
- package/dist/utils/testing/runner/test-runner.test.js.map +1 -0
- package/dist/utils/testing/test-log-parser.d.ts +32 -1
- package/dist/utils/testing/test-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/test-log-parser.js +53 -9
- package/dist/utils/testing/test-log-parser.js.map +1 -1
- package/dist/utils/testing/test-log-parser.test.d.ts +2 -0
- package/dist/utils/testing/test-log-parser.test.d.ts.map +1 -0
- package/dist/utils/testing/test-log-parser.test.js +56 -0
- package/dist/utils/testing/test-log-parser.test.js.map +1 -0
- 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 +86 -60
- package/src/commands/deploy-command/deploy-init.ts +1 -1
- package/src/commands/deploy-command/index.ts +327 -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 +31 -22
- 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 +131 -35
- package/src/utils/testing/parsers/batch-log-parser.test.ts +70 -5
- package/src/utils/testing/parsers/batch-log-parser.ts +141 -80
- package/src/utils/testing/parsers/traceback-parser.test.ts +64 -0
- package/src/utils/testing/parsers/traceback-parser.ts +122 -0
- package/src/utils/testing/runner/combined-project-generator.ts +1 -1
- package/src/utils/testing/runner/test-runner.test.ts +29 -0
- package/src/utils/testing/runner/test-runner.ts +36 -4
- package/src/utils/testing/test-log-parser.test.ts +70 -0
- package/src/utils/testing/test-log-parser.ts +80 -10
- 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,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
|
+
});
|
|
@@ -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
|
+
}
|