create-harness-vibe-coding 0.8.17 → 0.8.19
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/README-CN.md +2 -0
- package/README.md +3 -0
- package/package.json +5 -2
- package/src/generator.js +613 -97
- package/src/index.js +556 -309
- package/src/prompts.js +18 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
- package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
- package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
- package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
- package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
- package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
- package/src/ui/dist/index.html +18 -0
- package/src/ui/index.html +17 -0
- package/src/ui/package.json +33 -0
- package/src/ui/pnpm-lock.yaml +1582 -0
- package/src/ui/pnpm-workspace.yaml +2 -0
- package/src/ui/src/App.tsx +86 -0
- package/src/ui/src/api.ts +93 -0
- package/src/ui/src/components/AgentsRoute.tsx +503 -0
- package/src/ui/src/components/Footer.tsx +69 -0
- package/src/ui/src/components/Header.tsx +175 -0
- package/src/ui/src/components/LoadingView.tsx +22 -0
- package/src/ui/src/components/RolesRoute.tsx +169 -0
- package/src/ui/src/components/SettingsRoute.tsx +166 -0
- package/src/ui/src/components/TaskList.tsx +388 -0
- package/src/ui/src/components/TerminalDrawer.tsx +611 -0
- package/src/ui/src/components/WorkflowRoute.tsx +670 -0
- package/src/ui/src/hooks/useReducedMotion.ts +17 -0
- package/src/ui/src/hooks/useServerConnection.ts +114 -0
- package/src/ui/src/index.css +281 -0
- package/src/ui/src/main.tsx +11 -0
- package/src/ui/src/types.ts +108 -0
- package/src/ui/tsconfig.json +21 -0
- package/src/ui/vite.config.ts +19 -0
- package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
- package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
- package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
- package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
- package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
- package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
- package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
- package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
- package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
- package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
- package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
- package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
- package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
- package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
- package/src/wf-ui-server/a2a-store.mjs +296 -0
- package/src/wf-ui-server/peer-capsule.mjs +213 -0
- package/src/wf-ui-server/pty-adapter.mjs +155 -0
- package/src/wf-ui-server/runtime-config.mjs +153 -0
- package/src/wf-ui-server/runtime-detector.mjs +374 -0
- package/src/wf-ui-server/security.mjs +67 -0
- package/src/wf-ui-server/server.mjs +849 -0
- package/src/wf-ui-server/session-registry.mjs +204 -0
- package/src/wf-ui-server/settings.mjs +100 -0
- package/src/wf-ui-server/task-parser.mjs +133 -0
- package/src/wf-ui-server/terminal-store.mjs +225 -0
- package/src/wf-ui-server/token.mjs +41 -0
- package/src/wf-ui-server/ws-events.mjs +354 -0
- package/src/wf-ui-server/ws-terminal.mjs +473 -0
- package/templates/common/.claude/commands/wf-command-create.md +58 -0
- package/templates/common/.claude/commands/wf-help.md +5 -0
- package/templates/common/.claude/commands/wf-task-archive.md +26 -0
- package/templates/common/.claude/commands/wf-task-list.md +26 -0
- package/templates/common/.claude/commands/wf-task-record.md +24 -0
- package/templates/common/.claude/commands/wf-ui.md +26 -0
- package/templates/common/.claude/commands/wf-update.md +54 -9
- package/templates/common/.claude/rules/ecc/common.md +1 -1
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
- package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
- package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
- package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
- package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
- package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
- package/templates/common/.harness-version +105 -47
- package/templates/common/.opencode/commands/wf-command-create.md +61 -0
- package/templates/common/.opencode/commands/wf-help.md +5 -0
- package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
- package/templates/common/.opencode/commands/wf-task-list.md +29 -0
- package/templates/common/.opencode/commands/wf-task-record.md +27 -0
- package/templates/common/.opencode/commands/wf-ui.md +26 -0
- package/templates/common/.opencode/commands/wf-update.md +54 -9
- package/templates/common/CLAUDE.md +8 -6
- package/templates/common/Harness/MEMORY.md +11 -0
- package/templates/common/Harness/README.md +21 -38
- package/templates/common/Harness/a2a/role-graph.json +79 -0
- package/templates/common/Harness/a2a/runtime-registry.json +5 -0
- package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
- package/templates/common/Harness/ownership.manifest.json +142 -2
- package/templates/common/Harness/scripts/README.md +134 -0
- package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
- package/templates/common/Harness/scripts/context-budget.mjs +1 -1
- package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
- package/templates/common/Harness/scripts/task-state.mjs +949 -26
- package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
- package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
- package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
- package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
- package/templates/common/Harness/settings.json +35 -0
- package/templates/common/Harness/specs/guides/SETUP.md +8 -0
- package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
- package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
- package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
- package/templates/common/Harness/specs/runtime/subagents.md +6 -0
- package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
- package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
- package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
- package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
- package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
- package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
- package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
- package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
- package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
- package/templates/common/Harness/tasks/_template/STATE.json +6 -0
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
- package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import test, { before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { startServer, stopServer } from '../server.mjs';
|
|
9
|
+
import { attachEventsWs } from '../ws-events.mjs';
|
|
10
|
+
|
|
11
|
+
// ── Minimal WebSocket frame helpers ──
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a single unfragmented text frame from server->client data.
|
|
15
|
+
* Server frames are unmasked. Returns null if incomplete.
|
|
16
|
+
*/
|
|
17
|
+
function tryParseFrame(buf) {
|
|
18
|
+
if (buf.length < 2) return null;
|
|
19
|
+
const opcode = buf[0] & 0x0f;
|
|
20
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
21
|
+
let payloadLen = buf[1] & 0x7f;
|
|
22
|
+
let offset = 2;
|
|
23
|
+
|
|
24
|
+
if (payloadLen === 126) {
|
|
25
|
+
if (buf.length < 4) return null;
|
|
26
|
+
payloadLen = buf.readUInt16BE(2);
|
|
27
|
+
offset = 4;
|
|
28
|
+
} else if (payloadLen === 127) {
|
|
29
|
+
if (buf.length < 10) return null;
|
|
30
|
+
payloadLen = Number(buf.readBigUInt64BE(2));
|
|
31
|
+
offset = 10;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// For testing we only handle unmasked server frames
|
|
35
|
+
if (masked) return null;
|
|
36
|
+
if (buf.length < offset + payloadLen) return null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
opcode,
|
|
40
|
+
payload: buf.slice(offset, offset + payloadLen).toString('utf8'),
|
|
41
|
+
totalLen: offset + payloadLen,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a valid sec-websocket-accept response header value.
|
|
47
|
+
*/
|
|
48
|
+
function computeAccept(key) {
|
|
49
|
+
const hash = crypto.createHash('sha1')
|
|
50
|
+
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
51
|
+
.digest('base64');
|
|
52
|
+
return hash;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sendMaskedControlFrame(sock, opcode, payloadText = '') {
|
|
56
|
+
const data = Buffer.from(payloadText, 'utf8');
|
|
57
|
+
const maskKey = crypto.randomBytes(4);
|
|
58
|
+
const masked = Buffer.allocUnsafe(data.length);
|
|
59
|
+
for (let i = 0; i < data.length; i++) {
|
|
60
|
+
masked[i] = data[i] ^ maskKey[i % 4];
|
|
61
|
+
}
|
|
62
|
+
const header = Buffer.alloc(6);
|
|
63
|
+
header[0] = 0x80 | opcode;
|
|
64
|
+
header[1] = 0x80 | data.length;
|
|
65
|
+
maskKey.copy(header, 2);
|
|
66
|
+
sock.write(Buffer.concat([header, masked]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Connect to a WS endpoint using raw TCP.
|
|
71
|
+
* Returns an object with:
|
|
72
|
+
* - `sock` (net.Socket) — the raw connection
|
|
73
|
+
* - `readMessage(timeout)` — Promise<object> next text frame JSON
|
|
74
|
+
* - `close()` — destroy socket
|
|
75
|
+
* Or for non-101 responses: { status, body }
|
|
76
|
+
*/
|
|
77
|
+
async function wsConnect(port, tokenValue) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const sock = net.createConnection({ host: '127.0.0.1', port }, () => {
|
|
80
|
+
const wsKey = 'dGhlIHNhbXBsZSBub25jZQ==';
|
|
81
|
+
const q = tokenValue !== undefined && tokenValue !== null
|
|
82
|
+
? 'token=' + encodeURIComponent(tokenValue) : '';
|
|
83
|
+
sock.write(
|
|
84
|
+
'GET /ws/events?' + q + ' HTTP/1.1\r\n' +
|
|
85
|
+
'Host: 127.0.0.1\r\n' +
|
|
86
|
+
'Upgrade: websocket\r\n' +
|
|
87
|
+
'Connection: Upgrade\r\n' +
|
|
88
|
+
'Sec-WebSocket-Key: ' + wsKey + '\r\n' +
|
|
89
|
+
'Sec-WebSocket-Version: 13\r\n\r\n'
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let resolved = false;
|
|
94
|
+
const resolveOnce = (v) => { if (!resolved) { resolved = true; resolve(v); } };
|
|
95
|
+
|
|
96
|
+
const timeout = setTimeout(() => {
|
|
97
|
+
sock.destroy();
|
|
98
|
+
resolveOnce({ status: 0, body: 'timeout' });
|
|
99
|
+
}, 10000);
|
|
100
|
+
|
|
101
|
+
// Accumulate all data after the HTTP upgrade response
|
|
102
|
+
let frameBuf = Buffer.alloc(0);
|
|
103
|
+
let httpHeaderDone = false;
|
|
104
|
+
|
|
105
|
+
// Pending readMessage waiters
|
|
106
|
+
let pendingRead = null;
|
|
107
|
+
|
|
108
|
+
sock.on('data', (chunk) => {
|
|
109
|
+
if (!httpHeaderDone) {
|
|
110
|
+
// Still reading the HTTP upgrade response
|
|
111
|
+
frameBuf = Buffer.concat([frameBuf, chunk]);
|
|
112
|
+
const headerEnd = frameBuf.indexOf('\r\n\r\n');
|
|
113
|
+
if (headerEnd === -1) return; // wait for more
|
|
114
|
+
|
|
115
|
+
const header = frameBuf.slice(0, headerEnd).toString();
|
|
116
|
+
const rest = frameBuf.slice(headerEnd + 4);
|
|
117
|
+
const statusCode = parseInt(header.split(' ')[1] || '0', 10);
|
|
118
|
+
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
|
|
121
|
+
if (statusCode === 101) {
|
|
122
|
+
// Successful WS upgrade
|
|
123
|
+
httpHeaderDone = true;
|
|
124
|
+
frameBuf = rest; // remaining data after HTTP headers = WS frames
|
|
125
|
+
|
|
126
|
+
// Build the connection object
|
|
127
|
+
const conn = {
|
|
128
|
+
sock,
|
|
129
|
+
close() { sock.destroy(); },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
conn.readMessage = (ms = 10000) => new Promise((res, rej) => {
|
|
133
|
+
function consumeBuffer() {
|
|
134
|
+
const frame = tryParseFrame(frameBuf);
|
|
135
|
+
if (frame) {
|
|
136
|
+
if (frame.opcode === 0x8) {
|
|
137
|
+
// Close frame
|
|
138
|
+
sock.destroy();
|
|
139
|
+
return rej(new Error('Connection closed'));
|
|
140
|
+
}
|
|
141
|
+
if (frame.opcode === 0x1) {
|
|
142
|
+
frameBuf = frameBuf.slice(frame.totalLen);
|
|
143
|
+
try { res(JSON.parse(frame.payload)); } catch (e) { rej(e); }
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// Unknown/unhandled opcode — skip and try again
|
|
147
|
+
frameBuf = frameBuf.slice(frame.totalLen);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try immediately
|
|
154
|
+
if (consumeBuffer()) return;
|
|
155
|
+
|
|
156
|
+
// Wait for more data
|
|
157
|
+
const tid = setTimeout(() => rej(new Error('timeout')), ms);
|
|
158
|
+
const onData = () => consumeBuffer() && cleanup();
|
|
159
|
+
const onClose = () => { cleanup(); rej(new Error('Connection closed')); };
|
|
160
|
+
const cleanup = () => {
|
|
161
|
+
clearTimeout(tid);
|
|
162
|
+
sock.off('data', onData);
|
|
163
|
+
sock.off('close', onClose);
|
|
164
|
+
};
|
|
165
|
+
sock.on('data', onData);
|
|
166
|
+
sock.on('close', onClose);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
conn.readFrame = (ms = 10000) => new Promise((res, rej) => {
|
|
170
|
+
function consumeBuffer() {
|
|
171
|
+
const frame = tryParseFrame(frameBuf);
|
|
172
|
+
if (frame) {
|
|
173
|
+
frameBuf = frameBuf.slice(frame.totalLen);
|
|
174
|
+
if (frame.opcode === 0x8) {
|
|
175
|
+
sock.destroy();
|
|
176
|
+
return rej(new Error('Connection closed'));
|
|
177
|
+
}
|
|
178
|
+
res(frame);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (consumeBuffer()) return;
|
|
185
|
+
|
|
186
|
+
const tid = setTimeout(() => rej(new Error('timeout')), ms);
|
|
187
|
+
const onData = () => consumeBuffer() && cleanup();
|
|
188
|
+
const onClose = () => { cleanup(); rej(new Error('Connection closed')); };
|
|
189
|
+
const cleanup = () => {
|
|
190
|
+
clearTimeout(tid);
|
|
191
|
+
sock.off('data', onData);
|
|
192
|
+
sock.off('close', onClose);
|
|
193
|
+
};
|
|
194
|
+
sock.on('data', onData);
|
|
195
|
+
sock.on('close', onClose);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
conn.sendPong = () => sendMaskedControlFrame(sock, 0xA);
|
|
199
|
+
|
|
200
|
+
resolveOnce(conn);
|
|
201
|
+
} else {
|
|
202
|
+
sock.destroy();
|
|
203
|
+
resolveOnce({ status: statusCode, body: rest.toString() });
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
// WS data frame after upgrade
|
|
207
|
+
if (pendingRead) {
|
|
208
|
+
const p = pendingRead;
|
|
209
|
+
pendingRead = null;
|
|
210
|
+
// Try to consume on next tick
|
|
211
|
+
// Actually, push to frameBuf and let the readMessage's data handler pick it up
|
|
212
|
+
// But we need to trigger the onData handler...
|
|
213
|
+
}
|
|
214
|
+
// Just accumulate
|
|
215
|
+
frameBuf = Buffer.concat([frameBuf, chunk]);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
sock.on('close', () => {
|
|
220
|
+
clearTimeout(timeout);
|
|
221
|
+
resolveOnce({ status: 0, body: 'closed' });
|
|
222
|
+
});
|
|
223
|
+
sock.on('error', () => { /* ignore ECONNRESET on close */ });
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Send a WebSocket pong frame ──
|
|
228
|
+
function sendPong(sock) {
|
|
229
|
+
// Pong frame: opcode 0xA, FIN=1, mask=0, payload length 0
|
|
230
|
+
const frame = Buffer.alloc(2);
|
|
231
|
+
frame[0] = 0x8A; // FIN + opcode 0xA (pong)
|
|
232
|
+
frame[1] = 0x00; // no payload
|
|
233
|
+
sock.write(frame);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Test globals ──
|
|
237
|
+
|
|
238
|
+
let server;
|
|
239
|
+
let token;
|
|
240
|
+
let port;
|
|
241
|
+
let tempRoot;
|
|
242
|
+
let wsHandle;
|
|
243
|
+
|
|
244
|
+
before(async () => {
|
|
245
|
+
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wf-ui-ws-'));
|
|
246
|
+
const tasksDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-a');
|
|
247
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
248
|
+
fs.writeFileSync(path.join(tasksDir, 'STATE.json'), JSON.stringify({
|
|
249
|
+
schemaVersion: 1, taskId: 'task-test-a', status: 'active', phase: 'test',
|
|
250
|
+
updatedAt: '2026-07-29T00:00:00.000Z',
|
|
251
|
+
acceptance: [],
|
|
252
|
+
links: { dependsOn: [], blocks: [] },
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
const r = await startServer({ projectRoot: tempRoot, host: '127.0.0.1', port: 0 });
|
|
256
|
+
server = r.server;
|
|
257
|
+
token = r.token;
|
|
258
|
+
port = r.port;
|
|
259
|
+
|
|
260
|
+
// Attach WS with short keepalive for testing; suppress logs
|
|
261
|
+
const origLog = console.log;
|
|
262
|
+
console.log = () => {};
|
|
263
|
+
wsHandle = attachEventsWs(server, token, tempRoot, { keepaliveInterval: 2000, pingTimeout: 3000 });
|
|
264
|
+
console.log = origLog;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
after(async () => {
|
|
268
|
+
if (wsHandle) await wsHandle.close();
|
|
269
|
+
if (server) await stopServer(server);
|
|
270
|
+
if (tempRoot) {
|
|
271
|
+
try { fs.rmSync(tempRoot, { recursive: true, force: true }); } catch {}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── Tests ──
|
|
276
|
+
|
|
277
|
+
test('connects with valid token, receives server.connected handshake', async () => {
|
|
278
|
+
const conn = await wsConnect(port, token);
|
|
279
|
+
assert.ok(conn.sock, 'Should establish WS connection');
|
|
280
|
+
assert.ok(conn.sock.writable, 'Socket should be writable');
|
|
281
|
+
assert.ok(typeof conn.readMessage === 'function');
|
|
282
|
+
|
|
283
|
+
const msg = await conn.readMessage(3000);
|
|
284
|
+
assert.equal(msg.type, 'server.connected');
|
|
285
|
+
assert.equal(msg.seq, 0);
|
|
286
|
+
assert.ok(msg.ts, 'Should have timestamp');
|
|
287
|
+
assert.ok(msg.connectionId, 'Should have connectionId');
|
|
288
|
+
|
|
289
|
+
conn.close();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('connection with no token gets rejected', async () => {
|
|
293
|
+
const conn = await wsConnect(port, undefined);
|
|
294
|
+
assert.equal(conn.status, 401, 'No token should get 401');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('connection with invalid token gets rejected', async () => {
|
|
298
|
+
const conn = await wsConnect(port, 'bad-token-1234567890abcdef');
|
|
299
|
+
assert.equal(conn.status, 401, 'Invalid token should get 401');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('writing a STATE.json emits task.updated with incremented seq', async () => {
|
|
303
|
+
const conn = await wsConnect(port, token);
|
|
304
|
+
const handshake = await conn.readMessage(3000);
|
|
305
|
+
assert.equal(handshake.type, 'server.connected');
|
|
306
|
+
|
|
307
|
+
// Write a new STATE.json
|
|
308
|
+
const taskDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-b');
|
|
309
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
310
|
+
fs.writeFileSync(path.join(taskDir, 'STATE.json'), JSON.stringify({
|
|
311
|
+
schemaVersion: 1, taskId: 'task-test-b', status: 'active', phase: 'test',
|
|
312
|
+
updatedAt: '2026-07-29T00:00:00.000Z',
|
|
313
|
+
acceptance: [],
|
|
314
|
+
links: { dependsOn: [], blocks: [] },
|
|
315
|
+
}));
|
|
316
|
+
|
|
317
|
+
const msg = await conn.readMessage(5000);
|
|
318
|
+
assert.equal(msg.type, 'task.updated');
|
|
319
|
+
assert.ok(msg.seq >= 1, `seq ${msg.seq} should be >= 1`);
|
|
320
|
+
assert.ok(msg.ts, 'Should have timestamp');
|
|
321
|
+
assert.equal(msg.taskId, null);
|
|
322
|
+
assert.ok(msg.payload.taskCount >= 2, `taskCount ${msg.payload.taskCount} should be >= 2`);
|
|
323
|
+
|
|
324
|
+
conn.close();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('seq is strictly monotonic across multiple events', async () => {
|
|
328
|
+
const conn = await wsConnect(port, token);
|
|
329
|
+
await conn.readMessage(3000); // drain handshake
|
|
330
|
+
|
|
331
|
+
// Trigger first event
|
|
332
|
+
const taskDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-c');
|
|
333
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
334
|
+
fs.writeFileSync(path.join(taskDir, 'STATE.json'), JSON.stringify({
|
|
335
|
+
schemaVersion: 1, taskId: 'task-test-c', status: 'active', phase: 'test',
|
|
336
|
+
acceptance: [],
|
|
337
|
+
links: { dependsOn: [], blocks: [] },
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
const msg1 = await conn.readMessage(5000);
|
|
341
|
+
assert.equal(msg1.type, 'task.updated');
|
|
342
|
+
|
|
343
|
+
// Trigger second event
|
|
344
|
+
const taskDir2 = path.join(tempRoot, 'Harness', 'tasks', 'task-test-d');
|
|
345
|
+
fs.mkdirSync(taskDir2, { recursive: true });
|
|
346
|
+
fs.writeFileSync(path.join(taskDir2, 'STATE.json'), JSON.stringify({
|
|
347
|
+
schemaVersion: 1, taskId: 'task-test-d', status: 'active', phase: 'test',
|
|
348
|
+
acceptance: [],
|
|
349
|
+
links: { dependsOn: [], blocks: [] },
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
const msg2 = await conn.readMessage(5000);
|
|
353
|
+
assert.equal(msg2.type, 'task.updated');
|
|
354
|
+
assert.ok(msg2.seq > msg1.seq, `seq ${msg2.seq} should be > ${msg1.seq}`);
|
|
355
|
+
|
|
356
|
+
conn.close();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('ping keepalive frame received after idle period', async () => {
|
|
360
|
+
const conn = await wsConnect(port, token);
|
|
361
|
+
await conn.readMessage(3000); // drain handshake
|
|
362
|
+
|
|
363
|
+
// Wait for ping (keepalive is 2000ms, so should arrive within ~4000)
|
|
364
|
+
const frame = await conn.readFrame(10000);
|
|
365
|
+
assert.equal(frame.opcode, 0x9);
|
|
366
|
+
conn.sendPong();
|
|
367
|
+
|
|
368
|
+
conn.close();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('connection re-established after forced close', async () => {
|
|
372
|
+
const conn1 = await wsConnect(port, token);
|
|
373
|
+
await conn1.readMessage(3000); // drain
|
|
374
|
+
|
|
375
|
+
// Don't respond to ping — server should force close after ~5s
|
|
376
|
+
// keepaliveInterval=2000, pingTimeout=3000
|
|
377
|
+
await new Promise(r => setTimeout(r, 7000));
|
|
378
|
+
|
|
379
|
+
// Old connection should be dead
|
|
380
|
+
try {
|
|
381
|
+
await conn1.readMessage(2000);
|
|
382
|
+
assert.fail('Should have thrown');
|
|
383
|
+
} catch {
|
|
384
|
+
// Expected
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Reconnect
|
|
388
|
+
const conn2 = await wsConnect(port, token);
|
|
389
|
+
const msg2 = await conn2.readMessage(3000);
|
|
390
|
+
assert.equal(msg2.type, 'server.connected');
|
|
391
|
+
assert.ok(msg2.seq >= 0, 'reconnect seq should still be >= 0');
|
|
392
|
+
|
|
393
|
+
conn2.close();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('closing WS cleans up client set', async () => {
|
|
397
|
+
const conn1 = await wsConnect(port, token);
|
|
398
|
+
await conn1.readMessage(3000); // drain
|
|
399
|
+
|
|
400
|
+
const conn2 = await wsConnect(port, token);
|
|
401
|
+
await conn2.readMessage(3000); // drain
|
|
402
|
+
|
|
403
|
+
conn1.close();
|
|
404
|
+
await new Promise(r => setTimeout(r, 500));
|
|
405
|
+
|
|
406
|
+
// conn2 should still receive events
|
|
407
|
+
const taskDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-e');
|
|
408
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
409
|
+
fs.writeFileSync(path.join(taskDir, 'STATE.json'), JSON.stringify({
|
|
410
|
+
schemaVersion: 1, taskId: 'task-test-e', status: 'active', phase: 'test',
|
|
411
|
+
acceptance: [],
|
|
412
|
+
links: { dependsOn: [], blocks: [] },
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
const msg = await conn2.readMessage(5000);
|
|
416
|
+
assert.equal(msg.type, 'task.updated');
|
|
417
|
+
|
|
418
|
+
conn2.close();
|
|
419
|
+
});
|