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
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ private async Task<bool> InstallCoreAsync(AgentEnvironmentScanContext context, C
ExpectedWorkflowPath,
ExpectedBuildType,
refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) &&
string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal),
(string.Equals(refInfo.Name, $"{packageInfo.Version}", StringComparison.Ordinal) ||
string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal)),
cancellationToken,
sriIntegrity: packageInfo.Integrity);

Expand Down
166 changes: 166 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
using Aspire.TestUtilities;
using Xunit;

namespace Aspire.Cli.EndToEnd.Tests;

/// <summary>
/// End-to-end test verifying that the <c>aspire new</c> flow with AI agent initialization
/// completes without provenance verification errors when installing <c>@playwright/cli</c>.
/// </summary>
[OuterloopTest("Requires npm and network access to install @playwright/cli from the npm registry")]
public sealed class NewWithAgentInitTests(ITestOutputHelper output)
{
/// <summary>
/// Exercises the full <c>aspire new</c> → agent init → Playwright CLI install flow end-to-end.
/// This is the primary regression test for provenance verification failures (e.g., tag format changes
/// in upstream <c>@playwright/cli</c> releases).
///
/// The test:
/// 1. Runs <c>aspire new</c> to create a Starter project
/// 2. Accepts the agent init prompt (instead of declining)
/// 3. Selects Playwright CLI during skill selection
/// 4. Verifies no errors appear (especially no "Provenance verification failed")
/// 5. Verifies <c>playwright-cli</c> is installed and skill files are generated
/// </summary>
[Fact]
public async Task AspireNew_WithAgentInit_InstallsPlaywrightWithoutErrors()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
var workspace = TemporaryWorkspace.Create(output);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);

// Create .claude folder so agent init detects a Claude Code environment.
// This needs to exist in the workspace root before aspire new creates the project
// because agent init chains after project creation and looks for environment markers.
await auto.TypeAsync("mkdir -p .claude");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

// Run aspire new with the Starter template, going through all prompts manually
// so we can ACCEPT the agent init prompt instead of declining it.
await auto.TypeAsync("aspire new");
Comment on lines +48 to +57
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The test setup creates a .claude folder in the workspace root before running aspire new, but NewCommand chains agent init using the template output directory as the workspace root. This means the Claude Code scanner can end up detecting the parent .claude and writing config/skill files outside the newly created project directory, making the test less representative and potentially fragile if scanner boundary behavior changes.

Suggested change
// Create .claude folder so agent init detects a Claude Code environment.
// This needs to exist in the workspace root before aspire new creates the project
// because agent init chains after project creation and looks for environment markers.
await auto.TypeAsync("mkdir -p .claude");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Run aspire new with the Starter template, going through all prompts manually
// so we can ACCEPT the agent init prompt instead of declining it.
await auto.TypeAsync("aspire new");
// Create a dedicated project directory and place .claude inside it so agent init
// detects the Claude Code environment from the actual template output directory.
await auto.TypeAsync("mkdir -p agent-init-project && cd agent-init-project && mkdir -p .claude");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Run aspire new in the current directory, going through all prompts manually
// so we can ACCEPT the agent init prompt instead of declining it.
await auto.TypeAsync("aspire new .");

Copilot uses AI. Check for mistakes.
await auto.EnterAsync();

// Template selection: accept default Starter App
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("> Starter App").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(60),
description: "template selection list (> Starter App)");
await auto.EnterAsync();

// Project name
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(10),
description: "project name prompt");
await auto.TypeAsync("StarterApp");
await auto.EnterAsync();

// Output path: accept default
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Enter the output path").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(10),
description: "output path prompt");
await auto.EnterAsync();

// URLs prompt: accept default No
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(10),
description: "URLs prompt");
await auto.EnterAsync();

// Redis cache: accept default Yes
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Use Redis Cache").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(10),
description: "Redis cache prompt");
await auto.EnterAsync();

// Test project: accept default No
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Do you want to create a test project?").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(10),
description: "test project prompt");
await auto.EnterAsync();

// Agent init prompt: ACCEPT it (type 'y')
await auto.WaitUntilAsync(
s => s.ContainsText("configure AI agent environments"),
timeout: TimeSpan.FromSeconds(120),
description: "agent init prompt after aspire new");
await auto.WaitAsync(500);
await auto.TypeAsync("y");
await auto.EnterAsync();

// Agent init: workspace path - accept default
await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30));
await auto.WaitAsync(500);
await auto.EnterAsync();

// Agent init: skill location - select Claude Code
await auto.WaitUntilAsync(
s => s.ContainsText("skill files be installed"),
timeout: TimeSpan.FromSeconds(60),
description: "skill location prompt");
await auto.TypeAsync(" "); // Toggle off default Standard location
await auto.DownAsync();
await auto.TypeAsync(" "); // Toggle on Claude Code location
await auto.EnterAsync();

// Agent init: skill selection - toggle on Playwright CLI
await auto.WaitUntilAsync(
s => s.ContainsText("skills should be installed"),
timeout: TimeSpan.FromSeconds(30),
description: "skill selection prompt");
await auto.DownAsync();
await auto.TypeAsync(" "); // Toggle on Playwright CLI
await auto.EnterAsync();

Comment on lines +117 to +135
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

