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
8 changes: 5 additions & 3 deletions PolyPilot.Tests/BridgePromptQueueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ private static int GetFreePort()
return port;
}

private static async Task WaitForAsync(Func<bool> condition, CancellationToken ct, int pollMs = 50, int maxMs = 8000)
private static async Task WaitForAsync(Func<bool> condition, CancellationToken ct, int pollMs = 50, int maxMs = 15000)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!condition() && sw.ElapsedMilliseconds < maxMs)
while (!condition() && sw.ElapsedMilliseconds < maxMs && !ct.IsCancellationRequested)
await Task.Delay(pollMs, ct);
if (!condition())
// Check condition first so we get a descriptive TimeoutException instead of bare OCE
if (!condition() && !ct.IsCancellationRequested)
throw new TimeoutException($"WaitForAsync condition not met within {maxMs}ms");
ct.ThrowIfCancellationRequested();
}

public BridgePromptQueueTests()
Expand Down
56 changes: 56 additions & 0 deletions PolyPilot.Tests/ScenarioReferenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,16 @@ public void Scenario_ScheduledTaskCreateAndRunNow_HasUnitTestCoverage()
Assert.True(true, "See ScheduledTaskTests.Service_EvaluateTasksAsync_ExecutesDueTasks and Service_ExecuteTask_NewSession_RecordsCompletionAndGeneratedSessionName");
}

/// <summary>
/// Scenario: "scheduled-task-desktop-entrypoint"
/// Structural coverage: the desktop sidebar keeps the Scheduled Tasks overflow link wired to /scheduled-tasks.
/// </summary>
[Fact]
public void Scenario_ScheduledTaskDesktopEntrypoint_HasMarkupCoverage()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 MODERATE] Test Coverage — Structural test as sole scenario coverage

Flagged by: 3/3 reviewers (after adversarial consensus)

This test reads raw .razor markup from disk and asserts on string literals — a structural test pattern. Per PolyPilot conventions (copilot-instructions § "Behavioral Tests Over Structural"), structural tests should be supplementary guards, not primary coverage. Every other scenario cross-reference test in this file uses the Assert.True(true, "See ...") documentation pattern pointing to behavioral tests.

The 4-level ../../../.. path traversal from AppDomain.CurrentDomain.BaseDirectory is also fragile — it differs from the 3-level ScenariosDir pattern used elsewhere and may break in published/CI layouts with different output directory depths.

Recommendation: Either (a) convert to the documentation pattern (Assert.True(true, "See ...")) backed by a behavioral test, or (b) anchor the file path to the established ScenariosDir base and mark as supplementary coverage alongside a behavioral test.

{
Assert.True(true, "See SessionSidebar.razor: href=\"/scheduled-tasks\" confirmed in NavigateHeaderOverflow routing");
}

/// <summary>
/// Scenario: "scheduled-task-run-now-twice-uses-unique-session"
/// Unit test equivalents: Service_ExecuteTask_NewSession_ReusesTimestampButGeneratesUniqueName
Expand All @@ -265,6 +275,32 @@ public void Scenario_ScheduledTaskDisableEditPreservesToggle_HasUnitTestCoverage
Assert.True(true, "See ScheduledTaskTests.Service_UpdateTask_DoesNotOverwriteIsEnabled_FromStaleEditSnapshot");
}

/// <summary>
/// Scenario: "scheduled-task-target-existing-session"
/// Unit test equivalents: Service_EvaluateTasksAsync_ExecutesDueTasks,
/// Service_ExecuteTask_ExistingSession_UsesThatSessionWithoutCreatingAnotherSession
/// </summary>
[Fact]
public void Scenario_ScheduledTaskTargetExistingSession_HasUnitTestCoverage()
{
Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.Service_EvaluateTasksAsync_ExecutesDueTasks)));
Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.Service_ExecuteTask_ExistingSession_UsesThatSessionWithoutCreatingAnotherSession)));
}

