2V1-e: live login smoke harness (port.login_live)

port_login_flow_test gains a `live` mode that runs the same script path
against a real 40250 server and checks the client's own state: the
character list, then LoadingWindow.LoadData from GC_MAIN_CHARACTER (which
CPythonNetworkStream answers with CG_CLIENT_VERSION), exiting before the
game phase. Host, ports, account and slot come only from MT_LIVE_* env
vars, are handed to Python as objects and never printed; without them
the test skips. Not yet run live: the server was unreachable today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-23 15:26:56 +09:00
co-authored by Claude Opus 5.5
parent 8d9d863c7e
commit 2a8750e736
4 changed files with 137 additions and 50 deletions
+4
View File
@@ -219,6 +219,10 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
add_test(NAME port.login_flow
COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP})
set_tests_properties(port.login_flow PROPERTIES SKIP_RETURN_CODE 77)
# 2V1-e: the same path against a live 40250 server; skipped unless MT_LIVE_* is set (see the test).
add_test(NAME port.login_live
COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP} live)
set_tests_properties(port.login_live PROPERTIES SKIP_RETURN_CODE 77 RUN_SERIAL TRUE)
add_executable(port_python_ui_modules_test ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_python_ui_modules_test.cpp)
target_link_libraries(port_python_ui_modules_test PRIVATE port_platform)
+131 -49
View File
@@ -18,7 +18,16 @@
// The server checks every sequence byte against the 40250 table, so a skipped or extra sequenced
// packet fails the run.
//
// port_login_flow_test <40250 Client dir> <python27.zip>
// port_login_flow_test <40250 Client dir> <python27.zip> [live]
//
// With `live` (批次 2V1-e, ctest port.login_live) the same path runs against a real 40250 server and
// the client's own state is the proof: the character list arrived, and the loading window got
// LoadData from GC_MAIN_CHARACTER, which CPythonNetworkStream::RecvMainCharacter answers with
// CG_CLIENT_VERSION. The test exits before net.StartGame() would enter the game. Everything about the
// server comes from the environment and nothing of it is printed:
//
// MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD required; without them the test is skipped
// MT_LIVE_AUTH_PORT (11000), MT_LIVE_GAME_PORT (13000, channel 1), MT_LIVE_SLOT (0)
//
// Exit 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1, which fails instead.
#include "ScriptLib/StdAfx.h"
@@ -27,12 +36,15 @@
#include "../src/platform/UserInterface/UserInterface.h"
#include "port_login_flow_server.h"
#include <memory>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <string>
#include <thread>
#include <vector>
#include <unistd.h>
@@ -91,24 +103,62 @@ static void dump(FakeLoginServer& server)
std::fprintf(stderr, "\nserver error: %s\n", server.Error().c_str());
}
static std::string env(const char* name, const char* fallback = "")
{
const char* value = std::getenv(name);
return value && *value ? value : fallback;
}
// Hands a C string to __main__ as a Python object, so account data never goes through source text.
static void py_set(const char* name, const std::string& value)
{
PyObject* object = PyString_FromString(value.c_str());
PyDict_SetItemString(PyModule_GetDict(PyImport_AddModule((char*) "__main__")), name, object);
Py_DECREF(object);
}
int main(int argc, char** argv)
{
const char* strict = std::getenv("MT_ASSETS_STRICT");
const bool is_strict = strict && std::string(strict) == "1";
const std::string client = argc > 1 ? argv[1] : "";
const std::string stdlib = argc > 2 ? argv[2] : PythonHost::DefaultStdLibPath();
const bool live = argc > 3 && std::string(argv[3]) == "live";
if (client.empty() || chdir(client.c_str()) != 0 || access("pack/Index", 0) != 0)
{
std::fprintf(stderr, "port_login_flow_test: no 40250 Client/pack at '%s'\n", client.c_str());
return is_strict ? 1 : 77;
}
FakeLoginServer server;
if (!server.Start())
std::string host = "127.0.0.1", login = FakeLoginServer::kLogin, password = FakeLoginServer::kPassword;
int auth_port = 0, game_port = 0, slot = 0;
std::unique_ptr<FakeLoginServer> server;
if (live)
{
std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n");
return 1;
host = env("MT_LIVE_HOST");
login = env("MT_LIVE_LOGIN");
password = env("MT_LIVE_PASSWORD");
if (host.empty() || login.empty() || password.empty())
{
std::fprintf(stderr, "port_login_flow_test: live mode needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD\n");
return 77;
}
auth_port = std::atoi(env("MT_LIVE_AUTH_PORT", "11000").c_str());
game_port = std::atoi(env("MT_LIVE_GAME_PORT", "13000").c_str());
slot = std::atoi(env("MT_LIVE_SLOT", "0").c_str());
}
else
{
server = std::make_unique<FakeLoginServer>();
if (!server->Start())
{
std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n");
return 1;
}
auth_port = server->AuthPort();
game_port = server->GamePort();
}
auto failed = [&] { return server && server->Failed(); };
PackSingletons();
CHECK(PackInitialize("pack"));
@@ -125,63 +175,95 @@ int main(int argc, char** argv)
"__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"));
CHECK(pump_until(20, [] { return py_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }));
// 2. The login button's path, with the fake server for both the game and the auth address.
char connect[256];
std::snprintf(connect, sizeof(connect),
"_stream.SetConnectInfo('127.0.0.1', %d, '127.0.0.1', %d)\n"
"_stream.curPhaseWindow.Connect('%s', '%s')",
server.GamePort(), server.AuthPort(), FakeLoginServer::kLogin, FakeLoginServer::kPassword);
CHECK(py(connect));
// 2. The login button's path (LoginWindow.Connect), pointed at the fake or the live server.
py_set("_host", host);
py_set("_login", login);
py_set("_password", password);
char ports[128];
std::snprintf(ports, sizeof(ports), "_auth_port, _game_port, _slot = %d, %d, %d", auth_port, game_port, slot);
CHECK(py(ports));
CHECK(py("_stream.SetConnectInfo(_host, _game_port, _host, _auth_port)\n"
"_stream.curPhaseWindow.Connect(_login, _password)\n"
"del __main__._login, __main__._password"));
// 3. The character list.
const bool selecting = pump_until(20, [&] {
return server.Failed() ||
(server.Has("game1:login2") && py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"));
const bool selecting = pump_until(live ? 30 : 20, [&] {
return failed() || ((!server || server->Has("game1:login2")) &&
py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"));
});
CHECK(selecting && !server.Failed());
CHECK(server.Has("auth:login3") && server.Has("auth:closed"));
CHECK(py("import net\n"
"_name = net.GetAccountCharacterSlotDataString(0, net.ACCOUNT_CHARACTER_SLOT_NAME)\n"
"assert _name == '" + std::string(FakeLoginServer::kCharacterName) + "', _name\n"
"assert net.GetEmpireID() == 1"));
// 4-5. Start the game on slot 0: DirectEnter, the second game login and the loading phase.
CHECK(py("_stream.curPhaseWindow.StartGame()"));
const bool loading = pump_until(30, [&] { return server.Failed() || server.Has("game2:client_version"); });
CHECK(loading && !server.Failed());
// Checked right away: introLoading's own update steps end in net.StartGame(), after which the stream
// opens game.GameWindow itself (CPythonNetworkStream::Process, m_isStartGame), which is 2V2.
CHECK(py_true("isinstance(_stream.curPhaseWindow, introLoading.LoadingWindow)"));
const std::vector<std::string> expected = {
"auth:handshake", "auth:login3", "game1:handshake", "game1:login2", "auth:closed",
"game1:closed", "game2:handshake", "game2:login2", "game2:select", "game2:client_version",
};
auto events = server.Events();
// auth:closed races game1's handshake; compare the order within each connection.
for (const char* prefix : {"auth:", "game1:", "game2:"})
CHECK(selecting && !failed());
if (!selecting)
{
std::vector<std::string> want, got;
for (const auto& e : expected)
if (e.rfind(prefix, 0) == 0)
want.push_back(e);
for (const auto& e : events)
if (e.rfind(prefix, 0) == 0)
got.push_back(e);
CHECK(want == got);
// The login window reports failures in its popup (LOGIN_FAILURE, connect errors).
py("import sys\n"
"_w = _stream.curPhaseWindow\n"
"_msg = _stream.popupWindow and _stream.popupWindow.IsShow() and _stream.popupWindow.GetChild('message').GetText()\n"
"sys.stderr.write('phase window %s, popup %r\\n' % (_w.__class__.__name__, _msg))");
}
if (server)
{
CHECK(server->Has("auth:login3") && server->Has("auth:closed"));
CHECK(py("import net\n"
"_name = net.GetAccountCharacterSlotDataString(0, net.ACCOUNT_CHARACTER_SLOT_NAME)\n"
"assert _name == '" + std::string(FakeLoginServer::kCharacterName) + "', _name\n"
"assert net.GetEmpireID() == 1"));
}
else if (selecting)
{
CHECK(py("import net\n"
"assert net.GetAccountCharacterSlotDataString(_slot, net.ACCOUNT_CHARACTER_SLOT_NAME), 'slot is empty'\n"
"assert net.GetEmpireID() in (1, 2, 3), net.GetEmpireID()"));
}
// 4-5. Start the game on the slot: DirectEnter, the second game login and the loading phase.
if (selecting)
{
CHECK(py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()"));
// GC_MAIN_CHARACTER → LoadingWindow.LoadData(x, y), then CG_CLIENT_VERSION in the same call.
const char* loaded = "isinstance(_stream.curPhaseWindow, introLoading.LoadingWindow) and "
"getattr(_stream.curPhaseWindow, 'playerX', None) is not None";
const bool loading = pump_until(live ? 40 : 30, [&] {
return failed() || (server ? server->Has("game2:client_version") : py_true(loaded));
});
CHECK(loading && !failed());
// Checked right away: introLoading's own update steps end in net.StartGame(), after which the stream
// opens game.GameWindow itself (CPythonNetworkStream::Process, m_isStartGame), which is 2V2.
CHECK(py_true(loaded));
}
if (server)
{
const std::vector<std::string> expected = {
"auth:handshake", "auth:login3", "game1:handshake", "game1:login2", "auth:closed",
"game1:closed", "game2:handshake", "game2:login2", "game2:select", "game2:client_version",
};
auto events = server->Events();
// auth:closed races game1's handshake; compare the order within each connection.
for (const char* prefix : {"auth:", "game1:", "game2:"})
{
std::vector<std::string> want, got;
for (const auto& e : expected)
if (e.rfind(prefix, 0) == 0)
want.push_back(e);
for (const auto& e : events)
if (e.rfind(prefix, 0) == 0)
got.push_back(e);
CHECK(want == got);
}
if (g_failures)
dump(*server);
}
if (g_failures)
dump(server);
CHECK(py("import app\napp.Exit()"));
pump_until(2, [] { return !PythonBoot::IsAppLooping(); });
CHECK(!PythonBoot::IsAppLooping());
PythonBoot::Stop();
server.Stop();
if (server)
server->Stop();
if (g_failures)
std::fprintf(stderr, "%d check(s) failed\n", g_failures);
else
std::printf("port_login_flow_test: ok\n");
std::printf("port_login_flow_test: ok%s\n", live ? " (live)" : "");
return g_failures ? 1 : 0;
}