This E2E flow waits for prompt text like "skill files be installed" and "skills should be installed", but those strings don't appear in the current CLI resources/agent init command flow (agent init uses a single selection prompt like "What would you like to configure?"). As written, these waits are likely to time out/hang; consider matching the actual prompt text and selecting the relevant option(s) (e.g., the Playwright CLI install choice) based on the current AgentInitCommand output.

Suggested change
// Agent init: skill location - select Claude Code
await auto.WaitUntilAsync(
s => s.ContainsText("skill files be installed"),
timeout: TimeSpan.FromSeconds(60),
description: "skill location prompt");
await auto.TypeAsync(" "); // Toggle off default Standard location
await auto.DownAsync();
await auto.TypeAsync(" "); // Toggle on Claude Code location
await auto.EnterAsync();
// Agent init: skill selection - toggle on Playwright CLI
await auto.WaitUntilAsync(
s => s.ContainsText("skills should be installed"),
timeout: TimeSpan.FromSeconds(30),
description: "skill selection prompt");
await auto.DownAsync();
await auto.TypeAsync(" "); // Toggle on Playwright CLI
await auto.EnterAsync();
// Agent init: select the Playwright CLI configuration option from the current menu-based flow.
await auto.WaitUntilAsync(
s => s.ContainsText("What would you like to configure?") && s.ContainsText("Playwright CLI"),
timeout: TimeSpan.FromSeconds(60),
description: "agent init configuration prompt");
await auto.DownAsync();
await auto.EnterAsync();

Copilot uses AI. Check for mistakes.
// Wait for agent init to complete (downloads @playwright/cli from npm).
// Fail the test immediately if a provenance verification error appears.
await auto.WaitUntilAsync(s =>
{
if (s.ContainsText("Provenance verification failed"))
{
throw new InvalidOperationException(
"Provenance verification failed for @playwright/cli! " +
"This likely means the upstream package changed its tag format.");
}
return s.ContainsText("configuration complete");
}, timeout: TimeSpan.FromMinutes(5), description: "agent init configuration complete (no provenance errors)");
await auto.WaitForSuccessPromptAsync(counter);

// Verify playwright-cli is installed and functional.
await auto.TypeAsync("playwright-cli --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

// Verify skill file was generated in the Claude Code location.
await auto.TypeAsync("ls StarterApp/.claude/skills/playwright-cli/SKILL.md");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}
}
36 changes: 36 additions & 0 deletions tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,40 @@ public void VerifyIntegrity_WithNonSha512Prefix_ReturnsFalse()
}
}

[Fact]
public async Task InstallAsync_WorkflowRefValidator_AcceptsBothTagFormats()
{
var version = SemVersion.Parse("0.1.7", SemVersionStyles.Strict);
var npmRunner = new TestNpmRunner
{
ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
};
var provenanceChecker = new TestNpmProvenanceChecker();
var playwrightRunner = new TestPlaywrightCliRunner();
var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);

await installer.InstallAsync(CreateTestContext(), CancellationToken.None);

Assert.True(provenanceChecker.ProvenanceCalled);
Assert.NotNull(provenanceChecker.CapturedValidateWorkflowRef);

// Accept tags without 'v' prefix (0.1.7+)
Assert.True(WorkflowRefInfo.TryParse($"refs/tags/{version}", out var refWithout));
Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWithout!));

// Accept tags with 'v' prefix (pre-0.1.7)
Assert.True(WorkflowRefInfo.TryParse($"refs/tags/v{version}", out var refWith));
Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWith!));

// Reject wrong version
Assert.True(WorkflowRefInfo.TryParse("refs/tags/0.2.0", out var wrongVersion));
Assert.False(provenanceChecker.CapturedValidateWorkflowRef(wrongVersion!));

// Reject branch ref (not a tag)
Assert.True(WorkflowRefInfo.TryParse("refs/heads/main", out var branchRef));
Assert.False(provenanceChecker.CapturedValidateWorkflowRef(branchRef!));
}
Comment on lines +346 to +378
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

This new test calls InstallAsync but doesn't assert the returned result, and with the current TestNpmRunner/TestPlaywrightCliRunner defaults the install will return false (PackResult is null and InstallSkillsResult defaults to false). Consider either asserting the expected false result explicitly (to make the intent clear), or fully wiring the runner fakes (PackResult + valid integrity + InstallSkillsResult) so the install path succeeds while still capturing the workflow ref validator.

Copilot uses AI. Check for mistakes.

[Fact]
public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsFalse()
{
Expand Down Expand Up @@ -577,10 +611,12 @@ private sealed class TestNpmProvenanceChecker : INpmProvenanceChecker
{
public ProvenanceVerificationOutcome ProvenanceOutcome { get; set; } = ProvenanceVerificationOutcome.Verified;
public bool ProvenanceCalled { get; private set; }
public Func<WorkflowRefInfo, bool>? CapturedValidateWorkflowRef { get; private set; }

public Task<ProvenanceVerificationResult> VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func<WorkflowRefInfo, bool>? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null)
{
ProvenanceCalled = true;
CapturedValidateWorkflowRef = validateWorkflowRef;
return Task.FromResult(new ProvenanceVerificationResult
{
Outcome = ProvenanceOutcome,
Expand Down
Loading