patchwarden 0.6.0 → 0.6.4
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/PatchWarden-Control-Tray.cmd +11 -0
- package/PatchWarden-Control.cmd +6 -0
- package/PatchWarden-Desktop.cmd +5 -0
- package/PatchWarden.cmd +1 -1
- package/README.en.md +106 -18
- package/README.md +30 -19
- package/Restart-PatchWarden-Control.cmd +6 -0
- package/Stop-PatchWarden.cmd +11 -0
- package/dist/controlCenter.d.ts +14 -0
- package/dist/controlCenter.js +2002 -0
- package/dist/doctor.js +4 -4
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/changeCapture.d.ts +41 -0
- package/dist/runner/changeCapture.js +171 -1
- package/dist/runner/runTask.js +204 -24
- package/dist/smoke-test.js +8 -8
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/auditTask.js +11 -5
- package/dist/tools/getTaskSummary.d.ts +3 -0
- package/dist/tools/getTaskSummary.js +15 -1
- package/dist/tools/healthCheck.d.ts +5 -0
- package/dist/tools/healthCheck.js +21 -0
- package/dist/tools/registry.js +53 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +2 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/control-center/README.md +33 -0
- package/docs/control-center/control-center-daily-driver.md +211 -0
- package/docs/control-center/control-center-mvp.md +205 -0
- package/docs/control-center/control-center-phase2.md +159 -0
- package/docs/demo.md +3 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-v0.6.1.md +75 -0
- package/docs/release-v0.6.4.md +45 -0
- package/examples/openai-tunnel/README.md +5 -5
- package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
- package/package.json +23 -14
- package/scripts/README.md +47 -0
- package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
- package/scripts/checks/control-center-smoke.js +1098 -0
- package/scripts/{control-smoke.js → checks/control-smoke.js} +17 -3
- package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +1 -1
- package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +12 -4
- package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +347 -13
- package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +32 -9
- package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +14 -9
- package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
- package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
- package/scripts/checks/unit-tests.js +36 -0
- package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +11 -5
- package/scripts/control/control-center-tray.ps1 +281 -0
- package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
- package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
- package/scripts/control/restart-control-center.ps1 +173 -0
- package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
- package/scripts/control/start-control-center.ps1 +263 -0
- package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
- package/scripts/control/stop-patchwarden.ps1 +114 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
- package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
- package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
- package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
- package/scripts/release/pack-clean.js +305 -0
- package/src/controlCenter.ts +2152 -0
- package/src/doctor.ts +5 -5
- package/src/logging.ts +152 -0
- package/src/runner/changeCapture.ts +212 -1
- package/src/runner/runTask.ts +220 -22
- package/src/smoke-test.ts +5 -5
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/auditTask.ts +11 -5
- package/src/tools/getTaskSummary.ts +22 -1
- package/src/tools/healthCheck.ts +22 -0
- package/src/tools/registry.ts +63 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/toolCatalog.ts +2 -0
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/ui/colors_and_type.css +141 -0
- package/ui/pages/audit.html +743 -0
- package/ui/pages/dashboard.html +1154 -0
- package/ui/pages/direct-sessions.html +652 -0
- package/ui/pages/logs.html +502 -0
- package/ui/pages/task-detail.html +1229 -0
- package/ui/pages/tasks.html +702 -0
- package/ui/pages/workspace.html +947 -0
- package/ui/partials/project-shell.html +362 -0
- package/ui/vendor/lucide.js +12 -0
- package/ui/vendor/tailwindcss-browser.js +947 -0
- package/scripts/pack-clean.js +0 -141
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PatchWarden Control Center — smoke test (test mode).
|
|
4
|
+
*
|
|
5
|
+
* Strict invariants:
|
|
6
|
+
* - Does NOT open a browser.
|
|
7
|
+
* - Does NOT call manage-patchwarden.ps1 for real start/stop operations.
|
|
8
|
+
* - Does NOT kill any process other than the controlCenter child it spawned.
|
|
9
|
+
* - Uses test port 18090 via PATCHWARDEN_CONTROL_PORT.
|
|
10
|
+
* - Always shuts down the spawned server (even on failure).
|
|
11
|
+
*
|
|
12
|
+
* Run: node scripts/checks/control-center-smoke.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn, execSync } from "node:child_process";
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const projectRoot = join(__dirname, "..", "..");
|
|
23
|
+
const serverPath = join(projectRoot, "dist", "controlCenter.js");
|
|
24
|
+
const uiRoot = join(projectRoot, "ui");
|
|
25
|
+
const trayScriptPath = join(projectRoot, "scripts", "control", "control-center-tray.ps1");
|
|
26
|
+
|
|
27
|
+
const TEST_PORT = 18090;
|
|
28
|
+
const BASE_URL = `http://127.0.0.1:${TEST_PORT}`;
|
|
29
|
+
// Redirect control-center status/events/log files into a project-local temp
|
|
30
|
+
// dir so the smoke test can write them even when LOCALAPPDATA is restricted
|
|
31
|
+
// by the dev sandbox. Cleaned up on exit.
|
|
32
|
+
const TEST_LOG_DIR = join(projectRoot, ".tmp", "control-center-smoke");
|
|
33
|
+
try { mkdirSync(TEST_LOG_DIR, { recursive: true }); } catch { /* ignore */ }
|
|
34
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
35
|
+
const READY_POLL_MS = 500;
|
|
36
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
37
|
+
|
|
38
|
+
let child = null;
|
|
39
|
+
let childExitedEarly = false;
|
|
40
|
+
let childExitInfo = null;
|
|
41
|
+
let childStderr = "";
|
|
42
|
+
let childStdout = "";
|
|
43
|
+
|
|
44
|
+
const results = [];
|
|
45
|
+
let passed = 0;
|
|
46
|
+
let failed = 0;
|
|
47
|
+
|
|
48
|
+
function record(name, ok, detail) {
|
|
49
|
+
const tag = ok ? "[PASS]" : "[FAIL]";
|
|
50
|
+
results.push({ name, ok, detail });
|
|
51
|
+
if (ok) {
|
|
52
|
+
passed++;
|
|
53
|
+
console.log(`${tag} ${name}`);
|
|
54
|
+
} else {
|
|
55
|
+
failed++;
|
|
56
|
+
console.log(`${tag} ${name}`);
|
|
57
|
+
if (detail) {
|
|
58
|
+
console.log(` ${detail}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cleanup() {
|
|
64
|
+
if (child && !child.killed && child.exitCode === null && child.signalCode === null) {
|
|
65
|
+
try {
|
|
66
|
+
child.kill("SIGTERM");
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
72
|
+
try {
|
|
73
|
+
child.kill("SIGKILL");
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, 2000);
|
|
79
|
+
}
|
|
80
|
+
// Best-effort cleanup of the project-local test log dir.
|
|
81
|
+
try {
|
|
82
|
+
if (existsSync(TEST_LOG_DIR)) rmSync(TEST_LOG_DIR, { recursive: true, force: true });
|
|
83
|
+
} catch {
|
|
84
|
+
/* ignore */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function waitForChildExit(timeoutMs = 5000) {
|
|
89
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let done = false;
|
|
94
|
+
const finish = () => {
|
|
95
|
+
if (done) return;
|
|
96
|
+
done = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve();
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
102
|
+
try {
|
|
103
|
+
child.kill("SIGKILL");
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finish();
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
child.once("exit", finish);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.on("exit", cleanup);
|
|
115
|
+
process.on("SIGINT", () => {
|
|
116
|
+
cleanup();
|
|
117
|
+
process.exit(130);
|
|
118
|
+
});
|
|
119
|
+
process.on("SIGTERM", () => {
|
|
120
|
+
cleanup();
|
|
121
|
+
process.exit(143);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function httpGet(url) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const req = http.get(url, (res) => {
|
|
127
|
+
let data = "";
|
|
128
|
+
res.on("data", (chunk) => (data += chunk));
|
|
129
|
+
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
|
|
130
|
+
});
|
|
131
|
+
req.on("error", reject);
|
|
132
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error("timeout")));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function httpPost(url, headers = {}) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const parsed = new URL(url);
|
|
139
|
+
const req = http.request(
|
|
140
|
+
{
|
|
141
|
+
hostname: parsed.hostname,
|
|
142
|
+
port: parsed.port,
|
|
143
|
+
path: parsed.pathname,
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
},
|
|
147
|
+
(res) => {
|
|
148
|
+
let data = "";
|
|
149
|
+
res.on("data", (chunk) => (data += chunk));
|
|
150
|
+
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
req.on("error", reject);
|
|
154
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error("timeout")));
|
|
155
|
+
req.end();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function waitForServer() {
|
|
160
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
161
|
+
let lastErr = null;
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
if (childExitedEarly) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`controlCenter exited before ready${childExitInfo ? ` (code=${childExitInfo.code}, signal=${childExitInfo.signal})` : ""}\nstderr:\n${childStderr}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const res = await httpGet(`${BASE_URL}/api/status`);
|
|
170
|
+
if (res && typeof res.status === "number") {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
lastErr = err;
|
|
175
|
+
}
|
|
176
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
177
|
+
}
|
|
178
|
+
throw new Error(
|
|
179
|
+
`controlCenter not ready within ${READY_TIMEOUT_MS}ms (last error: ${lastErr ? lastErr.message : "n/a"})\nstderr:\n${childStderr}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function tryJson(body) {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(body);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isObject(v) {
|
|
192
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readHtmlFiles(dir) {
|
|
196
|
+
const out = [];
|
|
197
|
+
if (!existsSync(dir)) return out;
|
|
198
|
+
let entries;
|
|
199
|
+
try {
|
|
200
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
201
|
+
} catch {
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
if (!e.isFile()) continue;
|
|
206
|
+
if (!e.name.toLowerCase().endsWith(".html")) continue;
|
|
207
|
+
const full = join(dir, e.name);
|
|
208
|
+
try {
|
|
209
|
+
out.push({ path: full, content: readFileSync(full, "utf-8") });
|
|
210
|
+
} catch {
|
|
211
|
+
/* ignore unreadable */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Test 1: Static file serving ─────────────────────────────────
|
|
218
|
+
async function testStaticFiles() {
|
|
219
|
+
const name = "Test 1: static file serving";
|
|
220
|
+
try {
|
|
221
|
+
const root = await httpGet(`${BASE_URL}/`);
|
|
222
|
+
const vendor = await httpGet(`${BASE_URL}/vendor/tailwindcss-browser.js`);
|
|
223
|
+
const css = await httpGet(`${BASE_URL}/colors_and_type.css`);
|
|
224
|
+
const favicon = await httpGet(`${BASE_URL}/favicon.ico`);
|
|
225
|
+
|
|
226
|
+
const checks = [];
|
|
227
|
+
if (root.status !== 200) checks.push(`GET / -> status ${root.status} (expected 200)`);
|
|
228
|
+
const rootCt = root.headers["content-type"] || "";
|
|
229
|
+
if (!rootCt.includes("text/html")) checks.push(`GET / -> Content-Type ${rootCt} (expected text/html)`);
|
|
230
|
+
if (!root.body.includes("setup-checklist-card")) checks.push("dashboard missing setup checklist card");
|
|
231
|
+
if (!root.body.includes("Show Core / Direct log tails")) checks.push("dashboard activity log is not collapsed behind a summary");
|
|
232
|
+
|
|
233
|
+
if (vendor.status !== 200) checks.push(`GET /vendor/tailwindcss-browser.js -> status ${vendor.status}`);
|
|
234
|
+
const vendorCt = vendor.headers["content-type"] || "";
|
|
235
|
+
if (!vendorCt.includes("javascript")) checks.push(`GET /vendor/tailwindcss-browser.js -> Content-Type ${vendorCt}`);
|
|
236
|
+
|
|
237
|
+
if (css.status !== 200) checks.push(`GET /colors_and_type.css -> status ${css.status}`);
|
|
238
|
+
const cssCt = css.headers["content-type"] || "";
|
|
239
|
+
if (!cssCt.includes("text/css")) checks.push(`GET /colors_and_type.css -> Content-Type ${cssCt}`);
|
|
240
|
+
|
|
241
|
+
if (favicon.status !== 200) checks.push(`GET /favicon.ico -> status ${favicon.status}`);
|
|
242
|
+
const faviconCt = favicon.headers["content-type"] || "";
|
|
243
|
+
if (!faviconCt.includes("image/svg+xml")) checks.push(`GET /favicon.ico -> Content-Type ${faviconCt}`);
|
|
244
|
+
|
|
245
|
+
if (checks.length === 0) {
|
|
246
|
+
record(name, true);
|
|
247
|
+
} else {
|
|
248
|
+
record(name, false, checks.join("; "));
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
record(name, false, `error: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Test 2: /api/status returns valid JSON with required fields ─
|
|
256
|
+
async function testPageNavigationRoutes() {
|
|
257
|
+
const name = "Test 1b: page navigation routes and sidebar hrefs work";
|
|
258
|
+
const problems = [];
|
|
259
|
+
const routes = [
|
|
260
|
+
"/dashboard.html",
|
|
261
|
+
"/tasks.html",
|
|
262
|
+
"/workspace.html",
|
|
263
|
+
"/audit.html",
|
|
264
|
+
"/task-detail.html?id=smoke",
|
|
265
|
+
"/pages/dashboard.html",
|
|
266
|
+
"/pages/tasks.html",
|
|
267
|
+
"/pages/workspace.html",
|
|
268
|
+
"/pages/audit.html",
|
|
269
|
+
"/pages/task-detail.html?id=smoke",
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
for (const route of routes) {
|
|
273
|
+
try {
|
|
274
|
+
const res = await httpGet(`${BASE_URL}${route}`);
|
|
275
|
+
const contentType = res.headers["content-type"] || "";
|
|
276
|
+
if (res.status !== 200) {
|
|
277
|
+
problems.push(`GET ${route} -> status ${res.status} (expected 200)`);
|
|
278
|
+
} else if (!contentType.includes("text/html")) {
|
|
279
|
+
problems.push(`GET ${route} -> Content-Type ${contentType} (expected text/html)`);
|
|
280
|
+
} else if (!res.body.includes("PatchWarden")) {
|
|
281
|
+
problems.push(`GET ${route} returned HTML without PatchWarden page identity`);
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
problems.push(`GET ${route} error: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const htmlFiles = [
|
|
289
|
+
...readHtmlFiles(join(uiRoot, "pages")),
|
|
290
|
+
...readHtmlFiles(join(uiRoot, "partials")),
|
|
291
|
+
];
|
|
292
|
+
for (const f of htmlFiles) {
|
|
293
|
+
if (/href="#"\s+data-nav-key=/.test(f.content)) {
|
|
294
|
+
problems.push(`sidebar nav placeholder href remains in ${f.path}`);
|
|
295
|
+
}
|
|
296
|
+
if (/href="(?:dashboard|tasks|workspace|audit|task-detail)\.html/.test(f.content)) {
|
|
297
|
+
problems.push(`relative page href remains in ${f.path}`);
|
|
298
|
+
}
|
|
299
|
+
if (/(?:href|detailHref|const href|var detailHref)\s*=\s*['"](?:dashboard|tasks|workspace|audit|task-detail)\.html/.test(f.content)) {
|
|
300
|
+
problems.push(`relative script page target remains in ${f.path}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (problems.length === 0) {
|
|
305
|
+
record(name, true);
|
|
306
|
+
} else {
|
|
307
|
+
record(name, false, problems.join("; "));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function testStatusJson() {
|
|
312
|
+
const name = "Test 2: /api/status returns valid JSON and is fault-tolerant";
|
|
313
|
+
try {
|
|
314
|
+
const res = await httpGet(`${BASE_URL}/api/status`);
|
|
315
|
+
if (res.status !== 200) {
|
|
316
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const json = tryJson(res.body);
|
|
320
|
+
if (!json || !isObject(json)) {
|
|
321
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const problems = [];
|
|
325
|
+
|
|
326
|
+
if (!isObject(json.core)) problems.push("missing/invalid 'core' object");
|
|
327
|
+
else if (typeof json.core.available !== "boolean") problems.push("'core.available' not boolean");
|
|
328
|
+
|
|
329
|
+
if (!isObject(json.direct)) problems.push("missing/invalid 'direct' object");
|
|
330
|
+
else if (typeof json.direct.available !== "boolean") problems.push("'direct.available' not boolean");
|
|
331
|
+
|
|
332
|
+
if (!isObject(json.watcher)) problems.push("missing/invalid 'watcher' object");
|
|
333
|
+
else if (typeof json.watcher.status !== "string") problems.push("'watcher.status' not string");
|
|
334
|
+
|
|
335
|
+
if (!isObject(json.setup)) problems.push("missing/invalid 'setup' object");
|
|
336
|
+
else {
|
|
337
|
+
if (!isObject(json.setup.tunnel_client)) problems.push("'setup.tunnel_client' not object");
|
|
338
|
+
else if (typeof json.setup.tunnel_client.available !== "boolean") problems.push("'setup.tunnel_client.available' not boolean");
|
|
339
|
+
if (!isObject(json.setup.watcher)) problems.push("'setup.watcher' not object");
|
|
340
|
+
if (typeof json.setup.workspace_root !== "string" && json.setup.workspace_root !== null) {
|
|
341
|
+
problems.push("'setup.workspace_root' is neither string nor null");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!isObject(json.tunnel)) problems.push("missing/invalid 'tunnel' object");
|
|
346
|
+
else {
|
|
347
|
+
if (!isObject(json.tunnel.core)) problems.push("'tunnel.core' not object");
|
|
348
|
+
if (!isObject(json.tunnel.direct)) problems.push("'tunnel.direct' not object");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!isObject(json.tools)) problems.push("missing/invalid 'tools' object");
|
|
352
|
+
else {
|
|
353
|
+
if (!isObject(json.tools.core)) problems.push("'tools.core' not object");
|
|
354
|
+
if (!isObject(json.tools.direct)) problems.push("'tools.direct' not object");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!Array.isArray(json.agents)) problems.push("'agents' is not an array");
|
|
358
|
+
|
|
359
|
+
if (typeof json.workspace_root !== "string" && json.workspace_root !== null) {
|
|
360
|
+
problems.push("'workspace_root' is neither string nor null");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!isObject(json.tasks)) problems.push("missing/invalid 'tasks' object");
|
|
364
|
+
else {
|
|
365
|
+
if (!Array.isArray(json.tasks.tasks)) problems.push("'tasks.tasks' is not an array");
|
|
366
|
+
if (typeof json.tasks.total !== "number") problems.push("'tasks.total' is not a number");
|
|
367
|
+
if (typeof json.tasks.active !== "number") problems.push("'tasks.active' is not a number");
|
|
368
|
+
if (typeof json.tasks.stale !== "number") problems.push("'tasks.stale' is not a number");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Fault-tolerance: when Core/Direct are not started (probes point at dead
|
|
372
|
+
// ports in test mode), available should be false and a reason must be present.
|
|
373
|
+
// We verify the structure rather than hard-asserting false, so the test stays
|
|
374
|
+
// robust even if a stray service happens to answer on the probe port.
|
|
375
|
+
for (const key of ["core", "direct"]) {
|
|
376
|
+
if (json[key].available === false) {
|
|
377
|
+
if (typeof json[key].reason !== "string" || json[key].reason.length === 0) {
|
|
378
|
+
problems.push(`fault-tolerance: ${key}.available===false but reason is missing`);
|
|
379
|
+
}
|
|
380
|
+
} else if (json[key].available !== true && typeof json[key].available !== "boolean") {
|
|
381
|
+
problems.push(`fault-tolerance: ${key}.available is not a boolean`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (problems.length === 0) {
|
|
386
|
+
record(name, true);
|
|
387
|
+
} else {
|
|
388
|
+
record(name, false, problems.join("; "));
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
record(name, false, `error: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Test 3: /api/tasks returns valid JSON ───────────────────────
|
|
396
|
+
async function testTasksJson() {
|
|
397
|
+
const name = "Test 3: /api/tasks returns valid JSON";
|
|
398
|
+
try {
|
|
399
|
+
const res = await httpGet(`${BASE_URL}/api/tasks`);
|
|
400
|
+
if (res.status !== 200) {
|
|
401
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const json = tryJson(res.body);
|
|
405
|
+
if (!json || !isObject(json)) {
|
|
406
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const problems = [];
|
|
410
|
+
if (!Array.isArray(json.tasks)) problems.push("'tasks' is not an array");
|
|
411
|
+
if (typeof json.total !== "number") problems.push("'total' is not a number");
|
|
412
|
+
if (typeof json.returned !== "number") problems.push("'returned' is not a number");
|
|
413
|
+
|
|
414
|
+
if (problems.length === 0) {
|
|
415
|
+
record(name, true);
|
|
416
|
+
} else {
|
|
417
|
+
record(name, false, problems.join("; "));
|
|
418
|
+
}
|
|
419
|
+
} catch (err) {
|
|
420
|
+
record(name, false, `error: ${err.message}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Test 4: /control-token.json returns token + Cache-Control ──
|
|
425
|
+
async function testControlToken() {
|
|
426
|
+
const name = "Test 4: /control-token.json returns token with no-store";
|
|
427
|
+
try {
|
|
428
|
+
const res = await httpGet(`${BASE_URL}/control-token.json`);
|
|
429
|
+
if (res.status !== 200) {
|
|
430
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const json = tryJson(res.body);
|
|
434
|
+
if (!json || typeof json.token !== "string" || json.token.length === 0) {
|
|
435
|
+
record(name, false, `token missing or empty: ${res.body.slice(0, 200)}`);
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
const cc = res.headers["cache-control"] || "";
|
|
439
|
+
if (!cc.includes("no-store")) {
|
|
440
|
+
record(name, false, `Cache-Control header missing no-store: ${cc}`);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
record(name, true);
|
|
444
|
+
return json.token;
|
|
445
|
+
} catch (err) {
|
|
446
|
+
record(name, false, `error: ${err.message}`);
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Test 5: POST /api/start-all without token -> 403 ────────────
|
|
452
|
+
async function testStartAllNoToken() {
|
|
453
|
+
const name = "Test 5: POST /api/start-all without token -> 403";
|
|
454
|
+
try {
|
|
455
|
+
const res = await httpPost(`${BASE_URL}/api/start-all`, {});
|
|
456
|
+
const ok = res.status === 403;
|
|
457
|
+
let detail = null;
|
|
458
|
+
if (!ok) detail = `status ${res.status} (expected 403)`;
|
|
459
|
+
else {
|
|
460
|
+
const json = tryJson(res.body);
|
|
461
|
+
if (!json || typeof json.error !== "string") {
|
|
462
|
+
detail = `response body missing 'error' field: ${res.body.slice(0, 200)}`;
|
|
463
|
+
record(name, false, detail);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
record(name, ok, detail);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
record(name, false, `error: ${err.message}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Test 6: POST /api/start-all with wrong token -> 403 ─────────
|
|
474
|
+
async function testStartAllWrongToken() {
|
|
475
|
+
const name = "Test 6: POST /api/start-all with wrong token -> 403";
|
|
476
|
+
try {
|
|
477
|
+
const res = await httpPost(`${BASE_URL}/api/start-all`, {
|
|
478
|
+
"X-PatchWarden-Control-Token": "wrong-token",
|
|
479
|
+
});
|
|
480
|
+
const ok = res.status === 403;
|
|
481
|
+
record(name, ok, ok ? null : `status ${res.status} (expected 403)`);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
record(name, false, `error: ${err.message}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function testStartAllPreflightMissingTunnelClient(token) {
|
|
488
|
+
const name = "Test 6b: POST /api/start-all with token preflights missing tunnel-client";
|
|
489
|
+
try {
|
|
490
|
+
const res = await httpPost(`${BASE_URL}/api/start-all`, {
|
|
491
|
+
"X-PatchWarden-Control-Token": token,
|
|
492
|
+
});
|
|
493
|
+
const json = tryJson(res.body);
|
|
494
|
+
if (res.status !== 409) {
|
|
495
|
+
record(name, false, `status ${res.status} (expected 409) body=${res.body.slice(0, 200)}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (!isObject(json)) {
|
|
499
|
+
record(name, false, `body is not JSON object: ${res.body.slice(0, 200)}`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const missing = Array.isArray(json.missing) ? json.missing : [];
|
|
503
|
+
if (json.ok !== false || !String(json.error || "").includes("preflight") || !missing.includes("tunnel-client.exe")) {
|
|
504
|
+
record(name, false, `unexpected preflight response: ${res.body.slice(0, 300)}`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
record(name, true);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
record(name, false, `error: ${err.message}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Test 7: POST /api/open-logs-folder without token -> 403 ─────
|
|
514
|
+
async function testOpenLogsFolderNoToken() {
|
|
515
|
+
const name = "Test 7: POST /api/open-logs-folder without token -> 403";
|
|
516
|
+
try {
|
|
517
|
+
const res = await httpPost(`${BASE_URL}/api/open-logs-folder`, {});
|
|
518
|
+
const ok = res.status === 403;
|
|
519
|
+
let detail = null;
|
|
520
|
+
if (!ok) {
|
|
521
|
+
detail = `status ${res.status} (expected 403; 403 means the folder-open action was NOT executed)`;
|
|
522
|
+
} else {
|
|
523
|
+
const json = tryJson(res.body);
|
|
524
|
+
if (!json || typeof json.error !== "string") {
|
|
525
|
+
detail = `response body missing 'error' field: ${res.body.slice(0, 200)}`;
|
|
526
|
+
record(name, false, detail);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
record(name, ok, detail);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
record(name, false, `error: ${err.message}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Test 8: Token does not pollute git ──────────────────────────
|
|
537
|
+
function testTokenNotInGit() {
|
|
538
|
+
const name = "Test 8: token does not pollute git (ui/control-token.json absent)";
|
|
539
|
+
let output;
|
|
540
|
+
try {
|
|
541
|
+
output = execSync("git status --porcelain", {
|
|
542
|
+
cwd: projectRoot,
|
|
543
|
+
encoding: "utf-8",
|
|
544
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
545
|
+
});
|
|
546
|
+
} catch (err) {
|
|
547
|
+
record(name, true, `SKIP — git not available or not a repo (${err.message.split("\n")[0]})`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (output.includes("ui/control-token.json")) {
|
|
551
|
+
record(name, false, `git status contains ui/control-token.json:\n${output}`);
|
|
552
|
+
} else {
|
|
553
|
+
record(name, true);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ── Test 9: No CDN requests in UI HTML files ────────────────────
|
|
558
|
+
function testNoCdn() {
|
|
559
|
+
const name = "Test 9: no CDN references (https://cdn or https://unpkg) in UI HTML";
|
|
560
|
+
const files = [];
|
|
561
|
+
for (const f of readHtmlFiles(join(uiRoot, "pages"))) files.push(f);
|
|
562
|
+
for (const f of readHtmlFiles(join(uiRoot, "partials"))) files.push(f);
|
|
563
|
+
|
|
564
|
+
if (files.length === 0) {
|
|
565
|
+
record(name, false, "no HTML files found under ui/pages or ui/partials");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const offenders = [];
|
|
569
|
+
for (const f of files) {
|
|
570
|
+
if (f.content.includes("https://cdn") || f.content.includes("https://unpkg")) {
|
|
571
|
+
offenders.push(f.path);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (offenders.length === 0) {
|
|
575
|
+
record(name, true);
|
|
576
|
+
} else {
|
|
577
|
+
record(name, false, `CDN references found in: ${offenders.join(", ")}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Test 10: Other GET APIs are reachable ───────────────────────
|
|
582
|
+
function testTrayMenuContract() {
|
|
583
|
+
const name = "Test 9b: tray menu stays a lightweight quick-control surface";
|
|
584
|
+
let body = "";
|
|
585
|
+
try {
|
|
586
|
+
body = readFileSync(trayScriptPath, "utf-8");
|
|
587
|
+
} catch (err) {
|
|
588
|
+
record(name, false, `could not read tray script: ${err.message}`);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const requiredLabels = [
|
|
593
|
+
"Open Dashboard",
|
|
594
|
+
"Status",
|
|
595
|
+
"Start All",
|
|
596
|
+
"Stop All",
|
|
597
|
+
"Restart All",
|
|
598
|
+
"Open Logs",
|
|
599
|
+
"Quit Tray",
|
|
600
|
+
];
|
|
601
|
+
const forbiddenLabels = [
|
|
602
|
+
"Open Workspace",
|
|
603
|
+
"Open Logs Folder",
|
|
604
|
+
"Open Control Center",
|
|
605
|
+
"Direct Sessions",
|
|
606
|
+
"Audit",
|
|
607
|
+
"Tasks",
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
const problems = [];
|
|
611
|
+
for (const label of requiredLabels) {
|
|
612
|
+
if (!body.includes(`Items.Add("${label}"`)) {
|
|
613
|
+
problems.push(`missing tray label: ${label}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
for (const label of forbiddenLabels) {
|
|
617
|
+
if (body.includes(`Items.Add("${label}"`)) {
|
|
618
|
+
problems.push(`tray should not expose complex/old label: ${label}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (!body.includes("Open Dashboard for tasks, audit logs, Direct sessions, and setup details.")) {
|
|
622
|
+
problems.push("Status message should direct complex management to the dashboard");
|
|
623
|
+
}
|
|
624
|
+
for (const marker of ["New-PatchWardenTrayIcon", "MutexName", "Invoke-TrayControlAction", "PatchWarden: local control"]) {
|
|
625
|
+
if (!body.includes(marker)) problems.push(`tray missing desktop UX marker: ${marker}`);
|
|
626
|
+
}
|
|
627
|
+
if (!body.includes("Tray and dashboard stay available")) {
|
|
628
|
+
problems.push("Stop All should be described as stopping Core/Direct while tray/dashboard remain available");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (problems.length === 0) {
|
|
632
|
+
record(name, true);
|
|
633
|
+
} else {
|
|
634
|
+
record(name, false, problems.join("; "));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function testOtherGetApis() {
|
|
639
|
+
const name = "Test 10: other GET APIs are reachable with expected fields";
|
|
640
|
+
const problems = [];
|
|
641
|
+
|
|
642
|
+
// /api/workspace
|
|
643
|
+
try {
|
|
644
|
+
const res = await httpGet(`${BASE_URL}/api/workspace`);
|
|
645
|
+
if (res.status !== 200) problems.push(`/api/workspace -> ${res.status} (expected 200)`);
|
|
646
|
+
else {
|
|
647
|
+
const json = tryJson(res.body);
|
|
648
|
+
if (!json || !("workspace_root" in json)) {
|
|
649
|
+
problems.push(`/api/workspace missing 'workspace_root': ${res.body.slice(0, 120)}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} catch (err) {
|
|
653
|
+
problems.push(`/api/workspace error: ${err.message}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// /api/audit
|
|
657
|
+
try {
|
|
658
|
+
const res = await httpGet(`${BASE_URL}/api/audit`);
|
|
659
|
+
if (res.status !== 200) problems.push(`/api/audit -> ${res.status} (expected 200)`);
|
|
660
|
+
else {
|
|
661
|
+
const json = tryJson(res.body);
|
|
662
|
+
if (!json || !Array.isArray(json.audits)) {
|
|
663
|
+
problems.push(`/api/audit missing 'audits' array: ${res.body.slice(0, 120)}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} catch (err) {
|
|
667
|
+
problems.push(`/api/audit error: ${err.message}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// /api/logs/core
|
|
671
|
+
try {
|
|
672
|
+
const res = await httpGet(`${BASE_URL}/api/logs/core`);
|
|
673
|
+
if (res.status !== 200) problems.push(`/api/logs/core -> ${res.status} (expected 200)`);
|
|
674
|
+
else {
|
|
675
|
+
const json = tryJson(res.body);
|
|
676
|
+
if (!json || typeof json.stdout !== "string" || typeof json.stderr !== "string") {
|
|
677
|
+
problems.push(`/api/logs/core missing 'stdout'/'stderr' strings: ${res.body.slice(0, 120)}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch (err) {
|
|
681
|
+
problems.push(`/api/logs/core error: ${err.message}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// /api/tunnel-ui-url
|
|
685
|
+
try {
|
|
686
|
+
const res = await httpGet(`${BASE_URL}/api/tunnel-ui-url`);
|
|
687
|
+
if (res.status !== 200) problems.push(`/api/tunnel-ui-url -> ${res.status} (expected 200)`);
|
|
688
|
+
else {
|
|
689
|
+
const json = tryJson(res.body);
|
|
690
|
+
if (!json || !isObject(json.core) || !isObject(json.direct)) {
|
|
691
|
+
problems.push(`/api/tunnel-ui-url missing 'core'/'direct' objects: ${res.body.slice(0, 120)}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (err) {
|
|
695
|
+
problems.push(`/api/tunnel-ui-url error: ${err.message}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (problems.length === 0) {
|
|
699
|
+
record(name, true);
|
|
700
|
+
} else {
|
|
701
|
+
record(name, false, problems.join("; "));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ── Test 11: GET /api/tasks/stale returns valid JSON ────────────
|
|
706
|
+
async function testStaleTasksApi() {
|
|
707
|
+
const name = "Test 11: /api/tasks/stale returns valid JSON";
|
|
708
|
+
try {
|
|
709
|
+
const res = await httpGet(`${BASE_URL}/api/tasks/stale`);
|
|
710
|
+
if (res.status !== 200) {
|
|
711
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const json = tryJson(res.body);
|
|
715
|
+
if (!json || !isObject(json)) {
|
|
716
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const problems = [];
|
|
720
|
+
if (!Array.isArray(json.stale_tasks)) problems.push("'stale_tasks' is not an array");
|
|
721
|
+
if (typeof json.total !== "number") problems.push("'total' is not a number");
|
|
722
|
+
if (typeof json.stale_threshold_seconds !== "number") problems.push("'stale_threshold_seconds' is not a number");
|
|
723
|
+
// watcher must be present (object or null); reason must be null or string
|
|
724
|
+
if (json.watcher !== null && !isObject(json.watcher)) problems.push("'watcher' is neither object nor null");
|
|
725
|
+
if (json.reason !== null && typeof json.reason !== "string") problems.push("'reason' is neither null nor string");
|
|
726
|
+
|
|
727
|
+
if (problems.length === 0) {
|
|
728
|
+
record(name, true);
|
|
729
|
+
} else {
|
|
730
|
+
record(name, false, problems.join("; "));
|
|
731
|
+
}
|
|
732
|
+
} catch (err) {
|
|
733
|
+
record(name, false, `error: ${err.message}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── Test 12: GET /api/workspace/<traversal>/status -> 400 ───────
|
|
738
|
+
async function testWorkspaceRepoPathTraversal() {
|
|
739
|
+
const name = "Test 12: /api/workspace/<traversal>/status rejected with 400";
|
|
740
|
+
// Use percent-encoded dots to bypass URL-parser segment normalization;
|
|
741
|
+
// after decode the repoParam is "../secret" which contains ".." and is
|
|
742
|
+
// rejected by the server's traversal guard.
|
|
743
|
+
const traversalUrl = `${BASE_URL}/api/workspace/${encodeURIComponent("../secret")}/status`;
|
|
744
|
+
try {
|
|
745
|
+
const res = await httpGet(traversalUrl);
|
|
746
|
+
if (res.status === 400) {
|
|
747
|
+
const json = tryJson(res.body);
|
|
748
|
+
if (json && typeof json.error === "string" && /traversal/i.test(json.error)) {
|
|
749
|
+
record(name, true);
|
|
750
|
+
} else {
|
|
751
|
+
record(name, false, `status 400 but error field missing/invalid: ${res.body.slice(0, 200)}`);
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
record(name, false, `status ${res.status} (expected 400; traversal must be rejected before git runs)`);
|
|
755
|
+
}
|
|
756
|
+
} catch (err) {
|
|
757
|
+
record(name, false, `error: ${err.message}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ── Test 13: POST /api/tasks/:taskId/reconcile without token -> 403
|
|
762
|
+
async function testReconcileNoToken() {
|
|
763
|
+
const name = "Test 13: POST /api/tasks/:taskId/reconcile without token -> 403";
|
|
764
|
+
try {
|
|
765
|
+
const res = await httpPost(`${BASE_URL}/api/tasks/smoke-fake-task/reconcile`, {});
|
|
766
|
+
if (res.status !== 403) {
|
|
767
|
+
record(name, false, `status ${res.status} (expected 403 — token gate must fire before reconcile runs)`);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const json = tryJson(res.body);
|
|
771
|
+
if (!json || typeof json.error !== "string") {
|
|
772
|
+
record(name, false, `response body missing 'error' field: ${res.body.slice(0, 200)}`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
record(name, true);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
record(name, false, `error: ${err.message}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ── Test 14: GET /api/direct-sessions returns empty list (not 500)
|
|
782
|
+
async function testDirectSessionsEmptyList() {
|
|
783
|
+
const name = "Test 14: /api/direct-sessions returns empty list (not 500) when dir missing";
|
|
784
|
+
try {
|
|
785
|
+
const res = await httpGet(`${BASE_URL}/api/direct-sessions`);
|
|
786
|
+
if (res.status !== 200) {
|
|
787
|
+
record(name, false, `status ${res.status} (expected 200 — missing dir must NOT yield 500)`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const json = tryJson(res.body);
|
|
791
|
+
if (!json || !isObject(json)) {
|
|
792
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const problems = [];
|
|
796
|
+
if (!Array.isArray(json.sessions)) problems.push("'sessions' is not an array");
|
|
797
|
+
if (typeof json.total !== "number") problems.push("'total' is not a number");
|
|
798
|
+
if (json.reason !== null && typeof json.reason !== "string") problems.push("'reason' is neither null nor string");
|
|
799
|
+
if (Array.isArray(json.sessions) && json.sessions.length !== json.total) {
|
|
800
|
+
problems.push(`sessions.length (${json.sessions.length}) != total (${json.total})`);
|
|
801
|
+
}
|
|
802
|
+
if (problems.length === 0) {
|
|
803
|
+
record(name, true);
|
|
804
|
+
} else {
|
|
805
|
+
record(name, false, problems.join("; "));
|
|
806
|
+
}
|
|
807
|
+
} catch (err) {
|
|
808
|
+
record(name, false, `error: ${err.message}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── Test 15: /api/logs/core?tail=<n> honors tail parameter ──────
|
|
813
|
+
async function testLogsTailParam() {
|
|
814
|
+
const name = "Test 15: /api/logs/core?tail=300 returns tail=300 in response";
|
|
815
|
+
try {
|
|
816
|
+
const res = await httpGet(`${BASE_URL}/api/logs/core?tail=300`);
|
|
817
|
+
if (res.status !== 200) {
|
|
818
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const json = tryJson(res.body);
|
|
822
|
+
if (!json || !isObject(json)) {
|
|
823
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const problems = [];
|
|
827
|
+
if (typeof json.stdout !== "string") problems.push("'stdout' is not a string");
|
|
828
|
+
if (typeof json.stderr !== "string") problems.push("'stderr' is not a string");
|
|
829
|
+
if (json.tail !== 300) problems.push(`'tail' is ${json.tail} (expected 300)`);
|
|
830
|
+
if (json.category !== "core") problems.push(`'category' is ${json.category} (expected "core")`);
|
|
831
|
+
if (problems.length === 0) {
|
|
832
|
+
record(name, true);
|
|
833
|
+
} else {
|
|
834
|
+
record(name, false, problems.join("; "));
|
|
835
|
+
}
|
|
836
|
+
} catch (err) {
|
|
837
|
+
record(name, false, `error: ${err.message}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── Test 16: /api/status contains `suggestions` array (Daily Driver) ──
|
|
842
|
+
async function testStatusSuggestions() {
|
|
843
|
+
const name = "Test 16: /api/status contains 'suggestions' array";
|
|
844
|
+
try {
|
|
845
|
+
const res = await httpGet(`${BASE_URL}/api/status`);
|
|
846
|
+
if (res.status !== 200) {
|
|
847
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const json = tryJson(res.body);
|
|
851
|
+
if (!json || !isObject(json)) {
|
|
852
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (!Array.isArray(json.suggestions)) {
|
|
856
|
+
record(name, false, `'suggestions' is not an array: ${JSON.stringify(json.suggestions).slice(0, 120)}`);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
// When suggestions exist, each must have a stable code + message + severity.
|
|
860
|
+
const problems = [];
|
|
861
|
+
for (let i = 0; i < json.suggestions.length; i++) {
|
|
862
|
+
const s = json.suggestions[i];
|
|
863
|
+
if (!isObject(s)) {
|
|
864
|
+
problems.push(`suggestions[${i}] is not an object`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (typeof s.code !== "string" || s.code.length === 0) problems.push(`suggestions[${i}].code missing`);
|
|
868
|
+
if (typeof s.message !== "string" || s.message.length === 0) problems.push(`suggestions[${i}].message missing`);
|
|
869
|
+
if (typeof s.severity !== "string") problems.push(`suggestions[${i}].severity not string`);
|
|
870
|
+
}
|
|
871
|
+
if (problems.length === 0) {
|
|
872
|
+
record(name, true);
|
|
873
|
+
} else {
|
|
874
|
+
record(name, false, problems.join("; "));
|
|
875
|
+
}
|
|
876
|
+
} catch (err) {
|
|
877
|
+
record(name, false, `error: ${err.message}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── Test 17: /api/events returns valid JSON (Activity Timeline) ──
|
|
882
|
+
async function testEventsApi() {
|
|
883
|
+
const name = "Test 17: /api/events returns valid JSON with events array";
|
|
884
|
+
try {
|
|
885
|
+
const res = await httpGet(`${BASE_URL}/api/events?limit=100`);
|
|
886
|
+
if (res.status !== 200) {
|
|
887
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const json = tryJson(res.body);
|
|
891
|
+
if (!json || !isObject(json)) {
|
|
892
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const problems = [];
|
|
896
|
+
if (!Array.isArray(json.events)) problems.push("'events' is not an array");
|
|
897
|
+
if (typeof json.total !== "number") problems.push("'total' is not a number");
|
|
898
|
+
if (typeof json.limit !== "number") problems.push("'limit' is not a number");
|
|
899
|
+
// Each event (if any) must have timestamp + type strings.
|
|
900
|
+
if (Array.isArray(json.events)) {
|
|
901
|
+
for (let i = 0; i < json.events.length; i++) {
|
|
902
|
+
const ev = json.events[i];
|
|
903
|
+
if (!isObject(ev)) {
|
|
904
|
+
problems.push(`events[${i}] is not an object`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (typeof ev.timestamp !== "string") problems.push(`events[${i}].timestamp not string`);
|
|
908
|
+
if (typeof ev.type !== "string") problems.push(`events[${i}].type not string`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (problems.length === 0) {
|
|
912
|
+
record(name, true);
|
|
913
|
+
} else {
|
|
914
|
+
record(name, false, problems.join("; "));
|
|
915
|
+
}
|
|
916
|
+
} catch (err) {
|
|
917
|
+
record(name, false, `error: ${err.message}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ── Test 18: /api/logs/control-center returns valid JSON ─────────
|
|
922
|
+
async function testControlCenterLogApi() {
|
|
923
|
+
const name = "Test 18: /api/logs/control-center returns valid JSON";
|
|
924
|
+
try {
|
|
925
|
+
const res = await httpGet(`${BASE_URL}/api/logs/control-center`);
|
|
926
|
+
if (res.status !== 200) {
|
|
927
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const json = tryJson(res.body);
|
|
931
|
+
if (!json || !isObject(json)) {
|
|
932
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const problems = [];
|
|
936
|
+
if (typeof json.stdout !== "string") problems.push("'stdout' is not a string");
|
|
937
|
+
if (typeof json.stderr !== "string") problems.push("'stderr' is not a string");
|
|
938
|
+
if (json.category !== "control-center") problems.push(`'category' is ${json.category} (expected "control-center")`);
|
|
939
|
+
if (typeof json.tail !== "number") problems.push("'tail' is not a number");
|
|
940
|
+
if (problems.length === 0) {
|
|
941
|
+
record(name, true);
|
|
942
|
+
} else {
|
|
943
|
+
record(name, false, problems.join("; "));
|
|
944
|
+
}
|
|
945
|
+
} catch (err) {
|
|
946
|
+
record(name, false, `error: ${err.message}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ── Test 19: POST /api/restart-all without token -> 403 ──────────
|
|
951
|
+
async function testRestartAllNoToken() {
|
|
952
|
+
const name = "Test 19: POST /api/restart-all without token -> 403";
|
|
953
|
+
try {
|
|
954
|
+
const res = await httpPost(`${BASE_URL}/api/restart-all`, {});
|
|
955
|
+
if (res.status !== 403) {
|
|
956
|
+
record(name, false, `status ${res.status} (expected 403 — restart must be token-gated)`);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const json = tryJson(res.body);
|
|
960
|
+
if (!json || typeof json.error !== "string") {
|
|
961
|
+
record(name, false, `response body missing 'error' field: ${res.body.slice(0, 200)}`);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
record(name, true);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
record(name, false, `error: ${err.message}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ── Test 20: /api/control-center-status reports running instance ─
|
|
971
|
+
async function testControlCenterStatusApi() {
|
|
972
|
+
const name = "Test 20: /api/control-center-status reports running instance";
|
|
973
|
+
try {
|
|
974
|
+
const res = await httpGet(`${BASE_URL}/api/control-center-status`);
|
|
975
|
+
if (res.status !== 200) {
|
|
976
|
+
record(name, false, `status ${res.status} (expected 200)`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const json = tryJson(res.body);
|
|
980
|
+
if (!json || !isObject(json)) {
|
|
981
|
+
record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const problems = [];
|
|
985
|
+
if (json.running !== true) problems.push(`'running' is ${json.running} (expected true — server is alive)`);
|
|
986
|
+
if (typeof json.pid !== "number") problems.push("'pid' is not a number");
|
|
987
|
+
if (typeof json.port !== "number") problems.push("'port' is not a number");
|
|
988
|
+
if (json.port !== TEST_PORT) problems.push(`'port' is ${json.port} (expected ${TEST_PORT})`);
|
|
989
|
+
if (typeof json.started_at !== "string") problems.push("'started_at' is not a string");
|
|
990
|
+
if (typeof json.url !== "string" || !json.url.includes(`${TEST_PORT}`)) {
|
|
991
|
+
problems.push(`'url' missing or does not reference port ${TEST_PORT}: ${json.url}`);
|
|
992
|
+
}
|
|
993
|
+
if (typeof json.version !== "string") problems.push("'version' is not a string");
|
|
994
|
+
if (problems.length === 0) {
|
|
995
|
+
record(name, true);
|
|
996
|
+
} else {
|
|
997
|
+
record(name, false, problems.join("; "));
|
|
998
|
+
}
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
record(name, false, `error: ${err.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function main() {
|
|
1005
|
+
// Sanity: ensure the compiled server exists before spawning.
|
|
1006
|
+
if (!existsSync(serverPath) || !statSync(serverPath).isFile()) {
|
|
1007
|
+
console.error(`[control-center-smoke] FATAL: ${serverPath} not found. Run 'npm run build' first.`);
|
|
1008
|
+
process.exit(1);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
child = spawn("node", [serverPath], {
|
|
1012
|
+
cwd: projectRoot,
|
|
1013
|
+
env: {
|
|
1014
|
+
...process.env,
|
|
1015
|
+
PATCHWARDEN_CONTROL_PORT: String(TEST_PORT),
|
|
1016
|
+
PATCHWARDEN_CONTROL_LOG_DIR: TEST_LOG_DIR,
|
|
1017
|
+
PATCHWARDEN_CONTROL_FORCE_MISSING_TUNNEL_CLIENT: "1",
|
|
1018
|
+
// Point Core/Direct health probes at ports that are not listening, so the
|
|
1019
|
+
// smoke test does NOT depend on the real 8080/8081 being free on the host.
|
|
1020
|
+
// The probes will get ECONNREFUSED -> available:false with a reason, which
|
|
1021
|
+
// is exactly the fault-tolerance path we want to exercise.
|
|
1022
|
+
PATCHWARDEN_CORE_URL: "http://127.0.0.1:18080",
|
|
1023
|
+
PATCHWARDEN_DIRECT_URL: "http://127.0.0.1:18081",
|
|
1024
|
+
},
|
|
1025
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
child.stdout.on("data", (chunk) => {
|
|
1029
|
+
childStdout += chunk.toString("utf-8");
|
|
1030
|
+
});
|
|
1031
|
+
child.stderr.on("data", (chunk) => {
|
|
1032
|
+
childStderr += chunk.toString("utf-8");
|
|
1033
|
+
});
|
|
1034
|
+
child.on("error", (err) => {
|
|
1035
|
+
childExitedEarly = true;
|
|
1036
|
+
childExitInfo = { code: null, signal: null, error: err.message };
|
|
1037
|
+
});
|
|
1038
|
+
child.on("exit", (code, signal) => {
|
|
1039
|
+
childExitInfo = { code, signal };
|
|
1040
|
+
// Only treat as "exited early" if we haven't started cleanup yet.
|
|
1041
|
+
if (!child.killed) {
|
|
1042
|
+
childExitedEarly = true;
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
try {
|
|
1047
|
+
await waitForServer();
|
|
1048
|
+
console.log(`[control-center-smoke] server ready at ${BASE_URL}`);
|
|
1049
|
+
|
|
1050
|
+
await testStaticFiles();
|
|
1051
|
+
await testPageNavigationRoutes();
|
|
1052
|
+
await testStatusJson();
|
|
1053
|
+
await testTasksJson();
|
|
1054
|
+
const token = await testControlToken();
|
|
1055
|
+
await testStartAllNoToken();
|
|
1056
|
+
await testStartAllWrongToken();
|
|
1057
|
+
if (token) await testStartAllPreflightMissingTunnelClient(token);
|
|
1058
|
+
await testOpenLogsFolderNoToken();
|
|
1059
|
+
testTokenNotInGit();
|
|
1060
|
+
testNoCdn();
|
|
1061
|
+
testTrayMenuContract();
|
|
1062
|
+
await testOtherGetApis();
|
|
1063
|
+
// Phase 2 coverage
|
|
1064
|
+
await testStaleTasksApi();
|
|
1065
|
+
await testWorkspaceRepoPathTraversal();
|
|
1066
|
+
await testReconcileNoToken();
|
|
1067
|
+
await testDirectSessionsEmptyList();
|
|
1068
|
+
await testLogsTailParam();
|
|
1069
|
+
// Daily Driver coverage
|
|
1070
|
+
await testStatusSuggestions();
|
|
1071
|
+
await testEventsApi();
|
|
1072
|
+
await testControlCenterLogApi();
|
|
1073
|
+
await testRestartAllNoToken();
|
|
1074
|
+
await testControlCenterStatusApi();
|
|
1075
|
+
|
|
1076
|
+
// Use token to silence unused-var linters and confirm we actually fetched it.
|
|
1077
|
+
if (token) {
|
|
1078
|
+
// Token was successfully retrieved; no further action needed in test mode.
|
|
1079
|
+
}
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
record("server bootstrap", false, err.message);
|
|
1082
|
+
} finally {
|
|
1083
|
+
cleanup();
|
|
1084
|
+
// Wait for the child to actually exit so the port is released before we exit.
|
|
1085
|
+
await waitForChildExit(5000);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
console.log("");
|
|
1089
|
+
console.log(`Summary: ${passed} passed, ${failed} failed`);
|
|
1090
|
+
|
|
1091
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
main().catch((err) => {
|
|
1095
|
+
console.error(`[control-center-smoke] uncaught error: ${err && err.stack ? err.stack : err}`);
|
|
1096
|
+
cleanup();
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
});
|