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
5 changes: 3 additions & 2 deletions PolyPilot.Tests/DiagnosticsLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,10 @@ public void Debug_LogRotation_RotationConstantExists()
Assert.Contains("[SEND] first message", content);
Assert.Contains("[SEND] second message", content);

// Verify the file is small (nowhere near 10 MB rotation threshold)
// Verify the file is small (nowhere near 10 MB rotation threshold).
// Other tests may write to the same log file concurrently, so allow up to 10 KB.
var fi = new FileInfo(DiagnosticsLogPath);
Assert.True(fi.Length < 1024, "Log file should be tiny for two messages");
Assert.True(fi.Length < 10_240, $"Log file should be tiny, was {fi.Length} bytes");
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
<Compile Include="../PolyPilot/Models/ExternalSessionInfo.cs" Link="Shared/ExternalSessionInfo.cs" />
<Compile Include="../PolyPilot/Services/ExternalSessionScanner.cs" Link="Shared/ExternalSessionScanner.cs" />
<Compile Include="../PolyPilot/Services/WindowFocusHelper.cs" Link="Shared/WindowFocusHelper.cs" />
<Compile Include="../PolyPilot/Models/ScheduledTask.cs" Link="Shared/ScheduledTask.cs" />
<Compile Include="../PolyPilot/Services/ScheduledTaskService.cs" Link="Shared/ScheduledTaskService.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
114 changes: 91 additions & 23 deletions PolyPilot.Tests/ScenarioReferenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,42 +28,68 @@ public void ScenarioFiles_AreValidJson()
{
var json = File.ReadAllText(file);
var doc = JsonDocument.Parse(json); // throws on invalid JSON
Assert.NotNull(doc.RootElement.GetProperty("scenarios"));
Assert.True(doc.RootElement.TryGetProperty("scenarios", out _),
$"Scenario file '{Path.GetFileName(file)}' is missing a 'scenarios' array");
}
}

[Fact]
public void ModeSwitchScenarios_AllHaveRequiredFields()
public void ScenarioFiles_AllHaveRequiredFields()
{
var json = File.ReadAllText(Path.Combine(ScenariosDir, "mode-switch-scenarios.json"));
var doc = JsonDocument.Parse(json);
var scenarios = doc.RootElement.GetProperty("scenarios");

foreach (var scenario in scenarios.EnumerateArray())
foreach (var file in Directory.GetFiles(ScenariosDir, "*.json"))
{
Assert.True(scenario.TryGetProperty("id", out _), "Scenario missing 'id'");
Assert.True(scenario.TryGetProperty("name", out _), "Scenario missing 'name'");
Assert.True(scenario.TryGetProperty("steps", out var steps), "Scenario missing 'steps'");
Assert.True(steps.GetArrayLength() > 0,
$"Scenario '{scenario.GetProperty("id").GetString()}' has no steps");
var json = File.ReadAllText(file);
var doc = JsonDocument.Parse(json);
var scenarios = doc.RootElement.GetProperty("scenarios");

foreach (var scenario in scenarios.EnumerateArray())
{
Assert.True(scenario.TryGetProperty("id", out _), $"Scenario in '{Path.GetFileName(file)}' missing 'id'");
Assert.True(scenario.TryGetProperty("name", out _), $"Scenario in '{Path.GetFileName(file)}' missing 'name'");
Assert.True(scenario.TryGetProperty("steps", out var steps), $"Scenario in '{Path.GetFileName(file)}' missing 'steps'");
Assert.True(steps.GetArrayLength() > 0,
$"Scenario '{scenario.GetProperty("id").GetString()}' has no steps");
}
}
}