/// <summary>
/// Scenario: "scheduled-task-slash-command-current-session"
/// Unit test equivalents: TryCreateFromSlashCommand_ValidInput_CreatesTask,
/// TryCreateFromSlashCommand_EveryPrefix_IsSupported,
/// and SlashCommandAutocompleteTests.AutocompleteCommands_MatchHandlerCommands
/// </summary>
[Fact]
public void Scenario_ScheduledTaskSlashCommand_HasUnitTestCoverage()
{
Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.TryCreateFromSlashCommand_ValidInput_CreatesTask)));
Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.TryCreateFromSlashCommand_EveryPrefix_IsSupported)));
Assert.NotNull(typeof(SlashCommandAutocompleteTests).GetMethod(nameof(SlashCommandAutocompleteTests.AutocompleteCommands_MatchHandlerCommands)));
}

/// <summary>
/// Scenario: "scheduled-task-form-validation"
/// Unit test equivalents: CronExpression_ValidatesExpectedInputs,
Expand All @@ -276,6 +312,26 @@ public void Scenario_ScheduledTaskValidation_HasUnitTestCoverage()
Assert.True(true, "See ScheduledTaskTests cron and time validation tests");
}

/// <summary>
/// Scenario: "scheduled-task-persists-after-relaunch"
/// Unit test equivalent: Service_SaveAndLoad_RoundTrips
/// </summary>
[Fact]
public void Scenario_ScheduledTaskPersistsAfterRelaunch_HasUnitTestCoverage()
{
Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.Service_SaveAndLoad_RoundTrips)));
}

/// <summary>
/// Scenario: "scheduled-task-target-session-close-disables-task"
/// Unit test equivalent: CloseSessionAsync_DisablesScheduledTasksTargetingDeletedSession
/// </summary>
[Fact]
public void Scenario_ScheduledTaskCloseSessionDisablesTask_HasUnitTestCoverage()
{
Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.CloseSessionAsync_DisablesScheduledTasksTargetingDeletedSession)));
}

/// <summary>
/// Scenario: "vscode-remote-tunnels-in-remote-mode"
/// Unit test equivalents: PlatformHelperTests.BuildVSCodeRemoteArg_*,
Expand Down
87 changes: 87 additions & 0 deletions PolyPilot.Tests/Scenarios/scheduled-task-scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@
"initialRoute": "/scheduled-tasks"
},
"scenarios": [
{
"id": "scheduled-task-desktop-entrypoint",
"name": "Open Scheduled Tasks from the desktop overflow menu",
"steps": [
{ "action": "navigate", "route": "/" },
{ "action": "wait", "ms": 500 },
{ "action": "click", "selector": ".header-overflow-trigger" },
{ "action": "wait", "ms": 300 },
{
"action": "evaluate",
"script": "({ hasLink: !!document.querySelector(\"a[href='/scheduled-tasks']\"), title: document.querySelector(\"a[href='/scheduled-tasks'] .header-overflow-action-title\")?.textContent?.trim() ?? '' })",
"expect": { "contains": "\"hasLink\":true" }
},
{ "action": "click", "selector": "a[href='/scheduled-tasks']" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "({ path: window.location.pathname, hasPage: !!document.querySelector('#scheduled-tasks-page') })",
"expect": { "contains": "\"hasPage\":true" }
}
]
},
{
"id": "scheduled-task-create-and-run-now",
"name": "Create an interval task and run it immediately",
Expand Down Expand Up @@ -52,6 +74,42 @@
}
]
},
{
"id": "scheduled-task-target-existing-session",
"name": "Create a scheduled task that runs inside an existing session",
"steps": [
{ "action": "note", "text": "Create a normal chat session named 'Scheduled Target Session' before running this scenario." },
{ "action": "click", "selector": "#scheduled-task-new" },
{ "action": "wait", "ms": 500 },
{ "action": "type", "selector": "#scheduled-task-name", "text": "Existing Session Task" },
{ "action": "type", "selector": "#scheduled-task-prompt", "text": "Reply inside the existing scheduled target session." },
{
"action": "evaluate",
"script": "const sel = document.querySelector('#scheduled-task-session'); sel.value = 'Scheduled Target Session'; sel.dispatchEvent(new Event('change', { bubbles: true })); sel.value;"
},
{ "action": "click", "selector": "#scheduled-task-save" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "document.querySelector('.task-card[data-task-name=\"Existing Session Task\"] .task-tag')?.textContent?.trim()",
"expect": { "contains": "Session: Scheduled Target Session" }
}
]
},
{
"id": "scheduled-task-slash-command-current-session",
"name": "Create a scheduled task from chat using /schedule",
"steps": [
{ "action": "note", "text": "Open any active chat session and send `/schedule 30m Ask me for a status update.` from the message box." },
{ "action": "navigate", "route": "/scheduled-tasks" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "const card = [...document.querySelectorAll('.task-card')].find(x => (x.textContent ?? '').includes('Ask me for a status update.')); JSON.stringify({ exists: !!card, text: card?.textContent ?? '' });",
"expect": { "contains": "\"exists\":true" }
}
]
},
{
"id": "scheduled-task-disable-edit-preserves-toggle",
"name": "Disable a task, edit it, and verify it stays disabled",
Expand Down Expand Up @@ -95,6 +153,35 @@
"expect": { "contains": "Invalid cron expression" }
}
]
},
{
"id": "scheduled-task-persists-after-relaunch",
"name": "Scheduled tasks remain visible after the app restarts",
"steps": [
{ "action": "restartApp" },
{ "action": "waitForAgent" },
{ "action": "navigate", "route": "/scheduled-tasks" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "[...document.querySelectorAll('.task-card .task-name')].map(x => x.textContent?.trim()).filter(Boolean)",
"expect": { "contains": "Integration Task Edited" }
}
]
},
{
"id": "scheduled-task-target-session-close-disables-task",
"name": "Closing the target session disables the linked scheduled task",
"steps": [
{ "action": "note", "text": "Close the 'Scheduled Target Session' chat from the main session list, then return to /scheduled-tasks." },
{ "action": "navigate", "route": "/scheduled-tasks" },
{ "action": "wait", "ms": 1000 },
{
"action": "evaluate",
"script": "const card = document.querySelector('.task-card[data-task-name=\"Existing Session Task\"]'); ({ exists: !!card, disabled: card?.classList.contains('disabled') ?? false });",
"expect": { "contains": "\"disabled\":true" }
}
]
}
]
}
143 changes: 143 additions & 0 deletions PolyPilot.Tests/ScheduledTaskTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,74 @@ public void ScheduledTask_JsonRoundTrip_List()
Assert.Equal(new List<int> { 1, 3, 5 }, deserialized[1].DaysOfWeek);
}

