#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "devtools/DebugAdapter.hpp" #include "devtools/Debugger.hpp" #include "devtools/DevTool.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace eve::dev; namespace { class DapClient { public: explicit DapClient(int port) { Poco::Net::SocketAddress addr("127.0.0.1", static_cast(port)); sock_.connect(addr, Poco::Timespan(2, 0)); sock_.setBlocking(true); sock_.setReceiveTimeout(Poco::Timespan(0, 50000)); // 50ms sock_.setSendTimeout(Poco::Timespan(2, 0)); // Ensure the DAP server accepts before we speak. for (int i = 0; i < 100 && !DebugAdapter::instance().hasClient(); ++i) { DebugAdapter::instance().poll(); std::this_thread::sleep_for(std::chrono::milliseconds(5)); } REQUIRE(DebugAdapter::instance().hasClient()); } void sendRequest(const std::string& command, const std::string& argsJson = "{}") { std::ostringstream oss; oss << "{\"seq\":" << (++seq_) << ",\"type\":\"request\",\"command\":\"" << command << "\""; if (!argsJson.empty()) oss << ",\"arguments\":" << argsJson; oss << "}"; sendFrame(oss.str()); } void sendFrame(const std::string& json) { const std::string frame = "Content-Length: " + std::to_string(json.size()) + "\r\n\r\n" + json; const int n = sock_.sendBytes(frame.data(), static_cast(frame.size())); REQUIRE(n == static_cast(frame.size())); } /** Read one DAP message; pumps DebugAdapter::poll while waiting. */ Poco::JSON::Object::Ptr recv(int timeoutMs = 2000) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); while (std::chrono::steady_clock::now() < deadline) { DebugAdapter::instance().poll(); char buf[4096]; try { const int n = sock_.receiveBytes(buf, sizeof(buf)); if (n > 0) recvBuf_.append(buf, static_cast(n)); } catch (const Poco::TimeoutException&) { } catch (const Poco::Net::NetException&) { } auto msg = tryParseOne(); if (!msg) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); continue; } if (msg->optValue("type", "") == "event") events_.push_back(msg); return msg; } return nullptr; } /** Drain until a response for `command` (or timeout). Collects events. */ Poco::JSON::Object::Ptr expectResponse(const std::string& command, int timeoutMs = 2000) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); while (std::chrono::steady_clock::now() < deadline) { auto msg = recv(50); if (!msg) continue; if (msg->optValue("type", "") == "response" && msg->optValue("command", "") == command) return msg; } return nullptr; } /** Wait until a named event arrives. */ Poco::JSON::Object::Ptr expectEvent(const std::string& name, int timeoutMs = 2000) { if (auto e = findEvent(name)) return e; const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); while (std::chrono::steady_clock::now() < deadline) { auto msg = recv(50); if (!msg) continue; if (msg->optValue("type", "") == "event" && msg->optValue("event", "") == name) return msg; } return findEvent(name); } Poco::JSON::Object::Ptr findEvent(const std::string& name) const { for (const auto& e : events_) { if (e->optValue("event", "") == name) return e; } return nullptr; } void clearEvents() { events_.clear(); } const std::vector& events() const { return events_; } private: Poco::JSON::Object::Ptr tryParseOne() { const auto headerEnd = recvBuf_.find("\r\n\r\n"); if (headerEnd == std::string::npos) return nullptr; const std::string header = recvBuf_.substr(0, headerEnd); int contentLength = -1; { const std::string key = "Content-Length:"; auto pos = header.find(key); if (pos != std::string::npos) { pos += key.size(); while (pos < header.size() && (header[pos] == ' ' || header[pos] == '\t')) ++pos; contentLength = std::atoi(header.c_str() + pos); } } if (contentLength < 0) { recvBuf_.erase(0, headerEnd + 4); return nullptr; } const size_t total = headerEnd + 4 + static_cast(contentLength); if (recvBuf_.size() < total) return nullptr; const std::string json = recvBuf_.substr(headerEnd + 4, static_cast(contentLength)); recvBuf_.erase(0, total); Poco::JSON::Parser parser; auto root = parser.parse(json).extract(); return root; } Poco::Net::StreamSocket sock_; std::string recvBuf_; int seq_ = 0; std::vector events_; }; void pump(int ms = 20) { const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); while (std::chrono::steady_clock::now() < end) { DebugAdapter::instance().poll(); std::this_thread::sleep_for(std::chrono::milliseconds(2)); } } } // namespace TEST_CASE("devtools.dap.initializeLaunchContinueStep") { auto& dap = DebugAdapter::instance(); auto& dbg = Debugger::instance(); dbg.detach(); dbg.clearBreakpoints(); dbg.clearWatches(); dap.stop(); const int port = dap.listen(0); REQUIRE(port > 0); dap.setSourceRoot("/tmp/eve_game"); DapClient client(port); pump(30); // ---- initialize ---- client.sendRequest("initialize", "{\"clientID\":\"eve-test\",\"adapterID\":\"eve\",\"linesStartAt1\":true," "\"columnsStartAt1\":true,\"pathFormat\":\"path\"}"); auto initResp = client.expectResponse("initialize"); REQUIRE(initResp); CHECK(initResp->optValue("success", false)); auto initBody = initResp->getObject("body"); REQUIRE(initBody); CHECK(initBody->optValue("supportsConfigurationDoneRequest", false)); CHECK(initBody->optValue("supportsTerminateRequest", false)); // `initialized` is sent right after the initialize response; drain it. REQUIRE(client.expectEvent("initialized", 1000)); // ---- launch ---- client.clearEvents(); client.sendRequest("launch", "{\"program\":\"/tmp/eve_game\",\"cwd\":\"/tmp/eve_game\",\"stopOnEntry\":false}"); auto launchResp = client.expectResponse("launch"); REQUIRE(launchResp); CHECK(launchResp->optValue("success", false)); REQUIRE(client.expectEvent("process", 1000)); // ---- setBreakpoints ---- client.sendRequest( "setBreakpoints", "{\"source\":{\"path\":\"/tmp/eve_game/main.nut\",\"name\":\"main.nut\"}," "\"breakpoints\":[{\"line\":10},{\"line\":20}]}"); auto bpResp = client.expectResponse("setBreakpoints"); REQUIRE(bpResp); CHECK(bpResp->optValue("success", false)); { auto body = bpResp->getObject("body"); REQUIRE(body); auto arr = body->getArray("breakpoints"); REQUIRE(arr); CHECK_EQ(static_cast(arr->size()), 2); CHECK(dbg.hasBreakpoint("main.nut", 10)); CHECK(dbg.hasBreakpoint("/tmp/eve_game/main.nut", 20)); } // ---- configurationDone ---- client.sendRequest("configurationDone"); auto cfgResp = client.expectResponse("configurationDone"); REQUIRE(cfgResp); CHECK(cfgResp->optValue("success", false)); // ---- threads / stackTrace while paused at a breakpoint ---- dbg.resume(); SourceLoc loc; loc.source = "main.nut"; loc.line = 10; loc.function = "eve_update"; // Hit BP while Running so pauseLoc_ is recorded for stack synthesis. CHECK(dbg.onScriptLine(loc)); CHECK(dbg.isPaused()); CHECK(static_cast(dbg.lastPauseReason()) == static_cast(PauseReason::Breakpoint)); client.sendRequest("threads"); auto thResp = client.expectResponse("threads"); REQUIRE(thResp); CHECK(thResp->optValue("success", false)); client.sendRequest("stackTrace", "{\"threadId\":1,\"startFrame\":0,\"levels\":20}"); auto stResp = client.expectResponse("stackTrace"); REQUIRE(stResp); CHECK(stResp->optValue("success", false)); { auto body = stResp->getObject("body"); REQUIRE(body); auto frames = body->getArray("stackFrames"); REQUIRE(frames); CHECK(frames->size() >= 1); auto frame0 = frames->getObject(0); REQUIRE(frame0); auto src = frame0->getObject("source"); REQUIRE(src); const std::string path = src->optValue("path", ""); // Alias from setBreakpoints absolute path must win over relative Squirrel names. CHECK_EQ(path, std::string("/tmp/eve_game/main.nut")); } // ---- continue (F5) ---- client.clearEvents(); client.sendRequest("continue", "{\"threadId\":1}"); auto contResp = client.expectResponse("continue"); REQUIRE(contResp); CHECK(contResp->optValue("success", false)); CHECK(!dbg.isPaused()); REQUIRE(client.findEvent("continued")); // ---- pause (arms break-on-next-line; stopped comes from the line hook) ---- client.clearEvents(); client.sendRequest("pause", "{\"threadId\":1}"); auto pauseResp = client.expectResponse("pause"); REQUIRE(pauseResp); CHECK(pauseResp->optValue("success", false)); CHECK(static_cast(dbg.mode()) == static_cast(RunMode::StepInto)); CHECK(!client.findEvent("stopped")); { SourceLoc hit; hit.source = "main.nut"; hit.line = 11; hit.function = "eve_update"; CHECK(dbg.onScriptLine(hit)); CHECK(dbg.isPaused()); dap.notifyStopped(PauseReason::Step, hit); REQUIRE(client.expectEvent("stopped", 1000)); } // ---- next / stepIn / stepOut (F10 / F11 / Shift+F11) ---- client.clearEvents(); client.sendRequest("next", "{\"threadId\":1}"); auto nextResp = client.expectResponse("next"); REQUIRE(nextResp); CHECK(nextResp->optValue("success", false)); { auto body = nextResp->getObject("body"); REQUIRE(body); CHECK_EQ(body->optValue("threadId", 0), 1); } // No VM stack → stepOver opens as StepInto. CHECK(static_cast(dbg.mode()) == static_cast(RunMode::StepInto)); REQUIRE(client.findEvent("continued")); dbg.pause(PauseReason::PauseKey); client.clearEvents(); client.sendRequest("stepIn", "{\"threadId\":1}"); auto stepInResp = client.expectResponse("stepIn"); REQUIRE(stepInResp); CHECK(stepInResp->optValue("success", false)); CHECK(static_cast(dbg.mode()) == static_cast(RunMode::StepInto)); dbg.pause(PauseReason::PauseKey); client.clearEvents(); client.sendRequest("stepOut", "{\"threadId\":1}"); auto stepOutResp = client.expectResponse("stepOut"); REQUIRE(stepOutResp); CHECK(stepOutResp->optValue("success", false)); // No caller → stepOut falls back to stepOver, depth 0 ⇒ StepInto. CHECK(static_cast(dbg.mode()) == static_cast(RunMode::StepInto)); dbg.pause(PauseReason::PauseKey); client.clearEvents(); client.sendRequest("stepFrame", "{\"threadId\":1}"); auto stepFrameResp = client.expectResponse("stepFrame"); REQUIRE(stepFrameResp); CHECK(stepFrameResp->optValue("success", false)); CHECK(static_cast(dbg.mode()) == static_cast(RunMode::StepFrame)); dbg.notifyFrameDone(); CHECK(dbg.isPaused()); // ---- evaluate / watches ---- ssq::VM vm(1024, ssq::Libs::ALL); dbg.attach(vm.getHandle()); { HSQUIRRELVM v = vm.getHandle(); const SQInteger top = sq_gettop(v); sq_pushroottable(v); sq_pushstring(v, "score", -1); sq_pushinteger(v, 42); sq_newslot(v, -3, SQFalse); sq_settop(v, top); } client.sendRequest("evaluate", "{\"expression\":\"score\",\"context\":\"watch\"}"); auto evalResp = client.expectResponse("evaluate"); REQUIRE(evalResp); CHECK(evalResp->optValue("success", false)); { auto body = evalResp->getObject("body"); REQUIRE(body); const std::string result = body->optValue("result", ""); CHECK(result.find("42") != std::string::npos); } // ---- disconnect ---- client.clearEvents(); client.sendRequest("disconnect", "{\"terminateDebuggee\":true}"); auto discResp = client.expectResponse("disconnect"); REQUIRE(discResp); CHECK(discResp->optValue("success", false)); REQUIRE(client.expectEvent("terminated", 1000)); CHECK(!dbg.isPaused()); dbg.detach(); dap.stop(); } TEST_CASE("devtools.dap.stopOnEntry") { auto& dap = DebugAdapter::instance(); auto& dbg = Debugger::instance(); dbg.detach(); dbg.clearBreakpoints(); dap.stop(); const int port = dap.listen(0); REQUIRE(port > 0); DapClient client(port); pump(20); client.sendRequest("initialize", "{\"adapterID\":\"eve\"}"); REQUIRE(client.expectResponse("initialize")); client.clearEvents(); client.sendRequest("launch", "{\"program\":\".\",\"stopOnEntry\":true}"); REQUIRE(client.expectResponse("launch")); client.clearEvents(); client.sendRequest("configurationDone"); REQUIRE(client.expectResponse("configurationDone")); // stopOnEntry arms stepInto; stopped is emitted when the next line runs. CHECK(static_cast(dbg.mode()) == static_cast(RunMode::StepInto)); CHECK(!client.findEvent("stopped")); dbg.resume(); dap.stop(); } TEST_CASE("devtools.dap.scriptBreakpointHitViaHook") { ssq::VM vm(1024, ssq::Libs::ALL); auto& dt = DevTool::instance(); auto& dap = DebugAdapter::instance(); auto& dbg = Debugger::instance(); dap.stop(); dt.detach(); dbg.clearBreakpoints(); dt.attach(vm, /*sampleLocals=*/false); const int port = dap.listen(0); REQUIRE(port > 0); DapClient client(port); pump(20); client.sendRequest("initialize", "{\"adapterID\":\"eve\"}"); REQUIRE(client.expectResponse("initialize")); client.sendRequest("launch", "{\"program\":\".\"}"); REQUIRE(client.expectResponse("launch")); client.sendRequest("setBreakpoints", "{\"source\":{\"path\":\"hit.nut\",\"name\":\"hit.nut\"}," "\"breakpoints\":[{\"line\":3}]}"); REQUIRE(client.expectResponse("setBreakpoints")); client.sendRequest("configurationDone"); REQUIRE(client.expectResponse("configurationDone")); // Compile a tiny script with debug info and run until BP. // Line numbers in compiled buffer: function body lines map via squirrel. // Drive the line hook directly (avoids Squirrel line-number ambiguity across versions). client.clearEvents(); SourceLoc hit; hit.source = "hit.nut"; hit.line = 3; hit.function = "target"; CHECK(dbg.onScriptLine(hit)); CHECK(dbg.isPaused()); // notifyStopped is normally done by DevTool::handleDebugEvent; emit it here. dap.notifyStopped(PauseReason::Breakpoint, hit); REQUIRE(client.expectEvent("stopped", 1000)); // F5 continue from VS Code. client.sendRequest("continue", "{\"threadId\":1}"); auto cont = client.expectResponse("continue", 2000); REQUIRE(cont); CHECK(cont->optValue("success", false)); CHECK(!dbg.isPaused()); dt.detach(); dap.stop(); } TEST_CASE("devtools.dap.realScriptBreakpointAndCallStack") { auto& dap = DebugAdapter::instance(); auto& dbg = Debugger::instance(); auto& dt = DevTool::instance(); dap.stop(); dt.detach(); dbg.clearBreakpoints(); const char* src = "function inner() {\n" // line 1 " local a = 1\n" // line 2 " local b = a + 1\n" // line 3 <-- breakpoint "}\n" "function outer() {\n" " inner()\n" "}\n" "outer()\n"; std::atomic attached{false}; std::atomic go{false}; std::atomic scriptDone{false}; // Run the script on its own thread. DevTool must be attached HERE because // g_active is thread_local and the Squirrel debug hook fires on this thread. // DevTool::attach() internally calls detach()->stopDap(), so the DAP server // must NOT be listening yet — the main thread waits for `attached` first. std::thread scriptThread([&] { ssq::VM vm(1024, ssq::Libs::ALL); HSQUIRRELVM v = vm.getHandle(); dt.attach(v, /*sampleLocals=*/false); attached = true; while (!go.load()) std::this_thread::sleep_for(std::chrono::milliseconds(2)); const SQInteger len = static_cast(std::strlen(src)); SQRESULT rc = sq_compilebuffer(v, src, len, _SC("e2e.nut"), SQTrue); REQUIRE(SQ_SUCCEEDED(rc)); sq_pushroottable(v); REQUIRE(SQ_SUCCEEDED(sq_call(v, 1, SQFalse, SQTrue))); sq_poptop(v); dt.detach(); scriptDone = true; }); // Wait until DevTool is attached (and DAP stopped), then start DAP + handshake. while (!attached.load()) std::this_thread::sleep_for(std::chrono::milliseconds(2)); const int port = dap.listen(0); REQUIRE(port > 0); DapClient client(port); pump(20); client.sendRequest("initialize", "{\"adapterID\":\"eve\"}"); REQUIRE(client.expectResponse("initialize")); client.sendRequest("launch", "{\"program\":\".\"}"); REQUIRE(client.expectResponse("launch")); client.sendRequest("setBreakpoints", "{\"source\":{\"path\":\"e2e.nut\",\"name\":\"e2e.nut\"}," "\"breakpoints\":[{\"line\":3}]}"); REQUIRE(client.expectResponse("setBreakpoints")); client.sendRequest("configurationDone"); REQUIRE(client.expectResponse("configurationDone")); // Release the script now that breakpoints are installed. client.clearEvents(); go = true; // The breakpoint hit produces a `stopped` event with the real location. auto stopped = client.expectEvent("stopped", 8000); REQUIRE(stopped); { auto body = stopped->getObject("body"); REQUIRE(body); CHECK_EQ(body->optValue("reason", ""), std::string("breakpoint")); CHECK_EQ(body->optValue("line", 0), 3); } // The call stack must include the CURRENT (innermost) frame `inner` first. client.sendRequest("stackTrace", "{\"threadId\":1,\"startFrame\":0,\"levels\":20}"); auto stResp = client.expectResponse("stackTrace"); REQUIRE(stResp); { auto body = stResp->getObject("body"); REQUIRE(body); auto frames = body->getArray("stackFrames"); REQUIRE(frames); REQUIRE(frames->size() >= 3u); auto f0 = frames->getObject(0); REQUIRE(f0); CHECK_EQ(f0->optValue("line", 0), 3); CHECK(f0->optValue("name", "").find("inner") != std::string::npos); auto f1 = frames->getObject(1); REQUIRE(f1); CHECK(f1->optValue("name", "").find("outer") != std::string::npos); auto f2 = frames->getObject(2); REQUIRE(f2); CHECK(f2->optValue("name", "").find("main") != std::string::npos); } // F5 continue from VS Code → script finishes. client.sendRequest("continue", "{\"threadId\":1}"); REQUIRE(client.expectResponse("continue")); // Poll-based join: if a previous CHECK/REQUIRE failed the script may never // finish, so never block on an unconditional join (would hang the suite). for (int i = 0; i < 200 && !scriptDone.load(); ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); dap.poll(); } if (scriptThread.joinable()) { if (scriptDone.load()) { scriptThread.join(); } else { scriptThread.detach(); // test already failed; avoid std::terminate } } const bool done = scriptDone.load(); CHECK(done); CHECK(!dbg.isPaused()); dap.stop(); }