[Fact]
public void ModeSwitchScenarios_StepsHaveValidActions()
public void ScenarioFiles_StepsHaveValidActions()
{
var validActions = new HashSet<string> { "click", "evaluate", "wait", "shell", "screenshot", "type", "note" };
var json = File.ReadAllText(Path.Combine(ScenariosDir, "mode-switch-scenarios.json"));
var doc = JsonDocument.Parse(json);

foreach (var scenario in doc.RootElement.GetProperty("scenarios").EnumerateArray())
var validActions = new HashSet<string>
{
var id = scenario.GetProperty("id").GetString()!;
foreach (var step in scenario.GetProperty("steps").EnumerateArray())
"assertAllSessionsReceived", "assertAllSessionsResponded", "assertAllWorkers",
"assertDirectoryExists", "assertEqual", "assertEvaluatorWasUsed", "assertFileContains",
"assertFileExists", "assertGroupMembership", "assertNoDirectoryContains",
"assertNoOverlap", "assertNoPresetInSection", "assertNoReflectionLoop",
"assertNoSessionsInDefault", "assertNoSessionsWithGroupId", "assertOrchestratorReceivedRoutingContext",
"assertOrchestratorReceivedWorkerDescriptions", "assertOrchestratorSynthesized",
"assertOrgJson", "assertPresetInSection", "assertPresetVisible", "assertReflectionPaused",
"assertReflectionState", "assertSessionMeta", "assertWorkerPromptContains",
"captureGroupState", "click", "createGroup", "createGroupFromPreset", "createSquadDir",
"deleteGroup", "evaluate", "navigate", "note", "pauseReflection", "readOrgJson",
"restartApp", "resumeReflection", "saveGroupAsPreset", "selectWorktree", "sendPrompt",
"setEvaluator", "setMode", "shell", "type", "wait", "waitForAgent",
"waitForAllResponses", "waitForAllSessions", "waitForCompletion", "waitForPhase",
"screenshot"
};
foreach (var file in Directory.GetFiles(ScenariosDir, "*.json"))
{
var json = File.ReadAllText(file);
var doc = JsonDocument.Parse(json);

foreach (var scenario in doc.RootElement.GetProperty("scenarios").EnumerateArray())
{
Assert.True(step.TryGetProperty("action", out var action),
$"Step in '{id}' missing 'action'");
Assert.Contains(action.GetString(), validActions);
var id = scenario.GetProperty("id").GetString()!;
foreach (var step in scenario.GetProperty("steps").EnumerateArray())
{
Assert.True(step.TryGetProperty("action", out var action),
$"Step in '{id}' missing 'action'");
var actionValue = action.GetString();
Assert.False(string.IsNullOrWhiteSpace(actionValue),
$"Step in '{id}' has an empty action");
Assert.Contains(actionValue!, validActions);
}
}
}
}
Expand Down Expand Up @@ -199,6 +225,48 @@ public void Scenario_ShellCommandUsesPlatformShell_HasUnitTestCoverage()
Assert.True(true, "See PlatformHelperTests.GetShellCommand_* for platform shell selection tests");
}

/// <summary>
/// Scenario: "scheduled-task-create-and-run-now"
/// Unit test equivalents: Service_EvaluateTasksAsync_ExecutesDueTasks,
/// Service_ExecuteTask_NewSession_RecordsCompletionAndGeneratedSessionName
/// </summary>
[Fact]
public void Scenario_ScheduledTaskCreateAndRunNow_HasUnitTestCoverage()
{
Assert.True(true, "See ScheduledTaskTests.Service_EvaluateTasksAsync_ExecutesDueTasks and Service_ExecuteTask_NewSession_RecordsCompletionAndGeneratedSessionName");
}

/// <summary>
/// Scenario: "scheduled-task-run-now-twice-uses-unique-session"
/// Unit test equivalents: Service_ExecuteTask_NewSession_ReusesTimestampButGeneratesUniqueName
/// </summary>
[Fact]
public void Scenario_ScheduledTaskRunNowTwiceUsesUniqueSession_HasUnitTestCoverage()
{
Assert.True(true, "See ScheduledTaskTests.Service_ExecuteTask_NewSession_ReusesTimestampButGeneratesUniqueName");
}

/// <summary>
/// Scenario: "scheduled-task-disable-edit-preserves-toggle"
/// Unit test equivalents: Service_UpdateTask_DoesNotOverwriteIsEnabled_FromStaleEditSnapshot
/// </summary>
[Fact]
public void Scenario_ScheduledTaskDisableEditPreservesToggle_HasUnitTestCoverage()
{
Assert.True(true, "See ScheduledTaskTests.Service_UpdateTask_DoesNotOverwriteIsEnabled_FromStaleEditSnapshot");
}

/// <summary>
/// Scenario: "scheduled-task-form-validation"
/// Unit test equivalents: CronExpression_ValidatesExpectedInputs,
/// CronExpression_InvalidExpressions_ReturnFalse, IsValidTimeOfDay_ValidatesCorrectly
/// </summary>
[Fact]
public void Scenario_ScheduledTaskValidation_HasUnitTestCoverage()
{
Assert.True(true, "See ScheduledTaskTests cron and time validation tests");
}

