@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,581 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { createServer, type Server } from 'http';
|
|
3
|
+
import { WebSocketServer, type WebSocket } from 'ws';
|
|
4
|
+
import {
|
|
5
|
+
type WatchStreamNotify,
|
|
6
|
+
type WatchStreamReady,
|
|
7
|
+
} from '@quenty/nevermore-deploy';
|
|
8
|
+
import { WebSocketWatchStream } from './websocket-watch-stream.js';
|
|
9
|
+
|
|
10
|
+
const REGISTER_BASE = 'https://watch.example.com/v1/register/30m/';
|
|
11
|
+
|
|
12
|
+
function makeReady(
|
|
13
|
+
overrides: Partial<WatchStreamReady> = {}
|
|
14
|
+
): WatchStreamReady {
|
|
15
|
+
return {
|
|
16
|
+
type: 'ready',
|
|
17
|
+
monitorId: 'mon_1',
|
|
18
|
+
repository: 'Quenty/egg-hunt-2026',
|
|
19
|
+
name: 'egg-hunt/integration/local',
|
|
20
|
+
leaseExpiresAt: '2026-08-01T00:00:00Z',
|
|
21
|
+
serverTime: '2026-07-31T00:00:00Z',
|
|
22
|
+
watches: [],
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Harness {
|
|
28
|
+
url: string;
|
|
29
|
+
/** Every connection the server accepted, with the path and headers it was given. */
|
|
30
|
+
connections: {
|
|
31
|
+
url?: string;
|
|
32
|
+
headers: Record<string, string | string[] | undefined>;
|
|
33
|
+
}[];
|
|
34
|
+
onConnect: (socket: WebSocket, index: number) => void;
|
|
35
|
+
closeAsync: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const harnesses: Harness[] = [];
|
|
39
|
+
const plainServers: Server[] = [];
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
while (harnesses.length > 0) {
|
|
43
|
+
await harnesses.pop()!.closeAsync();
|
|
44
|
+
}
|
|
45
|
+
while (plainServers.length > 0) {
|
|
46
|
+
const server = plainServers.pop()!;
|
|
47
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function startServerAsync(
|
|
52
|
+
onConnect: (socket: WebSocket, index: number) => void
|
|
53
|
+
): Promise<Harness> {
|
|
54
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
55
|
+
await new Promise<void>((resolve) => wss.once('listening', resolve));
|
|
56
|
+
const { port } = wss.address() as { port: number };
|
|
57
|
+
|
|
58
|
+
const harness: Harness = {
|
|
59
|
+
url: `http://127.0.0.1:${port}/v1/register/30m/`,
|
|
60
|
+
connections: [],
|
|
61
|
+
onConnect,
|
|
62
|
+
closeAsync: () =>
|
|
63
|
+
new Promise<void>((resolve) => {
|
|
64
|
+
for (const client of wss.clients) {
|
|
65
|
+
client.terminate();
|
|
66
|
+
}
|
|
67
|
+
wss.close(() => resolve());
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
wss.on('connection', (socket, request) => {
|
|
72
|
+
const index = harness.connections.length;
|
|
73
|
+
harness.connections.push({ url: request.url, headers: request.headers });
|
|
74
|
+
harness.onConnect(socket, index);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
harnesses.push(harness);
|
|
78
|
+
return harness;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An HTTP server that answers the handshake without upgrading, like a proxy that ate it. */
|
|
82
|
+
async function startPlainServerAsync(
|
|
83
|
+
status: number,
|
|
84
|
+
body: unknown
|
|
85
|
+
): Promise<{ url: string; requests: number }> {
|
|
86
|
+
const state = { url: '', requests: 0 };
|
|
87
|
+
const server = createServer((_req, res) => {
|
|
88
|
+
state.requests++;
|
|
89
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify(body));
|
|
91
|
+
});
|
|
92
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
93
|
+
const { port } = server.address() as { port: number };
|
|
94
|
+
state.url = `http://127.0.0.1:${port}/v1/register/30m/`;
|
|
95
|
+
plainServers.push(server);
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function noopHandlers() {
|
|
100
|
+
return {
|
|
101
|
+
onReadyAsync: async () => {},
|
|
102
|
+
onNotifyAsync: async () => {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('WebSocketWatchStream URL derivation', () => {
|
|
107
|
+
// The register endpoint is the only place a service address is configured —
|
|
108
|
+
// this repo is public and must never carry one — so the stream URL has to be
|
|
109
|
+
// derived from it rather than configured a second time.
|
|
110
|
+
it('derives the stream path from the register endpoint', async () => {
|
|
111
|
+
const seen: string[] = [];
|
|
112
|
+
const stream = new WebSocketWatchStream({
|
|
113
|
+
registerUrl: REGISTER_BASE,
|
|
114
|
+
connect: (url) => {
|
|
115
|
+
seen.push(url);
|
|
116
|
+
throw new Error('stop connecting');
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await expect(
|
|
121
|
+
stream.runAsync({
|
|
122
|
+
monitorId: 'mon_abc',
|
|
123
|
+
credential: 'token',
|
|
124
|
+
signal: new AbortController().signal,
|
|
125
|
+
handlers: noopHandlers(),
|
|
126
|
+
})
|
|
127
|
+
).rejects.toThrowError(/stop connecting/);
|
|
128
|
+
|
|
129
|
+
expect(seen).toEqual([
|
|
130
|
+
'wss://watch.example.com/v1/monitors/mon_abc/stream',
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('speaks ws over a plaintext endpoint', async () => {
|
|
135
|
+
const seen: string[] = [];
|
|
136
|
+
const stream = new WebSocketWatchStream({
|
|
137
|
+
registerUrl: 'http://localhost:3002/v1/register/30m/',
|
|
138
|
+
connect: (url) => {
|
|
139
|
+
seen.push(url);
|
|
140
|
+
throw new Error('stop connecting');
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await expect(
|
|
145
|
+
stream.runAsync({
|
|
146
|
+
monitorId: 'mon_abc',
|
|
147
|
+
credential: 'token',
|
|
148
|
+
signal: new AbortController().signal,
|
|
149
|
+
handlers: noopHandlers(),
|
|
150
|
+
})
|
|
151
|
+
).rejects.toThrowError(/stop connecting/);
|
|
152
|
+
|
|
153
|
+
expect(seen).toEqual(['ws://localhost:3002/v1/monitors/mon_abc/stream']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('rejects an endpoint that is not a register URL', async () => {
|
|
157
|
+
const stream = new WebSocketWatchStream({
|
|
158
|
+
registerUrl: 'https://watch.example.com/elsewhere/30m/',
|
|
159
|
+
connect: () => {
|
|
160
|
+
throw new Error('should not connect');
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await expect(
|
|
165
|
+
stream.runAsync({
|
|
166
|
+
monitorId: 'mon_abc',
|
|
167
|
+
credential: 'token',
|
|
168
|
+
signal: new AbortController().signal,
|
|
169
|
+
handlers: noopHandlers(),
|
|
170
|
+
})
|
|
171
|
+
).rejects.toThrowError(/expected its path to contain/);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('WebSocketWatchStream', () => {
|
|
176
|
+
it('connects with a bearer header and no token in the URL', async () => {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const harness = await startServerAsync((socket) => {
|
|
179
|
+
socket.send(JSON.stringify(makeReady()));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
183
|
+
const ready: WatchStreamReady[] = [];
|
|
184
|
+
|
|
185
|
+
const run = stream.runAsync({
|
|
186
|
+
monitorId: 'mon_abc',
|
|
187
|
+
credential: 'ghp_secret',
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
handlers: {
|
|
190
|
+
onReadyAsync: async (message) => {
|
|
191
|
+
ready.push(message);
|
|
192
|
+
controller.abort();
|
|
193
|
+
},
|
|
194
|
+
onNotifyAsync: async () => {},
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await run;
|
|
199
|
+
|
|
200
|
+
expect(ready).toHaveLength(1);
|
|
201
|
+
expect(result.ending).toBe('aborted');
|
|
202
|
+
expect(harness.connections[0]!.headers['authorization']).toBe(
|
|
203
|
+
'Bearer ghp_secret'
|
|
204
|
+
);
|
|
205
|
+
// Never as a query parameter: a PAT in a URL lands in every access log
|
|
206
|
+
// between here and the service.
|
|
207
|
+
expect(harness.connections[0]!.url).toBe('/v1/monitors/mon_abc/stream');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('delivers notifications and counts them', async () => {
|
|
211
|
+
const controller = new AbortController();
|
|
212
|
+
const harness = await startServerAsync((socket) => {
|
|
213
|
+
socket.send(JSON.stringify(makeReady()));
|
|
214
|
+
socket.send(
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
type: 'notify',
|
|
217
|
+
watchName: 'hub',
|
|
218
|
+
sourceId: 'src_1',
|
|
219
|
+
version: '159',
|
|
220
|
+
previousVersion: '158',
|
|
221
|
+
payload: { target: 'integration.places.hub' },
|
|
222
|
+
at: '2026-07-31T00:00:00Z',
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
228
|
+
const notifications: WatchStreamNotify[] = [];
|
|
229
|
+
|
|
230
|
+
const result = await stream.runAsync({
|
|
231
|
+
monitorId: 'mon_abc',
|
|
232
|
+
credential: 'token',
|
|
233
|
+
signal: controller.signal,
|
|
234
|
+
handlers: {
|
|
235
|
+
onReadyAsync: async () => {},
|
|
236
|
+
onNotifyAsync: async (message) => {
|
|
237
|
+
notifications.push(message);
|
|
238
|
+
controller.abort();
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(notifications[0]!.version).toBe('159');
|
|
244
|
+
expect(notifications[0]!.payload).toEqual({
|
|
245
|
+
target: 'integration.places.hub',
|
|
246
|
+
});
|
|
247
|
+
expect(result.notifications).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Reconnection lives inside the stream precisely so a caller cannot skip the
|
|
251
|
+
// reconcile: every connection, including a retry, opens with `ready`.
|
|
252
|
+
it('reconnects after a drop and re-delivers ready', async () => {
|
|
253
|
+
const controller = new AbortController();
|
|
254
|
+
const harness = await startServerAsync((socket, index) => {
|
|
255
|
+
socket.send(JSON.stringify(makeReady()));
|
|
256
|
+
if (index === 0) {
|
|
257
|
+
socket.close();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
262
|
+
let readyCount = 0;
|
|
263
|
+
|
|
264
|
+
const result = await stream.runAsync({
|
|
265
|
+
monitorId: 'mon_abc',
|
|
266
|
+
credential: 'token',
|
|
267
|
+
signal: controller.signal,
|
|
268
|
+
handlers: {
|
|
269
|
+
onReadyAsync: async () => {
|
|
270
|
+
readyCount++;
|
|
271
|
+
if (readyCount === 2) {
|
|
272
|
+
controller.abort();
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
onNotifyAsync: async () => {},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(readyCount).toBe(2);
|
|
280
|
+
expect(result.reconnects).toBe(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// A lease that lapsed is a finished watch, not a fault: retrying the socket
|
|
284
|
+
// would fail forever against a monitor that no longer exists.
|
|
285
|
+
it('stops rather than retrying when the monitor is gone', async () => {
|
|
286
|
+
const harness = await startServerAsync((socket) => {
|
|
287
|
+
socket.close(4004, 'monitor released');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
291
|
+
const result = await stream.runAsync({
|
|
292
|
+
monitorId: 'mon_abc',
|
|
293
|
+
credential: 'token',
|
|
294
|
+
signal: new AbortController().signal,
|
|
295
|
+
handlers: noopHandlers(),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(result.ending).toBe('monitor-gone');
|
|
299
|
+
expect(harness.connections).toHaveLength(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// A websocket that fails to upgrade reports only "Unexpected server response:
|
|
303
|
+
// <status>" — the same sentence for a stripped Upgrade header and a dead
|
|
304
|
+
// token. Observed live: nginx in front of the service was not forwarding the
|
|
305
|
+
// upgrade, and the client retried forever saying nothing useful.
|
|
306
|
+
it('explains a 426 as a proxy stripping the upgrade, and keeps retrying', async () => {
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
const server = await startPlainServerAsync(426, {
|
|
309
|
+
error: 'This endpoint is a websocket.',
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const stream = new WebSocketWatchStream({ registerUrl: server.url });
|
|
313
|
+
const reasons: string[] = [];
|
|
314
|
+
|
|
315
|
+
const result = await stream.runAsync({
|
|
316
|
+
monitorId: 'mon_abc',
|
|
317
|
+
credential: 'token',
|
|
318
|
+
signal: controller.signal,
|
|
319
|
+
handlers: {
|
|
320
|
+
...noopHandlers(),
|
|
321
|
+
onReconnect: (_attempt, reason) => {
|
|
322
|
+
reasons.push(reason);
|
|
323
|
+
controller.abort();
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(reasons[0]).toMatch(/Upgrade header/);
|
|
329
|
+
expect(reasons[0]).toMatch(
|
|
330
|
+
/proxy configuration problem on the service side/
|
|
331
|
+
);
|
|
332
|
+
// Not permanent: a proxy fix should be picked up without restarting.
|
|
333
|
+
expect(result.ending).toBe('aborted');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it.each([
|
|
337
|
+
[401, /NEVERMORE_WATCH_TOKEN/],
|
|
338
|
+
[404, /cannot write to the/],
|
|
339
|
+
])(
|
|
340
|
+
'stops on a %i rather than retrying a dead token',
|
|
341
|
+
async (status, match) => {
|
|
342
|
+
const server = await startPlainServerAsync(status, { error: 'nope' });
|
|
343
|
+
|
|
344
|
+
const stream = new WebSocketWatchStream({ registerUrl: server.url });
|
|
345
|
+
const result = await stream.runAsync({
|
|
346
|
+
monitorId: 'mon_abc',
|
|
347
|
+
credential: 'token',
|
|
348
|
+
signal: new AbortController().signal,
|
|
349
|
+
handlers: noopHandlers(),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(result.ending).toBe('rejected');
|
|
353
|
+
expect(result.reason).toMatch(match);
|
|
354
|
+
expect(server.requests).toBe(1);
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// Reproduced before the fix: a throwing handler left runAsync unresolved and
|
|
359
|
+
// raised an unhandled rejection, which under Node's default kills the CLI.
|
|
360
|
+
// The watch has to outlive a bad handler, not hang on one.
|
|
361
|
+
it('survives a handler that throws, and keeps delivering', async () => {
|
|
362
|
+
const controller = new AbortController();
|
|
363
|
+
const harness = await startServerAsync((socket) => {
|
|
364
|
+
socket.send(JSON.stringify(makeReady()));
|
|
365
|
+
for (const version of ['159', '160']) {
|
|
366
|
+
socket.send(
|
|
367
|
+
JSON.stringify({
|
|
368
|
+
type: 'notify',
|
|
369
|
+
watchName: 'hub',
|
|
370
|
+
sourceId: 'src_1',
|
|
371
|
+
version,
|
|
372
|
+
previousVersion: null,
|
|
373
|
+
payload: {},
|
|
374
|
+
at: '2026-07-31T00:00:00Z',
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
381
|
+
const seen: string[] = [];
|
|
382
|
+
const errors: Error[] = [];
|
|
383
|
+
|
|
384
|
+
const result = await stream.runAsync({
|
|
385
|
+
monitorId: 'mon_abc',
|
|
386
|
+
credential: 'token',
|
|
387
|
+
signal: controller.signal,
|
|
388
|
+
handlers: {
|
|
389
|
+
onReadyAsync: async () => {
|
|
390
|
+
throw new Error('ready handler exploded');
|
|
391
|
+
},
|
|
392
|
+
onNotifyAsync: async (message) => {
|
|
393
|
+
seen.push(message.version!);
|
|
394
|
+
if (seen.length === 2) {
|
|
395
|
+
controller.abort();
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
onHandlerError: (err) => errors.push(err),
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// A rejected tail must not swallow every later frame.
|
|
403
|
+
expect(seen).toEqual(['159', '160']);
|
|
404
|
+
expect(errors.map((e) => e.message)).toEqual(['ready handler exploded']);
|
|
405
|
+
expect(result.ending).toBe('aborted');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Reproduced before the fix at ~94 connections a second: a server that sends
|
|
409
|
+
// `ready` then closes 1001 was reconnected to with zero delay and no backoff,
|
|
410
|
+
// aiming a stampede at something already failing.
|
|
411
|
+
it('does not hot-loop against a server that greets then closes', async () => {
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const harness = await startServerAsync((socket) => {
|
|
414
|
+
socket.send(JSON.stringify(makeReady()));
|
|
415
|
+
socket.close(1001, 'Server shutting down');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
419
|
+
setTimeout(() => controller.abort(), 1200);
|
|
420
|
+
|
|
421
|
+
await stream.runAsync({
|
|
422
|
+
monitorId: 'mon_abc',
|
|
423
|
+
credential: 'token',
|
|
424
|
+
signal: controller.signal,
|
|
425
|
+
handlers: noopHandlers(),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Without the floor and the minimum-lifetime gate this ran into the
|
|
429
|
+
// hundreds; a handful over a second is the whole point.
|
|
430
|
+
expect(harness.connections.length).toBeLessThan(12);
|
|
431
|
+
expect(harness.connections.length).toBeGreaterThan(1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// The service retires a connection about hourly so reconnecting re-verifies
|
|
435
|
+
// the token. Routine by design — warning about it every hour trains people to
|
|
436
|
+
// ignore the warnings that matter.
|
|
437
|
+
it('reconnects quietly after a 4005 retirement', async () => {
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const harness = await startServerAsync((socket, index) => {
|
|
440
|
+
socket.send(JSON.stringify(makeReady()));
|
|
441
|
+
if (index === 0) {
|
|
442
|
+
socket.close(4005, 'Reconnect to re-authorize');
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Treat any connection that got its `ready` frame as healthy, so the
|
|
447
|
+
// retirement reads as the scheduled one it is rather than as a flap.
|
|
448
|
+
const stream = new WebSocketWatchStream({
|
|
449
|
+
registerUrl: harness.url,
|
|
450
|
+
healthyConnectionMs: 0,
|
|
451
|
+
});
|
|
452
|
+
const reported: string[] = [];
|
|
453
|
+
let readyCount = 0;
|
|
454
|
+
|
|
455
|
+
const result = await stream.runAsync({
|
|
456
|
+
monitorId: 'mon_abc',
|
|
457
|
+
credential: 'token',
|
|
458
|
+
signal: controller.signal,
|
|
459
|
+
handlers: {
|
|
460
|
+
onReadyAsync: async () => {
|
|
461
|
+
readyCount++;
|
|
462
|
+
if (readyCount === 2) {
|
|
463
|
+
controller.abort();
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
onNotifyAsync: async () => {},
|
|
467
|
+
onReconnect: (_attempt, reason) => reported.push(reason),
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(readyCount).toBe(2);
|
|
472
|
+
expect(reported).toEqual([]);
|
|
473
|
+
expect(result.reconnects).toBe(1);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Suppressing 4005 unconditionally would hide a server stuck emitting it —
|
|
477
|
+
// the client would reconnect forever while the developer saw nothing.
|
|
478
|
+
it('does report a retirement that arrives on a short-lived connection', async () => {
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
const harness = await startServerAsync((socket) => {
|
|
481
|
+
socket.send(JSON.stringify(makeReady()));
|
|
482
|
+
socket.close(4005, 'Reconnect to re-authorize');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
486
|
+
const reported: string[] = [];
|
|
487
|
+
|
|
488
|
+
setTimeout(() => controller.abort(), 900);
|
|
489
|
+
await stream.runAsync({
|
|
490
|
+
monitorId: 'mon_abc',
|
|
491
|
+
credential: 'token',
|
|
492
|
+
signal: controller.signal,
|
|
493
|
+
handlers: {
|
|
494
|
+
...noopHandlers(),
|
|
495
|
+
onReconnect: (_attempt, reason) => reported.push(reason),
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(reported.length).toBeGreaterThan(0);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// A frame from a newer service is not a reason to drop a working connection.
|
|
503
|
+
it('ignores a frame it cannot parse or does not know', async () => {
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
const harness = await startServerAsync((socket) => {
|
|
506
|
+
socket.send('not json at all');
|
|
507
|
+
socket.send(JSON.stringify({ type: 'something-new', value: 1 }));
|
|
508
|
+
socket.send(JSON.stringify(makeReady()));
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
512
|
+
let readyCount = 0;
|
|
513
|
+
|
|
514
|
+
const result = await stream.runAsync({
|
|
515
|
+
monitorId: 'mon_abc',
|
|
516
|
+
credential: 'token',
|
|
517
|
+
signal: controller.signal,
|
|
518
|
+
handlers: {
|
|
519
|
+
onReadyAsync: async () => {
|
|
520
|
+
readyCount++;
|
|
521
|
+
controller.abort();
|
|
522
|
+
},
|
|
523
|
+
onNotifyAsync: async () => {},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(readyCount).toBe(1);
|
|
528
|
+
expect(result.ending).toBe('aborted');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Messages arrive whenever they arrive; two rebuilds of the same place racing
|
|
532
|
+
// each other's uploads is the failure this prevents.
|
|
533
|
+
it('runs handlers one at a time', async () => {
|
|
534
|
+
const controller = new AbortController();
|
|
535
|
+
const harness = await startServerAsync((socket) => {
|
|
536
|
+
socket.send(JSON.stringify(makeReady()));
|
|
537
|
+
for (const version of ['159', '160']) {
|
|
538
|
+
socket.send(
|
|
539
|
+
JSON.stringify({
|
|
540
|
+
type: 'notify',
|
|
541
|
+
watchName: 'hub',
|
|
542
|
+
sourceId: 'src_1',
|
|
543
|
+
version,
|
|
544
|
+
previousVersion: null,
|
|
545
|
+
payload: {},
|
|
546
|
+
at: '2026-07-31T00:00:00Z',
|
|
547
|
+
})
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const stream = new WebSocketWatchStream({ registerUrl: harness.url });
|
|
553
|
+
const order: string[] = [];
|
|
554
|
+
let inFlight = 0;
|
|
555
|
+
let overlapped = false;
|
|
556
|
+
|
|
557
|
+
await stream.runAsync({
|
|
558
|
+
monitorId: 'mon_abc',
|
|
559
|
+
credential: 'token',
|
|
560
|
+
signal: controller.signal,
|
|
561
|
+
handlers: {
|
|
562
|
+
onReadyAsync: async () => {},
|
|
563
|
+
onNotifyAsync: async (message) => {
|
|
564
|
+
inFlight++;
|
|
565
|
+
if (inFlight > 1) {
|
|
566
|
+
overlapped = true;
|
|
567
|
+
}
|
|
568
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
569
|
+
order.push(message.version!);
|
|
570
|
+
inFlight--;
|
|
571
|
+
if (order.length === 2) {
|
|
572
|
+
controller.abort();
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
expect(overlapped).toBe(false);
|
|
579
|
+
expect(order).toEqual(['159', '160']);
|
|
580
|
+
});
|
|
581
|
+
});
|