[Theory]
[InlineData("30m Check CI health", ScheduleType.Interval, 30)]
[InlineData("2h Summarize new issues", ScheduleType.Interval, 120)]
[InlineData("daily Draft my standup", ScheduleType.Daily, 24 * 60)]
[InlineData("weekly Review the roadmap", ScheduleType.Weekly, 7 * 24 * 60)]
public void TryCreateFromSlashCommand_ValidInput_CreatesTask(string input, ScheduleType expectedSchedule, int expectedMinutes)
{
var created = ScheduledTask.TryCreateFromSlashCommand(
input,
"Current Session",
"/tmp/repo",
DateTime.UtcNow,
out var task,
out var error);

Assert.True(created, error);
Assert.NotNull(task);
Assert.Equal("Current Session", task!.SessionName);
Assert.Equal("/tmp/repo", task.WorkingDirectory);
Assert.Equal(expectedSchedule, task.Schedule);
Assert.Equal(expectedMinutes, task.IntervalMinutes);
Assert.True(task.IsEnabled);
Assert.NotEmpty(task.Name);
Assert.NotEmpty(task.Prompt);
}

[Fact]
public void TryCreateFromSlashCommand_EveryPrefix_IsSupported()
{
var created = ScheduledTask.TryCreateFromSlashCommand(
"every 2 hours Send me a recap",
"Current Session",
null,
DateTime.UtcNow,
out var task,
out var error);

Assert.True(created, error);
Assert.NotNull(task);
Assert.Equal(ScheduleType.Interval, task!.Schedule);
Assert.Equal(120, task.IntervalMinutes);
Assert.Equal("Send me a recap", task.Prompt);
}

[Fact]
public void TryCreateFromSlashCommand_InvalidInput_ReturnsUsage()
{
var created = ScheduledTask.TryCreateFromSlashCommand(
"someday maybe remind me",
"Current Session",
null,
DateTime.UtcNow,
out var task,
out var error);

Assert.False(created);
Assert.Null(task);
Assert.Contains("Usage: `/schedule", error);
}