/// <summary>
/// Scenario: "vscode-remote-tunnels-in-remote-mode"
/// Unit test equivalents: PlatformHelperTests.BuildVSCodeRemoteArg_*,
Expand Down
100 changes: 100 additions & 0 deletions PolyPilot.Tests/Scenarios/scheduled-task-scenarios.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"description": "UI scenario tests for the scheduled tasks feature. Execute against a running PolyPilot app using MauiDevFlow/CDP and the stable selectors on the /scheduled-tasks page.",
"prerequisites": {
"build": "cd PolyPilot && ./relaunch.sh",
"waitForAgent": "maui devflow wait",
"initialRoute": "/scheduled-tasks"
},
"scenarios": [
{
"id": "scheduled-task-create-and-run-now",
"name": "Create an interval task and run it immediately",
"steps": [
{ "action": "click", "selector": "a[href='/scheduled-tasks']" },
{ "action": "wait", "ms": 1000 },
{ "action": "click", "selector": "#scheduled-task-new" },
{ "action": "type", "selector": "#scheduled-task-name", "text": "Integration Task" },
{ "action": "type", "selector": "#scheduled-task-prompt", "text": "Say hello from the scheduled task integration test." },
{
"action": "evaluate",
"script": "const sel = document.querySelector('#scheduled-task-schedule'); sel.value = 'Interval'; sel.dispatchEvent(new Event('change', { bubbles: true })); 'interval';"
},
{ "action": "type", "selector": "#scheduled-task-interval", "text": "1" },
{ "action": "click", "selector": "#scheduled-task-save" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "document.querySelector('.task-card[data-task-name=\"Integration Task\"] .task-schedule')?.textContent?.trim()",
"expect": { "contains": "Every 1 minute" }
},
{ "action": "click", "selector": ".task-card[data-task-name='Integration Task'] [data-task-action='run-now']" },
{ "action": "wait", "ms": 5000 },
{
"action": "evaluate",
"script": "document.querySelector('.task-card[data-task-name=\"Integration Task\"] .run-status.success, .task-card[data-task-name=\"Integration Task\"] .run-status-icon')?.textContent?.trim()",
"expect": { "contains": "✓" }
}
]
},
{
"id": "scheduled-task-run-now-twice-uses-unique-session",
"name": "Running the same task twice quickly records two distinct successful sessions",
"steps": [
{ "action": "click", "selector": ".task-card[data-task-name='Integration Task'] [data-task-action='run-now']" },
{ "action": "wait", "ms": 5000 },
{ "action": "click", "selector": ".task-card[data-task-name='Integration Task'] [data-task-action='run-now']" },
{ "action": "wait", "ms": 5000 },
{ "action": "click", "selector": ".task-card[data-task-name='Integration Task'] .history-toggle" },
{
"action": "evaluate",
"script": "const card = document.querySelector('.task-card[data-task-name=\"Integration Task\"]'); const sessions = [...card.querySelectorAll('.run-session')].map(x => x.textContent?.trim()).filter(Boolean); const errors = [...card.querySelectorAll('.run-error')].map(x => x.textContent?.trim()).filter(Boolean); JSON.stringify({ sessions, uniqueSessions: [...new Set(sessions)].length, errors });",
"expect": { "contains": "\"uniqueSessions\":2" }
}
]
},
{
"id": "scheduled-task-disable-edit-preserves-toggle",
"name": "Disable a task, edit it, and verify it stays disabled",
"steps": [
{ "action": "click", "selector": ".task-card[data-task-name='Integration Task'] [data-task-action='toggle-enabled']" },
{ "action": "wait", "ms": 500 },
{
"action": "evaluate",
"script": "document.querySelector('.task-card[data-task-name=\"Integration Task\"]')?.className",
"expect": { "contains": "disabled" }
},
{ "action": "click", "selector": ".task-card[data-task-name='Integration Task'] [data-task-action='edit']" },
{ "action": "wait", "ms": 500 },
{ "action": "type", "selector": "#scheduled-task-name", "text": "Integration Task Edited" },
{ "action": "click", "selector": "#scheduled-task-save" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "const card = document.querySelector('.task-card[data-task-name=\"Integration Task Edited\"]'); ({ exists: !!card, disabled: card?.classList.contains('disabled') ?? false });",
"expect": { "contains": "\"disabled\":true" }
}
]
},
{
"id": "scheduled-task-form-validation",
"name": "Invalid cron input shows a validation error instead of creating a task",
"steps": [
{ "action": "click", "selector": "#scheduled-task-new" },
{ "action": "wait", "ms": 500 },
{ "action": "type", "selector": "#scheduled-task-name", "text": "Broken Cron Task" },
{ "action": "type", "selector": "#scheduled-task-prompt", "text": "This should not save." },
{
"action": "evaluate",
"script": "const sel = document.querySelector('#scheduled-task-schedule'); sel.value = 'Cron'; sel.dispatchEvent(new Event('change', { bubbles: true })); 'cron';"
},
{ "action": "type", "selector": "#scheduled-task-cron", "text": "not a cron" },
{ "action": "click", "selector": "#scheduled-task-save" },
{
"action": "evaluate",
"script": "document.querySelector('#scheduled-task-form-error')?.textContent?.trim()",
"expect": { "contains": "Invalid cron expression" }
}
]
}
]
}
Loading