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
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
platform: [x64, ARM64]
steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'

- name: Restore
run: dotnet restore PaletteShellExtension.sln

- name: Build
run: dotnet build PaletteShellExtension.sln -c Release -p:Platform=${{ matrix.platform }} --no-restore

test:
name: Test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'

- name: Restore
run: dotnet restore PaletteShellExtension.sln

- name: Test
run: dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 --no-restore --logger "trx;LogFileName=test-results.trx"

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/TestResults/test-results.trx'
retention-days: 7
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,3 @@ BenchmarkDotNet.Artifacts/
# =========================
!Directory.Build.rsp
/PaletteShellExtension/bundle_mapping.txt
/PaletteShellExtension/PackageApplication.ps1
85 changes: 85 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Contributing to PaletteShell

This covers building, testing, and debugging the **extension itself** (the C# host). If you're
looking to write a PaletteShell *script*, see the [README](README.md#-creating-your-own-scripts)
and [AGENTS.md](AGENTS.md) instead — this doc is for people changing the extension's code.

## Prerequisites

- .NET 9 SDK with the Windows 10.0.26100 platform (`dotnet --list-sdks` should show a `9.0.x` entry)
- Windows 10 (10.0.19041) or later
- [Windows Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview)
(part of PowerToys) installed, for the sideload/debug loop below
- Visual Studio 2022 (17.13+) with the "Windows application development" workload, only needed for
MSIX packaging and process-attach debugging — everyday build/test/edit works from the CLI alone

## Project layout

Two projects, one solution (`PaletteShellExtension.sln`):

- `PaletteShellExtension/` — the extension itself (see the project structure table in the
[README](README.md#project-structure) for what each file does)
- `PaletteShellExtension.Tests/` — xUnit tests, currently focused on `PowerShellScriptParser`

Both target `net9.0-windows10.0.26100.0` and only build for `x64`/`ARM64` (no `x86`/`AnyCPU`) —
that's enforced by `Directory.Build.props` at the repo root, so pass `-p:Platform=x64` (or
`ARM64`) to any `dotnet build`/`test` invocation.

## Building and testing

```powershell
dotnet restore PaletteShellExtension.sln
dotnet build PaletteShellExtension.sln -c Debug -p:Platform=x64
dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64
```

For most changes — anything in `PowerShellScriptParser`, the manifest models, or output
handling — `dotnet test` is the fast loop: it doesn't need Command Palette, sideloading, or a
debugger attached. Add cases to `PaletteShellExtension.Tests/PowerShellScriptParserTests.cs`
alongside behavior changes.

`.github/workflows/ci.yml` runs the same build (both platforms, Release) and test suite on every
PR, so a green `dotnet build`/`dotnet test` locally is a reliable predictor of CI passing.

## Running and debugging the extension locally

Open `PaletteShellExtension.sln` in Visual Studio with `PaletteShellExtension` as the startup
project and press **F5**. VS's single-project MSIX tooling (`EnableMsixTooling`,
`HasPackageAndPublishMenu` in the `.csproj`) handles build, sideload deployment, and registering
the COM server in one step, and arms the debugger for it — when Command Palette activates
PaletteShell, breakpoints just hit. No manual `Attach to Process` needed.

Open Command Palette and search **PaletteShell** to trigger activation. If you're mid-debug
session and want to force a fresh activation after a code change, stop debugging, rebuild, and F5
again — VS redeploys the updated package.

For issues you can't easily catch with a debugger (e.g. something a user reports), check the
extension's log file instead — parse failures, icon-validation warnings, and script execution
errors are written to `%LOCALAPPDATA%\PaletteShell\logs\palette-shell-<yyyyMMdd>.log`
(see `Classes/Log.cs`), which is pruned automatically after 7 days.

### Producing a standalone test package

To hand someone a sideloadable build without Visual Studio attached (or to test the packaged
artifact itself), right-click the `PaletteShellExtension` project → **Publish → Create App
Packages...** → choose **Sideloading** → build for the platform you need (`x64` or `ARM64`). This
produces
`PaletteShellExtension/AppPackages/<platform>/PaletteShellExtension_<version>_<platform>_Test/`,
containing the `.msix` and an `Add-AppDevPackage.ps1` installer script — run that script (as your
normal user, not elevated) to install it. On first use it also installs the developer certificate
needed to trust the unsigned test package.

## Bumping the package version

`AppxPackageVersion` in `PaletteShellExtension.csproj` and `<Identity Version="...">` in
`Package.appxmanifest` must match — bump both together.

## Submitting a change

- Keep `dotnet build` and `dotnet test` (both platforms if the change could plausibly differ
between them) green before opening a PR; CI re-checks the same thing.
- If you change parsing or manifest behavior, add or update a test in
`PaletteShellExtension.Tests` rather than relying on manual sideload testing alone.
- If you change end-user-facing behavior (attributes, output modes, sample scripts), update the
relevant section of [README.md](README.md) and, if it affects script authoring, [AGENTS.md](AGENTS.md)
in the same PR — they're both meant to stay authoritative.
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<ItemGroup>
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
Expand Down
137 changes: 137 additions & 0 deletions PaletteShellExtension.Tests/NewScriptWizardFormTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System.IO;
using PaletteShellExtension.Forms;
using Xunit;

namespace PaletteShellExtension.Tests;

public class NewScriptWizardFormTests
{
[Fact]
public void SubmitForm_Create_WritesScriptWithAllOptions_AndParserReadsItBack()
{
var root = Directory.CreateTempSubdirectory("pswizard-").FullName;
try
{
var form = new NewScriptWizardForm(root);
var inputs = """
{
"name": "MyTestScript",
"description": "Does a thing",
"group": "My Group",
"icon": "🎯",
"output": "Result",
"host": "pwsh",
"timeout": "45000",
"elevate": "true",
"confirm": "Are you sure?",
"open": "false"
}
""";

form.SubmitForm(inputs, """{"verb":"create"}""");

var path = Path.Combine(root, "MyTestScript.ps1");
Assert.True(File.Exists(path));

var manifest = PowerShellScriptParser.TryParseManifest(path);
Assert.NotNull(manifest);
Assert.Equal("MyTestScript", manifest!.Title);
Assert.Equal("Does a thing", manifest.Description);
Assert.Equal("My Group", manifest.Group);
Assert.Equal("🎯", manifest.IconGlyph);
Assert.Equal("Result", manifest.Output);
Assert.Equal("pwsh", manifest.Host);
Assert.Equal(45000, manifest.TimeoutMs);
Assert.True(manifest.RequiresAdmin);
Assert.Equal("Are you sure?", manifest.ConfirmMessage);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void SubmitForm_OmittedOptions_OmitHostAndTimeoutForRuntimeDefaults()
{
var root = Directory.CreateTempSubdirectory("pswizard-").FullName;
try
{
var form = new NewScriptWizardForm(root);
form.SubmitForm("""{"name":"Defaults","open":"false"}""", """{"verb":"create"}""");

var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "Defaults.ps1"));

Assert.NotNull(manifest);
Assert.Equal("General", manifest!.Group);
Assert.Equal("None", manifest.Output);
// Host/timeout are left unset in the file itself, so the script picks up
// whatever the global default settings are at run time.
Assert.Null(manifest.Host);
Assert.Null(manifest.TimeoutMs);
Assert.Null(manifest.RequiresAdmin);
Assert.Null(manifest.ConfirmMessage);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void SubmitForm_TimeoutBelowMinimum_ClampsToMinimum()
{
var root = Directory.CreateTempSubdirectory("pswizard-").FullName;
try
{
var form = new NewScriptWizardForm(root);
form.SubmitForm("""{"name":"BadTimeout","timeout":"10","open":"false"}""", """{"verb":"create"}""");

var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "BadTimeout.ps1"));

Assert.Equal(1000, manifest!.TimeoutMs);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void SubmitForm_HostDefault_OmitsScriptHostAttribute()
{
var root = Directory.CreateTempSubdirectory("pswizard-").FullName;
try
{
var form = new NewScriptWizardForm(root);
form.SubmitForm("""{"name":"DefaultHost","host":"default","open":"false"}""", """{"verb":"create"}""");

var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "DefaultHost.ps1"));

Assert.Null(manifest!.Host);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void SubmitForm_Cancel_DoesNotCreateFile()
{
var root = Directory.CreateTempSubdirectory("pswizard-").FullName;
try
{
var form = new NewScriptWizardForm(root);

form.SubmitForm("{}", """{"verb":"cancel"}""");

Assert.False(Directory.Exists(root) && Directory.GetFiles(root).Length > 0);
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
}
27 changes: 27 additions & 0 deletions PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Underscore-separated test names (Method_Scenario_Expectation) are the xUnit convention. -->
<NoWarn>$(NoWarn);CA1707</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PaletteShellExtension\PaletteShellExtension.csproj" />
</ItemGroup>

</Project>
Loading
Loading