Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions lib/public/modules/sticky-notes-fmt.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,41 @@
// sticky-notes.js (it is browser-ESM with DOM deps). This module mirrors its exact
// implementation so that the regression tests call the real production logic pattern.
// Any change to fmt() in sticky-notes.js must be reflected here.
export function fmt(s) {
var escaped = s

// The URL auto-link regex used in fmt(), exported for direct inspection in tests.
// Excludes whitespace, HTML special chars, and quote chars from URL matches so that
// a crafted URL like http://x.com"onmouseover="alert(1) cannot break out of the href.
export var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g;

function escapeHtmlBasic(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped
.replace(/(https?:\/\/[^\s<>"']+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>')
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

export function fmt(s) {
// Split on raw URLs first (before any HTML escaping), so quote chars in the
// surrounding text still act as natural URL terminators in AUTO_LINK_RE.
// Non-URL segments are HTML-escaped; URL segments are escaped then wrapped in
// an anchor (the href value is also escaped so &, <, > inside URLs are safe).
AUTO_LINK_RE.lastIndex = 0;
var parts = s.split(AUTO_LINK_RE);
// split() with a capturing group produces: [text, url, text, url, ...]
var out = "";
for (var i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
// Non-URL segment — HTML-escape fully.
out += escapeHtmlBasic(parts[i]);
} else {
// URL segment — escape for safe embedding in href and link text.
var safeUrl = escapeHtmlBasic(parts[i]);
out += '<a href="' + safeUrl + '" target="_blank" rel="noopener">' + safeUrl + "</a>";
}
}
return out
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
Expand All @@ -22,6 +50,3 @@ export function fmt(s) {
.replace(/^- \[ \]/gm, '<span class="sn-check">☐</span>')
.replace(/\n/g, "<br>");
}

// The URL auto-link regex used in fmt(), exported for direct inspection in tests.
export var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g;
28 changes: 23 additions & 5 deletions lib/public/modules/sticky-notes.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,31 @@ function renderMiniMarkdown(text) {
var title = lines[0];
var body = lines.slice(1).join("\n");

function fmt(s) {
var escaped = s
var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g;
function escHtml(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped
.replace(/(https?:\/\/[^\s<>"']+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>')
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function fmt(s) {
// Split on raw URLs first so quote chars still terminate URL matches even
// after HTML-escaping. Non-URL segments are fully escaped; URL segments are
// escaped and wrapped in an anchor.
AUTO_LINK_RE.lastIndex = 0;
var parts = s.split(AUTO_LINK_RE);
var out = "";
for (var i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
out += escHtml(parts[i]);
} else {
var safeUrl = escHtml(parts[i]);
out += '<a href="' + safeUrl + '" target="_blank" rel="noopener">' + safeUrl + "</a>";
}
}
return out
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
Expand Down
222 changes: 222 additions & 0 deletions test/boot-smoke-lr-1a5f.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// boot-smoke-lr-1a5f.test.js
//
// Daemon boot smoke test (lr-1a5f).
//
// What this test proves (server-side only):
// 1. The daemon starts and its HTTP server responds on /info (boot success).
// 2. The frontend HTML is served with HTTP 200 (static asset pipeline works).
// 3. A WebSocket upgrade to the project endpoint succeeds (server-side WS
// handler wired up, auth gate passes, relay code loaded without errors).
//
// What this test does NOT prove:
// - That browser-side ESM (lib/public/*.js) loads without errors.
// Browser ESM imports are NOT loaded by the daemon; they are served as
// static assets. Broken browser imports (the lr-8657 failure class) are
// caught by the static import-resolution check (lr-5e24), not here.
// - That the full UI renders correctly (integration/E2E territory).
//
// The test FAILS if any of the three server-side checks fail.
// The test is isolated: uses an ephemeral port + a tmpdir home, never touches
// the real ~/.clagentic directory.

"use strict";

var test = require("node:test");
var assert = require("node:assert/strict");
var fs = require("fs");
var path = require("path");
var os = require("os");
var net = require("net");
var http = require("http");
var { spawn } = require("child_process");
var WebSocket = require("ws");

// ── constants ────────────────────────────────────────────────────────────────

var DAEMON_SCRIPT = path.resolve(__dirname, "..", "lib", "daemon.js");
var TEST_TIMEOUT_MS = 45000;
var DAEMON_READY_MS = 20000;
var WS_CONNECT_MS = 10000;

// ── helpers ──────────────────────────────────────────────────────────────────

function findFreePort() {
return new Promise(function (resolve, reject) {
var srv = net.createServer();
srv.listen(0, "127.0.0.1", function () {
var p = srv.address().port;
srv.close(function () { resolve(p); });
});
srv.on("error", reject);
});
}

function waitForServer(port, timeoutMs) {
var start = Date.now();
return new Promise(function (resolve, reject) {
function attempt() {
if (Date.now() - start > timeoutMs) {
reject(new Error("daemon did not respond on /info within " + timeoutMs + " ms"));
return;
}
var req = http.get("http://127.0.0.1:" + port + "/info", function (res) {
res.resume();
if (res.statusCode === 200) { resolve(); }
else { setTimeout(attempt, 250); }
});
req.on("error", function () { setTimeout(attempt, 250); });
req.setTimeout(500, function () { req.destroy(); });
}
attempt();
});
}

function httpGet(url) {
return new Promise(function (resolve, reject) {
var req = http.get(url, function (res) {
var body = "";
res.setEncoding("utf8");
res.on("data", function (c) { body += c; });
res.on("end", function () { resolve({ status: res.statusCode, body: body }); });
});
req.on("error", reject);
req.setTimeout(5000, function () { req.destroy(new Error("httpGet timeout")); });
});
}

function killAndWait(proc) {
return new Promise(function (resolve) {
if (proc.exitCode !== null) { resolve(); return; }
proc.once("exit", resolve);
try { proc.kill("SIGTERM"); } catch (_) {}
setTimeout(function () {
try { proc.kill("SIGKILL"); } catch (_) {}
resolve();
}, 4000);
});
}

// ── test ─────────────────────────────────────────────────────────────────────

test("boot smoke: daemon starts, HTTP 200, WS connects (lr-1a5f)", { timeout: TEST_TIMEOUT_MS }, function (t, done) {
var tmpHome = null;
var daemonProc = null;
var daemonLog = [];

// t.after: reliable cleanup even if the outer timeout fires.
t.after(function () {
var p = daemonProc && daemonProc.exitCode === null
? killAndWait(daemonProc).catch(function (e) { t.diagnostic("t.after daemon kill: " + e.message); })
: Promise.resolve();
return p.then(function () {
if (tmpHome) {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); }
catch (e) { t.diagnostic("t.after tmpdir cleanup failed: " + e.message + " (leaked: " + tmpHome + ")"); }
}
});
});

findFreePort().then(function (port) {
// ── 1. Isolated home + minimal config ────────────────────────────────────
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-smoke-"));
var projectDir = path.join(tmpHome, "smoke-project");
fs.mkdirSync(projectDir, { recursive: true });

var configFile = path.join(tmpHome, "daemon.json");
fs.writeFileSync(configFile, JSON.stringify({
port: port,
host: "127.0.0.1",
tls: false,
pinHash: null,
mode: "single",
setupCompleted: true,
debug: false,
projects: [{ path: projectDir, slug: "smoke-project", addedAt: Date.now() }],
}, null, 2), { mode: 0o600 });

// ── 2. Spawn daemon ───────────────────────────────────────────────────────
daemonProc = spawn(process.execPath, [DAEMON_SCRIPT], {
env: Object.assign({}, process.env, {
CLAGENTIC_HOME: tmpHome,
CLAGENTIC_CONFIG: configFile,
}),
stdio: ["ignore", "pipe", "pipe"],
});
daemonProc.stdout.on("data", function (d) { daemonLog.push(d.toString()); });
daemonProc.stderr.on("data", function (d) { daemonLog.push("[err] " + d.toString()); });
daemonProc.once("exit", function (code) {
if (code !== 0 && code !== null) {
t.diagnostic("daemon exited with code " + code);
t.diagnostic("daemon log:\n" + daemonLog.slice(-30).join(""));
}
});

// ── 3. Check 1 — HTTP /info responds ─────────────────────────────────────
return waitForServer(port, DAEMON_READY_MS).then(function () { return port; });

}).then(function (port) {
// ── 4. Check 2 — frontend HTML served with 200 ───────────────────────────
return httpGet("http://127.0.0.1:" + port + "/p/smoke-project/").then(function (res) {
assert.strictEqual(res.status, 200,
"Expected HTTP 200 for frontend page, got " + res.status);
assert.ok(
res.body.includes("<html") || res.body.includes("<!DOCTYPE"),
"Expected HTML response for frontend page"
);
return port;
});

}).then(function (port) {
// ── 5. Check 3 — WebSocket upgrade succeeds ───────────────────────────────
// Single-user mode with pinHash:null means no auth token is required.
return new Promise(function (resolve, reject) {
var wsUrl = "ws://127.0.0.1:" + port + "/p/smoke-project/ws";
var ws = new WebSocket(wsUrl);
var timer = setTimeout(function () {
ws.terminate();
reject(new Error(
"WebSocket did not open within " + WS_CONNECT_MS + " ms. " +
"Check that the daemon booted cleanly and the WS handler is registered. " +
"WS URL: " + wsUrl
));
}, WS_CONNECT_MS);

ws.once("open", function () {
clearTimeout(timer);
ws.close();
resolve(port);
});
ws.once("error", function (err) {
clearTimeout(timer);
reject(new Error("WebSocket error: " + err.message + " — URL: " + wsUrl));
});
});

}).then(function () {
t.diagnostic("boot smoke passed: /info OK, frontend HTTP 200, WS connected");

// ── 6. Cleanup ────────────────────────────────────────────────────────────
var p = daemonProc ? killAndWait(daemonProc) : Promise.resolve();
return p.then(function () {
if (tmpHome) {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); }
catch (e) { t.diagnostic("cleanup: tmpdir removal failed: " + e.message); }
tmpHome = null;
}
done();
});

}).catch(function (err) {
var p = daemonProc ? killAndWait(daemonProc).catch(function (e) {
t.diagnostic("error-path daemon kill: " + e.message);
}) : Promise.resolve();
p.then(function () {
if (tmpHome) {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); }
catch (e) { t.diagnostic("error-path tmpdir cleanup failed: " + e.message + " (leaked: " + tmpHome + ")"); }
tmpHome = null;
}
done(err);
});
});
});
Loading