Files
zaloopipe/pipeline-runner/tests/test_runner.cpp
T
andrey 5ca6927baa feat: add ProcessExecutor, FAKE mode, fix thread lifecycle crash
- Add ProcessExecutor class wrapping PIProcess with proper cleanup
- Add CMake FAKE option (-DFAKE=ON) for testing without opencode
- Add 3 FAKE-mode tests for full pipeline execution, status transitions, output
- Fix PIThread lifecycle: use startOnce() instead of start(), track threads,
  wait + delete in destructor to prevent use-after-free of PIDeque<PIChar>
- Update build.sh with --fake flag and automatic cache invalidation
- Upgrade CMake to C++17 standard
- Update AGENTS.md with build commands and FAKE mode
2026-07-16 09:51:39 +03:00

298 lines
7.5 KiB
C++

#include "pipeline.h"
#include "runner.h"
#include <gtest/gtest.h>
#include <pidir.h>
#include <pifile.h>
#include <piprocess.h>
#include <pisystemtime.h>
#include <pitime.h>
static const PIString testDir = "/tmp/pipeline-runner-test/";
static const PIString testPipelinesDir = testDir + "pipelines/";
static const PIString testLogsDir = testDir + "logs/";
static void setupDirs() {
PIDir::make(testDir);
PIDir::make(testPipelinesDir);
PIDir::make(testLogsDir);
}
static void cleanupDirs() {
PIDir pdir(testPipelinesDir);
for (const auto & e: pdir.entries()) {
if (e.isFile()) PIFile::remove(e.path);
}
PIDir ldir(testLogsDir);
for (const auto & e: ldir.entries()) {
if (e.isFile()) PIFile::remove(e.path);
}
PIDir::remove(testPipelinesDir);
PIDir::remove(testLogsDir);
PIDir::remove(testDir);
}
static void waitForRun(PipelineRunner & runner, const PIString & runId, int timeoutMs = 5000) {
for (int i = 0; i < timeoutMs / 50; ++i) {
RunState state = runner.getRunState(runId);
if (state.status == RunStatus::Completed || state.status == RunStatus::Error) {
return;
}
piMSleep(50);
}
}
static Pipeline makeEchoPipeline() {
Pipeline pl;
pl.id = "echo-test";
pl.name = "Echo Test";
pl.working_dir = "/tmp";
pl.created_at = nowISO();
pl.updated_at = pl.created_at;
Prompt p;
p.id = "prompt-1";
p.text = "echo hello";
p.title = "Echo Step";
p.order = 0;
pl.prompts << p;
return pl;
}
TEST(RunnerTest, StartRunNotFound) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
PIString runId = runner.startRun("nonexistent");
EXPECT_TRUE(runId.isEmpty());
cleanupDirs();
}
TEST(RunnerTest, StartRunAndGetState) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
// Create a pipeline that runs 'echo hello' (uses shell command, not opencode)
Pipeline pl;
pl.id = "state-test";
pl.name = "State Test";
pl.working_dir = "/tmp";
pl.created_at = nowISO();
pl.updated_at = pl.created_at;
Prompt p;
p.id = "p1";
p.text = "test";
p.title = "Step 1";
p.order = 0;
pl.prompts << p;
savePipeline(testPipelinesDir, pl);
PIString runId = runner.startRun("state-test");
EXPECT_FALSE(runId.isEmpty());
RunState state = runner.getRunState(runId);
EXPECT_EQ(state.run_id, runId);
EXPECT_EQ(state.pipeline_id, "state-test");
EXPECT_EQ(state.status, RunStatus::Running);
EXPECT_EQ(state.steps.size(), 1);
EXPECT_EQ(state.steps[0].title, "Step 1");
// Wait for background thread to finish before cleanup
waitForRun(runner, runId);
cleanupDirs();
}
TEST(RunnerTest, GetStateNotFound) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
RunState state = runner.getRunState("nonexistent");
EXPECT_TRUE(state.run_id.isEmpty());
cleanupDirs();
}
TEST(RunnerTest, IsRunActive) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
Pipeline pl;
pl.id = "active-test";
pl.name = "Active Test";
pl.working_dir = "/tmp";
pl.created_at = nowISO();
pl.updated_at = pl.created_at;
Prompt p;
p.id = "p1";
p.text = "test";
p.title = "Step 1";
p.order = 0;
pl.prompts << p;
savePipeline(testPipelinesDir, pl);
PIString runId = runner.startRun("active-test");
EXPECT_TRUE(runner.isRunActive(runId));
// Wait for background thread to finish before cleanup
waitForRun(runner, runId);
cleanupDirs();
}
TEST(RunnerTest, RunStatusToString) {
EXPECT_EQ(runStatusToString(RunStatus::Pending), "pending");
EXPECT_EQ(runStatusToString(RunStatus::Running), "running");
EXPECT_EQ(runStatusToString(RunStatus::Completed), "completed");
EXPECT_EQ(runStatusToString(RunStatus::Error), "error");
}
#ifdef FAKE
static Pipeline makeFakePipeline(const PIString & id, const PIString & name, int numSteps) {
Pipeline pl;
pl.id = id;
pl.name = name;
pl.working_dir = "/tmp";
pl.created_at = nowISO();
pl.updated_at = pl.created_at;
for (int i = 0; i < numSteps; ++i) {
Prompt p;
p.id = "p" + PIString::fromNumber(i);
p.text = "fake text " + PIString::fromNumber(i);
p.title = "Fake Step " + PIString::fromNumber(i);
p.order = i;
pl.prompts << p;
}
return pl;
}
TEST(FakeRunnerTest, FullPipelineCompletes) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
Pipeline pl = makeFakePipeline("fake-full", "Fake Full Pipeline", 3);
savePipeline(testPipelinesDir, pl);
PIString runId = runner.startRun("fake-full");
EXPECT_FALSE(runId.isEmpty());
// Wait for pipeline to finish (3 steps * 100ms = 300ms, allow extra time)
for (int i = 0; i < 100; ++i) {
piMSleep(50);
RunState state = runner.getRunState(runId);
if (state.status == RunStatus::Completed) {
bool allDone = true;
for (int j = 0; j < state.steps.size(); ++j) {
if (state.steps[j].status != RunStatus::Completed) {
allDone = false;
break;
}
}
if (allDone) break;
}
if (state.status == RunStatus::Error) break;
}
RunState state = runner.getRunState(runId);
EXPECT_EQ(state.status, RunStatus::Completed);
EXPECT_EQ(state.steps.size(), 3);
for (int i = 0; i < 3; ++i) {
EXPECT_EQ(state.steps[i].status, RunStatus::Completed);
EXPECT_EQ(state.steps[i].returncode, 0);
}
// Wait for background thread to finish before cleanup
waitForRun(runner, runId, 2000);
cleanupDirs();
}
TEST(FakeRunnerTest, RunStatusTransitions) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
Pipeline pl = makeFakePipeline("fake-transition", "Fake Transition Pipeline", 2);
savePipeline(testPipelinesDir, pl);
PIString runId = runner.startRun("fake-transition");
EXPECT_FALSE(runId.isEmpty());
// Initially running
RunState state = runner.getRunState(runId);
EXPECT_EQ(state.status, RunStatus::Running);
// Wait for completion (2 steps * 100ms = 200ms, allow extra time)
for (int i = 0; i < 100; ++i) {
piMSleep(50);
state = runner.getRunState(runId);
if (state.status == RunStatus::Completed) {
bool allDone = true;
for (int j = 0; j < state.steps.size(); ++j) {
if (state.steps[j].status != RunStatus::Completed) {
allDone = false;
break;
}
}
if (allDone) break;
}
if (state.status == RunStatus::Error) break;
}
state = runner.getRunState(runId);
EXPECT_EQ(state.status, RunStatus::Completed);
EXPECT_FALSE(runner.isRunActive(runId));
// Wait for background thread to finish before cleanup
waitForRun(runner, runId, 2000);
cleanupDirs();
}
TEST(FakeRunnerTest, StepOutputContainsFakeMarker) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
Pipeline pl = makeFakePipeline("fake-output", "Fake Output Pipeline", 2);
savePipeline(testPipelinesDir, pl);
PIString runId = runner.startRun("fake-output");
EXPECT_FALSE(runId.isEmpty());
// Wait for completion (2 steps * 100ms = 200ms, allow extra time)
for (int i = 0; i < 100; ++i) {
piMSleep(50);
RunState state = runner.getRunState(runId);
if (state.status == RunStatus::Completed) {
bool allDone = true;
for (int j = 0; j < state.steps.size(); ++j) {
if (state.steps[j].status != RunStatus::Completed) {
allDone = false;
break;
}
}
if (allDone) break;
}
if (state.status == RunStatus::Error) break;
}
RunState state = runner.getRunState(runId);
EXPECT_EQ(state.status, RunStatus::Completed);
for (int i = 0; i < state.steps.size(); ++i) {
EXPECT_TRUE(state.steps[i].output.contains("FAKE"));
EXPECT_TRUE(state.steps[i].output.contains("Fake Step " + PIString::fromNumber(i)));
EXPECT_TRUE(state.steps[i].error.isEmpty());
}
// Wait for background thread to finish before cleanup
waitForRun(runner, runId, 2000);
cleanupDirs();
}
#endif