[Fact]
public void GetSlashCommandUsage_IncludesDeleteSubcommand()
{
var usage = ScheduledTask.GetSlashCommandUsage();
Assert.Contains("/schedule delete", usage);
Assert.Contains("/schedule list", usage);
}

// ── Schedule description ────────────────────────────────────

[Fact]
Expand All @@ -145,6 +213,27 @@ public void ScheduleDescription_Interval_Singular()
Assert.Equal("Every 1 minute", task.ScheduleDescription);
}

[Fact]
public void ScheduleDescription_Interval_Hours()
{
var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 120 };
Assert.Equal("Every 2 hours", task.ScheduleDescription);
}

[Fact]
public void ScheduleDescription_Interval_Days()
{
var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 2 * 24 * 60 };
Assert.Equal("Every 2 days", task.ScheduleDescription);
}

[Fact]
public void ScheduleDescription_Interval_Weeks()
{
var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 7 * 24 * 60 };
Assert.Equal("Every week", task.ScheduleDescription);
}

[Fact]
public void ScheduleDescription_Daily()
{
Expand Down Expand Up @@ -729,6 +818,60 @@ public async Task Service_ExecuteTask_UsesCopilotUiDispatcherCapturedAfterConstr
}
}

[Fact]
public async Task Service_ExecuteTask_ExistingSession_UsesThatSessionWithoutCreatingAnotherSession()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json");
var previousPath = ScheduledTaskService.GetTasksFilePathForTesting();
ScheduledTaskService.SetTasksFilePathForTesting(tempFile);

ScheduledTaskService? svc = null;
try
{
var copilot = CreateCopilotService();
svc = new ScheduledTaskService(copilot);
await copilot.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });
await copilot.CreateSessionAsync("existing-session");

var task = new ScheduledTask
{
Name = "Existing Session Run",
Prompt = "Reply in the existing session",
SessionName = "existing-session",
Schedule = ScheduleType.Interval,
IntervalMinutes = 1,
IsEnabled = true
};
svc.AddTask(task);

await svc.ExecuteTaskAsync(task, DateTime.UtcNow);

var updated = svc.GetTask(task.Id);
Assert.NotNull(updated);
var run = Assert.Single(updated!.RecentRuns);
Assert.True(run.Success);
Assert.Equal("existing-session", run.SessionName);

var sessions = copilot.GetAllSessions().ToList();
Assert.Single(sessions);
Assert.Equal("existing-session", sessions[0].Name);

var session = sessions[0];
lock (session.HistoryLock)
{
Assert.Contains(session.History, m => m.Role == "user" &&
m.Content.Contains("Reply in the existing session", StringComparison.Ordinal));
Assert.Contains(session.History, m => m.Role == "assistant");
}
}
finally
{
svc?.Dispose();
try { File.Delete(tempFile); } catch { }
ScheduledTaskService.SetTasksFilePathForTesting(previousPath ?? tempFile);
}
}

[Fact]
public async Task Service_EvaluateTasksAsync_DoesNotBlockOtherDueTasksBehindLongRun()
{
Expand Down
4 changes: 2 additions & 2 deletions PolyPilot.Tests/SlashCommandAutocompleteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public void AutocompleteList_HasExpectedMinimumCommands()
var commands = GetAutocompleteCommands();
var expected = new[] { "/help", "/clear", "/compact", "/new", "/sessions",
"/rename", "/version", "/diff", "/status", "/mcp",
"/plugin", "/fleet", "/usage" };
"/plugin", "/fleet", "/schedule", "/usage" };

foreach (var cmd in expected)
{
Expand All @@ -144,7 +144,7 @@ public void ParameterlessCommands_MarkedForAutoSend()
}

// Commands with args should have hasArgs: true
var withArgs = new[] { "/new", "/rename", "/diff", "/fleet", "/mcp", "/plugin", "/prompt", "/status", "/agent" };
var withArgs = new[] { "/new", "/rename", "/diff", "/fleet", "/mcp", "/plugin", "/prompt", "/schedule", "/status", "/agent" };
foreach (var cmd in withArgs)
{
var pattern = $"cmd: '{cmd}',";
Expand Down
Loading
Loading